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