Skip to content

Commit 5b60318

Browse files
dsarnoclaude
andcommitted
fix: handle interpolated raw strings and make anchor ops use best-match
1. Add """...""" and $"""...""" (C# 11 interpolated raw string) support to both C# CSharpLexer and Python _iter_csharp_tokens. Dollar count determines interpolation threshold (N dollars = N consecutive braces to open an interpolation hole). 2. Make anchor_delete and anchor_replace use FindBestAnchorMatch (same as anchor_insert) instead of rx.Match for consistent target selection when multiple anchors match. Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
1 parent 48af93f commit 5b60318

File tree

4 files changed

+288
-7
lines changed

4 files changed

+288
-7
lines changed

MCPForUnity/Editor/Tools/ManageScript.cs

Lines changed: 111 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -922,13 +922,29 @@ public bool Advance(out char c)
922922
if (c == '/' && next == '/') { _inSingleComment = true; InNonCode = true; _pos += 2; return true; }
923923
if (c == '/' && next == '*') { _inMultiComment = true; InNonCode = true; _pos += 2; return true; }
924924

925-
// Raw string literal: """...""" (C# 11)
925+
// Interpolated raw string: $"""...""" or $$"""...""" etc. (C# 11)
926+
// Must check BEFORE regular $" and BEFORE plain """
927+
if (c == '$')
928+
{
929+
int dollarCount = 1;
930+
while (_pos + dollarCount < _end && _text[_pos + dollarCount] == '$') dollarCount++;
931+
int afterDollars = _pos + dollarCount;
932+
if (afterDollars + 2 < _end && _text[afterDollars] == '"' && _text[afterDollars + 1] == '"' && _text[afterDollars + 2] == '"')
933+
{
934+
int q = 3;
935+
while (afterDollars + q < _end && _text[afterDollars + q] == '"') q++;
936+
_pos = afterDollars + q; // past all opening quotes
937+
SkipInterpolatedRawStringBody(dollarCount, q);
938+
InNonCode = true; return true;
939+
}
940+
}
941+
942+
// Raw string literal: """...""" (C# 11, non-interpolated)
926943
if (c == '"' && next == '"' && _pos + 2 < _end && _text[_pos + 2] == '"')
927944
{
928945
int q = 3;
929946
while (_pos + q < _end && _text[_pos + q] == '"') q++;
930947
_pos += q; // past opening quotes
931-
// Find matching closing sequence of q quotes
932948
int closeCount = 0;
933949
while (_pos < _end)
934950
{
@@ -1061,6 +1077,91 @@ private void SkipInterpolatedStringBody(bool isVerbatim)
10611077
_pos++;
10621078
}
10631079
}
1080+
1081+
/// <summary>
1082+
/// Skip the body of an interpolated raw string ($"""...""", $$"""...""", etc.).
1083+
/// dollarCount determines how many consecutive { start an interpolation hole.
1084+
/// quoteCount is the number of " that close the string.
1085+
/// _pos should be right after the opening quotes.
1086+
/// </summary>
1087+
private void SkipInterpolatedRawStringBody(int dollarCount, int quoteCount)
1088+
{
1089+
int interpDepth = 0;
1090+
while (_pos < _end)
1091+
{
1092+
char ch = _text[_pos];
1093+
if (ch == '\n') _line++;
1094+
1095+
if (interpDepth > 0)
1096+
{
1097+
// Inside interpolation hole — code context
1098+
if (ch == '{') { interpDepth++; _pos++; continue; }
1099+
if (ch == '}') { interpDepth--; _pos++; continue; }
1100+
if (ch == '"')
1101+
{
1102+
_pos++;
1103+
while (_pos < _end)
1104+
{
1105+
if (_text[_pos] == '\\') { _pos += 2; continue; }
1106+
if (_text[_pos] == '"') { _pos++; break; }
1107+
if (_text[_pos] == '\n') _line++;
1108+
_pos++;
1109+
}
1110+
continue;
1111+
}
1112+
if (ch == '/' && _pos + 1 < _end)
1113+
{
1114+
if (_text[_pos + 1] == '/') { _pos += 2; while (_pos < _end && _text[_pos] != '\n') _pos++; continue; }
1115+
if (_text[_pos + 1] == '*') { _pos += 2; while (_pos + 1 < _end && !(_text[_pos] == '*' && _text[_pos + 1] == '/')) { if (_text[_pos] == '\n') _line++; _pos++; } _pos += 2; continue; }
1116+
}
1117+
if (ch == '\'') { _pos++; while (_pos < _end) { if (_text[_pos] == '\\') { _pos += 2; continue; } if (_text[_pos] == '\'') { _pos++; break; } _pos++; } continue; }
1118+
_pos++;
1119+
continue;
1120+
}
1121+
1122+
// String content (interpDepth == 0)
1123+
// Check for closing quote sequence
1124+
if (ch == '"')
1125+
{
1126+
int qc = 1;
1127+
while (_pos + qc < _end && _text[_pos + qc] == '"') qc++;
1128+
if (qc >= quoteCount) { _pos += quoteCount; return; }
1129+
// Fewer quotes than needed — literal content
1130+
_pos += qc;
1131+
continue;
1132+
}
1133+
1134+
// Check for interpolation hole: dollarCount consecutive {'s
1135+
if (ch == '{')
1136+
{
1137+
int bc = 1;
1138+
while (_pos + bc < _end && _text[_pos + bc] == '{') bc++;
1139+
if (bc >= dollarCount)
1140+
{
1141+
// Exactly dollarCount opens an interpolation hole; extras are literal
1142+
_pos += dollarCount;
1143+
interpDepth = 1;
1144+
}
1145+
else
1146+
{
1147+
// Fewer than dollarCount — literal braces
1148+
_pos += bc;
1149+
}
1150+
continue;
1151+
}
1152+
1153+
// Closing braces with dollarCount threshold — literal if fewer
1154+
if (ch == '}')
1155+
{
1156+
int bc = 1;
1157+
while (_pos + bc < _end && _text[_pos + bc] == '}') bc++;
1158+
_pos += bc; // all literal at depth 0
1159+
continue;
1160+
}
1161+
1162+
_pos++;
1163+
}
1164+
}
10641165
}
10651166

10661167
private static bool CheckBalancedDelimiters(string text, out int line, out char expected)
@@ -1424,8 +1525,10 @@ private static object EditScript(
14241525
try
14251526
{
14261527
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
1427-
var m = rx.Match(working);
1428-
if (!m.Success) return new ErrorResponse($"anchor_delete: anchor not found: {anchor}");
1528+
var allDelMatches = rx.Matches(working);
1529+
if (allDelMatches.Count == 0) return new ErrorResponse($"anchor_delete: anchor not found: {anchor}");
1530+
var m = FindBestAnchorMatch(allDelMatches, working, anchor);
1531+
if (m == null) return new ErrorResponse($"anchor_delete: anchor not found (filtered): {anchor}");
14291532
int delAt = m.Index;
14301533
int delLen = m.Length;
14311534
if (applySequentially)
@@ -1453,8 +1556,10 @@ private static object EditScript(
14531556
try
14541557
{
14551558
var rx = new Regex(anchor, RegexOptions.Multiline, TimeSpan.FromSeconds(2));
1456-
var m = rx.Match(working);
1457-
if (!m.Success) return new ErrorResponse($"anchor_replace: anchor not found: {anchor}");
1559+
var allReplMatches = rx.Matches(working);
1560+
if (allReplMatches.Count == 0) return new ErrorResponse($"anchor_replace: anchor not found: {anchor}");
1561+
var m = FindBestAnchorMatch(allReplMatches, working, anchor);
1562+
if (m == null) return new ErrorResponse($"anchor_replace: anchor not found (filtered): {anchor}");
14581563
int at = m.Index;
14591564
int len = m.Length;
14601565
string norm = NormalizeNewlines(replacement);

Server/src/services/tools/script_apply_edits.py

Lines changed: 112 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -68,7 +68,118 @@ def _iter_csharp_tokens(text: str):
6868
i += 1
6969
continue
7070

71-
# Raw string literal: """ ... """
71+
# Interpolated raw string: $"""...""" or $$"""...""" etc. (C# 11)
72+
# Must check BEFORE regular $" and BEFORE plain """
73+
if c == '$':
74+
dollar_count = 1
75+
while i + dollar_count < end and text[i + dollar_count] == '$':
76+
dollar_count += 1
77+
after_dollars = i + dollar_count
78+
if (after_dollars + 2 < end and text[after_dollars] == '"'
79+
and text[after_dollars + 1] == '"' and text[after_dollars + 2] == '"'):
80+
q = 3
81+
while after_dollars + q < end and text[after_dollars + q] == '"':
82+
q += 1
83+
# Yield all prefix chars ($s and quotes) as non-code
84+
for _ in range(dollar_count + q):
85+
yield (i, text[i], False, 0)
86+
i += 1
87+
# Scan body with interpolation tracking
88+
interp_depth = 0
89+
while i < end:
90+
ch = text[i]
91+
if interp_depth > 0:
92+
# Inside interpolation hole — code
93+
if ch == '{':
94+
interp_depth += 1
95+
yield (i, ch, True, interp_depth)
96+
i += 1
97+
elif ch == '}':
98+
yield (i, ch, True, interp_depth)
99+
interp_depth -= 1
100+
i += 1
101+
elif ch == '"':
102+
yield (i, ch, False, interp_depth)
103+
i += 1
104+
while i < end:
105+
yield (i, text[i], False, interp_depth)
106+
if text[i] == '\\':
107+
i += 1
108+
if i < end:
109+
yield (i, text[i], False, interp_depth)
110+
i += 1
111+
continue
112+
if text[i] == '"':
113+
i += 1
114+
break
115+
i += 1
116+
elif ch == '/' and i + 1 < end and text[i + 1] == '/':
117+
yield (i, ch, False, interp_depth)
118+
i += 1
119+
while i < end and text[i] != '\n':
120+
yield (i, text[i], False, interp_depth)
121+
i += 1
122+
elif ch == '/' and i + 1 < end and text[i + 1] == '*':
123+
yield (i, ch, False, interp_depth)
124+
i += 1
125+
yield (i, text[i], False, interp_depth)
126+
i += 1
127+
while i + 1 < end and not (text[i] == '*' and text[i + 1] == '/'):
128+
yield (i, text[i], False, interp_depth)
129+
i += 1
130+
if i + 1 < end:
131+
yield (i, text[i], False, interp_depth)
132+
i += 1
133+
yield (i, text[i], False, interp_depth)
134+
i += 1
135+
else:
136+
yield (i, ch, True, interp_depth)
137+
i += 1
138+
continue
139+
# String content (interp_depth == 0)
140+
# Check for closing quote sequence
141+
if ch == '"':
142+
qc = 1
143+
while i + qc < end and text[i + qc] == '"':
144+
qc += 1
145+
if qc >= q:
146+
for _ in range(q):
147+
yield (i, text[i], False, 0)
148+
i += 1
149+
break
150+
for _ in range(qc):
151+
yield (i, text[i], False, 0)
152+
i += 1
153+
continue
154+
# Check for interpolation hole: dollar_count consecutive {'s
155+
if ch == '{':
156+
bc = 1
157+
while i + bc < end and text[i + bc] == '{':
158+
bc += 1
159+
if bc >= dollar_count:
160+
for _ in range(dollar_count):
161+
yield (i, text[i], True, 1)
162+
i += 1
163+
interp_depth = 1
164+
else:
165+
for _ in range(bc):
166+
yield (i, text[i], False, 0)
167+
i += 1
168+
continue
169+
# Closing braces — literal at depth 0
170+
if ch == '}':
171+
bc = 1
172+
while i + bc < end and text[i + bc] == '}':
173+
bc += 1
174+
for _ in range(bc):
175+
yield (i, text[i], False, 0)
176+
i += 1
177+
continue
178+
yield (i, ch, False, 0)
179+
i += 1
180+
continue
181+
182+
# Raw string literal: """ ... """ (non-interpolated)
72183
if c == '"' and nxt == '"' and i + 2 < end and text[i + 2] == '"':
73184
q = 3
74185
while i + q < end and text[i + q] == '"':

Server/tests/integration/test_script_apply_edits_local.py

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,36 @@ def test_raw_string_literal(self):
7777
pos = text.index("{ }")
7878
assert _is_in_string_context(text, pos)
7979

80+
def test_interpolated_raw_string_content(self):
81+
text = 'string s = $"""\n Hello {name}\n """;'
82+
# "Hello" is string content (non-code)
83+
pos = text.index("Hello")
84+
assert _is_in_string_context(text, pos)
85+
86+
def test_interpolated_raw_string_hole_is_code(self):
87+
text = 'string s = $"""\n Hello {name}\n """;'
88+
# "name" inside {name} is in an interpolation hole — code
89+
pos = text.index("name")
90+
assert not _is_in_string_context(text, pos)
91+
92+
def test_multi_dollar_raw_string_content(self):
93+
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
94+
# {literal} has only 1 brace — it's literal string content
95+
pos = text.index("literal")
96+
assert _is_in_string_context(text, pos)
97+
98+
def test_multi_dollar_raw_string_hole_is_code(self):
99+
text = 'string s = $$"""\n {literal} {{interp}}\n """;'
100+
# {{interp}} has 2 braces matching $$ — it's an interpolation hole
101+
pos = text.index("interp")
102+
assert not _is_in_string_context(text, pos)
103+
104+
def test_interpolated_raw_string_closing(self):
105+
text = 'string s = $"""\n body\n """; int x = 1;'
106+
# "x" after the closing """ is code
107+
pos = text.index("x = 1")
108+
assert not _is_in_string_context(text, pos)
109+
80110

81111
# ── _find_best_closing_brace_match ───────────────────────────────────
82112

@@ -141,6 +171,23 @@ def test_prefers_class_brace_over_method_brace(self):
141171
# Class close is the last "}" — line 13 (0-indexed)
142172
assert best_line == 13
143173

174+
def test_skips_braces_in_interpolated_raw_strings(self):
175+
"""$\"\"\"{x}\"\"\" braces should not confuse the scorer."""
176+
code = (
177+
'public class Foo {\n'
178+
' string s = $"""\n'
179+
' { literal }\n'
180+
' {interp}\n'
181+
' """;\n'
182+
'}\n'
183+
)
184+
pattern = r'^\s*}\s*$'
185+
matches = list(re.finditer(pattern, code, re.MULTILINE))
186+
best = _find_best_closing_brace_match(matches, code)
187+
assert best is not None
188+
best_line = code[:best.start()].count('\n')
189+
assert best_line == 5 # class-closing brace
190+
144191
def test_closing_brace_scorer_with_interpolated_code(self):
145192
"""Realistic C# with multiple $"" strings should still find class-end."""
146193
code = (

TestProjects/UnityMCPTests/Assets/Tests/EditMode/Tools/ManageScriptDelimiterTests.cs

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -87,6 +87,24 @@ public void CheckBalancedDelimiters_InterpolatedEscapedBraces()
8787
"Escaped braces in interpolated strings should not break balance");
8888
}
8989

90+
[Test]
91+
public void CheckBalancedDelimiters_InterpolatedRawString()
92+
{
93+
// $"""...{expr}...""" — interpolated raw string literal (C# 11)
94+
string code = "class C { void M() { int x = 1; string s = $\"\"\"\n Hello {x}\n \"\"\"; } }";
95+
Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
96+
"Interpolated raw string should not break delimiter balance");
97+
}
98+
99+
[Test]
100+
public void CheckBalancedDelimiters_MultiDollarRawString()
101+
{
102+
// $$"""...{{expr}}...""" — multi-dollar interpolated raw string
103+
string code = "class C { void M() { int x = 1; string s = $$\"\"\"\n {literal} {{x}}\n \"\"\"; } }";
104+
Assert.IsTrue(CallCheckBalancedDelimiters(code, out _, out _),
105+
"Multi-dollar raw string should not break delimiter balance");
106+
}
107+
90108
[Test]
91109
public void CheckBalancedDelimiters_BracesInComments_Ignored()
92110
{

0 commit comments

Comments
 (0)