diff --git a/MCPForUnity/Editor/Tools/ManageScript.cs b/MCPForUnity/Editor/Tools/ManageScript.cs
index 3e98172d5..619175125 100644
--- a/MCPForUnity/Editor/Tools/ManageScript.cs
+++ b/MCPForUnity/Editor/Tools/ManageScript.cs
@@ -683,23 +683,10 @@ private static object ApplyTextEdits(
}
string working = original;
- bool relaxed = string.Equals(validateMode, "relaxed", StringComparison.OrdinalIgnoreCase);
bool syntaxOnly = string.Equals(validateMode, "syntax", StringComparison.OrdinalIgnoreCase);
foreach (var sp in spans)
{
- string next = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty);
- if (relaxed)
- {
- // Scoped balance check: validate just around the changed region to avoid false positives
- int originalLength = sp.end - sp.start;
- int newLength = sp.text?.Length ?? 0;
- int endPos = sp.start + newLength;
- if (!CheckScopedBalance(next, Math.Max(0, sp.start - 500), Math.Min(next.Length, endPos + 500)))
- {
- return new ErrorResponse("unbalanced_braces", new { status = "unbalanced_braces", line = 0, expected = "{}()[] (scoped)", hint = "Use standard validation or shrink the edit range." });
- }
- }
- working = next;
+ working = working.Remove(sp.start, sp.end - sp.start).Insert(sp.start, sp.text ?? string.Empty);
}
// No-op guard: if resulting text is identical, avoid writes and return explicit no-op
@@ -720,7 +707,7 @@ private static object ApplyTextEdits(
);
}
- // Always check final structural balance regardless of relaxed mode
+ // Always check final structural balance
if (!CheckBalancedDelimiters(working, out int line, out char expected))
{
int startLine = Math.Max(1, line - 5);
@@ -870,62 +857,340 @@ private static string ComputeSha256(string contents)
}
}
- private static bool CheckBalancedDelimiters(string text, out int line, out char expected)
+ ///
+ /// Lightweight lexer that tracks whether the current position is inside a
+ /// string literal (regular, verbatim, interpolated, raw) or a comment.
+ /// Callers advance character-by-character and check .
+ ///
+ private struct CSharpLexer
{
- var braceStack = new Stack();
- var parenStack = new Stack();
- var bracketStack = new Stack();
- bool inString = false, inChar = false, inSingle = false, inMulti = false, escape = false;
- line = 1; expected = '\0';
+ private readonly string _text;
+ private int _pos;
+ private readonly int _end;
+ private int _line;
+
+ // String/comment state
+ private bool _inSingleComment;
+ private bool _inMultiComment;
- for (int i = 0; i < text.Length; i++)
+ public CSharpLexer(string text, int start = 0, int end = -1)
{
- char c = text[i];
- char next = i + 1 < text.Length ? text[i + 1] : '\0';
+ _text = text;
+ _pos = start;
+ _end = end < 0 ? text.Length : end;
+ _line = 1;
+ // count newlines before start
+ for (int i = 0; i < start && i < text.Length; i++)
+ if (text[i] == '\n') _line++;
+ _inSingleComment = false;
+ _inMultiComment = false;
+ InNonCode = false;
+ }
- if (c == '\n') { line++; if (inSingle) inSingle = false; }
+ public bool InNonCode { get; private set; }
+ public int Position => _pos;
+ public int Line => _line;
+
+ ///
+ /// Advance to the next character, updating all state.
+ /// Returns false at end of range.
+ ///
+ public bool Advance(out char c)
+ {
+ if (_pos >= _end) { c = '\0'; return false; }
- if (escape) { escape = false; continue; }
+ c = _text[_pos];
+ char next = _pos + 1 < _end ? _text[_pos + 1] : '\0';
- if (inString)
+ if (c == '\n')
{
- if (c == '\\') { escape = true; }
- else if (c == '"') inString = false;
- continue;
+ _line++;
+ if (_inSingleComment) _inSingleComment = false;
}
- if (inChar)
+
+ // Inside single-line comment
+ if (_inSingleComment) { InNonCode = true; _pos++; return true; }
+
+ // Inside multi-line comment
+ if (_inMultiComment)
{
- if (c == '\\') { escape = true; }
- else if (c == '\'') inChar = false;
- continue;
+ if (c == '*' && next == '/') { _inMultiComment = false; InNonCode = true; _pos += 2; c = '/'; return true; }
+ InNonCode = true; _pos++; return true;
}
- if (inSingle) continue;
- if (inMulti)
+
+ // Start of comment
+ if (c == '/' && next == '/') { _inSingleComment = true; InNonCode = true; _pos += 2; return true; }
+ if (c == '/' && next == '*') { _inMultiComment = true; InNonCode = true; _pos += 2; return true; }
+
+ // Interpolated raw string: $"""...""" or $$"""...""" etc. (C# 11)
+ // Must check BEFORE regular $" and BEFORE plain """
+ if (c == '$')
{
- if (c == '*' && next == '/') { inMulti = false; i++; }
- continue;
+ int dollarCount = 1;
+ while (_pos + dollarCount < _end && _text[_pos + dollarCount] == '$') dollarCount++;
+ int afterDollars = _pos + dollarCount;
+ if (afterDollars + 2 < _end && _text[afterDollars] == '"' && _text[afterDollars + 1] == '"' && _text[afterDollars + 2] == '"')
+ {
+ int q = 3;
+ while (afterDollars + q < _end && _text[afterDollars + q] == '"') q++;
+ _pos = afterDollars + q; // past all opening quotes
+ SkipInterpolatedRawStringBody(dollarCount, q);
+ InNonCode = true; return true;
+ }
}
- if (c == '"') { inString = true; continue; }
- if (c == '\'') { inChar = true; continue; }
- if (c == '/' && next == '/') { inSingle = true; i++; continue; }
- if (c == '/' && next == '*') { inMulti = true; i++; continue; }
+ // Raw string literal: """...""" (C# 11, non-interpolated)
+ if (c == '"' && next == '"' && _pos + 2 < _end && _text[_pos + 2] == '"')
+ {
+ int q = 3;
+ while (_pos + q < _end && _text[_pos + q] == '"') q++;
+ _pos += q; // past opening quotes
+ int closeCount = 0;
+ while (_pos < _end)
+ {
+ if (_text[_pos] == '\n') _line++;
+ if (_text[_pos] == '"') { closeCount++; if (closeCount >= q) { _pos++; break; } }
+ else closeCount = 0;
+ _pos++;
+ }
+ InNonCode = true; return true;
+ }
+
+ // Interpolated string: $"..." or $@"..." or @$"..."
+ if ((c == '$' && next == '"') ||
+ (c == '$' && next == '@' && _pos + 2 < _end && _text[_pos + 2] == '"') ||
+ (c == '@' && next == '$' && _pos + 2 < _end && _text[_pos + 2] == '"'))
+ {
+ bool isVerbatim = (next == '@') || (c == '@');
+ _pos += (c == '$' && next == '"') ? 2 : 3;
+ SkipInterpolatedStringBody(isVerbatim);
+ InNonCode = true; return true;
+ }
+
+ // Verbatim string: @"..."
+ if (c == '@' && next == '"')
+ {
+ _pos += 2;
+ while (_pos < _end)
+ {
+ if (_text[_pos] == '\n') _line++;
+ if (_text[_pos] == '"')
+ {
+ if (_pos + 1 < _end && _text[_pos + 1] == '"') { _pos += 2; continue; }
+ _pos++; break;
+ }
+ _pos++;
+ }
+ InNonCode = true; return true;
+ }
+
+ // Regular string: "..."
+ if (c == '"')
+ {
+ _pos++;
+ while (_pos < _end)
+ {
+ if (_text[_pos] == '\\') { _pos += 2; continue; }
+ if (_text[_pos] == '"') { _pos++; break; }
+ if (_text[_pos] == '\n') _line++;
+ _pos++;
+ }
+ InNonCode = true; return true;
+ }
+
+ // Char literal: '...'
+ if (c == '\'')
+ {
+ _pos++;
+ while (_pos < _end)
+ {
+ if (_text[_pos] == '\\') { _pos += 2; continue; }
+ if (_text[_pos] == '\'') { _pos++; break; }
+ _pos++;
+ }
+ InNonCode = true; return true;
+ }
+
+ InNonCode = false;
+ _pos++;
+ return true;
+ }
+
+ ///
+ /// Skip the body of an interpolated string, handling nested interpolation holes.
+ /// _pos should be right after the opening quote.
+ ///
+ private void SkipInterpolatedStringBody(bool isVerbatim)
+ {
+ int interpDepth = 0;
+ while (_pos < _end)
+ {
+ char ch = _text[_pos];
+ if (ch == '\n') _line++;
+
+ if (interpDepth > 0)
+ {
+ // Inside interpolation hole — this is code, scan for nested strings/braces
+ if (ch == '{') { interpDepth++; _pos++; continue; }
+ if (ch == '}') { interpDepth--; _pos++; continue; }
+ if (ch == '"')
+ {
+ // Nested string inside interpolation hole
+ _pos++;
+ while (_pos < _end)
+ {
+ if (_text[_pos] == '\\') { _pos += 2; continue; }
+ if (_text[_pos] == '"') { _pos++; break; }
+ if (_text[_pos] == '\n') _line++;
+ _pos++;
+ }
+ continue;
+ }
+ if (ch == '/' && _pos + 1 < _end)
+ {
+ if (_text[_pos + 1] == '/') { _pos += 2; while (_pos < _end && _text[_pos] != '\n') _pos++; continue; }
+ if (_text[_pos + 1] == '*') { _pos += 2; while (_pos + 1 < _end && !(_text[_pos] == '*' && _text[_pos + 1] == '/')) { if (_text[_pos] == '\n') _line++; _pos++; } if (_pos + 1 < _end) _pos += 2; continue; }
+ }
+ if (ch == '\'') { _pos++; while (_pos < _end) { if (_text[_pos] == '\\') { _pos += 2; continue; } if (_text[_pos] == '\'') { _pos++; break; } _pos++; } continue; }
+ _pos++;
+ continue;
+ }
+
+ // interpDepth == 0: inside string content
+ if (ch == '{')
+ {
+ if (_pos + 1 < _end && _text[_pos + 1] == '{') { _pos += 2; continue; } // escaped {{
+ interpDepth = 1; _pos++; continue;
+ }
+ if (ch == '}')
+ {
+ if (_pos + 1 < _end && _text[_pos + 1] == '}') { _pos += 2; continue; } // escaped }}
+ // Stray } at depth 0 — shouldn't happen in valid code, just advance
+ _pos++; continue;
+ }
+ if (ch == '"')
+ {
+ if (isVerbatim && _pos + 1 < _end && _text[_pos + 1] == '"') { _pos += 2; continue; } // doubled quote
+ _pos++; return; // closing quote
+ }
+ if (!isVerbatim && ch == '\\') { _pos += 2; continue; } // escape in regular interpolated
+ _pos++;
+ }
+ }
+
+ ///
+ /// Skip the body of an interpolated raw string ($"""...""", $$"""...""", etc.).
+ /// dollarCount determines how many consecutive { start an interpolation hole.
+ /// quoteCount is the number of " that close the string.
+ /// _pos should be right after the opening quotes.
+ ///
+ private void SkipInterpolatedRawStringBody(int dollarCount, int quoteCount)
+ {
+ int interpDepth = 0;
+ while (_pos < _end)
+ {
+ char ch = _text[_pos];
+ if (ch == '\n') _line++;
+
+ if (interpDepth > 0)
+ {
+ // Inside interpolation hole — code context
+ if (ch == '{') { interpDepth++; _pos++; continue; }
+ if (ch == '}') { interpDepth--; _pos++; continue; }
+ if (ch == '"')
+ {
+ _pos++;
+ while (_pos < _end)
+ {
+ if (_text[_pos] == '\\') { _pos += 2; continue; }
+ if (_text[_pos] == '"') { _pos++; break; }
+ if (_text[_pos] == '\n') _line++;
+ _pos++;
+ }
+ continue;
+ }
+ if (ch == '/' && _pos + 1 < _end)
+ {
+ if (_text[_pos + 1] == '/') { _pos += 2; while (_pos < _end && _text[_pos] != '\n') _pos++; continue; }
+ if (_text[_pos + 1] == '*') { _pos += 2; while (_pos + 1 < _end && !(_text[_pos] == '*' && _text[_pos + 1] == '/')) { if (_text[_pos] == '\n') _line++; _pos++; } if (_pos + 1 < _end) _pos += 2; continue; }
+ }
+ if (ch == '\'') { _pos++; while (_pos < _end) { if (_text[_pos] == '\\') { _pos += 2; continue; } if (_text[_pos] == '\'') { _pos++; break; } _pos++; } continue; }
+ _pos++;
+ continue;
+ }
+
+ // String content (interpDepth == 0)
+ // Check for closing quote sequence
+ if (ch == '"')
+ {
+ int qc = 1;
+ while (_pos + qc < _end && _text[_pos + qc] == '"') qc++;
+ if (qc >= quoteCount) { _pos += quoteCount; return; }
+ // Fewer quotes than needed — literal content
+ _pos += qc;
+ continue;
+ }
+
+ // Check for interpolation hole: dollarCount consecutive {'s
+ if (ch == '{')
+ {
+ int bc = 1;
+ while (_pos + bc < _end && _text[_pos + bc] == '{') bc++;
+ if (bc >= dollarCount)
+ {
+ // Exactly dollarCount opens an interpolation hole; extras are literal
+ _pos += dollarCount;
+ interpDepth = 1;
+ }
+ else
+ {
+ // Fewer than dollarCount — literal braces
+ _pos += bc;
+ }
+ continue;
+ }
+
+ // Closing braces with dollarCount threshold — literal if fewer
+ if (ch == '}')
+ {
+ int bc = 1;
+ while (_pos + bc < _end && _text[_pos + bc] == '}') bc++;
+ _pos += bc; // all literal at depth 0
+ continue;
+ }
+
+ _pos++;
+ }
+ }
+ }
+
+ private static bool CheckBalancedDelimiters(string text, out int line, out char expected)
+ {
+ var braceStack = new Stack();
+ var parenStack = new Stack();
+ var bracketStack = new Stack();
+ line = 1; expected = '\0';
+
+ var lexer = new CSharpLexer(text);
+ while (lexer.Advance(out char c))
+ {
+ if (lexer.InNonCode) continue;
switch (c)
{
- case '{': braceStack.Push(line); break;
+ case '{': braceStack.Push(lexer.Line); break;
case '}':
- if (braceStack.Count == 0) { expected = '{'; return false; }
+ if (braceStack.Count == 0) { line = lexer.Line; expected = '{'; return false; }
braceStack.Pop();
break;
- case '(': parenStack.Push(line); break;
+ case '(': parenStack.Push(lexer.Line); break;
case ')':
- if (parenStack.Count == 0) { expected = '('; return false; }
+ if (parenStack.Count == 0) { line = lexer.Line; expected = '('; return false; }
parenStack.Pop();
break;
- case '[': bracketStack.Push(line); break;
+ case '[': bracketStack.Push(lexer.Line); break;
case ']':
- if (bracketStack.Count == 0) { expected = '['; return false; }
+ if (bracketStack.Count == 0) { line = lexer.Line; expected = '['; return false; }
bracketStack.Pop();
break;
}
@@ -938,39 +1203,6 @@ private static bool CheckBalancedDelimiters(string text, out int line, out char
return true;
}
- // Lightweight scoped balance: checks delimiters within a substring, ignoring outer context
- private static bool CheckScopedBalance(string text, int start, int end)
- {
- start = Math.Max(0, Math.Min(text.Length, start));
- end = Math.Max(start, Math.Min(text.Length, end));
- int brace = 0, paren = 0, bracket = 0;
- bool inStr = false, inChr = false, esc = false;
- for (int i = start; i < end; i++)
- {
- char c = text[i];
- char n = (i + 1 < end) ? text[i + 1] : '\0';
- if (inStr)
- {
- if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue;
- }
- if (inChr)
- {
- if (!esc && c == '\'') inChr = false; esc = (!esc && c == '\\'); continue;
- }
- if (c == '"') { inStr = true; esc = false; continue; }
- if (c == '\'') { inChr = true; esc = false; continue; }
- if (c == '/' && n == '/') { while (i < end && text[i] != '\n') i++; continue; }
- if (c == '/' && n == '*') { i += 2; while (i + 1 < end && !(text[i] == '*' && text[i + 1] == '/')) i++; i++; continue; }
- if (c == '{') brace++;
- else if (c == '}') brace--;
- else if (c == '(') paren++;
- else if (c == ')') paren--;
- else if (c == '[') bracket++; else if (c == ']') bracket--;
- // Allow temporary negative balance - will check tolerance at end
- }
- return brace >= -3 && paren >= -3 && bracket >= -3; // tolerate more context from outside region
- }
-
private static object DeleteScript(string fullPath, string relativePath)
{
if (!File.Exists(fullPath))
@@ -1248,8 +1480,10 @@ private static object EditScript(
try
{
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
- var m = rx.Match(working);
- if (!m.Success) return new ErrorResponse($"anchor_insert: anchor not found: {anchor}");
+ var allMatches = rx.Matches(working);
+ if (allMatches.Count == 0) return new ErrorResponse($"anchor_insert: anchor not found: {anchor}");
+ var m = FindBestAnchorMatch(allMatches, working, anchor);
+ if (m == null) return new ErrorResponse($"anchor_insert: anchor not found (filtered): {anchor}");
int insAt = position == "after" ? m.Index + m.Length : m.Index;
string norm = NormalizeNewlines(text);
if (!norm.EndsWith("\n"))
@@ -1291,8 +1525,10 @@ private static object EditScript(
try
{
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
- var m = rx.Match(working);
- if (!m.Success) return new ErrorResponse($"anchor_delete: anchor not found: {anchor}");
+ var allDelMatches = rx.Matches(working);
+ if (allDelMatches.Count == 0) return new ErrorResponse($"anchor_delete: anchor not found: {anchor}");
+ var m = FindBestAnchorMatch(allDelMatches, working, anchor);
+ if (m == null) return new ErrorResponse($"anchor_delete: anchor not found (filtered): {anchor}");
int delAt = m.Index;
int delLen = m.Length;
if (applySequentially)
@@ -1320,8 +1556,10 @@ private static object EditScript(
try
{
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
- var m = rx.Match(working);
- if (!m.Success) return new ErrorResponse($"anchor_replace: anchor not found: {anchor}");
+ var allReplMatches = rx.Matches(working);
+ if (allReplMatches.Count == 0) return new ErrorResponse($"anchor_replace: anchor not found: {anchor}");
+ var m = FindBestAnchorMatch(allReplMatches, working, anchor);
+ if (m == null) return new ErrorResponse($"anchor_replace: anchor not found (filtered): {anchor}");
int at = m.Index;
int len = m.Length;
string norm = NormalizeNewlines(replacement);
@@ -1573,28 +1811,17 @@ private static bool TryComputeClassSpanBalanced(string source, string className,
while (i < source.Length && source[i] != '{') i++;
if (i >= source.Length) { why = "no opening brace after class header"; return false; }
- int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false;
+ int depth = 0;
int startSpan = lineStart;
- for (; i < source.Length; i++)
+ var lexer = new CSharpLexer(source, i);
+ while (lexer.Advance(out char c))
{
- char c = source[i];
- char n = i + 1 < source.Length ? source[i + 1] : '\0';
-
- if (inSL) { if (c == '\n') inSL = false; continue; }
- if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; }
- if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; }
- if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; }
-
- if (c == '/' && n == '/') { inSL = true; i++; continue; }
- if (c == '/' && n == '*') { inML = true; i++; continue; }
- if (c == '"') { inStr = true; continue; }
- if (c == '\'') { inChar = true; continue; }
-
+ if (lexer.InNonCode) continue;
if (c == '{') { depth++; }
else if (c == '}')
{
depth--;
- if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; }
+ if (depth == 0) { start = startSpan; length = (lexer.Position - 1 - startSpan) + 1; return true; }
if (depth < 0) { why = "brace underflow"; return false; }
}
}
@@ -1692,24 +1919,15 @@ private static bool TryComputeMethodSpan(
if (sigOpenParen < 0) { why = "method parameter list '(' not found"; return false; }
int i = sigOpenParen;
- int parenDepth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false;
- for (; i < searchEnd; i++)
+ int parenDepth = 0;
+ var parenLexer = new CSharpLexer(source, i, searchEnd);
+ while (parenLexer.Advance(out char pc))
{
- char c = source[i];
- char n = i + 1 < searchEnd ? source[i + 1] : '\0';
- if (inSL) { if (c == '\n') inSL = false; continue; }
- if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; }
- if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; }
- if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; }
-
- if (c == '/' && n == '/') { inSL = true; i++; continue; }
- if (c == '/' && n == '*') { inML = true; i++; continue; }
- if (c == '"') { inStr = true; continue; }
- if (c == '\'') { inChar = true; continue; }
-
- if (c == '(') parenDepth++;
- if (c == ')') { parenDepth--; if (parenDepth == 0) { i++; break; } }
+ if (parenLexer.InNonCode) continue;
+ if (pc == '(') parenDepth++;
+ if (pc == ')') { parenDepth--; if (parenDepth == 0) break; }
}
+ i = parenLexer.Position;
// After params: detect expression-bodied or block-bodied
// Skip whitespace/comments
@@ -1792,27 +2010,17 @@ private static bool TryComputeMethodSpan(
if (i >= searchEnd || source[i] != '{') { why = "no opening brace after method signature"; return false; }
- int depth = 0; inStr = false; inChar = false; inSL = false; inML = false; esc = false;
+ int depth = 0;
int startSpan = attrStart;
- for (; i < searchEnd; i++)
+ var bodyLexer = new CSharpLexer(source, i, searchEnd);
+ while (bodyLexer.Advance(out char bc))
{
- char c = source[i];
- char n = i + 1 < searchEnd ? source[i + 1] : '\0';
- if (inSL) { if (c == '\n') inSL = false; continue; }
- if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; }
- if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; }
- if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; }
-
- if (c == '/' && n == '/') { inSL = true; i++; continue; }
- if (c == '/' && n == '*') { inML = true; i++; continue; }
- if (c == '"') { inStr = true; continue; }
- if (c == '\'') { inChar = true; continue; }
-
- if (c == '{') depth++;
- else if (c == '}')
+ if (bodyLexer.InNonCode) continue;
+ if (bc == '{') depth++;
+ else if (bc == '}')
{
depth--;
- if (depth == 0) { start = startSpan; length = (i - startSpan) + 1; return true; }
+ if (depth == 0) { start = startSpan; length = (bodyLexer.Position - 1 - startSpan) + 1; return true; }
if (depth < 0) { why = "brace underflow in method"; return false; }
}
}
@@ -1843,26 +2051,16 @@ private static bool TryFindClassInsertionPoint(string source, int classStart, in
// walk to matching closing brace of class and insert just before it
int i = IndexOfTokenWithin(source, "{", searchStart, searchEnd);
if (i < 0) { why = "could not find class opening brace"; return false; }
- int depth = 0; bool inStr = false, inChar = false, inSL = false, inML = false, esc = false;
- for (; i < searchEnd; i++)
+ int depth = 0;
+ var lexer = new CSharpLexer(source, i, searchEnd);
+ while (lexer.Advance(out char c))
{
- char c = source[i];
- char n = i + 1 < searchEnd ? source[i + 1] : '\0';
- if (inSL) { if (c == '\n') inSL = false; continue; }
- if (inML) { if (c == '*' && n == '/') { inML = false; i++; } continue; }
- if (inStr) { if (!esc && c == '"') inStr = false; esc = (!esc && c == '\\'); continue; }
- if (inChar) { if (!esc && c == '\'') inChar = false; esc = (!esc && c == '\\'); continue; }
-
- if (c == '/' && n == '/') { inSL = true; i++; continue; }
- if (c == '/' && n == '*') { inML = true; i++; continue; }
- if (c == '"') { inStr = true; continue; }
- if (c == '\'') { inChar = true; continue; }
-
+ if (lexer.InNonCode) continue;
if (c == '{') depth++;
else if (c == '}')
{
depth--;
- if (depth == 0) { insertAt = i; return true; }
+ if (depth == 0) { insertAt = lexer.Position - 1; return true; }
if (depth < 0) { why = "brace underflow while scanning class"; return false; }
}
}
@@ -1870,11 +2068,105 @@ private static bool TryFindClassInsertionPoint(string source, int classStart, in
}
}
+ ///
+ /// Given multiple regex matches in C# source, pick the best one for
+ /// anchor-based insertions. For closing-brace patterns, uses actual
+ /// brace-depth analysis to prefer the outermost (class-level) brace.
+ /// Otherwise, returns the last match.
+ ///
+ private static Match FindBestAnchorMatch(MatchCollection matches, string text, string pattern)
+ {
+ if (matches.Count == 0) return null;
+ if (matches.Count == 1) return matches[0];
+
+ bool isClosingBracePattern = pattern.Contains("}") &&
+ (pattern.Contains("$") || pattern.EndsWith(@"\s*"));
+
+ if (isClosingBracePattern)
+ {
+ // Compute brace depth at each match's '}' position using CSharpLexer
+ Match best = null;
+ int bestDepth = int.MaxValue;
+ int bestPos = -1;
+
+ // Single-pass: scan text and record depth at every '}' in real code
+ var depthMap = new Dictionary();
+ int depth = 0;
+ var lexer = new CSharpLexer(text);
+ while (lexer.Advance(out char c))
+ {
+ if (lexer.InNonCode) continue;
+ if (c == '{') depth++;
+ else if (c == '}')
+ {
+ depthMap[lexer.Position - 1] = depth;
+ depth = Math.Max(0, depth - 1);
+ }
+ }
+
+ foreach (Match m in matches)
+ {
+ // Find the '}' within the match span
+ int bracePos = -1;
+ for (int k = m.Index; k < m.Index + m.Length && k < text.Length; k++)
+ {
+ if (text[k] == '}') { bracePos = k; break; }
+ }
+ if (bracePos < 0) continue;
+ if (!depthMap.TryGetValue(bracePos, out int d)) continue; // in string/comment
+
+ // Prefer shallowest depth, then latest position
+ if (d < bestDepth || (d == bestDepth && bracePos > bestPos))
+ {
+ bestDepth = d;
+ bestPos = bracePos;
+ best = m;
+ }
+ }
+ return best ?? matches[matches.Count - 1];
+ }
+
+ // Default: prefer last match
+ return matches[matches.Count - 1];
+ }
+
private static int IndexOfClassToken(string s, string className)
{
- // simple token search; could be tightened with Regex for word boundaries
var pattern = "class " + className;
- return s.IndexOf(pattern, StringComparison.Ordinal);
+ int searchFrom = 0;
+ while (searchFrom < s.Length)
+ {
+ int idx = s.IndexOf(pattern, searchFrom, StringComparison.Ordinal);
+ if (idx < 0) return -1;
+
+ // Word boundary on left: char before "class" must not be letter/digit/_
+ if (idx > 0)
+ {
+ char left = s[idx - 1];
+ if (char.IsLetterOrDigit(left) || left == '_') { searchFrom = idx + 1; continue; }
+ }
+
+ // Word boundary on right: char after className must not be letter/digit/_
+ int afterEnd = idx + pattern.Length;
+ if (afterEnd < s.Length)
+ {
+ char right = s[afterEnd];
+ if (char.IsLetterOrDigit(right) || right == '_') { searchFrom = idx + 1; continue; }
+ }
+
+ // Check that this position is not inside a string or comment
+ var lexer = new CSharpLexer(s, 0, idx + 1);
+ bool inNonCode = false;
+ while (lexer.Advance(out _))
+ {
+ inNonCode = lexer.InNonCode;
+ }
+ // After advancing to idx+1, check if the last character processed was in non-code
+ if (inNonCode) { searchFrom = idx + 1; continue; }
+
+ return idx;
+ }
+ return -1;
}
private static bool AppearsWithinNamespaceHeader(string s, int pos, string ns)
@@ -1976,9 +2268,10 @@ private static bool ValidateScriptSyntax(string contents, ValidationLevel level,
return true; // Empty content is valid
}
- // Basic structural validation
- if (!ValidateBasicStructure(contents, errorList))
+ // Basic structural validation: check balanced delimiters
+ if (!CheckBalancedDelimiters(contents, out int errLine, out char errExpected))
{
+ errorList.Add($"ERROR: Unbalanced delimiter at line {errLine} (expected '{errExpected}')");
errors = errorList.ToArray();
return false;
}
@@ -2034,148 +2327,6 @@ private enum ValidationLevel
Strict // Treat all issues as errors
}
- ///
- /// Validates basic code structure (braces, quotes, comments)
- ///
- private static bool ValidateBasicStructure(string contents, System.Collections.Generic.List errors)
- {
- bool isValid = true;
- int braceBalance = 0;
- int parenBalance = 0;
- int bracketBalance = 0;
- bool inStringLiteral = false;
- bool inCharLiteral = false;
- bool inSingleLineComment = false;
- bool inMultiLineComment = false;
- bool escaped = false;
-
- for (int i = 0; i < contents.Length; i++)
- {
- char c = contents[i];
- char next = i + 1 < contents.Length ? contents[i + 1] : '\0';
-
- // Handle escape sequences
- if (escaped)
- {
- escaped = false;
- continue;
- }
-
- if (c == '\\' && (inStringLiteral || inCharLiteral))
- {
- escaped = true;
- continue;
- }
-
- // Handle comments
- if (!inStringLiteral && !inCharLiteral)
- {
- if (c == '/' && next == '/' && !inMultiLineComment)
- {
- inSingleLineComment = true;
- continue;
- }
- if (c == '/' && next == '*' && !inSingleLineComment)
- {
- inMultiLineComment = true;
- i++; // Skip next character
- continue;
- }
- if (c == '*' && next == '/' && inMultiLineComment)
- {
- inMultiLineComment = false;
- i++; // Skip next character
- continue;
- }
- }
-
- if (c == '\n')
- {
- inSingleLineComment = false;
- continue;
- }
-
- if (inSingleLineComment || inMultiLineComment)
- continue;
-
- // Handle string and character literals
- if (c == '"' && !inCharLiteral)
- {
- inStringLiteral = !inStringLiteral;
- continue;
- }
- if (c == '\'' && !inStringLiteral)
- {
- inCharLiteral = !inCharLiteral;
- continue;
- }
-
- if (inStringLiteral || inCharLiteral)
- continue;
-
- // Count brackets and braces
- switch (c)
- {
- case '{': braceBalance++; break;
- case '}': braceBalance--; break;
- case '(': parenBalance++; break;
- case ')': parenBalance--; break;
- case '[': bracketBalance++; break;
- case ']': bracketBalance--; break;
- }
-
- // Check for negative balances (closing without opening)
- if (braceBalance < 0)
- {
- errors.Add("ERROR: Unmatched closing brace '}'");
- isValid = false;
- }
- if (parenBalance < 0)
- {
- errors.Add("ERROR: Unmatched closing parenthesis ')'");
- isValid = false;
- }
- if (bracketBalance < 0)
- {
- errors.Add("ERROR: Unmatched closing bracket ']'");
- isValid = false;
- }
- }
-
- // Check final balances
- if (braceBalance != 0)
- {
- errors.Add($"ERROR: Unbalanced braces (difference: {braceBalance})");
- isValid = false;
- }
- if (parenBalance != 0)
- {
- errors.Add($"ERROR: Unbalanced parentheses (difference: {parenBalance})");
- isValid = false;
- }
- if (bracketBalance != 0)
- {
- errors.Add($"ERROR: Unbalanced brackets (difference: {bracketBalance})");
- isValid = false;
- }
- if (inStringLiteral)
- {
- errors.Add("ERROR: Unterminated string literal");
- isValid = false;
- }
- if (inCharLiteral)
- {
- errors.Add("ERROR: Unterminated character literal");
- isValid = false;
- }
- if (inMultiLineComment)
- {
- errors.Add("WARNING: Unterminated multi-line comment");
- }
-
- return isValid;
- }
-
#if USE_ROSLYN
///
/// Cached compilation references for performance
diff --git a/Server/src/services/tools/script_apply_edits.py b/Server/src/services/tools/script_apply_edits.py
index cc5772e94..86d6b38c8 100644
--- a/Server/src/services/tools/script_apply_edits.py
+++ b/Server/src/services/tools/script_apply_edits.py
@@ -13,6 +13,367 @@
from transport.legacy.unity_connection import async_send_command_with_retry
+def _iter_csharp_tokens(text: str):
+ """Iterate over C# source text yielding (position, char, is_code, interp_depth).
+
+ A single-pass lexer that handles all C# string/comment variants:
+ - Regular strings ("..." with \\ escaping)
+ - Verbatim strings (@"..." with "" escaping)
+ - Interpolated strings ($"..." with {/} depth tracking, {{/}} escapes)
+ - Verbatim interpolated ($@"..." / @$"...")
+ - Raw string literals (C# 11: triple+ quotes)
+ - Char literals ('...')
+ - Single-line comments (//...)
+ - Multi-line comments (/*...*/)
+
+ Yields (position, char, is_code, interp_depth) for every character.
+ is_code is False inside strings/comments, True for real code.
+ interp_depth tracks nesting inside interpolation holes (0 = string content).
+ """
+ i = 0
+ end = len(text)
+ while i < end:
+ c = text[i]
+ nxt = text[i + 1] if i + 1 < end else '\0'
+
+ # Single-line comment
+ if c == '/' and nxt == '/':
+ yield (i, c, False, 0)
+ i += 1
+ while i < end and text[i] != '\n':
+ yield (i, text[i], False, 0)
+ i += 1
+ if i < end:
+ yield (i, text[i], True, 0) # newline itself is code
+ i += 1
+ continue
+
+ # Multi-line comment
+ if c == '/' and nxt == '*':
+ yield (i, c, False, 0)
+ i += 1
+ yield (i, text[i], False, 0)
+ i += 1
+ while i + 1 < end:
+ yield (i, text[i], False, 0)
+ if text[i] == '*' and text[i + 1] == '/':
+ i += 1
+ yield (i, text[i], False, 0)
+ i += 1
+ break
+ i += 1
+ else:
+ if i < end:
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+
+ # Interpolated raw string: $"""...""" or $$"""...""" etc. (C# 11)
+ # Must check BEFORE regular $" and BEFORE plain """
+ if c == '$':
+ dollar_count = 1
+ while i + dollar_count < end and text[i + dollar_count] == '$':
+ dollar_count += 1
+ after_dollars = i + dollar_count
+ if (after_dollars + 2 < end and text[after_dollars] == '"'
+ and text[after_dollars + 1] == '"' and text[after_dollars + 2] == '"'):
+ q = 3
+ while after_dollars + q < end and text[after_dollars + q] == '"':
+ q += 1
+ # Yield all prefix chars ($s and quotes) as non-code
+ for _ in range(dollar_count + q):
+ yield (i, text[i], False, 0)
+ i += 1
+ # Scan body with interpolation tracking
+ interp_depth = 0
+ while i < end:
+ ch = text[i]
+ if interp_depth > 0:
+ # Inside interpolation hole — code
+ if ch == '{':
+ interp_depth += 1
+ yield (i, ch, True, interp_depth)
+ i += 1
+ elif ch == '}':
+ yield (i, ch, True, interp_depth)
+ interp_depth -= 1
+ i += 1
+ elif ch == '"':
+ yield (i, ch, False, interp_depth)
+ i += 1
+ while i < end:
+ yield (i, text[i], False, interp_depth)
+ if text[i] == '\\':
+ i += 1
+ if i < end:
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ continue
+ if text[i] == '"':
+ i += 1
+ break
+ i += 1
+ elif ch == '/' and i + 1 < end and text[i + 1] == '/':
+ yield (i, ch, False, interp_depth)
+ i += 1
+ while i < end and text[i] != '\n':
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ elif ch == '/' and i + 1 < end and text[i + 1] == '*':
+ yield (i, ch, False, interp_depth)
+ i += 1
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ while i + 1 < end and not (text[i] == '*' and text[i + 1] == '/'):
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ if i + 1 < end:
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ else:
+ yield (i, ch, True, interp_depth)
+ i += 1
+ continue
+ # String content (interp_depth == 0)
+ # Check for closing quote sequence
+ if ch == '"':
+ qc = 1
+ while i + qc < end and text[i + qc] == '"':
+ qc += 1
+ if qc >= q:
+ for _ in range(q):
+ yield (i, text[i], False, 0)
+ i += 1
+ break
+ for _ in range(qc):
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ # Check for interpolation hole: dollar_count consecutive {'s
+ if ch == '{':
+ bc = 1
+ while i + bc < end and text[i + bc] == '{':
+ bc += 1
+ if bc >= dollar_count:
+ for _ in range(dollar_count):
+ yield (i, text[i], True, 1)
+ i += 1
+ interp_depth = 1
+ else:
+ for _ in range(bc):
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ # Closing braces — literal at depth 0
+ if ch == '}':
+ bc = 1
+ while i + bc < end and text[i + bc] == '}':
+ bc += 1
+ for _ in range(bc):
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ yield (i, ch, False, 0)
+ i += 1
+ continue
+
+ # Raw string literal: """ ... """ (non-interpolated)
+ if c == '"' and nxt == '"' and i + 2 < end and text[i + 2] == '"':
+ q = 3
+ while i + q < end and text[i + q] == '"':
+ q += 1
+ for _ in range(q):
+ yield (i, text[i], False, 0)
+ i += 1
+ close_count = 0
+ while i < end:
+ yield (i, text[i], False, 0)
+ if text[i] == '"':
+ close_count += 1
+ if close_count >= q:
+ i += 1
+ break
+ else:
+ close_count = 0
+ i += 1
+ continue
+
+ # Interpolated string: $"..." or $@"..." or @$"..."
+ if (c == '$' and nxt == '"') or \
+ (c == '$' and nxt == '@' and i + 2 < end and text[i + 2] == '"') or \
+ (c == '@' and nxt == '$' and i + 2 < end and text[i + 2] == '"'):
+ is_verbatim = (nxt == '@') or (c == '@')
+ prefix_len = 2 if (c == '$' and nxt == '"') else 3
+ for _ in range(prefix_len):
+ yield (i, text[i], False, 0)
+ i += 1
+ interp_depth = 0
+ while i < end:
+ ch = text[i]
+ if interp_depth > 0:
+ # Inside interpolation hole — this is code
+ if ch == '{':
+ interp_depth += 1
+ yield (i, ch, True, interp_depth)
+ i += 1
+ elif ch == '}':
+ yield (i, ch, True, interp_depth)
+ interp_depth -= 1
+ i += 1
+ elif ch == '"':
+ # Nested string inside interpolation hole
+ yield (i, ch, False, interp_depth)
+ i += 1
+ while i < end:
+ yield (i, text[i], False, interp_depth)
+ if text[i] == '\\':
+ i += 1
+ if i < end:
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ continue
+ if text[i] == '"':
+ i += 1
+ break
+ i += 1
+ elif ch == '/' and i + 1 < end and text[i + 1] == '/':
+ yield (i, ch, False, interp_depth)
+ i += 1
+ while i < end and text[i] != '\n':
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ elif ch == '/' and i + 1 < end and text[i + 1] == '*':
+ yield (i, ch, False, interp_depth)
+ i += 1
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ while i + 1 < end and not (text[i] == '*' and text[i + 1] == '/'):
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ if i + 1 < end:
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ yield (i, text[i], False, interp_depth)
+ i += 1
+ else:
+ yield (i, ch, True, interp_depth)
+ i += 1
+ continue
+ # interp_depth == 0: inside string content
+ if ch == '{':
+ if i + 1 < end and text[i + 1] == '{':
+ yield (i, ch, False, 0)
+ i += 1
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ interp_depth = 1
+ yield (i, ch, True, interp_depth)
+ i += 1
+ continue
+ if ch == '}':
+ if i + 1 < end and text[i + 1] == '}':
+ yield (i, ch, False, 0)
+ i += 1
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ yield (i, ch, False, 0)
+ i += 1
+ continue
+ if ch == '"':
+ if is_verbatim and i + 1 < end and text[i + 1] == '"':
+ yield (i, ch, False, 0)
+ i += 1
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ yield (i, ch, False, 0)
+ i += 1
+ break
+ if not is_verbatim and ch == '\\':
+ yield (i, ch, False, 0)
+ i += 1
+ if i < end:
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ yield (i, ch, False, 0)
+ i += 1
+ continue
+
+ # Verbatim string: @"..."
+ if c == '@' and nxt == '"':
+ yield (i, c, False, 0)
+ i += 1
+ yield (i, text[i], False, 0)
+ i += 1
+ while i < end:
+ yield (i, text[i], False, 0)
+ if text[i] == '"':
+ if i + 1 < end and text[i + 1] == '"':
+ i += 1
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ i += 1
+ break
+ i += 1
+ continue
+
+ # Regular string: "..."
+ if c == '"':
+ yield (i, c, False, 0)
+ i += 1
+ while i < end:
+ yield (i, text[i], False, 0)
+ if text[i] == '\\':
+ i += 1
+ if i < end:
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ if text[i] == '"':
+ i += 1
+ break
+ i += 1
+ continue
+
+ # Char literal: '...'
+ if c == '\'':
+ yield (i, c, False, 0)
+ i += 1
+ while i < end:
+ yield (i, text[i], False, 0)
+ if text[i] == '\\':
+ i += 1
+ if i < end:
+ yield (i, text[i], False, 0)
+ i += 1
+ continue
+ if text[i] == '\'':
+ i += 1
+ break
+ i += 1
+ continue
+
+ # Real code character
+ yield (i, c, True, 0)
+ i += 1
+
+
+def _is_in_string_context(text: str, position: int) -> bool:
+ """Check if a position in C# source text is inside a string literal or comment."""
+ for pos, _, is_code, _ in _iter_csharp_tokens(text):
+ if pos == position:
+ return not is_code
+ if pos > position:
+ break
+ return False
+
+
async def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str:
text = original_text
for edit in edits or []:
@@ -137,16 +498,36 @@ def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bo
return matches[-1] if prefer_last else matches[0]
+def _brace_depth_at_positions(text: str, positions: set[int]) -> dict[int, int]:
+ """Compute the brace depth just before each requested position.
+
+ For every ``}`` in real code at a position in *positions*, stores the
+ depth **before** that ``}`` is applied (i.e. the depth it decrements from).
+
+ Returns a dict mapping position -> depth-before.
+ """
+ depths: dict[int, int] = {}
+ depth = 0
+ for pos, c, is_code, _ in _iter_csharp_tokens(text):
+ if not is_code:
+ continue
+ if c == '{':
+ depth += 1
+ elif c == '}':
+ if pos in positions:
+ depths[pos] = depth
+ depth = max(0, depth - 1)
+ return depths
+
+
def _find_best_closing_brace_match(matches, text: str):
"""
- Find the best closing brace match using C# structure heuristics.
+ Find the best closing brace match using brace-depth analysis.
- Enhanced heuristics for scope-aware matching:
- 1. Prefer matches with lower indentation (likely class-level)
- 2. Prefer matches closer to end of file
- 3. Avoid matches that seem to be inside method bodies
- 4. For #endregion patterns, ensure class-level context
- 5. Validate insertion point is at appropriate scope
+ Scans the text once to compute the actual brace nesting depth at each
+ candidate ``}`` position (skipping strings/comments). Prefers the
+ shallowest (outermost) brace — typically the class-closing brace.
+ Among equal-depth candidates, prefers the last one (closest to EOF).
Args:
matches: List of regex match objects
@@ -158,51 +539,30 @@ def _find_best_closing_brace_match(matches, text: str):
if not matches:
return None
- scored_matches = []
- lines = text.splitlines()
-
- for match in matches:
- score = 0
- start_pos = match.start()
-
- # Find which line this match is on
- lines_before = text[:start_pos].count('\n')
- line_num = lines_before
-
- if line_num < len(lines):
- line_content = lines[line_num]
-
- # Calculate indentation level (lower is better for class braces)
- indentation = len(line_content) - len(line_content.lstrip())
-
- # Prefer lower indentation (class braces are typically less indented than method braces)
- # Max 20 points for indentation=0
- score += max(0, 20 - indentation)
-
- # Prefer matches closer to end of file (class closing braces are typically at the end)
- distance_from_end = len(lines) - line_num
- # More points for being closer to end
- score += max(0, 10 - distance_from_end)
-
- # Look at surrounding context to avoid method braces
- context_start = max(0, line_num - 3)
- context_end = min(len(lines), line_num + 2)
- context_lines = lines[context_start:context_end]
-
- # Penalize if this looks like it's inside a method (has method-like patterns above)
- for context_line in context_lines:
- if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line):
- score -= 5 # Penalty for being near method signatures
-
- # Bonus if this looks like a class-ending brace (very minimal indentation and near EOF)
- if indentation <= 4 and distance_from_end <= 3:
- score += 15 # Bonus for likely class-ending brace
+ # Find the position of the '}' character within each match, filtering out
+ # braces inside strings/comments
+ brace_positions: dict[int, object] = {} # brace_pos → match
+ for m in matches:
+ for offset in range(m.start(), m.end()):
+ if offset < len(text) and text[offset] == '}':
+ if not _is_in_string_context(text, offset):
+ brace_positions[offset] = m
+ break
- scored_matches.append((score, match))
+ if not brace_positions:
+ return None
- # Return the match with the highest score
- scored_matches.sort(key=lambda x: x[0], reverse=True)
- best_match = scored_matches[0][1]
+ depths = _brace_depth_at_positions(text, set(brace_positions.keys()))
+
+ # Score: prefer shallowest depth (outermost brace), then latest position
+ best_match = None
+ best_key = (float('inf'), -1) # (depth, -position) — lower is better
+ for pos, m in brace_positions.items():
+ d = depths.get(pos, float('inf'))
+ key = (d, -pos) # lower depth wins, then later position wins
+ if key < best_key:
+ best_key = key
+ best_match = m
return best_match
diff --git a/Server/tests/integration/test_script_apply_edits_local.py b/Server/tests/integration/test_script_apply_edits_local.py
new file mode 100644
index 000000000..91dff3239
--- /dev/null
+++ b/Server/tests/integration/test_script_apply_edits_local.py
@@ -0,0 +1,291 @@
+"""Tests for script_apply_edits.py local helper functions.
+
+Focuses on _apply_edits_locally, _find_best_closing_brace_match,
+and _is_in_string_context — especially around C# string variants
+(verbatim, interpolated, raw) that can fool brace/anchor matching.
+"""
+import re
+import pytest
+
+from services.tools.script_apply_edits import (
+ _apply_edits_locally,
+ _find_best_closing_brace_match,
+ _find_best_anchor_match,
+ _is_in_string_context,
+)
+
+
+# ── _is_in_string_context ────────────────────────────────────────────
+
+class TestIsInStringContext:
+ def test_plain_code_not_in_string(self):
+ text = 'int x = 42;'
+ assert not _is_in_string_context(text, 4)
+
+ def test_inside_regular_string(self):
+ text = 'string s = "hello world";'
+ # Position inside "hello world"
+ pos = text.index("hello")
+ assert _is_in_string_context(text, pos)
+
+ def test_inside_verbatim_string(self):
+ text = 'string s = @"C:\\Users\\file";'
+ pos = text.index("C:")
+ assert _is_in_string_context(text, pos)
+
+ def test_inside_interpolated_string(self):
+ text = 'string s = $"Value: {x}";'
+ # The "Value" part is inside the string
+ pos = text.index("Value")
+ assert _is_in_string_context(text, pos)
+
+ def test_interpolation_hole_is_not_string(self):
+ text = 'string s = $"Value: {x}";'
+ # The x inside {x} is in an interpolation hole — it's code, not string
+ brace_pos = text.index("{x}") + 1 # the 'x'
+ assert not _is_in_string_context(text, brace_pos)
+
+ def test_inside_single_line_comment(self):
+ text = 'int x = 1; // this is a comment'
+ pos = text.index("this")
+ assert _is_in_string_context(text, pos)
+
+ def test_inside_multi_line_comment(self):
+ text = 'int x = 1; /* block { } */ int y = 2;'
+ pos = text.index("block")
+ assert _is_in_string_context(text, pos)
+
+ def test_after_comment_is_code(self):
+ text = '// comment\nint x = 1;'
+ pos = text.index("int")
+ assert not _is_in_string_context(text, pos)
+
+ def test_verbatim_string_doubled_quotes(self):
+ text = 'string s = @"He said ""hello""";'
+ # The whole thing is one string ending at the final ";
+ pos = text.index("hello")
+ assert _is_in_string_context(text, pos)
+
+ def test_interpolated_verbatim_combined(self):
+ text = 'string s = $@"Path: {dir}\\file";'
+ # "Path" is inside the string
+ pos = text.index("Path")
+ assert _is_in_string_context(text, pos)
+
+ def test_raw_string_literal(self):
+ text = 'string s = """\n{ }\n""";'
+ pos = text.index("{ }")
+ assert _is_in_string_context(text, pos)
+
+ def test_interpolated_raw_string_content(self):
+ text = 'string s = $"""\n Hello {name}\n """;'
+ # "Hello" is string content (non-code)
+ pos = text.index("Hello")
+ assert _is_in_string_context(text, pos)
+
+ def test_interpolated_raw_string_hole_is_code(self):
+ text = 'string s = $"""\n Hello {name}\n """;'
+ # "name" inside {name} is in an interpolation hole — code
+ pos = text.index("name")
+ assert not _is_in_string_context(text, pos)
+
+ def test_multi_dollar_raw_string_content(self):
+ text = 'string s = $$"""\n {literal} {{interp}}\n """;'
+ # {literal} has only 1 brace — it's literal string content
+ pos = text.index("literal")
+ assert _is_in_string_context(text, pos)
+
+ def test_multi_dollar_raw_string_hole_is_code(self):
+ text = 'string s = $$"""\n {literal} {{interp}}\n """;'
+ # {{interp}} has 2 braces matching $$ — it's an interpolation hole
+ pos = text.index("interp")
+ assert not _is_in_string_context(text, pos)
+
+ def test_interpolated_raw_string_closing(self):
+ text = 'string s = $"""\n body\n """; int x = 1;'
+ # "x" after the closing """ is code
+ pos = text.index("x = 1")
+ assert not _is_in_string_context(text, pos)
+
+
+# ── _find_best_closing_brace_match ───────────────────────────────────
+
+class TestFindBestClosingBraceMatch:
+ def test_skips_braces_in_interpolated_strings(self):
+ """Braces inside $"...{x}..." should not be scored as class-end."""
+ code = (
+ 'public class Foo {\n'
+ ' void M() {\n'
+ ' string s = $"Score: {score}";\n'
+ ' }\n'
+ '}\n'
+ )
+ pattern = r'^\s*}\s*$'
+ matches = list(re.finditer(pattern, code, re.MULTILINE))
+ # There should be matches for the method close and class close
+ assert len(matches) >= 1
+ best = _find_best_closing_brace_match(matches, code)
+ # The best match should be the class-closing brace, not one inside a string
+ assert best is not None
+ line_num = code[:best.start()].count('\n')
+ # Class close is the last "}" line
+ assert line_num == 4 # 0-indexed, line 5 is "}"
+
+ def test_skips_braces_in_verbatim_strings(self):
+ """@"{ }" should not confuse the scorer."""
+ code = (
+ 'public class Foo {\n'
+ ' string s = @"{ }";\n'
+ '}\n'
+ )
+ pattern = r'^\s*}\s*$'
+ matches = list(re.finditer(pattern, code, re.MULTILINE))
+ best = _find_best_closing_brace_match(matches, code)
+ assert best is not None
+
+ def test_prefers_class_brace_over_method_brace(self):
+ """Should pick class-closing } (depth 1) over method-closing } (depth 2)."""
+ code = (
+ 'public class Foo : MonoBehaviour\n'
+ '{\n'
+ ' private int score = 42;\n'
+ '\n'
+ ' void Start()\n'
+ ' {\n'
+ ' Debug.Log($"Score: {score}");\n'
+ ' }\n'
+ '\n'
+ ' void OnGUI()\n'
+ ' {\n'
+ ' GUI.Label(new Rect(10, 10, 200, 20), $"Score: {score}");\n'
+ ' }\n'
+ '}\n'
+ )
+ pattern = r'^\s*}\s*$'
+ matches = list(re.finditer(pattern, code, re.MULTILINE))
+ # Should have 3 matches: Start close, OnGUI close, class close
+ assert len(matches) == 3
+ best = _find_best_closing_brace_match(matches, code)
+ assert best is not None
+ best_line = code[:best.start()].count('\n')
+ # Class close is the last "}" — line 13 (0-indexed)
+ assert best_line == 13
+
+ def test_skips_braces_in_interpolated_raw_strings(self):
+ """$\"\"\"{x}\"\"\" braces should not confuse the scorer."""
+ code = (
+ 'public class Foo {\n'
+ ' string s = $"""\n'
+ ' { literal }\n'
+ ' {interp}\n'
+ ' """;\n'
+ '}\n'
+ )
+ pattern = r'^\s*}\s*$'
+ matches = list(re.finditer(pattern, code, re.MULTILINE))
+ best = _find_best_closing_brace_match(matches, code)
+ assert best is not None
+ best_line = code[:best.start()].count('\n')
+ assert best_line == 5 # class-closing brace
+
+ def test_closing_brace_scorer_with_interpolated_code(self):
+ """Realistic C# with multiple $"" strings should still find class-end."""
+ code = (
+ 'using UnityEngine;\n'
+ 'public class HUD : MonoBehaviour {\n'
+ ' void OnGUI() {\n'
+ ' Debug.Log($"Score: {score}");\n'
+ ' Debug.Log($@"Path: {path}\\save");\n'
+ ' }\n'
+ '}\n'
+ )
+ pattern = r'^\s*}\s*$'
+ matches = list(re.finditer(pattern, code, re.MULTILINE))
+ best = _find_best_closing_brace_match(matches, code)
+ assert best is not None
+ # Should pick the class-closing brace (last one)
+ best_line = code[:best.start()].count('\n')
+ assert best_line == 6 # 0-indexed
+
+
+# ── _apply_edits_locally regression guards ───────────────────────────
+
+class TestApplyEditsLocally:
+ @pytest.mark.asyncio
+ async def test_replace_range_basic(self):
+ original = "line1\nline2\nline3\n"
+ edits = [{
+ "op": "replace_range",
+ "startLine": 2,
+ "startCol": 1,
+ "endLine": 2,
+ "endCol": 6,
+ "text": "REPLACED",
+ }]
+ result = await _apply_edits_locally(original, edits)
+ assert "REPLACED" in result
+ assert "line1" in result
+ assert "line3" in result
+
+ @pytest.mark.asyncio
+ async def test_prepend_and_append(self):
+ original = "middle\n"
+ edits = [
+ {"op": "prepend", "text": "top\n"},
+ {"op": "append", "text": "bottom\n"},
+ ]
+ result = await _apply_edits_locally(original, edits)
+ assert result.startswith("top\n")
+ assert "bottom" in result
+
+ @pytest.mark.asyncio
+ async def test_regex_replace_near_interpolated_strings(self):
+ """regex_replace should work even when interpolated strings are in the code."""
+ original = (
+ 'void M() {\n'
+ ' Debug.Log($"x={x}");\n'
+ ' int OLD = 1;\n'
+ '}\n'
+ )
+ edits = [{
+ "op": "regex_replace",
+ "pattern": r"OLD",
+ "replacement": "NEW",
+ "text": "NEW",
+ }]
+ result = await _apply_edits_locally(original, edits)
+ assert "NEW" in result
+ assert "OLD" not in result
+
+
+# ── _find_best_anchor_match with string-aware filtering ──────────────
+
+class TestAnchorMatchFiltering:
+ def test_anchor_skips_braces_in_interpolated_strings(self):
+ """$"...{x}..." brace should not be picked as anchor match."""
+ code = (
+ 'class Foo {\n'
+ ' string s = $"val: {x}";\n'
+ ' void M() { }\n'
+ '}\n'
+ )
+ # Pattern looking for closing brace at end of line
+ pattern = r'^\s*}\s*$'
+ flags = re.MULTILINE
+ match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
+ assert match is not None
+ # Should match the class-closing brace, not anything inside the string
+ best_line = code[:match.start()].count('\n')
+ assert best_line == 3 # 0-indexed, class close
+
+ def test_anchor_skips_braces_in_verbatim_strings(self):
+ """@"{ }" should not confuse anchor matching."""
+ code = (
+ 'class Foo {\n'
+ ' string s = @"{ }";\n'
+ '}\n'
+ )
+ pattern = r'^\s*}\s*$'
+ flags = re.MULTILINE
+ match = _find_best_anchor_match(pattern, code, flags, prefer_last=True)
+ assert match is not None
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs
new file mode 100644
index 000000000..5dd592f76
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs
@@ -0,0 +1,230 @@
+using System;
+using System.Reflection;
+using NUnit.Framework;
+using UnityEngine;
+using MCPForUnity.Editor.Tools;
+
+namespace MCPForUnityTests.Editor.Tools
+{
+ ///
+ /// Tests for ManageScript delimiter-checking and token-finding logic,
+ /// specifically covering C# string variants that the old lexer missed:
+ /// verbatim strings, interpolated strings, raw string literals.
+ ///
+ public class ManageScriptDelimiterTests
+ {
+ // ── CheckBalancedDelimiters ──────────────────────────────────────
+
+ [Test]
+ public void CheckBalancedDelimiters_VerbatimString_WithBackslashes()
+ {
+ // @"C:\Users\file" — backslashes are NOT escape chars in verbatim strings
+ string code = "class C { string s = @\"C:\\Users\\file\"; }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Verbatim string with backslashes should not break delimiter balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_VerbatimString_DoubledQuotes()
+ {
+ // @"He said ""hello""" — doubled quotes are the escape in verbatim strings
+ string code = "class C { string s = @\"He said \"\"hello\"\"\"; }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Verbatim string with doubled quotes should not break delimiter balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_InterpolatedString_WithBraces()
+ {
+ // $"Value: {x}" — the { } are interpolation holes, not real braces
+ string code = "class C { void M() { int x = 1; string s = $\"Value: {x}\"; } }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Interpolated string braces should not be counted as delimiters");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_InterpolatedVerbatim_Combined()
+ {
+ // $@"Path: {dir}\file" — interpolated + verbatim combined
+ string code = "class C { void M() { string dir = \"d\"; string s = $@\"Path: {dir}\\file\"; } }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Interpolated verbatim string should not break delimiter balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_NestedInterpolation()
+ {
+ // $"Outer {$"Inner {x}"}" — nested interpolated strings
+ string code = "class C { void M() { int x = 1; string s = $\"Outer {$\"Inner {x}\"}\"; } }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Nested interpolated strings should not break delimiter balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_RawStringLiteral()
+ {
+ // C# 11 raw string literal: """{ }"""
+ string code = "class C { string s = \"\"\"\n{ }\n\"\"\"; }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Raw string literal braces should not be counted as delimiters");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_MultilineVerbatimString()
+ {
+ // Verbatim string spanning multiple lines with braces
+ string code = "class C { string s = @\"line1\n{ }\"; }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Multiline verbatim string with braces should not break balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_InterpolatedEscapedBraces()
+ {
+ // $"literal {{braces}}" — escaped braces in interpolated string
+ string code = "class C { string s = $\"literal {{braces}}\"; }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Escaped braces in interpolated strings should not break balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_InterpolatedRawString()
+ {
+ // $"""...{expr}...""" — interpolated raw string literal (C# 11)
+ string code = "class C { void M() { int x = 1; string s = $\"\"\"\n Hello {x}\n \"\"\"; } }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Interpolated raw string should not break delimiter balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_MultiDollarRawString()
+ {
+ // $$"""...{{expr}}...""" — multi-dollar interpolated raw string
+ string code = "class C { void M() { int x = 1; string s = $$\"\"\"\n {literal} {{x}}\n \"\"\"; } }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Multi-dollar raw string should not break delimiter balance");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_BracesInComments_Ignored()
+ {
+ string code = "class C {\n// {\n/* { */\nvoid M() { }\n}";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Braces in comments should be ignored");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_BracesInRegularStrings_Ignored()
+ {
+ string code = "class C { string s = \"{ }\"; }";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Braces in regular strings should be ignored");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_ActuallyUnbalanced_ReturnsFalse()
+ {
+ string code = "class C { void M() { }";
+ Assert.IsFalse(CallCheckBalancedDelimiters(code, out _, out _),
+ "Actually unbalanced code should return false");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_ExtraClosingBrace_ReturnsFalse()
+ {
+ string code = "class C { } }";
+ Assert.IsFalse(CallCheckBalancedDelimiters(code, out _, out _),
+ "Extra closing brace should return false");
+ }
+
+ [Test]
+ public void CheckBalancedDelimiters_RealWorldUnityScript()
+ {
+ string code = @"using UnityEngine;
+
+public class PlayerHUD : MonoBehaviour
+{
+ private int score;
+ private string playerName;
+
+ void Start()
+ {
+ score = 0;
+ playerName = ""Player"";
+ }
+
+ void OnGUI()
+ {
+ string label = $""Score: {score}"";
+ string path = @""C:\Games\SaveData"";
+ string msg = $@""Player {playerName} at path {path}"";
+ Debug.Log($""HUD initialized for {playerName} with score {score}"");
+ Debug.Log(""Literal {{braces}}"");
+ }
+}";
+ Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
+ "Real-world Unity script with interpolated/verbatim strings should pass");
+ }
+
+ // ── IndexOfClassToken ────────────────────────────────────────────
+
+ [Test]
+ public void IndexOfClassToken_FindsClass_NormalCode()
+ {
+ string code = "public class Foo { }";
+ int idx = CallIndexOfClassToken(code, "Foo");
+ Assert.GreaterOrEqual(idx, 0, "Should find class Foo in normal code");
+ }
+
+ [Test]
+ public void IndexOfClassToken_SkipsClassInComment()
+ {
+ string code = "// class Foo\npublic class Real { }";
+ int idx = CallIndexOfClassToken(code, "Foo");
+ Assert.AreEqual(-1, idx, "Should not find 'class Foo' inside a comment");
+ }
+
+ [Test]
+ public void IndexOfClassToken_SkipsClassInString()
+ {
+ string code = "class Real { string s = \"class Foo { }\"; }";
+ int idx = CallIndexOfClassToken(code, "Foo");
+ Assert.AreEqual(-1, idx, "Should not find 'class Foo' inside a string literal");
+ }
+
+ [Test]
+ public void IndexOfClassToken_FindsSecondClass_WhenFirstInComment()
+ {
+ string code = "// class Fake\npublic class Real { }";
+ int idx = CallIndexOfClassToken(code, "Real");
+ Assert.GreaterOrEqual(idx, 0, "Should find class Real even when a commented class precedes it");
+ }
+
+ // ── Reflection helpers ───────────────────────────────────────────
+
+ private static bool CallCheckBalancedDelimiters(string text, out int line, out char expected)
+ {
+ line = 0;
+ expected = '\0';
+
+ var method = typeof(ManageScript).GetMethod("CheckBalancedDelimiters",
+ BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.IsNotNull(method, "CheckBalancedDelimiters method should exist");
+
+ var parameters = new object[] { text, 0, '\0' };
+ var result = (bool)method.Invoke(null, parameters);
+ line = (int)parameters[1];
+ expected = (char)parameters[2];
+ return result;
+ }
+
+ private static int CallIndexOfClassToken(string source, string className)
+ {
+ var method = typeof(ManageScript).GetMethod("IndexOfClassToken",
+ BindingFlags.NonPublic | BindingFlags.Static);
+ Assert.IsNotNull(method, "IndexOfClassToken method should exist");
+
+ return (int)method.Invoke(null, new object[] { source, className });
+ }
+ }
+}
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs.meta b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs.meta
new file mode 100644
index 000000000..41985b7fb
--- /dev/null
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs.meta
@@ -0,0 +1,11 @@
+fileFormatVersion: 2
+guid: 5cbe58d767a3d4ddf995332459d66830
+MonoImporter:
+ externalObjects: {}
+ serializedVersion: 2
+ defaultReferences: []
+ executionOrder: 0
+ icon: {instanceID: 0}
+ userData:
+ assetBundleName:
+ assetBundleVariant:
diff --git a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs
index 37f2f2687..dcd034df9 100644
--- a/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs
+++ b/TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptValidationTests.cs
@@ -61,27 +61,6 @@ public void CheckBalancedDelimiters_StringWithBraces_ReturnsTrue()
Assert.IsTrue(result, "Code with braces in strings should pass balance check");
}
- [Test]
- public void CheckScopedBalance_ValidCode_ReturnsTrue()
- {
- string validCode = "{ Debug.Log(\"test\"); }";
-
- bool result = CallCheckScopedBalance(validCode, 0, validCode.Length);
- Assert.IsTrue(result, "Valid scoped code should pass balance check");
- }
-
- [Test]
- public void CheckScopedBalance_ShouldTolerateOuterContext_ReturnsTrue()
- {
- // This simulates a snippet extracted from a larger context
- string contextSnippet = " Debug.Log(\"inside method\");\n} // This closing brace is from outer context";
-
- bool result = CallCheckScopedBalance(contextSnippet, 0, contextSnippet.Length);
-
- // Scoped balance should tolerate some imbalance from outer context
- Assert.IsTrue(result, "Scoped balance should tolerate outer context imbalance");
- }
-
[Test]
public void TicTacToe3D_ValidationScenario_DoesNotCrash()
{
@@ -90,10 +69,8 @@ public void TicTacToe3D_ValidationScenario_DoesNotCrash()
// Test that the validation methods don't crash on this code
bool balanceResult = CallCheckBalancedDelimiters(ticTacToeCode, out int line, out char expected);
- bool scopedResult = CallCheckScopedBalance(ticTacToeCode, 0, ticTacToeCode.Length);
Assert.IsTrue(balanceResult, "TicTacToe3D code should pass balance validation");
- Assert.IsTrue(scopedResult, "TicTacToe3D code should pass scoped balance validation");
}
// Helper methods to access private ManageScript methods via reflection
@@ -125,26 +102,6 @@ private bool CallCheckBalancedDelimiters(string contents, out int line, out char
return BasicBalanceCheck(contents);
}
- private bool CallCheckScopedBalance(string text, int start, int end)
- {
- try
- {
- var method = typeof(ManageScript).GetMethod("CheckScopedBalance",
- BindingFlags.NonPublic | BindingFlags.Static);
-
- if (method != null)
- {
- return (bool)method.Invoke(null, new object[] { text, start, end });
- }
- }
- catch (Exception ex)
- {
- Debug.LogWarning($"Could not test CheckScopedBalance directly: {ex.Message}");
- }
-
- return true; // Default to passing if we can't test the actual method
- }
-
private bool BasicBalanceCheck(string contents)
{
// Simple fallback balance check