@@ -143,6 +143,34 @@ def collapse_duplicate_tail(s: str) -> str:
143143 return base_name , (p or "Assets" )
144144
145145
146+ def _with_norm (resp : Dict [str , Any ] | Any , edits : List [Dict [str , Any ]], routing : str | None = None ) -> Dict [str , Any ] | Any :
147+ if not isinstance (resp , dict ):
148+ return resp
149+ data = resp .setdefault ("data" , {})
150+ data .setdefault ("normalizedEdits" , edits )
151+ if routing :
152+ data ["routing" ] = routing
153+ return resp
154+
155+
156+ def _err (code : str , message : str , * , expected : Dict [str , Any ] | None = None , rewrite : Dict [str , Any ] | None = None ,
157+ normalized : List [Dict [str , Any ]] | None = None , routing : str | None = None , extra : Dict [str , Any ] | None = None ) -> Dict [str , Any ]:
158+ payload : Dict [str , Any ] = {"success" : False , "code" : code , "message" : message }
159+ data : Dict [str , Any ] = {}
160+ if expected :
161+ data ["expected" ] = expected
162+ if rewrite :
163+ data ["rewrite_suggestion" ] = rewrite
164+ if normalized is not None :
165+ data ["normalizedEdits" ] = normalized
166+ if routing :
167+ data ["routing" ] = routing
168+ if extra :
169+ data .update (extra )
170+ if data :
171+ payload ["data" ] = data
172+ return payload
173+
146174# Natural-language parsing removed; clients should send structured edits.
147175
148176
@@ -297,7 +325,7 @@ def _unwrap_and_alias(edit: Dict[str, Any]) -> Dict[str, Any]:
297325
298326 # Validate required fields and produce machine-parsable hints
299327 def error_with_hint (message : str , expected : Dict [str , Any ], suggestion : Dict [str , Any ]) -> Dict [str , Any ]:
300- return { "success" : False , " message" : message , " expected" : expected , "rewrite_suggestion" : suggestion }
328+ return _err ( "missing_field" , message , expected = expected , rewrite = suggestion , normalized = normalized_for_echo )
301329
302330 for e in edits or []:
303331 op = e .get ("op" , "" )
@@ -355,29 +383,32 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str
355383 {"edits[0].text" : "/* comment */\n " }
356384 )
357385
358- for e in edits or []:
359- op = (e .get ("op" ) or e .get ("operation" ) or e .get ("type" ) or e .get ("mode" ) or "" ).strip ().lower ()
360- if op in ("replace_class" , "delete_class" , "replace_method" , "delete_method" , "insert_method" , "anchor_insert" , "anchor_delete" , "anchor_replace" ):
361- # Default applyMode to sequential if mixing insert + replace in the same batch
362- ops_in_batch = { (x .get ("op" ) or "" ).lower () for x in edits or [] }
363- options = dict (options or {})
364- if "insert_method" in ops_in_batch and "replace_method" in ops_in_batch and "applyMode" not in options :
365- options ["applyMode" ] = "sequential"
366-
367- params : Dict [str , Any ] = {
368- "action" : "edit" ,
369- "name" : name ,
370- "path" : path ,
371- "namespace" : namespace ,
372- "scriptType" : script_type ,
373- "edits" : edits ,
374- }
375- if options is not None :
376- params ["options" ] = options
377- resp = send_command_with_retry ("manage_script" , params )
378- if isinstance (resp , dict ):
379- resp .setdefault ("data" , {})["normalizedEdits" ] = normalized_for_echo
380- return resp if isinstance (resp , dict ) else {"success" : False , "message" : str (resp )}
386+ # Decide routing: structured vs text vs mixed
387+ STRUCT = {"replace_class" ,"delete_class" ,"replace_method" ,"delete_method" ,"insert_method" ,"anchor_insert" ,"anchor_delete" ,"anchor_replace" }
388+ TEXT = {"prepend" ,"append" ,"replace_range" ,"regex_replace" ,"anchor_insert" }
389+ ops_set = { (e .get ("op" ) or "" ).lower () for e in edits or [] }
390+ all_struct = ops_set .issubset (STRUCT )
391+ all_text = ops_set .issubset (TEXT )
392+ mixed = not (all_struct or all_text )
393+
394+ # If everything is structured (method/class/anchor ops), forward directly to Unity's structured editor.
395+ if all_struct :
396+ opts2 = dict (options or {})
397+ # Be conservative: when multiple structured ops are present, ensure deterministic order
398+ if len (edits or []) > 1 :
399+ opts2 .setdefault ("applyMode" , "sequential" )
400+ opts2 .setdefault ("refresh" , "immediate" )
401+ params_struct : Dict [str , Any ] = {
402+ "action" : "edit" ,
403+ "name" : name ,
404+ "path" : path ,
405+ "namespace" : namespace ,
406+ "scriptType" : script_type ,
407+ "edits" : edits ,
408+ "options" : opts2 ,
409+ }
410+ resp_struct = send_command_with_retry ("manage_script" , params_struct )
411+ return _with_norm (resp_struct if isinstance (resp_struct , dict ) else {"success" : False , "message" : str (resp_struct )}, normalized_for_echo , routing = "structured" )
381412
382413 # 1) read from Unity
383414 read_resp = send_command_with_retry ("manage_script" , {
@@ -400,6 +431,104 @@ def error_with_hint(message: str, expected: Dict[str, Any], suggestion: Dict[str
400431 # Optional preview/dry-run: apply locally and return diff without writing
401432 preview = bool ((options or {}).get ("preview" ))
402433
434+ # If we have a mixed batch (TEXT + STRUCT), apply text first with precondition, then structured
435+ if mixed :
436+ text_edits = [e for e in edits or [] if (e .get ("op" ) or "" ).lower () in TEXT ]
437+ struct_edits = [e for e in edits or [] if (e .get ("op" ) or "" ).lower () in STRUCT and (e .get ("op" ) or "" ).lower () not in {"anchor_insert" }]
438+ try :
439+ current_text = contents
440+ def line_col_from_index (idx : int ) -> Tuple [int , int ]:
441+ line = current_text .count ("\n " , 0 , idx ) + 1
442+ last_nl = current_text .rfind ("\n " , 0 , idx )
443+ col = (idx - (last_nl + 1 )) + 1 if last_nl >= 0 else idx + 1
444+ return line , col
445+
446+ at_edits : List [Dict [str , Any ]] = []
447+ import re as _re
448+ for e in text_edits :
449+ opx = (e .get ("op" ) or e .get ("operation" ) or e .get ("type" ) or e .get ("mode" ) or "" ).strip ().lower ()
450+ text_field = e .get ("text" ) or e .get ("insert" ) or e .get ("content" ) or e .get ("replacement" ) or ""
451+ if opx == "anchor_insert" :
452+ anchor = e .get ("anchor" ) or ""
453+ position = (e .get ("position" ) or "before" ).lower ()
454+ m = _re .search (anchor , current_text , _re .MULTILINE )
455+ if not m :
456+ return _with_norm ({"success" : False , "code" : "anchor_not_found" , "message" : f"anchor not found: { anchor } " }, normalized_for_echo , routing = "mixed/text-first" )
457+ idx = m .start () if position == "before" else m .end ()
458+ sl , sc = line_col_from_index (idx )
459+ at_edits .append ({"startLine" : sl , "startCol" : sc , "endLine" : sl , "endCol" : sc , "newText" : text_field })
460+ current_text = current_text [:idx ] + text_field + current_text [idx :]
461+ elif opx == "replace_range" :
462+ if all (k in e for k in ("startLine" ,"startCol" ,"endLine" ,"endCol" )):
463+ at_edits .append ({
464+ "startLine" : int (e .get ("startLine" , 1 )),
465+ "startCol" : int (e .get ("startCol" , 1 )),
466+ "endLine" : int (e .get ("endLine" , 1 )),
467+ "endCol" : int (e .get ("endCol" , 1 )),
468+ "newText" : text_field
469+ })
470+ else :
471+ 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" )
472+ elif opx == "regex_replace" :
473+ pattern = e .get ("pattern" ) or ""
474+ m = _re .search (pattern , current_text , _re .MULTILINE )
475+ if not m :
476+ continue
477+ sl , sc = line_col_from_index (m .start ())
478+ el , ec = line_col_from_index (m .end ())
479+ at_edits .append ({"startLine" : sl , "startCol" : sc , "endLine" : el , "endCol" : ec , "newText" : text_field })
480+ current_text = current_text [:m .start ()] + text_field + current_text [m .end ():]
481+ elif opx in ("prepend" ,"append" ):
482+ if opx == "prepend" :
483+ sl , sc = 1 , 1
484+ at_edits .append ({"startLine" : sl , "startCol" : sc , "endLine" : sl , "endCol" : sc , "newText" : text_field })
485+ current_text = text_field + current_text
486+ else :
487+ lines = current_text .splitlines (keepends = True )
488+ sl = len (lines ) + (0 if current_text .endswith ("\n " ) else 1 )
489+ sc = 1
490+ at_edits .append ({"startLine" : sl , "startCol" : sc , "endLine" : sl , "endCol" : sc , "newText" : ("\n " if not current_text .endswith ("\n " ) else "" ) + text_field })
491+ current_text = current_text + ("\n " if not current_text .endswith ("\n " ) else "" ) + text_field
492+ else :
493+ 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" )
494+
495+ import hashlib
496+ sha = hashlib .sha256 (contents .encode ("utf-8" )).hexdigest ()
497+ if at_edits :
498+ params_text : Dict [str , Any ] = {
499+ "action" : "apply_text_edits" ,
500+ "name" : name ,
501+ "path" : path ,
502+ "namespace" : namespace ,
503+ "scriptType" : script_type ,
504+ "edits" : at_edits ,
505+ "precondition_sha256" : sha ,
506+ "options" : {"refresh" : "immediate" , "validate" : (options or {}).get ("validate" , "standard" )}
507+ }
508+ resp_text = send_command_with_retry ("manage_script" , params_text )
509+ if not (isinstance (resp_text , dict ) and resp_text .get ("success" )):
510+ return _with_norm (resp_text if isinstance (resp_text , dict ) else {"success" : False , "message" : str (resp_text )}, normalized_for_echo , routing = "mixed/text-first" )
511+ except Exception as e :
512+ return _with_norm ({"success" : False , "message" : f"Text edit conversion failed: { e } " }, normalized_for_echo , routing = "mixed/text-first" )
513+
514+ if struct_edits :
515+ opts2 = dict (options or {})
516+ opts2 .setdefault ("applyMode" , "sequential" )
517+ opts2 .setdefault ("refresh" , "immediate" )
518+ params_struct : Dict [str , Any ] = {
519+ "action" : "edit" ,
520+ "name" : name ,
521+ "path" : path ,
522+ "namespace" : namespace ,
523+ "scriptType" : script_type ,
524+ "edits" : struct_edits ,
525+ "options" : opts2
526+ }
527+ resp_struct = send_command_with_retry ("manage_script" , params_struct )
528+ return _with_norm (resp_struct if isinstance (resp_struct , dict ) else {"success" : False , "message" : str (resp_struct )}, normalized_for_echo , routing = "mixed/text-first" )
529+
530+ return _with_norm ({"success" : True , "message" : "Applied text edits (no structured ops)" }, normalized_for_echo , routing = "mixed/text-first" )
531+
403532 # If the edits are text-ops, prefer sending them to Unity's apply_text_edits with precondition
404533 # so header guards and validation run on the C# side.
405534 # Supported conversions: anchor_insert, replace_range, regex_replace (first match only).
@@ -427,7 +556,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
427556 position = (e .get ("position" ) or "before" ).lower ()
428557 m = _re .search (anchor , current_text , _re .MULTILINE )
429558 if not m :
430- return {"success" : False , "message" : f"anchor not found: { anchor } " }
559+ return _with_norm ( {"success" : False , "code" : "anchor_not_found" , " message" : f"anchor not found: { anchor } " }, normalized_for_echo , routing = "text" )
431560 idx = m .start () if position == "before" else m .end ()
432561 sl , sc = line_col_from_index (idx )
433562 at_edits .append ({
@@ -451,7 +580,7 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
451580 })
452581 else :
453582 # If only indices provided, skip (we don't support index-based here)
454- return {"success" : False , "message" : "replace_range requires startLine/startCol/endLine/endCol" }
583+ return _with_norm ( {"success" : False , "code" : "missing_field" , " message" : "replace_range requires startLine/startCol/endLine/endCol" }, normalized_for_echo , routing = "text" )
455584 elif op == "regex_replace" :
456585 pattern = e .get ("pattern" ) or ""
457586 repl = text_field
@@ -469,10 +598,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
469598 })
470599 current_text = current_text [:m .start ()] + repl + current_text [m .end ():]
471600 else :
472- return {"success" : False , "message" : f"Unsupported text edit op for server-side apply_text_edits: { op } " }
601+ 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" )
473602
474603 if not at_edits :
475- return {"success" : False , "message" : "No applicable text edit spans computed (anchor not found or zero-length)." }
604+ 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" )
476605
477606 # Send to Unity with precondition SHA to enforce guards and immediate refresh
478607 import hashlib
@@ -491,28 +620,10 @@ def line_col_from_index(idx: int) -> Tuple[int, int]:
491620 }
492621 }
493622 resp = send_command_with_retry ("manage_script" , params )
494- if isinstance (resp , dict ):
495- resp .setdefault ("data" , {})["normalizedEdits" ] = normalized_for_echo
496- return resp if isinstance (resp , dict ) else {"success" : False , "message" : str (resp )}
623+ return _with_norm (resp if isinstance (resp , dict ) else {"success" : False , "message" : str (resp )}, normalized_for_echo , routing = "text" )
497624 except Exception as e :
498- return {"success" : False , "message" : f"Edit conversion failed: { e } " }
625+ return _with_norm ( {"success" : False , "code" : "conversion_failed" , " message" : f"Edit conversion failed: { e } " }, normalized_for_echo , routing = "text" )
499626
500- # If we have anchor_* only (structured), forward to ManageScript.EditScript to avoid raw text path
501- if text_ops .issubset ({"anchor_insert" , "anchor_delete" , "anchor_replace" }):
502- params : Dict [str , Any ] = {
503- "action" : "edit" ,
504- "name" : name ,
505- "path" : path ,
506- "namespace" : namespace ,
507- "scriptType" : script_type ,
508- "edits" : edits ,
509- "options" : {"refresh" : "immediate" , "validate" : (options or {}).get ("validate" , "standard" )}
510- }
511- resp2 = send_command_with_retry ("manage_script" , params )
512- if isinstance (resp2 , dict ):
513- resp2 .setdefault ("data" , {})["normalizedEdits" ] = normalized_for_echo
514- return resp2 if isinstance (resp2 , dict ) else {"success" : False , "message" : str (resp2 )}
515-
516627 # For regex_replace on large files, support preview/confirm
517628 if "regex_replace" in text_ops and not (options or {}).get ("confirm" ):
518629 try :
0 commit comments