Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

All notable changes to TokenSaver.Mcp are documented here.

## [1.15.1] — 2026-06-16

### Fixed
- `OutlineCSharpFile` on `.razor` files now reports **correct `// L..` line ranges**.
The Razor preprocessor previously rebuilt the `@code`/`@functions` C# into a fresh
synthetic class, so every member's line number was offset by however far the `@code`
block sat in the file — a narrow `Read` of a printed range landed on the wrong lines.
Extraction is now line-aligned: each extracted line keeps its original position, so
the ranges map straight back to the real `.razor` file. (Surfaced once the warm-start
flow — outline then Read the line-range — became the primary path.)

## [1.15.0] — 2026-06-16

### Changed — stripped to a warm-start tool set
Expand Down
245 changes: 149 additions & 96 deletions Emitters/RazorPreprocessor.cs
Original file line number Diff line number Diff line change
@@ -1,139 +1,192 @@
using System.Text;

namespace RoslynLean;

internal static class RazorPreprocessor
{
public static bool IsRazor(string path) =>
path.EndsWith(".razor", StringComparison.OrdinalIgnoreCase);

/// <summary>
/// Extracts the C# from a .razor file's <c>@code</c> / <c>@functions</c> blocks into a
/// single synthetic component class, <b>preserving original line numbers</b>: every
/// extracted line stays on the line it occupied in the .razor file, markup lines become
/// blank, and the class braces reuse the first block's <c>{</c> and the last block's
/// <c>}</c>. As a result the syntax-tree line spans — and the <c>// L..</c> ranges the
/// outline prints — map straight back to the real file, so a narrow Read of a printed
/// range lands on the right lines.
/// </summary>
public static string ExtractCSharp(string razorSource)
{
var sb = new StringBuilder();

foreach (var ns in FindLines(razorSource, "@using"))
sb.Append("using ").Append(ns).AppendLine(";");
sb.AppendLine();
var src = razorSource.Replace("\r\n", "\n").Replace("\r", "\n");
var lines = src.Split('\n');
var outLines = new string[lines.Length];
for (int i = 0; i < outLines.Length; i++)
outLines[i] = "";

sb.AppendLine("internal sealed class _RazorComponent");
sb.AppendLine("{");
var spans = ExtractBlockSpans(src);

foreach (var body in ExtractBlocks(razorSource, "@code"))
sb.AppendLine(body);
foreach (var body in ExtractBlocks(razorSource, "@functions"))
sb.AppendLine(body);
if (spans.Count == 0)
// No @code/@functions — emit an empty component so parsing yields no members.
return "internal sealed class _RazorComponent\n{\n}\n";

sb.AppendLine("}");
return sb.ToString();
}
int firstOpenLine = LineOf(src, spans[0].Open);
int lastCloseLine = LineOf(src, spans[^1].Close);

private static IEnumerable<string> FindLines(string source, string directive)
{
foreach (var line in source.Split('\n'))
// @using directives before the first code block become using statements, kept on
// their own lines so nothing downstream shifts. (A using after the class would be
// invalid C#, so anything at/after the first block is left as markup.)
for (int i = 0; i < lines.Length && i < firstOpenLine; i++)
{
var trimmed = line.TrimStart();
if (!trimmed.StartsWith(directive + " ", StringComparison.Ordinal))
var trimmed = lines[i].TrimStart();
if (!trimmed.StartsWith("@using ", StringComparison.Ordinal))
continue;
var rest = trimmed.Substring(directive.Length + 1).Trim().TrimEnd(';');
var rest = trimmed.Substring("@using ".Length).Trim().TrimEnd(';');
if (rest.Length > 0)
yield return rest;
outLines[i] = "using " + rest + ";";
}
}

private static IEnumerable<string> ExtractBlocks(string source, string directive)
{
int i = 0;
while (true)
// Each block's body, placed verbatim at its original line numbers. A block's body is
// a contiguous substring of the source, so its internal newlines keep every line in
// place. Multiple blocks all flow into the one class.
foreach (var (open, close) in spans)
{
var idx = source.IndexOf(directive, i, StringComparison.Ordinal);
if (idx < 0)
yield break;

// Don't match "@codeblock", "@functions2", etc.
var after = idx + directive.Length;
if (after < source.Length && (char.IsLetterOrDigit(source[after]) || source[after] == '_'))
int openLine = LineOf(src, open);
var body = src.Substring(open + 1, close - open - 1);
var bodyLines = body.Split('\n');
for (int k = 0; k < bodyLines.Length; k++)
{
i = idx + 1;
continue;
int target = openLine + k;
outLines[target] = outLines[target].Length == 0
? bodyLines[k]
: outLines[target] + " " + bodyLines[k];
}
}

// Reuse the first block's '{' to open the class and the last block's '}' to close it;
// every intermediate block's own braces are simply absorbed.
outLines[firstOpenLine] = "internal sealed class _RazorComponent { " + outLines[firstOpenLine];
outLines[lastCloseLine] = outLines[lastCloseLine] + " }";

var brace = source.IndexOf('{', after);
if (brace < 0)
yield break;
return string.Join("\n", outLines);
}

private static int LineOf(string source, int index)
{
int line = 0;
int max = Math.Min(index, source.Length);
for (int k = 0; k < max; k++)
if (source[k] == '\n')
line++;
return line;
}

int depth = 1;
int pos = brace + 1;
// Yields the (Open, Close) char indices of the '{' and its matching '}' for every
// @code / @functions block, in document order. String/char literals and comments are
// skipped so a brace inside them never miscounts depth.
private static List<(int Open, int Close)> ExtractBlockSpans(string source)
{
var spans = new List<(int Open, int Close)>();

while (pos < source.Length && depth > 0)
foreach (var directive in new[] { "@code", "@functions" })
{
int i = 0;
while (true)
{
char c = source[pos];
char next = pos + 1 < source.Length ? source[pos + 1] : '\0';
var idx = source.IndexOf(directive, i, StringComparison.Ordinal);
if (idx < 0)
break;

if (c == '/' && next == '/')
// Don't match "@codeblock", "@functions2", etc.
var after = idx + directive.Length;
if (after < source.Length && (char.IsLetterOrDigit(source[after]) || source[after] == '_'))
{
// line comment — skip to end of line
pos += 2;
while (pos < source.Length && source[pos] != '\n') pos++;
i = idx + 1;
continue;
}
else if (c == '/' && next == '*')
{
// block comment — skip to */
pos += 2;
while (pos + 1 < source.Length && !(source[pos] == '*' && source[pos + 1] == '/')) pos++;
pos += 2;
}
else if (c == '@' && next == '"')
{
// verbatim string @"..." — "" is escaped quote inside
pos += 2;
while (pos < source.Length)
{
if (source[pos] == '"')
{
pos++;
if (pos < source.Length && source[pos] == '"') pos++; // escaped ""
else break;
}
else pos++;
}
}
else if (c == '"')

var brace = source.IndexOf('{', after);
if (brace < 0)
break;

var close = FindMatchingBrace(source, brace);
if (close < 0)
break;

spans.Add((brace, close));
i = close + 1;
}
}

spans.Sort((a, b) => a.Open.CompareTo(b.Open));
return spans;
}

// Returns the index of the '}' that matches the '{' at <paramref name="openBrace"/>,
// or -1 if unbalanced. Skips line/block comments and string/char/verbatim literals.
private static int FindMatchingBrace(string source, int openBrace)
{
int depth = 1;
int pos = openBrace + 1;

while (pos < source.Length && depth > 0)
{
char c = source[pos];
char next = pos + 1 < source.Length ? source[pos + 1] : '\0';

if (c == '/' && next == '/')
{
pos += 2;
while (pos < source.Length && source[pos] != '\n') pos++;
}
else if (c == '/' && next == '*')
{
pos += 2;
while (pos + 1 < source.Length && !(source[pos] == '*' && source[pos + 1] == '/')) pos++;
pos += 2;
}
else if (c == '@' && next == '"')
{
pos += 2;
while (pos < source.Length)
{
// regular string — backslash escaping
pos++;
while (pos < source.Length && source[pos] != '"')
if (source[pos] == '"')
{
if (source[pos] == '\\') pos++;
pos++;
if (pos < source.Length && source[pos] == '"') pos++; // escaped ""
else break;
}
pos++; // closing "
else pos++;
}
else if (c == '\'')
}
else if (c == '"')
{
pos++;
while (pos < source.Length && source[pos] != '"')
{
// character literal
if (source[pos] == '\\') pos++;
pos++;
while (pos < source.Length && source[pos] != '\'')
{
if (source[pos] == '\\') pos++;
pos++;
}
pos++; // closing '
}
else if (c == '{') { depth++; pos++; }
else if (c == '}')
pos++; // closing "
}
else if (c == '\'')
{
pos++;
while (pos < source.Length && source[pos] != '\'')
{
depth--;
if (depth == 0) break;
if (source[pos] == '\\') pos++;
pos++;
}
else pos++;
pos++; // closing '
}

if (depth != 0)
yield break;

yield return source.Substring(brace + 1, pos - brace - 1);
i = pos + 1;
else if (c == '{') { depth++; pos++; }
else if (c == '}')
{
depth--;
if (depth == 0) return pos;
pos++;
}
else pos++;
}

return -1;
}
}
2 changes: 1 addition & 1 deletion mcp/TokenSaver.Mcp.csproj
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
<PackAsTool>true</PackAsTool>
<ToolCommandName>tokensaver-mcp</ToolCommandName>
<PackageId>TokenSaver.Mcp</PackageId>
<Version>1.15.0</Version>
<Version>1.15.1</Version>
<Authors>Sebastian Larsson</Authors>
<Description>MCP server for .NET developers — gives AI assistants a token-efficient view of C#, VB, Razor, and .NET project files using the Roslyn compiler platform. Reduces tokens by 50-95% with no loss of logic.</Description>
<PackageTags>mcp;dotnet;csharp;visualbasic;razor;roslyn;copilot;tokens;llm;tokensaver</PackageTags>
Expand Down
4 changes: 2 additions & 2 deletions server.json
Original file line number Diff line number Diff line change
Expand Up @@ -7,12 +7,12 @@
"url": "https://github.com/Byggarepop/TokenSaver",
"source": "github"
},
"version": "1.15.0",
"version": "1.15.1",
"packages": [
{
"registryType": "nuget",
"identifier": "TokenSaver.Mcp",
"version": "1.15.0",
"version": "1.15.1",
"runtimeHint": "dnx",
"runtimeArguments": [
{
Expand Down
Loading
Loading