mcpforunityserver 8.2.3__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- __init__.py +0 -0
- core/__init__.py +0 -0
- core/config.py +56 -0
- core/logging_decorator.py +37 -0
- core/telemetry.py +533 -0
- core/telemetry_decorator.py +164 -0
- main.py +411 -0
- mcpforunityserver-8.2.3.dist-info/METADATA +222 -0
- mcpforunityserver-8.2.3.dist-info/RECORD +65 -0
- mcpforunityserver-8.2.3.dist-info/WHEEL +5 -0
- mcpforunityserver-8.2.3.dist-info/entry_points.txt +2 -0
- mcpforunityserver-8.2.3.dist-info/licenses/LICENSE +21 -0
- mcpforunityserver-8.2.3.dist-info/top_level.txt +8 -0
- models/__init__.py +4 -0
- models/models.py +56 -0
- models/unity_response.py +47 -0
- routes/__init__.py +0 -0
- services/__init__.py +0 -0
- services/custom_tool_service.py +339 -0
- services/registry/__init__.py +22 -0
- services/registry/resource_registry.py +53 -0
- services/registry/tool_registry.py +51 -0
- services/resources/__init__.py +81 -0
- services/resources/active_tool.py +47 -0
- services/resources/custom_tools.py +57 -0
- services/resources/editor_state.py +42 -0
- services/resources/layers.py +29 -0
- services/resources/menu_items.py +34 -0
- services/resources/prefab_stage.py +39 -0
- services/resources/project_info.py +39 -0
- services/resources/selection.py +55 -0
- services/resources/tags.py +30 -0
- services/resources/tests.py +55 -0
- services/resources/unity_instances.py +122 -0
- services/resources/windows.py +47 -0
- services/tools/__init__.py +76 -0
- services/tools/batch_execute.py +78 -0
- services/tools/debug_request_context.py +71 -0
- services/tools/execute_custom_tool.py +38 -0
- services/tools/execute_menu_item.py +29 -0
- services/tools/find_in_file.py +174 -0
- services/tools/manage_asset.py +129 -0
- services/tools/manage_editor.py +63 -0
- services/tools/manage_gameobject.py +240 -0
- services/tools/manage_material.py +95 -0
- services/tools/manage_prefabs.py +62 -0
- services/tools/manage_scene.py +75 -0
- services/tools/manage_script.py +602 -0
- services/tools/manage_shader.py +64 -0
- services/tools/read_console.py +115 -0
- services/tools/run_tests.py +108 -0
- services/tools/script_apply_edits.py +998 -0
- services/tools/set_active_instance.py +112 -0
- services/tools/utils.py +60 -0
- transport/__init__.py +0 -0
- transport/legacy/port_discovery.py +329 -0
- transport/legacy/stdio_port_registry.py +65 -0
- transport/legacy/unity_connection.py +785 -0
- transport/models.py +62 -0
- transport/plugin_hub.py +412 -0
- transport/plugin_registry.py +123 -0
- transport/unity_instance_middleware.py +141 -0
- transport/unity_transport.py +103 -0
- utils/module_discovery.py +55 -0
- utils/reload_sentinel.py +9 -0
|
@@ -0,0 +1,998 @@
|
|
|
1
|
+
import base64
|
|
2
|
+
import hashlib
|
|
3
|
+
import re
|
|
4
|
+
from typing import Annotated, Any, Union
|
|
5
|
+
|
|
6
|
+
from fastmcp import Context
|
|
7
|
+
|
|
8
|
+
from services.registry import mcp_for_unity_tool
|
|
9
|
+
from services.tools import get_unity_instance_from_context
|
|
10
|
+
from services.tools.utils import parse_json_payload
|
|
11
|
+
from transport.unity_transport import send_with_unity_instance
|
|
12
|
+
from transport.legacy.unity_connection import async_send_command_with_retry
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
async def _apply_edits_locally(original_text: str, edits: list[dict[str, Any]]) -> str:
|
|
16
|
+
text = original_text
|
|
17
|
+
for edit in edits or []:
|
|
18
|
+
op = (
|
|
19
|
+
(edit.get("op")
|
|
20
|
+
or edit.get("operation")
|
|
21
|
+
or edit.get("type")
|
|
22
|
+
or edit.get("mode")
|
|
23
|
+
or "")
|
|
24
|
+
.strip()
|
|
25
|
+
.lower()
|
|
26
|
+
)
|
|
27
|
+
|
|
28
|
+
if not op:
|
|
29
|
+
allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
|
|
30
|
+
raise RuntimeError(
|
|
31
|
+
f"op is required; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation)."
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
if op == "prepend":
|
|
35
|
+
prepend_text = edit.get("text", "")
|
|
36
|
+
text = (prepend_text if prepend_text.endswith(
|
|
37
|
+
"\n") else prepend_text + "\n") + text
|
|
38
|
+
elif op == "append":
|
|
39
|
+
append_text = edit.get("text", "")
|
|
40
|
+
if not text.endswith("\n"):
|
|
41
|
+
text += "\n"
|
|
42
|
+
text += append_text
|
|
43
|
+
if not text.endswith("\n"):
|
|
44
|
+
text += "\n"
|
|
45
|
+
elif op == "anchor_insert":
|
|
46
|
+
anchor = edit.get("anchor", "")
|
|
47
|
+
position = (edit.get("position") or "before").lower()
|
|
48
|
+
insert_text = edit.get("text", "")
|
|
49
|
+
flags = re.MULTILINE | (
|
|
50
|
+
re.IGNORECASE if edit.get("ignore_case") else 0)
|
|
51
|
+
|
|
52
|
+
# Find the best match using improved heuristics
|
|
53
|
+
match = _find_best_anchor_match(
|
|
54
|
+
anchor, text, flags, bool(edit.get("prefer_last", True)))
|
|
55
|
+
if not match:
|
|
56
|
+
if edit.get("allow_noop", True):
|
|
57
|
+
continue
|
|
58
|
+
raise RuntimeError(f"anchor not found: {anchor}")
|
|
59
|
+
idx = match.start() if position == "before" else match.end()
|
|
60
|
+
text = text[:idx] + insert_text + text[idx:]
|
|
61
|
+
elif op == "replace_range":
|
|
62
|
+
start_line = int(edit.get("startLine", 1))
|
|
63
|
+
start_col = int(edit.get("startCol", 1))
|
|
64
|
+
end_line = int(edit.get("endLine", start_line))
|
|
65
|
+
end_col = int(edit.get("endCol", 1))
|
|
66
|
+
replacement = edit.get("text", "")
|
|
67
|
+
lines = text.splitlines(keepends=True)
|
|
68
|
+
max_line = len(lines) + 1 # 1-based, exclusive end
|
|
69
|
+
if (start_line < 1 or end_line < start_line or end_line > max_line
|
|
70
|
+
or start_col < 1 or end_col < 1):
|
|
71
|
+
raise RuntimeError("replace_range out of bounds")
|
|
72
|
+
|
|
73
|
+
def index_of(line: int, col: int) -> int:
|
|
74
|
+
if line <= len(lines):
|
|
75
|
+
return sum(len(l) for l in lines[: line - 1]) + (col - 1)
|
|
76
|
+
return sum(len(l) for l in lines)
|
|
77
|
+
a = index_of(start_line, start_col)
|
|
78
|
+
b = index_of(end_line, end_col)
|
|
79
|
+
text = text[:a] + replacement + text[b:]
|
|
80
|
+
elif op == "regex_replace":
|
|
81
|
+
pattern = edit.get("pattern", "")
|
|
82
|
+
repl = edit.get("replacement", "")
|
|
83
|
+
# Translate $n backrefs (our input) to Python \g<n>
|
|
84
|
+
repl_py = re.sub(r"\$(\d+)", r"\\g<\1>", repl)
|
|
85
|
+
count = int(edit.get("count", 0)) # 0 = replace all
|
|
86
|
+
flags = re.MULTILINE
|
|
87
|
+
if edit.get("ignore_case"):
|
|
88
|
+
flags |= re.IGNORECASE
|
|
89
|
+
text = re.sub(pattern, repl_py, text, count=count, flags=flags)
|
|
90
|
+
else:
|
|
91
|
+
allowed = "anchor_insert, prepend, append, replace_range, regex_replace"
|
|
92
|
+
raise RuntimeError(
|
|
93
|
+
f"unknown edit op: {op}; allowed: {allowed}. Use 'op' (aliases accepted: type/mode/operation).")
|
|
94
|
+
return text
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
def _find_best_anchor_match(pattern: str, text: str, flags: int, prefer_last: bool = True):
|
|
98
|
+
"""
|
|
99
|
+
Find the best anchor match using improved heuristics.
|
|
100
|
+
|
|
101
|
+
For patterns like \\s*}\\s*$ that are meant to find class-ending braces,
|
|
102
|
+
this function uses heuristics to choose the most semantically appropriate match:
|
|
103
|
+
|
|
104
|
+
1. If prefer_last=True, prefer the last match (common for class-end insertions)
|
|
105
|
+
2. Use indentation levels to distinguish class vs method braces
|
|
106
|
+
3. Consider context to avoid matches inside strings/comments
|
|
107
|
+
|
|
108
|
+
Args:
|
|
109
|
+
pattern: Regex pattern to search for
|
|
110
|
+
text: Text to search in
|
|
111
|
+
flags: Regex flags
|
|
112
|
+
prefer_last: If True, prefer the last match over the first
|
|
113
|
+
|
|
114
|
+
Returns:
|
|
115
|
+
Match object of the best match, or None if no match found
|
|
116
|
+
"""
|
|
117
|
+
|
|
118
|
+
# Find all matches
|
|
119
|
+
matches = list(re.finditer(pattern, text, flags))
|
|
120
|
+
if not matches:
|
|
121
|
+
return None
|
|
122
|
+
|
|
123
|
+
# If only one match, return it
|
|
124
|
+
if len(matches) == 1:
|
|
125
|
+
return matches[0]
|
|
126
|
+
|
|
127
|
+
# For patterns that look like they're trying to match closing braces at end of lines
|
|
128
|
+
is_closing_brace_pattern = '}' in pattern and (
|
|
129
|
+
'$' in pattern or pattern.endswith(r'\s*'))
|
|
130
|
+
|
|
131
|
+
if is_closing_brace_pattern and prefer_last:
|
|
132
|
+
# Use heuristics to find the best closing brace match
|
|
133
|
+
return _find_best_closing_brace_match(matches, text)
|
|
134
|
+
|
|
135
|
+
# Default behavior: use last match if prefer_last, otherwise first match
|
|
136
|
+
return matches[-1] if prefer_last else matches[0]
|
|
137
|
+
|
|
138
|
+
|
|
139
|
+
def _find_best_closing_brace_match(matches, text: str):
|
|
140
|
+
"""
|
|
141
|
+
Find the best closing brace match using C# structure heuristics.
|
|
142
|
+
|
|
143
|
+
Enhanced heuristics for scope-aware matching:
|
|
144
|
+
1. Prefer matches with lower indentation (likely class-level)
|
|
145
|
+
2. Prefer matches closer to end of file
|
|
146
|
+
3. Avoid matches that seem to be inside method bodies
|
|
147
|
+
4. For #endregion patterns, ensure class-level context
|
|
148
|
+
5. Validate insertion point is at appropriate scope
|
|
149
|
+
|
|
150
|
+
Args:
|
|
151
|
+
matches: List of regex match objects
|
|
152
|
+
text: The full text being searched
|
|
153
|
+
|
|
154
|
+
Returns:
|
|
155
|
+
The best match object
|
|
156
|
+
"""
|
|
157
|
+
if not matches:
|
|
158
|
+
return None
|
|
159
|
+
|
|
160
|
+
scored_matches = []
|
|
161
|
+
lines = text.splitlines()
|
|
162
|
+
|
|
163
|
+
for match in matches:
|
|
164
|
+
score = 0
|
|
165
|
+
start_pos = match.start()
|
|
166
|
+
|
|
167
|
+
# Find which line this match is on
|
|
168
|
+
lines_before = text[:start_pos].count('\n')
|
|
169
|
+
line_num = lines_before
|
|
170
|
+
|
|
171
|
+
if line_num < len(lines):
|
|
172
|
+
line_content = lines[line_num]
|
|
173
|
+
|
|
174
|
+
# Calculate indentation level (lower is better for class braces)
|
|
175
|
+
indentation = len(line_content) - len(line_content.lstrip())
|
|
176
|
+
|
|
177
|
+
# Prefer lower indentation (class braces are typically less indented than method braces)
|
|
178
|
+
# Max 20 points for indentation=0
|
|
179
|
+
score += max(0, 20 - indentation)
|
|
180
|
+
|
|
181
|
+
# Prefer matches closer to end of file (class closing braces are typically at the end)
|
|
182
|
+
distance_from_end = len(lines) - line_num
|
|
183
|
+
# More points for being closer to end
|
|
184
|
+
score += max(0, 10 - distance_from_end)
|
|
185
|
+
|
|
186
|
+
# Look at surrounding context to avoid method braces
|
|
187
|
+
context_start = max(0, line_num - 3)
|
|
188
|
+
context_end = min(len(lines), line_num + 2)
|
|
189
|
+
context_lines = lines[context_start:context_end]
|
|
190
|
+
|
|
191
|
+
# Penalize if this looks like it's inside a method (has method-like patterns above)
|
|
192
|
+
for context_line in context_lines:
|
|
193
|
+
if re.search(r'\b(void|public|private|protected)\s+\w+\s*\(', context_line):
|
|
194
|
+
score -= 5 # Penalty for being near method signatures
|
|
195
|
+
|
|
196
|
+
# Bonus if this looks like a class-ending brace (very minimal indentation and near EOF)
|
|
197
|
+
if indentation <= 4 and distance_from_end <= 3:
|
|
198
|
+
score += 15 # Bonus for likely class-ending brace
|
|
199
|
+
|
|
200
|
+
scored_matches.append((score, match))
|
|
201
|
+
|
|
202
|
+
# Return the match with the highest score
|
|
203
|
+
scored_matches.sort(key=lambda x: x[0], reverse=True)
|
|
204
|
+
best_match = scored_matches[0][1]
|
|
205
|
+
|
|
206
|
+
return best_match
|
|
207
|
+
|
|
208
|
+
|
|
209
|
+
def _infer_class_name(script_name: str) -> str:
|
|
210
|
+
# Default to script name as class name (common Unity pattern)
|
|
211
|
+
return (script_name or "").strip()
|
|
212
|
+
|
|
213
|
+
|
|
214
|
+
def _extract_code_after(keyword: str, request: str) -> str:
|
|
215
|
+
# Deprecated with NL removal; retained as no-op for compatibility
|
|
216
|
+
idx = request.lower().find(keyword)
|
|
217
|
+
if idx >= 0:
|
|
218
|
+
return request[idx + len(keyword):].strip()
|
|
219
|
+
return ""
|
|
220
|
+
# Removed _is_structurally_balanced - validation now handled by C# side using Unity's compiler services
|
|
221
|
+
|
|
222
|
+
|
|
223
|
+
def _normalize_script_locator(name: str, path: str) -> tuple[str, str]:
|
|
224
|
+
"""Best-effort normalization of script "name" and "path".
|
|
225
|
+
|
|
226
|
+
Accepts any of:
|
|
227
|
+
- name = "SmartReach", path = "Assets/Scripts/Interaction"
|
|
228
|
+
- name = "SmartReach.cs", path = "Assets/Scripts/Interaction"
|
|
229
|
+
- name = "Assets/Scripts/Interaction/SmartReach.cs", path = ""
|
|
230
|
+
- path = "Assets/Scripts/Interaction/SmartReach.cs" (name empty)
|
|
231
|
+
- name or path using uri prefixes: unity://path/..., file://...
|
|
232
|
+
- accidental duplicates like "Assets/.../SmartReach.cs/SmartReach.cs"
|
|
233
|
+
|
|
234
|
+
Returns (name_without_extension, directory_path_under_Assets).
|
|
235
|
+
"""
|
|
236
|
+
n = (name or "").strip()
|
|
237
|
+
p = (path or "").strip()
|
|
238
|
+
|
|
239
|
+
def strip_prefix(s: str) -> str:
|
|
240
|
+
if s.startswith("unity://path/"):
|
|
241
|
+
return s[len("unity://path/"):]
|
|
242
|
+
if s.startswith("file://"):
|
|
243
|
+
return s[len("file://"):]
|
|
244
|
+
return s
|
|
245
|
+
|
|
246
|
+
def collapse_duplicate_tail(s: str) -> str:
|
|
247
|
+
# Collapse trailing "/X.cs/X.cs" to "/X.cs"
|
|
248
|
+
parts = s.split("/")
|
|
249
|
+
if len(parts) >= 2 and parts[-1] == parts[-2]:
|
|
250
|
+
parts = parts[:-1]
|
|
251
|
+
return "/".join(parts)
|
|
252
|
+
|
|
253
|
+
# Prefer a full path if provided in either field
|
|
254
|
+
candidate = ""
|
|
255
|
+
for v in (n, p):
|
|
256
|
+
v2 = strip_prefix(v)
|
|
257
|
+
if v2.endswith(".cs") or v2.startswith("Assets/"):
|
|
258
|
+
candidate = v2
|
|
259
|
+
break
|
|
260
|
+
|
|
261
|
+
if candidate:
|
|
262
|
+
candidate = collapse_duplicate_tail(candidate)
|
|
263
|
+
# If a directory was passed in path and file in name, join them
|
|
264
|
+
if not candidate.endswith(".cs") and n.endswith(".cs"):
|
|
265
|
+
v2 = strip_prefix(n)
|
|
266
|
+
candidate = (candidate.rstrip("/") + "/" + v2.split("/")[-1])
|
|
267
|
+
if candidate.endswith(".cs"):
|
|
268
|
+
parts = candidate.split("/")
|
|
269
|
+
file_name = parts[-1]
|
|
270
|
+
dir_path = "/".join(parts[:-1]) if len(parts) > 1 else "Assets"
|
|
271
|
+
base = file_name[:-
|
|
272
|
+
3] if file_name.lower().endswith(".cs") else file_name
|
|
273
|
+
return base, dir_path
|
|
274
|
+
|
|
275
|
+
# Fall back: remove extension from name if present and return given path
|
|
276
|
+
base_name = n[:-3] if n.lower().endswith(".cs") else n
|
|
277
|
+
return base_name, (p or "Assets")
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
def _with_norm(resp: dict[str, Any] | Any, edits: list[dict[str, Any]], routing: str | None = None) -> dict[str, Any] | Any:
|
|
281
|
+
if not isinstance(resp, dict):
|
|
282
|
+
return resp
|
|
283
|
+
data = resp.setdefault("data", {})
|
|
284
|
+
data.setdefault("normalizedEdits", edits)
|
|
285
|
+
if routing:
|
|
286
|
+
data["routing"] = routing
|
|
287
|
+
return resp
|
|
288
|
+
|
|
289
|
+
|
|
290
|
+
def _err(code: str, message: str, *, expected: dict[str, Any] | None = None, rewrite: dict[str, Any] | None = None,
|
|
291
|
+
normalized: list[dict[str, Any]] | None = None, routing: str | None = None, extra: dict[str, Any] | None = None) -> dict[str, Any]:
|
|
292
|
+
payload: dict[str, Any] = {"success": False,
|
|
293
|
+
"code": code, "message": message}
|
|
294
|
+
data: dict[str, Any] = {}
|
|
295
|
+
if expected:
|
|
296
|
+
data["expected"] = expected
|
|
297
|
+
if rewrite:
|
|
298
|
+
data["rewrite_suggestion"] = rewrite
|
|
299
|
+
if normalized is not None:
|
|
300
|
+
data["normalizedEdits"] = normalized
|
|
301
|
+
if routing:
|
|
302
|
+
data["routing"] = routing
|
|
303
|
+
if extra:
|
|
304
|
+
data.update(extra)
|
|
305
|
+
if data:
|
|
306
|
+
payload["data"] = data
|
|
307
|
+
return payload
|
|
308
|
+
|
|
309
|
+
# Natural-language parsing removed; clients should send structured edits.
|
|
310
|
+
|
|
311
|
+
|
|
312
|
+
@mcp_for_unity_tool(name="script_apply_edits", description=(
|
|
313
|
+
"""Structured C# edits (methods/classes) with safer boundaries - prefer this over raw text.
|
|
314
|
+
Best practices:
|
|
315
|
+
- Prefer anchor_* ops for pattern-based insert/replace near stable markers
|
|
316
|
+
- Use replace_method/delete_method for whole-method changes (keeps signatures balanced)
|
|
317
|
+
- Avoid whole-file regex deletes; validators will guard unbalanced braces
|
|
318
|
+
- For tail insertions, prefer anchor/regex_replace on final brace (class closing)
|
|
319
|
+
- Pass options.validate='standard' for structural checks; 'relaxed' for interior-only edits
|
|
320
|
+
Canonical fields (use these exact keys):
|
|
321
|
+
- op: replace_method | insert_method | delete_method | anchor_insert | anchor_delete | anchor_replace
|
|
322
|
+
- className: string (defaults to 'name' if omitted on method/class ops)
|
|
323
|
+
- methodName: string (required for replace_method, delete_method)
|
|
324
|
+
- replacement: string (required for replace_method, insert_method)
|
|
325
|
+
- position: start | end | after | before (insert_method only)
|
|
326
|
+
- afterMethodName / beforeMethodName: string (required when position='after'/'before')
|
|
327
|
+
- anchor: regex string (for anchor_* ops)
|
|
328
|
+
- text: string (for anchor_insert/anchor_replace)
|
|
329
|
+
Examples:
|
|
330
|
+
1) Replace a method:
|
|
331
|
+
{
|
|
332
|
+
"name": "SmartReach",
|
|
333
|
+
"path": "Assets/Scripts/Interaction",
|
|
334
|
+
"edits": [
|
|
335
|
+
{
|
|
336
|
+
"op": "replace_method",
|
|
337
|
+
"className": "SmartReach",
|
|
338
|
+
"methodName": "HasTarget",
|
|
339
|
+
"replacement": "public bool HasTarget(){ return currentTarget!=null; }"
|
|
340
|
+
}
|
|
341
|
+
],
|
|
342
|
+
"options": {"validate": "standard", "refresh": "immediate"}
|
|
343
|
+
}
|
|
344
|
+
"2) Insert a method after another:
|
|
345
|
+
{
|
|
346
|
+
"name": "SmartReach",
|
|
347
|
+
"path": "Assets/Scripts/Interaction",
|
|
348
|
+
"edits": [
|
|
349
|
+
{
|
|
350
|
+
"op": "insert_method",
|
|
351
|
+
"className": "SmartReach",
|
|
352
|
+
"replacement": "public void PrintSeries(){ Debug.Log(seriesName); }",
|
|
353
|
+
"position": "after",
|
|
354
|
+
"afterMethodName": "GetCurrentTarget"
|
|
355
|
+
}
|
|
356
|
+
],
|
|
357
|
+
}
|
|
358
|
+
]"""
|
|
359
|
+
))
|
|
360
|
+
async def script_apply_edits(
|
|
361
|
+
ctx: Context,
|
|
362
|
+
name: Annotated[str, "Name of the script to edit"],
|
|
363
|
+
path: Annotated[str, "Path to the script to edit under Assets/ directory"],
|
|
364
|
+
edits: Annotated[Union[list[dict[str, Any]], str], "List of edits to apply to the script (JSON list or stringified JSON)"],
|
|
365
|
+
options: Annotated[dict[str, Any],
|
|
366
|
+
"Options for the script edit"] | None = None,
|
|
367
|
+
script_type: Annotated[str,
|
|
368
|
+
"Type of the script to edit"] = "MonoBehaviour",
|
|
369
|
+
namespace: Annotated[str,
|
|
370
|
+
"Namespace of the script to edit"] | None = None,
|
|
371
|
+
) -> dict[str, Any]:
|
|
372
|
+
unity_instance = get_unity_instance_from_context(ctx)
|
|
373
|
+
await ctx.info(
|
|
374
|
+
f"Processing script_apply_edits: {name} (unity_instance={unity_instance or 'default'})")
|
|
375
|
+
|
|
376
|
+
# Parse edits if they came as a stringified JSON
|
|
377
|
+
edits = parse_json_payload(edits)
|
|
378
|
+
if not isinstance(edits, list):
|
|
379
|
+
return {"success": False, "message": f"Edits must be a list or JSON string of a list, got {type(edits)}"}
|
|
380
|
+
|
|
381
|
+
# Normalize locator first so downstream calls target the correct script file.
|
|
382
|
+
name, path = _normalize_script_locator(name, path)
|
|
383
|
+
# Normalize unsupported or aliased ops to known structured/text paths
|
|
384
|
+
|
|
385
|
+
def _unwrap_and_alias(edit: dict[str, Any]) -> dict[str, Any]:
|
|
386
|
+
# Unwrap single-key wrappers like {"replace_method": {...}}
|
|
387
|
+
for wrapper_key in (
|
|
388
|
+
"replace_method", "insert_method", "delete_method",
|
|
389
|
+
"replace_class", "delete_class",
|
|
390
|
+
"anchor_insert", "anchor_replace", "anchor_delete",
|
|
391
|
+
):
|
|
392
|
+
if wrapper_key in edit and isinstance(edit[wrapper_key], dict):
|
|
393
|
+
inner = dict(edit[wrapper_key])
|
|
394
|
+
inner["op"] = wrapper_key
|
|
395
|
+
edit = inner
|
|
396
|
+
break
|
|
397
|
+
|
|
398
|
+
e = dict(edit)
|
|
399
|
+
op = (e.get("op") or e.get("operation") or e.get(
|
|
400
|
+
"type") or e.get("mode") or "").strip().lower()
|
|
401
|
+
if op:
|
|
402
|
+
e["op"] = op
|
|
403
|
+
|
|
404
|
+
# Common field aliases
|
|
405
|
+
if "class_name" in e and "className" not in e:
|
|
406
|
+
e["className"] = e.pop("class_name")
|
|
407
|
+
if "class" in e and "className" not in e:
|
|
408
|
+
e["className"] = e.pop("class")
|
|
409
|
+
if "method_name" in e and "methodName" not in e:
|
|
410
|
+
e["methodName"] = e.pop("method_name")
|
|
411
|
+
# Some clients use a generic 'target' for method name
|
|
412
|
+
if "target" in e and "methodName" not in e:
|
|
413
|
+
e["methodName"] = e.pop("target")
|
|
414
|
+
if "method" in e and "methodName" not in e:
|
|
415
|
+
e["methodName"] = e.pop("method")
|
|
416
|
+
if "new_content" in e and "replacement" not in e:
|
|
417
|
+
e["replacement"] = e.pop("new_content")
|
|
418
|
+
if "newMethod" in e and "replacement" not in e:
|
|
419
|
+
e["replacement"] = e.pop("newMethod")
|
|
420
|
+
if "new_method" in e and "replacement" not in e:
|
|
421
|
+
e["replacement"] = e.pop("new_method")
|
|
422
|
+
if "content" in e and "replacement" not in e:
|
|
423
|
+
e["replacement"] = e.pop("content")
|
|
424
|
+
if "after" in e and "afterMethodName" not in e:
|
|
425
|
+
e["afterMethodName"] = e.pop("after")
|
|
426
|
+
if "after_method" in e and "afterMethodName" not in e:
|
|
427
|
+
e["afterMethodName"] = e.pop("after_method")
|
|
428
|
+
if "before" in e and "beforeMethodName" not in e:
|
|
429
|
+
e["beforeMethodName"] = e.pop("before")
|
|
430
|
+
if "before_method" in e and "beforeMethodName" not in e:
|
|
431
|
+
e["beforeMethodName"] = e.pop("before_method")
|
|
432
|
+
# anchor_method → before/after based on position (default after)
|
|
433
|
+
if "anchor_method" in e:
|
|
434
|
+
anchor = e.pop("anchor_method")
|
|
435
|
+
pos = (e.get("position") or "after").strip().lower()
|
|
436
|
+
if pos == "before" and "beforeMethodName" not in e:
|
|
437
|
+
e["beforeMethodName"] = anchor
|
|
438
|
+
elif "afterMethodName" not in e:
|
|
439
|
+
e["afterMethodName"] = anchor
|
|
440
|
+
if "anchorText" in e and "anchor" not in e:
|
|
441
|
+
e["anchor"] = e.pop("anchorText")
|
|
442
|
+
if "pattern" in e and "anchor" not in e and e.get("op") and e["op"].startswith("anchor_"):
|
|
443
|
+
e["anchor"] = e.pop("pattern")
|
|
444
|
+
if "newText" in e and "text" not in e:
|
|
445
|
+
e["text"] = e.pop("newText")
|
|
446
|
+
|
|
447
|
+
# CI compatibility (T‑A/T‑E):
|
|
448
|
+
# Accept method-anchored anchor_insert and upgrade to insert_method
|
|
449
|
+
# Example incoming shape:
|
|
450
|
+
# {"op":"anchor_insert","afterMethodName":"GetCurrentTarget","text":"..."}
|
|
451
|
+
if (
|
|
452
|
+
e.get("op") == "anchor_insert"
|
|
453
|
+
and not e.get("anchor")
|
|
454
|
+
and (e.get("afterMethodName") or e.get("beforeMethodName"))
|
|
455
|
+
):
|
|
456
|
+
e["op"] = "insert_method"
|
|
457
|
+
if "replacement" not in e:
|
|
458
|
+
e["replacement"] = e.get("text", "")
|
|
459
|
+
|
|
460
|
+
# LSP-like range edit -> replace_range
|
|
461
|
+
if "range" in e and isinstance(e["range"], dict):
|
|
462
|
+
rng = e.pop("range")
|
|
463
|
+
start = rng.get("start", {})
|
|
464
|
+
end = rng.get("end", {})
|
|
465
|
+
# Convert 0-based to 1-based line/col
|
|
466
|
+
e["op"] = "replace_range"
|
|
467
|
+
e["startLine"] = int(start.get("line", 0)) + 1
|
|
468
|
+
e["startCol"] = int(start.get("character", 0)) + 1
|
|
469
|
+
e["endLine"] = int(end.get("line", 0)) + 1
|
|
470
|
+
e["endCol"] = int(end.get("character", 0)) + 1
|
|
471
|
+
if "newText" in edit and "text" not in e:
|
|
472
|
+
e["text"] = edit.get("newText", "")
|
|
473
|
+
return e
|
|
474
|
+
|
|
475
|
+
normalized_edits: list[dict[str, Any]] = []
|
|
476
|
+
for raw in edits or []:
|
|
477
|
+
e = _unwrap_and_alias(raw)
|
|
478
|
+
op = (e.get("op") or e.get("operation") or e.get(
|
|
479
|
+
"type") or e.get("mode") or "").strip().lower()
|
|
480
|
+
|
|
481
|
+
# Default className to script name if missing on structured method/class ops
|
|
482
|
+
if op in ("replace_class", "delete_class", "replace_method", "delete_method", "insert_method") and not e.get("className"):
|
|
483
|
+
e["className"] = name
|
|
484
|
+
|
|
485
|
+
# Map common aliases for text ops
|
|
486
|
+
if op in ("text_replace",):
|
|
487
|
+
e["op"] = "replace_range"
|
|
488
|
+
normalized_edits.append(e)
|
|
489
|
+
continue
|
|
490
|
+
if op in ("regex_delete",):
|
|
491
|
+
e["op"] = "regex_replace"
|
|
492
|
+
e.setdefault("text", "")
|
|
493
|
+
normalized_edits.append(e)
|
|
494
|
+
continue
|
|
495
|
+
if op == "regex_replace" and ("replacement" not in e):
|
|
496
|
+
if "text" in e:
|
|
497
|
+
e["replacement"] = e.get("text", "")
|
|
498
|
+
elif "insert" in e or "content" in e:
|
|
499
|
+
e["replacement"] = e.get(
|
|
500
|
+
"insert") or e.get("content") or ""
|
|
501
|
+
if op == "anchor_insert" and not (e.get("text") or e.get("insert") or e.get("content") or e.get("replacement")):
|
|
502
|
+
e["op"] = "anchor_delete"
|
|
503
|
+
normalized_edits.append(e)
|
|
504
|
+
continue
|
|
505
|
+
normalized_edits.append(e)
|
|
506
|
+
|
|
507
|
+
edits = normalized_edits
|
|
508
|
+
normalized_for_echo = edits
|
|
509
|
+
|
|
510
|
+
# Validate required fields and produce machine-parsable hints
|
|
511
|
+
def error_with_hint(message: str, expected: dict[str, Any], suggestion: dict[str, Any]) -> dict[str, Any]:
|
|
512
|
+
return _err("missing_field", message, expected=expected, rewrite=suggestion, normalized=normalized_for_echo)
|
|
513
|
+
|
|
514
|
+
for e in edits or []:
|
|
515
|
+
op = e.get("op", "")
|
|
516
|
+
if op == "replace_method":
|
|
517
|
+
if not e.get("methodName"):
|
|
518
|
+
return error_with_hint(
|
|
519
|
+
"replace_method requires 'methodName'.",
|
|
520
|
+
{"op": "replace_method", "required": [
|
|
521
|
+
"className", "methodName", "replacement"]},
|
|
522
|
+
{"edits[0].methodName": "HasTarget"}
|
|
523
|
+
)
|
|
524
|
+
if not (e.get("replacement") or e.get("text")):
|
|
525
|
+
return error_with_hint(
|
|
526
|
+
"replace_method requires 'replacement' (inline or base64).",
|
|
527
|
+
{"op": "replace_method", "required": [
|
|
528
|
+
"className", "methodName", "replacement"]},
|
|
529
|
+
{"edits[0].replacement": "public bool X(){ return true; }"}
|
|
530
|
+
)
|
|
531
|
+
elif op == "insert_method":
|
|
532
|
+
if not (e.get("replacement") or e.get("text")):
|
|
533
|
+
return error_with_hint(
|
|
534
|
+
"insert_method requires a non-empty 'replacement'.",
|
|
535
|
+
{"op": "insert_method", "required": ["className", "replacement"], "position": {
|
|
536
|
+
"after_requires": "afterMethodName", "before_requires": "beforeMethodName"}},
|
|
537
|
+
{"edits[0].replacement": "public void PrintSeries(){ Debug.Log(\"1,2,3\"); }"}
|
|
538
|
+
)
|
|
539
|
+
pos = (e.get("position") or "").lower()
|
|
540
|
+
if pos == "after" and not e.get("afterMethodName"):
|
|
541
|
+
return error_with_hint(
|
|
542
|
+
"insert_method with position='after' requires 'afterMethodName'.",
|
|
543
|
+
{"op": "insert_method", "position": {
|
|
544
|
+
"after_requires": "afterMethodName"}},
|
|
545
|
+
{"edits[0].afterMethodName": "GetCurrentTarget"}
|
|
546
|
+
)
|
|
547
|
+
if pos == "before" and not e.get("beforeMethodName"):
|
|
548
|
+
return error_with_hint(
|
|
549
|
+
"insert_method with position='before' requires 'beforeMethodName'.",
|
|
550
|
+
{"op": "insert_method", "position": {
|
|
551
|
+
"before_requires": "beforeMethodName"}},
|
|
552
|
+
{"edits[0].beforeMethodName": "GetCurrentTarget"}
|
|
553
|
+
)
|
|
554
|
+
elif op == "delete_method":
|
|
555
|
+
if not e.get("methodName"):
|
|
556
|
+
return error_with_hint(
|
|
557
|
+
"delete_method requires 'methodName'.",
|
|
558
|
+
{"op": "delete_method", "required": [
|
|
559
|
+
"className", "methodName"]},
|
|
560
|
+
{"edits[0].methodName": "PrintSeries"}
|
|
561
|
+
)
|
|
562
|
+
elif op in ("anchor_insert", "anchor_replace", "anchor_delete"):
|
|
563
|
+
if not e.get("anchor"):
|
|
564
|
+
return error_with_hint(
|
|
565
|
+
f"{op} requires 'anchor' (regex).",
|
|
566
|
+
{"op": op, "required": ["anchor"]},
|
|
567
|
+
{"edits[0].anchor": "(?m)^\\s*public\\s+bool\\s+HasTarget\\s*\\("}
|
|
568
|
+
)
|
|
569
|
+
if op in ("anchor_insert", "anchor_replace") and not (e.get("text") or e.get("replacement")):
|
|
570
|
+
return error_with_hint(
|
|
571
|
+
f"{op} requires 'text'.",
|
|
572
|
+
{"op": op, "required": ["anchor", "text"]},
|
|
573
|
+
{"edits[0].text": "/* comment */\n"}
|
|
574
|
+
)
|
|
575
|
+
|
|
576
|
+
# Decide routing: structured vs text vs mixed
|
|
577
|
+
STRUCT = {"replace_class", "delete_class", "replace_method", "delete_method",
|
|
578
|
+
"insert_method", "anchor_delete", "anchor_replace", "anchor_insert"}
|
|
579
|
+
TEXT = {"prepend", "append", "replace_range", "regex_replace"}
|
|
580
|
+
ops_set = {(e.get("op") or "").lower() for e in edits or []}
|
|
581
|
+
all_struct = ops_set.issubset(STRUCT)
|
|
582
|
+
all_text = ops_set.issubset(TEXT)
|
|
583
|
+
mixed = not (all_struct or all_text)
|
|
584
|
+
|
|
585
|
+
# If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
|
|
586
|
+
if all_struct:
|
|
587
|
+
opts2 = dict(options or {})
|
|
588
|
+
# For structured edits, prefer immediate refresh to avoid missed reloads when Editor is unfocused
|
|
589
|
+
opts2.setdefault("refresh", "immediate")
|
|
590
|
+
params_struct: dict[str, Any] = {
|
|
591
|
+
"action": "edit",
|
|
592
|
+
"name": name,
|
|
593
|
+
"path": path,
|
|
594
|
+
"namespace": namespace,
|
|
595
|
+
"scriptType": script_type,
|
|
596
|
+
"edits": edits,
|
|
597
|
+
"options": opts2,
|
|
598
|
+
}
|
|
599
|
+
resp_struct = await send_with_unity_instance(
|
|
600
|
+
async_send_command_with_retry,
|
|
601
|
+
unity_instance,
|
|
602
|
+
"manage_script",
|
|
603
|
+
params_struct,
|
|
604
|
+
)
|
|
605
|
+
if isinstance(resp_struct, dict) and resp_struct.get("success"):
|
|
606
|
+
pass # Optional sentinel reload removed (deprecated)
|
|
607
|
+
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="structured")
|
|
608
|
+
|
|
609
|
+
# 1) read from Unity
|
|
610
|
+
read_resp = await async_send_command_with_retry("manage_script", {
|
|
611
|
+
"action": "read",
|
|
612
|
+
"name": name,
|
|
613
|
+
"path": path,
|
|
614
|
+
"namespace": namespace,
|
|
615
|
+
"scriptType": script_type,
|
|
616
|
+
}, instance_id=unity_instance)
|
|
617
|
+
if not isinstance(read_resp, dict) or not read_resp.get("success"):
|
|
618
|
+
return read_resp if isinstance(read_resp, dict) else {"success": False, "message": str(read_resp)}
|
|
619
|
+
|
|
620
|
+
data = read_resp.get("data") or read_resp.get(
|
|
621
|
+
"result", {}).get("data") or {}
|
|
622
|
+
contents = data.get("contents")
|
|
623
|
+
if contents is None and data.get("contentsEncoded") and data.get("encodedContents"):
|
|
624
|
+
contents = base64.b64decode(
|
|
625
|
+
data["encodedContents"]).decode("utf-8")
|
|
626
|
+
if contents is None:
|
|
627
|
+
return {"success": False, "message": "No contents returned from Unity read."}
|
|
628
|
+
|
|
629
|
+
# Optional preview/dry-run: apply locally and return diff without writing
|
|
630
|
+
preview = bool((options or {}).get("preview"))
|
|
631
|
+
|
|
632
|
+
# If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
|
|
633
|
+
if mixed:
|
|
634
|
+
text_edits = [e for e in edits or [] if (
|
|
635
|
+
e.get("op") or "").lower() in TEXT]
|
|
636
|
+
struct_edits = [e for e in edits or [] if (
|
|
637
|
+
e.get("op") or "").lower() in STRUCT]
|
|
638
|
+
try:
|
|
639
|
+
base_text = contents
|
|
640
|
+
|
|
641
|
+
def line_col_from_index(idx: int) -> tuple[int, int]:
|
|
642
|
+
line = base_text.count("\n", 0, idx) + 1
|
|
643
|
+
last_nl = base_text.rfind("\n", 0, idx)
|
|
644
|
+
col = (idx - (last_nl + 1)) + \
|
|
645
|
+
1 if last_nl >= 0 else idx + 1
|
|
646
|
+
return line, col
|
|
647
|
+
|
|
648
|
+
at_edits: list[dict[str, Any]] = []
|
|
649
|
+
for e in text_edits:
|
|
650
|
+
opx = (e.get("op") or e.get("operation") or e.get(
|
|
651
|
+
"type") or e.get("mode") or "").strip().lower()
|
|
652
|
+
text_field = e.get("text") or e.get("insert") or e.get(
|
|
653
|
+
"content") or e.get("replacement") or ""
|
|
654
|
+
if opx == "anchor_insert":
|
|
655
|
+
anchor = e.get("anchor") or ""
|
|
656
|
+
position = (e.get("position") or "after").lower()
|
|
657
|
+
flags = re.MULTILINE | (
|
|
658
|
+
re.IGNORECASE if e.get("ignore_case") else 0)
|
|
659
|
+
try:
|
|
660
|
+
# Use improved anchor matching logic
|
|
661
|
+
m = _find_best_anchor_match(
|
|
662
|
+
anchor, base_text, flags, prefer_last=True)
|
|
663
|
+
except Exception as ex:
|
|
664
|
+
return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="mixed/text-first")
|
|
665
|
+
if not m:
|
|
666
|
+
return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="mixed/text-first")
|
|
667
|
+
idx = m.start() if position == "before" else m.end()
|
|
668
|
+
# Normalize insertion to avoid jammed methods
|
|
669
|
+
text_field_norm = text_field
|
|
670
|
+
if not text_field_norm.startswith("\n"):
|
|
671
|
+
text_field_norm = "\n" + text_field_norm
|
|
672
|
+
if not text_field_norm.endswith("\n"):
|
|
673
|
+
text_field_norm = text_field_norm + "\n"
|
|
674
|
+
sl, sc = line_col_from_index(idx)
|
|
675
|
+
at_edits.append(
|
|
676
|
+
{"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field_norm})
|
|
677
|
+
# do not mutate base_text when building atomic spans
|
|
678
|
+
elif opx == "replace_range":
|
|
679
|
+
if all(k in e for k in ("startLine", "startCol", "endLine", "endCol")):
|
|
680
|
+
at_edits.append({
|
|
681
|
+
"startLine": int(e.get("startLine", 1)),
|
|
682
|
+
"startCol": int(e.get("startCol", 1)),
|
|
683
|
+
"endLine": int(e.get("endLine", 1)),
|
|
684
|
+
"endCol": int(e.get("endCol", 1)),
|
|
685
|
+
"newText": text_field
|
|
686
|
+
})
|
|
687
|
+
else:
|
|
688
|
+
return _with_norm(_err("missing_field", "replace_range requires startLine/startCol/endLine/endCol", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
|
|
689
|
+
elif opx == "regex_replace":
|
|
690
|
+
pattern = e.get("pattern") or ""
|
|
691
|
+
try:
|
|
692
|
+
regex_obj = re.compile(pattern, re.MULTILINE | (
|
|
693
|
+
re.IGNORECASE if e.get("ignore_case") else 0))
|
|
694
|
+
except Exception as ex:
|
|
695
|
+
return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="mixed/text-first", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="mixed/text-first")
|
|
696
|
+
m = regex_obj.search(base_text)
|
|
697
|
+
if not m:
|
|
698
|
+
continue
|
|
699
|
+
# Expand $1, $2... in replacement using this match
|
|
700
|
+
|
|
701
|
+
def _expand_dollars(rep: str, _m=m) -> str:
|
|
702
|
+
return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
|
|
703
|
+
repl = _expand_dollars(text_field)
|
|
704
|
+
sl, sc = line_col_from_index(m.start())
|
|
705
|
+
el, ec = line_col_from_index(m.end())
|
|
706
|
+
at_edits.append(
|
|
707
|
+
{"startLine": sl, "startCol": sc, "endLine": el, "endCol": ec, "newText": repl})
|
|
708
|
+
# do not mutate base_text when building atomic spans
|
|
709
|
+
elif opx in ("prepend", "append"):
|
|
710
|
+
if opx == "prepend":
|
|
711
|
+
sl, sc = 1, 1
|
|
712
|
+
at_edits.append(
|
|
713
|
+
{"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": text_field})
|
|
714
|
+
# prepend can be applied atomically without local mutation
|
|
715
|
+
else:
|
|
716
|
+
# Insert at true EOF position (handles both \n and \r\n correctly)
|
|
717
|
+
eof_idx = len(base_text)
|
|
718
|
+
sl, sc = line_col_from_index(eof_idx)
|
|
719
|
+
new_text = ("\n" if not base_text.endswith(
|
|
720
|
+
"\n") else "") + text_field
|
|
721
|
+
at_edits.append(
|
|
722
|
+
{"startLine": sl, "startCol": sc, "endLine": sl, "endCol": sc, "newText": new_text})
|
|
723
|
+
# do not mutate base_text when building atomic spans
|
|
724
|
+
else:
|
|
725
|
+
return _with_norm(_err("unknown_op", f"Unsupported text edit op: {opx}", normalized=normalized_for_echo, routing="mixed/text-first"), normalized_for_echo, routing="mixed/text-first")
|
|
726
|
+
|
|
727
|
+
sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
|
|
728
|
+
if at_edits:
|
|
729
|
+
params_text: dict[str, Any] = {
|
|
730
|
+
"action": "apply_text_edits",
|
|
731
|
+
"name": name,
|
|
732
|
+
"path": path,
|
|
733
|
+
"namespace": namespace,
|
|
734
|
+
"scriptType": script_type,
|
|
735
|
+
"edits": at_edits,
|
|
736
|
+
"precondition_sha256": sha,
|
|
737
|
+
"options": {"refresh": (options or {}).get("refresh", "debounced"), "validate": (options or {}).get("validate", "standard"), "applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))}
|
|
738
|
+
}
|
|
739
|
+
resp_text = await send_with_unity_instance(
|
|
740
|
+
async_send_command_with_retry,
|
|
741
|
+
unity_instance,
|
|
742
|
+
"manage_script",
|
|
743
|
+
params_text,
|
|
744
|
+
)
|
|
745
|
+
if not (isinstance(resp_text, dict) and resp_text.get("success")):
|
|
746
|
+
return _with_norm(resp_text if isinstance(resp_text, dict) else {"success": False, "message": str(resp_text)}, normalized_for_echo, routing="mixed/text-first")
|
|
747
|
+
# Optional sentinel reload removed (deprecated)
|
|
748
|
+
except Exception as e:
|
|
749
|
+
return _with_norm({"success": False, "message": f"Text edit conversion failed: {e}"}, normalized_for_echo, routing="mixed/text-first")
|
|
750
|
+
|
|
751
|
+
if struct_edits:
|
|
752
|
+
opts2 = dict(options or {})
|
|
753
|
+
# Prefer debounced background refresh unless explicitly overridden
|
|
754
|
+
opts2.setdefault("refresh", "debounced")
|
|
755
|
+
params_struct: dict[str, Any] = {
|
|
756
|
+
"action": "edit",
|
|
757
|
+
"name": name,
|
|
758
|
+
"path": path,
|
|
759
|
+
"namespace": namespace,
|
|
760
|
+
"scriptType": script_type,
|
|
761
|
+
"edits": struct_edits,
|
|
762
|
+
"options": opts2
|
|
763
|
+
}
|
|
764
|
+
resp_struct = await send_with_unity_instance(
|
|
765
|
+
async_send_command_with_retry,
|
|
766
|
+
unity_instance,
|
|
767
|
+
"manage_script",
|
|
768
|
+
params_struct,
|
|
769
|
+
)
|
|
770
|
+
if isinstance(resp_struct, dict) and resp_struct.get("success"):
|
|
771
|
+
pass # Optional sentinel reload removed (deprecated)
|
|
772
|
+
return _with_norm(resp_struct if isinstance(resp_struct, dict) else {"success": False, "message": str(resp_struct)}, normalized_for_echo, routing="mixed/text-first")
|
|
773
|
+
|
|
774
|
+
return _with_norm({"success": True, "message": "Applied text edits (no structured ops)"}, normalized_for_echo, routing="mixed/text-first")
|
|
775
|
+
|
|
776
|
+
# If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
|
|
777
|
+
# so header guards and validation run on the C# side.
|
|
778
|
+
# Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
|
|
779
|
+
text_ops = {(e.get("op") or e.get("operation") or e.get("type") or e.get(
|
|
780
|
+
"mode") or "").strip().lower() for e in (edits or [])}
|
|
781
|
+
structured_kinds = {"replace_class", "delete_class",
|
|
782
|
+
"replace_method", "delete_method", "insert_method", "anchor_insert"}
|
|
783
|
+
if not text_ops.issubset(structured_kinds):
|
|
784
|
+
# Convert to apply_text_edits payload
|
|
785
|
+
try:
|
|
786
|
+
base_text = contents
|
|
787
|
+
|
|
788
|
+
def line_col_from_index(idx: int) -> tuple[int, int]:
|
|
789
|
+
# 1-based line/col against base buffer
|
|
790
|
+
line = base_text.count("\n", 0, idx) + 1
|
|
791
|
+
last_nl = base_text.rfind("\n", 0, idx)
|
|
792
|
+
col = (idx - (last_nl + 1)) + \
|
|
793
|
+
1 if last_nl >= 0 else idx + 1
|
|
794
|
+
return line, col
|
|
795
|
+
|
|
796
|
+
at_edits: list[dict[str, Any]] = []
|
|
797
|
+
for e in edits or []:
|
|
798
|
+
op = (e.get("op") or e.get("operation") or e.get(
|
|
799
|
+
"type") or e.get("mode") or "").strip().lower()
|
|
800
|
+
# aliasing for text field
|
|
801
|
+
text_field = e.get("text") or e.get(
|
|
802
|
+
"insert") or e.get("content") or ""
|
|
803
|
+
if op == "anchor_insert":
|
|
804
|
+
anchor = e.get("anchor") or ""
|
|
805
|
+
position = (e.get("position") or "after").lower()
|
|
806
|
+
# Use improved anchor matching logic with helpful errors, honoring ignore_case
|
|
807
|
+
try:
|
|
808
|
+
flags = re.MULTILINE | (
|
|
809
|
+
re.IGNORECASE if e.get("ignore_case") else 0)
|
|
810
|
+
m = _find_best_anchor_match(
|
|
811
|
+
anchor, base_text, flags, prefer_last=True)
|
|
812
|
+
except Exception as ex:
|
|
813
|
+
return _with_norm(_err("bad_regex", f"Invalid anchor regex: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape parentheses/braces or use a simpler anchor."}), normalized_for_echo, routing="text")
|
|
814
|
+
if not m:
|
|
815
|
+
return _with_norm({"success": False, "code": "anchor_not_found", "message": f"anchor not found: {anchor}"}, normalized_for_echo, routing="text")
|
|
816
|
+
idx = m.start() if position == "before" else m.end()
|
|
817
|
+
# Normalize insertion newlines
|
|
818
|
+
if text_field and not text_field.startswith("\n"):
|
|
819
|
+
text_field = "\n" + text_field
|
|
820
|
+
if text_field and not text_field.endswith("\n"):
|
|
821
|
+
text_field = text_field + "\n"
|
|
822
|
+
sl, sc = line_col_from_index(idx)
|
|
823
|
+
at_edits.append({
|
|
824
|
+
"startLine": sl,
|
|
825
|
+
"startCol": sc,
|
|
826
|
+
"endLine": sl,
|
|
827
|
+
"endCol": sc,
|
|
828
|
+
"newText": text_field or ""
|
|
829
|
+
})
|
|
830
|
+
# Do not mutate base buffer when building an atomic batch
|
|
831
|
+
elif op == "replace_range":
|
|
832
|
+
# Directly forward if already in line/col form
|
|
833
|
+
if "startLine" in e:
|
|
834
|
+
at_edits.append({
|
|
835
|
+
"startLine": int(e.get("startLine", 1)),
|
|
836
|
+
"startCol": int(e.get("startCol", 1)),
|
|
837
|
+
"endLine": int(e.get("endLine", 1)),
|
|
838
|
+
"endCol": int(e.get("endCol", 1)),
|
|
839
|
+
"newText": text_field
|
|
840
|
+
})
|
|
841
|
+
else:
|
|
842
|
+
# If only indices provided, skip (we don't support index-based here)
|
|
843
|
+
return _with_norm({"success": False, "code": "missing_field", "message": "replace_range requires startLine/startCol/endLine/endCol"}, normalized_for_echo, routing="text")
|
|
844
|
+
elif op == "regex_replace":
|
|
845
|
+
pattern = e.get("pattern") or ""
|
|
846
|
+
repl = text_field
|
|
847
|
+
flags = re.MULTILINE | (
|
|
848
|
+
re.IGNORECASE if e.get("ignore_case") else 0)
|
|
849
|
+
# Early compile for clearer error messages
|
|
850
|
+
try:
|
|
851
|
+
regex_obj = re.compile(pattern, flags)
|
|
852
|
+
except Exception as ex:
|
|
853
|
+
return _with_norm(_err("bad_regex", f"Invalid regex pattern: {ex}", normalized=normalized_for_echo, routing="text", extra={"hint": "Escape special chars or prefer structured delete for methods."}), normalized_for_echo, routing="text")
|
|
854
|
+
# Use smart anchor matching for consistent behavior with anchor_insert
|
|
855
|
+
m = _find_best_anchor_match(
|
|
856
|
+
pattern, base_text, flags, prefer_last=True)
|
|
857
|
+
if not m:
|
|
858
|
+
continue
|
|
859
|
+
# Expand $1, $2... backrefs in replacement using the first match (consistent with mixed-path behavior)
|
|
860
|
+
|
|
861
|
+
def _expand_dollars(rep: str, _m=m) -> str:
|
|
862
|
+
return re.sub(r"\$(\d+)", lambda g: _m.group(int(g.group(1))) or "", rep)
|
|
863
|
+
repl_expanded = _expand_dollars(repl)
|
|
864
|
+
# Let C# side handle validation using Unity's built-in compiler services
|
|
865
|
+
sl, sc = line_col_from_index(m.start())
|
|
866
|
+
el, ec = line_col_from_index(m.end())
|
|
867
|
+
at_edits.append({
|
|
868
|
+
"startLine": sl,
|
|
869
|
+
"startCol": sc,
|
|
870
|
+
"endLine": el,
|
|
871
|
+
"endCol": ec,
|
|
872
|
+
"newText": repl_expanded
|
|
873
|
+
})
|
|
874
|
+
# Do not mutate base buffer when building an atomic batch
|
|
875
|
+
else:
|
|
876
|
+
return _with_norm({"success": False, "code": "unsupported_op", "message": f"Unsupported text edit op for server-side apply_text_edits: {op}"}, normalized_for_echo, routing="text")
|
|
877
|
+
|
|
878
|
+
if not at_edits:
|
|
879
|
+
return _with_norm({"success": False, "code": "no_spans", "message": "No applicable text edit spans computed (anchor not found or zero-length)."}, normalized_for_echo, routing="text")
|
|
880
|
+
|
|
881
|
+
sha = hashlib.sha256(base_text.encode("utf-8")).hexdigest()
|
|
882
|
+
params: dict[str, Any] = {
|
|
883
|
+
"action": "apply_text_edits",
|
|
884
|
+
"name": name,
|
|
885
|
+
"path": path,
|
|
886
|
+
"namespace": namespace,
|
|
887
|
+
"scriptType": script_type,
|
|
888
|
+
"edits": at_edits,
|
|
889
|
+
"precondition_sha256": sha,
|
|
890
|
+
"options": {
|
|
891
|
+
"refresh": (options or {}).get("refresh", "debounced"),
|
|
892
|
+
"validate": (options or {}).get("validate", "standard"),
|
|
893
|
+
"applyMode": ("atomic" if len(at_edits) > 1 else (options or {}).get("applyMode", "sequential"))
|
|
894
|
+
}
|
|
895
|
+
}
|
|
896
|
+
resp = await send_with_unity_instance(
|
|
897
|
+
async_send_command_with_retry,
|
|
898
|
+
unity_instance,
|
|
899
|
+
"manage_script",
|
|
900
|
+
params,
|
|
901
|
+
)
|
|
902
|
+
if isinstance(resp, dict) and resp.get("success"):
|
|
903
|
+
pass # Optional sentinel reload removed (deprecated)
|
|
904
|
+
return _with_norm(
|
|
905
|
+
resp if isinstance(resp, dict)
|
|
906
|
+
else {"success": False, "message": str(resp)},
|
|
907
|
+
normalized_for_echo,
|
|
908
|
+
routing="text",
|
|
909
|
+
)
|
|
910
|
+
except Exception as e:
|
|
911
|
+
return _with_norm({"success": False, "code": "conversion_failed", "message": f"Edit conversion failed: {e}"}, normalized_for_echo, routing="text")
|
|
912
|
+
|
|
913
|
+
# For regex_replace, honor preview consistently: if preview=true, always return diff without writing.
|
|
914
|
+
# If confirm=false (default) and preview not requested, return diff and instruct confirm=true to apply.
|
|
915
|
+
if "regex_replace" in text_ops and (preview or not (options or {}).get("confirm")):
|
|
916
|
+
try:
|
|
917
|
+
preview_text = _apply_edits_locally(contents, edits)
|
|
918
|
+
import difflib
|
|
919
|
+
diff = list(difflib.unified_diff(contents.splitlines(
|
|
920
|
+
), preview_text.splitlines(), fromfile="before", tofile="after", n=2))
|
|
921
|
+
if len(diff) > 800:
|
|
922
|
+
diff = diff[:800] + ["... (diff truncated) ..."]
|
|
923
|
+
if preview:
|
|
924
|
+
return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
|
|
925
|
+
return _with_norm({"success": False, "message": "Preview diff; set options.confirm=true to apply.", "data": {"diff": "\n".join(diff)}}, normalized_for_echo, routing="text")
|
|
926
|
+
except Exception as e:
|
|
927
|
+
return _with_norm({"success": False, "code": "preview_failed", "message": f"Preview failed: {e}"}, normalized_for_echo, routing="text")
|
|
928
|
+
# 2) apply edits locally (only if not text-ops)
|
|
929
|
+
try:
|
|
930
|
+
new_contents = _apply_edits_locally(contents, edits)
|
|
931
|
+
except Exception as e:
|
|
932
|
+
return {"success": False, "message": f"Edit application failed: {e}"}
|
|
933
|
+
|
|
934
|
+
# Short-circuit no-op edits to avoid false "applied" reports downstream
|
|
935
|
+
if new_contents == contents:
|
|
936
|
+
return _with_norm({
|
|
937
|
+
"success": True,
|
|
938
|
+
"message": "No-op: contents unchanged",
|
|
939
|
+
"data": {"no_op": True, "evidence": {"reason": "identical_content"}}
|
|
940
|
+
}, normalized_for_echo, routing="text")
|
|
941
|
+
|
|
942
|
+
if preview:
|
|
943
|
+
# Produce a compact unified diff limited to small context
|
|
944
|
+
import difflib
|
|
945
|
+
a = contents.splitlines()
|
|
946
|
+
b = new_contents.splitlines()
|
|
947
|
+
diff = list(difflib.unified_diff(
|
|
948
|
+
a, b, fromfile="before", tofile="after", n=3))
|
|
949
|
+
# Limit diff size to keep responses small
|
|
950
|
+
if len(diff) > 2000:
|
|
951
|
+
diff = diff[:2000] + ["... (diff truncated) ..."]
|
|
952
|
+
return {"success": True, "message": "Preview only (no write)", "data": {"diff": "\n".join(diff), "normalizedEdits": normalized_for_echo}}
|
|
953
|
+
|
|
954
|
+
# 3) update to Unity
|
|
955
|
+
# Default refresh/validate for natural usage on text path as well
|
|
956
|
+
options = dict(options or {})
|
|
957
|
+
options.setdefault("validate", "standard")
|
|
958
|
+
options.setdefault("refresh", "debounced")
|
|
959
|
+
|
|
960
|
+
# Compute the SHA of the current file contents for the precondition
|
|
961
|
+
old_lines = contents.splitlines(keepends=True)
|
|
962
|
+
end_line = len(old_lines) + 1 # 1-based exclusive end
|
|
963
|
+
sha = hashlib.sha256(contents.encode("utf-8")).hexdigest()
|
|
964
|
+
|
|
965
|
+
# Apply a whole-file text edit rather than the deprecated 'update' action
|
|
966
|
+
params = {
|
|
967
|
+
"action": "apply_text_edits",
|
|
968
|
+
"name": name,
|
|
969
|
+
"path": path,
|
|
970
|
+
"namespace": namespace,
|
|
971
|
+
"scriptType": script_type,
|
|
972
|
+
"edits": [
|
|
973
|
+
{
|
|
974
|
+
"startLine": 1,
|
|
975
|
+
"startCol": 1,
|
|
976
|
+
"endLine": end_line,
|
|
977
|
+
"endCol": 1,
|
|
978
|
+
"newText": new_contents,
|
|
979
|
+
}
|
|
980
|
+
],
|
|
981
|
+
"precondition_sha256": sha,
|
|
982
|
+
"options": options or {"validate": "standard", "refresh": "debounced"},
|
|
983
|
+
}
|
|
984
|
+
|
|
985
|
+
write_resp = await send_with_unity_instance(
|
|
986
|
+
async_send_command_with_retry,
|
|
987
|
+
unity_instance,
|
|
988
|
+
"manage_script",
|
|
989
|
+
params,
|
|
990
|
+
)
|
|
991
|
+
if isinstance(write_resp, dict) and write_resp.get("success"):
|
|
992
|
+
pass # Optional sentinel reload removed (deprecated)
|
|
993
|
+
return _with_norm(
|
|
994
|
+
write_resp if isinstance(write_resp, dict)
|
|
995
|
+
else {"success": False, "message": str(write_resp)},
|
|
996
|
+
normalized_for_echo,
|
|
997
|
+
routing="text",
|
|
998
|
+
)
|