unityflow 0.3.4__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.
@@ -0,0 +1,717 @@
1
+ """C# Script Parser for Unity SerializeField extraction.
2
+
3
+ Parses C# MonoBehaviour scripts to extract serialized field names
4
+ and their declaration order. Used for proper field ordering in
5
+ prefab/scene file manipulation.
6
+ """
7
+
8
+ from __future__ import annotations
9
+
10
+ import re
11
+ from dataclasses import dataclass, field
12
+ from pathlib import Path
13
+
14
+ from unityflow.asset_tracker import GUIDIndex, build_guid_index
15
+
16
+
17
+ @dataclass
18
+ class SerializedField:
19
+ """Represents a serialized field in a Unity MonoBehaviour."""
20
+
21
+ name: str
22
+ unity_name: str # m_FieldName format
23
+ field_type: str
24
+ is_public: bool = False
25
+ has_serialize_field: bool = False
26
+ line_number: int = 0
27
+ former_names: list[str] = field(default_factory=list) # FormerlySerializedAs names
28
+ default_value: any = None # Parsed default value for Unity serialization
29
+
30
+ @classmethod
31
+ def from_field_name(
32
+ cls,
33
+ name: str,
34
+ field_type: str = "",
35
+ former_names: list[str] | None = None,
36
+ default_value: any = None,
37
+ **kwargs,
38
+ ) -> SerializedField:
39
+ """Create a SerializedField with auto-generated Unity name."""
40
+ # Unity uses m_FieldName format for serialized fields
41
+ unity_name = f"m_{name[0].upper()}{name[1:]}" if name else ""
42
+ return cls(
43
+ name=name,
44
+ unity_name=unity_name,
45
+ field_type=field_type,
46
+ former_names=former_names or [],
47
+ default_value=default_value,
48
+ **kwargs,
49
+ )
50
+
51
+
52
+ @dataclass
53
+ class ScriptInfo:
54
+ """Information extracted from a C# script."""
55
+
56
+ class_name: str
57
+ namespace: str | None = None
58
+ base_class: str | None = None
59
+ fields: list[SerializedField] = field(default_factory=list)
60
+ path: Path | None = None
61
+ guid: str | None = None
62
+
63
+ def get_field_order(self) -> list[str]:
64
+ """Get the list of Unity field names in declaration order."""
65
+ return [f.unity_name for f in self.fields]
66
+
67
+ def get_field_index(self, unity_name: str) -> int:
68
+ """Get the index of a field by its Unity name.
69
+
70
+ Returns -1 if not found.
71
+ """
72
+ for i, f in enumerate(self.fields):
73
+ if f.unity_name == unity_name:
74
+ return i
75
+ return -1
76
+
77
+ def get_valid_field_names(self) -> set[str]:
78
+ """Get all valid field names (current names only)."""
79
+ return {f.unity_name for f in self.fields}
80
+
81
+ def get_rename_mapping(self) -> dict[str, str]:
82
+ """Get mapping of old field names to new field names.
83
+
84
+ Returns:
85
+ Dict mapping old Unity name -> new Unity name
86
+ """
87
+ mapping = {}
88
+ for f in self.fields:
89
+ for former in f.former_names:
90
+ mapping[former] = f.unity_name
91
+ return mapping
92
+
93
+ def is_obsolete_field(self, unity_name: str) -> bool:
94
+ """Check if a field name is obsolete (not in current script).
95
+
96
+ Note: FormerlySerializedAs names are considered obsolete.
97
+ """
98
+ valid_names = self.get_valid_field_names()
99
+ return unity_name not in valid_names
100
+
101
+ def get_missing_fields(self, existing_names: set[str]) -> list[SerializedField]:
102
+ """Get fields that exist in script but not in the existing set.
103
+
104
+ Args:
105
+ existing_names: Set of field names already present
106
+
107
+ Returns:
108
+ List of SerializedField objects that are missing
109
+ """
110
+ missing = []
111
+ for f in self.fields:
112
+ if f.unity_name not in existing_names:
113
+ missing.append(f)
114
+ return missing
115
+
116
+
117
+ # Regex patterns for C# parsing
118
+ # Note: These are simplified patterns that work for common cases
119
+
120
+ # Match class declaration
121
+ CLASS_PATTERN = re.compile(
122
+ r"(?:public\s+)?(?:partial\s+)?class\s+(\w+)" r"(?:\s*:\s*(\w+(?:\s*,\s*\w+)*))?" r"\s*\{", re.MULTILINE
123
+ )
124
+
125
+ # Match namespace declaration
126
+ NAMESPACE_PATTERN = re.compile(r"namespace\s+([\w.]+)\s*\{", re.MULTILINE)
127
+
128
+ # Match field declarations with attributes
129
+ # Captures: attributes, access_modifier, static/const/readonly, type, name, default_value
130
+ # Note: Access modifier is required to avoid matching method parameters
131
+ FIELD_PATTERN = re.compile(
132
+ r"(?P<attrs>(?:\[\s*[\w.()=,\s\"\']+\s*\]\s*)*)" # Attributes
133
+ r"(?P<access>public|private|protected|internal)\s+" # Access modifier (required)
134
+ r"(?P<modifiers>(?:(?:static|const|readonly|volatile|new)\s+)*)" # Other modifiers
135
+ r"(?P<type>[\w.<>,\[\]\s?]+?)\s+" # Type (including generics, arrays, nullable)
136
+ r"(?P<name>\w+)\s*" # Field name
137
+ r"(?:=\s*(?P<default>[^;]+))?\s*;", # Optional initializer and semicolon
138
+ re.MULTILINE,
139
+ )
140
+
141
+ # Match SerializeField attribute
142
+ SERIALIZE_FIELD_ATTR = re.compile(r"\[\s*SerializeField\s*\]", re.IGNORECASE)
143
+
144
+ # Match NonSerialized attribute
145
+ NON_SERIALIZED_ATTR = re.compile(r"\[\s*(?:System\.)?NonSerialized\s*\]", re.IGNORECASE)
146
+
147
+ # Match HideInInspector attribute (still serialized, just hidden)
148
+ HIDE_IN_INSPECTOR_ATTR = re.compile(r"\[\s*HideInInspector\s*\]", re.IGNORECASE)
149
+
150
+ # Match FormerlySerializedAs attribute - captures the old field name
151
+ # Example: [FormerlySerializedAs("oldName")] or [UnityEngine.Serialization.FormerlySerializedAs("oldName")]
152
+ FORMERLY_SERIALIZED_AS_ATTR = re.compile(
153
+ r"\[\s*(?:UnityEngine\.Serialization\.)?FormerlySerializedAs\s*\(\s*\"(\w+)\"\s*\)\s*\]", re.IGNORECASE
154
+ )
155
+
156
+
157
+ def _parse_default_value(value_str: str | None, field_type: str) -> any:
158
+ """Parse a C# default value string into a Unity-compatible Python value.
159
+
160
+ Args:
161
+ value_str: The default value string from C# code (e.g., "100", "5.5f", '"hello"')
162
+ field_type: The C# type name
163
+
164
+ Returns:
165
+ Python value suitable for Unity YAML serialization, or None if cannot parse
166
+ """
167
+ if value_str is None:
168
+ # Return type-appropriate default for common types
169
+ return _get_type_default(field_type)
170
+
171
+ value_str = value_str.strip()
172
+
173
+ # Handle null/default
174
+ if value_str in ("null", "default"):
175
+ return _get_type_default(field_type)
176
+
177
+ # Handle boolean
178
+ if value_str == "true":
179
+ return 1 # Unity uses 1 for true
180
+ if value_str == "false":
181
+ return 0 # Unity uses 0 for false
182
+
183
+ # Handle string literals
184
+ if value_str.startswith('"') and value_str.endswith('"'):
185
+ return value_str[1:-1] # Remove quotes
186
+
187
+ # Handle character literals
188
+ if value_str.startswith("'") and value_str.endswith("'"):
189
+ return value_str[1:-1]
190
+
191
+ # Handle float/double (remove suffix)
192
+ if value_str.endswith("f") or value_str.endswith("F"):
193
+ try:
194
+ return float(value_str[:-1])
195
+ except ValueError:
196
+ pass
197
+ if value_str.endswith("d") or value_str.endswith("D"):
198
+ try:
199
+ return float(value_str[:-1])
200
+ except ValueError:
201
+ pass
202
+
203
+ # Handle integer (remove suffix)
204
+ int_suffixes = ["u", "U", "l", "L", "ul", "UL", "lu", "LU"]
205
+ for suffix in int_suffixes:
206
+ if value_str.endswith(suffix):
207
+ value_str = value_str[: -len(suffix)]
208
+ break
209
+
210
+ # Try integer
211
+ try:
212
+ return int(value_str)
213
+ except ValueError:
214
+ pass
215
+
216
+ # Try float
217
+ try:
218
+ return float(value_str)
219
+ except ValueError:
220
+ pass
221
+
222
+ # Handle Vector2/Vector3/Vector4 constructors
223
+ # new Vector3(1, 2, 3) or Vector3.zero etc.
224
+ vector_match = re.match(r"new\s+(Vector[234]|Quaternion|Color)\s*\(\s*([^)]*)\s*\)", value_str, re.IGNORECASE)
225
+ if vector_match:
226
+ vec_type = vector_match.group(1).lower()
227
+ args_str = vector_match.group(2)
228
+ return _parse_vector_args(vec_type, args_str)
229
+
230
+ # Handle static members like Vector3.zero, Color.white
231
+ static_match = re.match(r"(Vector[234]|Quaternion|Color)\.(\w+)", value_str, re.IGNORECASE)
232
+ if static_match:
233
+ type_name = static_match.group(1).lower()
234
+ member = static_match.group(2).lower()
235
+ return _get_static_member_value(type_name, member)
236
+
237
+ # Cannot parse - return type default
238
+ return _get_type_default(field_type)
239
+
240
+
241
+ def _parse_vector_args(vec_type: str, args_str: str) -> dict | None:
242
+ """Parse vector constructor arguments."""
243
+ if not args_str.strip():
244
+ return _get_type_default(vec_type)
245
+
246
+ # Split by comma and parse each value
247
+ parts = [p.strip() for p in args_str.split(",")]
248
+
249
+ try:
250
+ values = []
251
+ for p in parts:
252
+ # Remove float suffix
253
+ if p.endswith("f") or p.endswith("F"):
254
+ p = p[:-1]
255
+ values.append(float(p))
256
+ except ValueError:
257
+ return _get_type_default(vec_type)
258
+
259
+ if vec_type == "vector2" and len(values) >= 2:
260
+ return {"x": values[0], "y": values[1]}
261
+ elif vec_type == "vector3" and len(values) >= 3:
262
+ return {"x": values[0], "y": values[1], "z": values[2]}
263
+ elif vec_type == "vector4" and len(values) >= 4:
264
+ return {"x": values[0], "y": values[1], "z": values[2], "w": values[3]}
265
+ elif vec_type == "quaternion" and len(values) >= 4:
266
+ return {"x": values[0], "y": values[1], "z": values[2], "w": values[3]}
267
+ elif vec_type == "color" and len(values) >= 3:
268
+ r, g, b = values[0], values[1], values[2]
269
+ a = values[3] if len(values) >= 4 else 1.0
270
+ return {"r": r, "g": g, "b": b, "a": a}
271
+
272
+ return _get_type_default(vec_type)
273
+
274
+
275
+ def _get_static_member_value(type_name: str, member: str) -> dict | None:
276
+ """Get value for static members like Vector3.zero, Color.white."""
277
+ static_values = {
278
+ "vector2": {
279
+ "zero": {"x": 0, "y": 0},
280
+ "one": {"x": 1, "y": 1},
281
+ "up": {"x": 0, "y": 1},
282
+ "down": {"x": 0, "y": -1},
283
+ "left": {"x": -1, "y": 0},
284
+ "right": {"x": 1, "y": 0},
285
+ },
286
+ "vector3": {
287
+ "zero": {"x": 0, "y": 0, "z": 0},
288
+ "one": {"x": 1, "y": 1, "z": 1},
289
+ "up": {"x": 0, "y": 1, "z": 0},
290
+ "down": {"x": 0, "y": -1, "z": 0},
291
+ "left": {"x": -1, "y": 0, "z": 0},
292
+ "right": {"x": 1, "y": 0, "z": 0},
293
+ "forward": {"x": 0, "y": 0, "z": 1},
294
+ "back": {"x": 0, "y": 0, "z": -1},
295
+ },
296
+ "vector4": {
297
+ "zero": {"x": 0, "y": 0, "z": 0, "w": 0},
298
+ "one": {"x": 1, "y": 1, "z": 1, "w": 1},
299
+ },
300
+ "quaternion": {
301
+ "identity": {"x": 0, "y": 0, "z": 0, "w": 1},
302
+ },
303
+ "color": {
304
+ "white": {"r": 1, "g": 1, "b": 1, "a": 1},
305
+ "black": {"r": 0, "g": 0, "b": 0, "a": 1},
306
+ "red": {"r": 1, "g": 0, "b": 0, "a": 1},
307
+ "green": {"r": 0, "g": 1, "b": 0, "a": 1},
308
+ "blue": {"r": 0, "g": 0, "b": 1, "a": 1},
309
+ "yellow": {"r": 1, "g": 0.92156863, "b": 0.015686275, "a": 1},
310
+ "cyan": {"r": 0, "g": 1, "b": 1, "a": 1},
311
+ "magenta": {"r": 1, "g": 0, "b": 1, "a": 1},
312
+ "gray": {"r": 0.5, "g": 0.5, "b": 0.5, "a": 1},
313
+ "grey": {"r": 0.5, "g": 0.5, "b": 0.5, "a": 1},
314
+ "clear": {"r": 0, "g": 0, "b": 0, "a": 0},
315
+ },
316
+ }
317
+
318
+ type_values = static_values.get(type_name, {})
319
+ return type_values.get(member, _get_type_default(type_name))
320
+
321
+
322
+ def _get_type_default(field_type: str) -> any:
323
+ """Get the default value for a Unity field type."""
324
+ if field_type is None:
325
+ return None
326
+
327
+ type_lower = field_type.lower().strip()
328
+
329
+ # Remove nullable marker
330
+ if type_lower.endswith("?"):
331
+ type_lower = type_lower[:-1]
332
+
333
+ # Primitives
334
+ if type_lower in ("int", "int32", "uint", "uint32", "short", "ushort", "long", "ulong", "byte", "sbyte"):
335
+ return 0
336
+ if type_lower in ("float", "single", "double"):
337
+ return 0.0
338
+ if type_lower in ("bool", "boolean"):
339
+ return 0 # Unity uses 0 for false
340
+ if type_lower in ("string",):
341
+ return ""
342
+ if type_lower in ("char",):
343
+ return ""
344
+
345
+ # Unity types
346
+ if type_lower == "vector2":
347
+ return {"x": 0, "y": 0}
348
+ if type_lower == "vector3":
349
+ return {"x": 0, "y": 0, "z": 0}
350
+ if type_lower == "vector4":
351
+ return {"x": 0, "y": 0, "z": 0, "w": 0}
352
+ if type_lower == "quaternion":
353
+ return {"x": 0, "y": 0, "z": 0, "w": 1}
354
+ if type_lower == "color":
355
+ return {"r": 0, "g": 0, "b": 0, "a": 1}
356
+ if type_lower == "color32":
357
+ return {"r": 0, "g": 0, "b": 0, "a": 255}
358
+ if type_lower == "rect":
359
+ return {"x": 0, "y": 0, "width": 0, "height": 0}
360
+ if type_lower == "bounds":
361
+ return {"m_Center": {"x": 0, "y": 0, "z": 0}, "m_Extent": {"x": 0, "y": 0, "z": 0}}
362
+
363
+ # Reference types - use Unity's null reference format
364
+ # For GameObject, Component, etc. references, use fileID: 0
365
+ if _is_reference_type(type_lower):
366
+ return {"fileID": 0}
367
+
368
+ # Arrays/Lists - empty array
369
+ if type_lower.endswith("[]") or type_lower.startswith("list<"):
370
+ return []
371
+
372
+ # Unknown type - return None (will be skipped)
373
+ return None
374
+
375
+
376
+ def _is_reference_type(type_name: str) -> bool:
377
+ """Check if a type is a Unity reference type."""
378
+ reference_types = {
379
+ "gameobject",
380
+ "transform",
381
+ "component",
382
+ "monobehaviour",
383
+ "rigidbody",
384
+ "rigidbody2d",
385
+ "collider",
386
+ "collider2d",
387
+ "renderer",
388
+ "spriterenderer",
389
+ "meshrenderer",
390
+ "animator",
391
+ "animation",
392
+ "audioSource",
393
+ "camera",
394
+ "light",
395
+ "sprite",
396
+ "texture",
397
+ "texture2d",
398
+ "material",
399
+ "mesh",
400
+ "scriptableobject",
401
+ "object",
402
+ }
403
+ return type_name.lower() in reference_types
404
+
405
+
406
+ def parse_script(content: str, path: Path | None = None) -> ScriptInfo | None:
407
+ """Parse a C# script and extract serialized field information.
408
+
409
+ Args:
410
+ content: The C# script content
411
+ path: Optional path to the script file
412
+
413
+ Returns:
414
+ ScriptInfo object with extracted information, or None if parsing fails
415
+ """
416
+ # Remove comments to avoid false matches
417
+ content = _remove_comments(content)
418
+
419
+ # Find namespace
420
+ namespace_match = NAMESPACE_PATTERN.search(content)
421
+ namespace = namespace_match.group(1) if namespace_match else None
422
+
423
+ # Find class declaration
424
+ class_match = CLASS_PATTERN.search(content)
425
+ if not class_match:
426
+ return None
427
+
428
+ class_name = class_match.group(1)
429
+ base_class = class_match.group(2).split(",")[0].strip() if class_match.group(2) else None
430
+
431
+ # Check if it's a MonoBehaviour or ScriptableObject
432
+ is_unity_class = _is_unity_serializable_class(base_class, content)
433
+
434
+ info = ScriptInfo(
435
+ class_name=class_name,
436
+ namespace=namespace,
437
+ base_class=base_class,
438
+ path=path,
439
+ )
440
+
441
+ if not is_unity_class:
442
+ # Not a Unity serializable class, but we can still try to extract fields
443
+ # for [System.Serializable] classes used as nested types
444
+ pass
445
+
446
+ # Find class body
447
+ class_start = class_match.end()
448
+ class_body = _extract_class_body(content, class_start)
449
+
450
+ if class_body is None:
451
+ return info
452
+
453
+ # Extract fields
454
+ for match in FIELD_PATTERN.finditer(class_body):
455
+ attrs = match.group("attrs") or ""
456
+ access = match.group("access") or "private"
457
+ modifiers = match.group("modifiers") or ""
458
+ field_type = match.group("type").strip()
459
+ field_name = match.group("name")
460
+ default_str = match.group("default") # May be None
461
+
462
+ # Skip static, const, readonly fields
463
+ if any(mod in modifiers.lower() for mod in ["static", "const", "readonly"]):
464
+ continue
465
+
466
+ # Skip non-serialized fields
467
+ if NON_SERIALIZED_ATTR.search(attrs):
468
+ continue
469
+
470
+ # Determine if field is serialized
471
+ is_public = access == "public"
472
+ has_serialize_field = bool(SERIALIZE_FIELD_ATTR.search(attrs))
473
+
474
+ # In Unity, fields are serialized if:
475
+ # - Public (and not NonSerialized)
476
+ # - Private/Protected with [SerializeField]
477
+ if is_public or has_serialize_field:
478
+ # Calculate line number
479
+ line_num = content[: class_start + match.start()].count("\n") + 1
480
+
481
+ # Extract FormerlySerializedAs names
482
+ former_names = FORMERLY_SERIALIZED_AS_ATTR.findall(attrs)
483
+
484
+ # Parse default value
485
+ default_value = _parse_default_value(default_str, field_type)
486
+
487
+ info.fields.append(
488
+ SerializedField.from_field_name(
489
+ name=field_name,
490
+ field_type=field_type,
491
+ former_names=former_names,
492
+ default_value=default_value,
493
+ is_public=is_public,
494
+ has_serialize_field=has_serialize_field,
495
+ line_number=line_num,
496
+ )
497
+ )
498
+
499
+ return info
500
+
501
+
502
+ def parse_script_file(path: Path) -> ScriptInfo | None:
503
+ """Parse a C# script file.
504
+
505
+ Args:
506
+ path: Path to the .cs file
507
+
508
+ Returns:
509
+ ScriptInfo object or None if parsing fails
510
+ """
511
+ try:
512
+ content = path.read_text(encoding="utf-8-sig") # Handle BOM
513
+ info = parse_script(content, path)
514
+ return info
515
+ except (OSError, UnicodeDecodeError):
516
+ return None
517
+
518
+
519
+ def get_script_field_order(
520
+ script_guid: str,
521
+ guid_index: GUIDIndex | None = None,
522
+ project_root: Path | None = None,
523
+ ) -> list[str] | None:
524
+ """Get the field order for a script by its GUID.
525
+
526
+ Args:
527
+ script_guid: The GUID of the script asset
528
+ guid_index: Optional pre-built GUID index
529
+ project_root: Optional project root (for building index)
530
+
531
+ Returns:
532
+ List of Unity field names (m_FieldName format) in declaration order,
533
+ or None if script cannot be found or parsed
534
+ """
535
+ if not script_guid:
536
+ return None
537
+
538
+ # Build index if not provided
539
+ if guid_index is None:
540
+ if project_root is None:
541
+ return None
542
+ guid_index = build_guid_index(project_root)
543
+
544
+ # Find script path
545
+ script_path = guid_index.get_path(script_guid)
546
+ if script_path is None:
547
+ return None
548
+
549
+ # Resolve to absolute path
550
+ if guid_index.project_root and not script_path.is_absolute():
551
+ script_path = guid_index.project_root / script_path
552
+
553
+ # Check if it's a C# script
554
+ if script_path.suffix.lower() != ".cs":
555
+ return None
556
+
557
+ # Parse script
558
+ info = parse_script_file(script_path)
559
+ if info is None:
560
+ return None
561
+
562
+ return info.get_field_order()
563
+
564
+
565
+ def reorder_fields(
566
+ fields: dict[str, any],
567
+ field_order: list[str],
568
+ unity_fields_first: bool = True,
569
+ ) -> dict[str, any]:
570
+ """Reorder dictionary fields according to the script field order.
571
+
572
+ Args:
573
+ fields: Dictionary of field name -> value
574
+ field_order: List of field names in desired order
575
+ unity_fields_first: If True, keep Unity standard fields first
576
+
577
+ Returns:
578
+ New dictionary with reordered fields
579
+ """
580
+ # Unity standard fields that should always come first
581
+ unity_standard_fields = [
582
+ "m_ObjectHideFlags",
583
+ "m_CorrespondingSourceObject",
584
+ "m_PrefabInstance",
585
+ "m_PrefabAsset",
586
+ "m_GameObject",
587
+ "m_Enabled",
588
+ "m_EditorHideFlags",
589
+ "m_Script",
590
+ "m_Name",
591
+ "m_EditorClassIdentifier",
592
+ ]
593
+
594
+ result = {}
595
+
596
+ # Add Unity standard fields first (if present)
597
+ if unity_fields_first:
598
+ for key in unity_standard_fields:
599
+ if key in fields:
600
+ result[key] = fields[key]
601
+
602
+ # Add fields in script order
603
+ for key in field_order:
604
+ if key in fields and key not in result:
605
+ result[key] = fields[key]
606
+
607
+ # Add any remaining fields (not in order list)
608
+ for key in fields:
609
+ if key not in result:
610
+ result[key] = fields[key]
611
+
612
+ return result
613
+
614
+
615
+ def _remove_comments(content: str) -> str:
616
+ """Remove C# comments from source code."""
617
+ # Remove multi-line comments
618
+ content = re.sub(r"/\*.*?\*/", "", content, flags=re.DOTALL)
619
+ # Remove single-line comments
620
+ content = re.sub(r"//.*$", "", content, flags=re.MULTILINE)
621
+ return content
622
+
623
+
624
+ def _extract_class_body(content: str, start_pos: int) -> str | None:
625
+ """Extract the body of a class from the opening brace.
626
+
627
+ Args:
628
+ content: Full source content
629
+ start_pos: Position after the opening brace
630
+
631
+ Returns:
632
+ The class body content, or None if parsing fails
633
+ """
634
+ # We start just after the opening brace of the class
635
+ # Need to find the matching closing brace
636
+ depth = 1
637
+ pos = start_pos
638
+
639
+ while pos < len(content) and depth > 0:
640
+ char = content[pos]
641
+ if char == "{":
642
+ depth += 1
643
+ elif char == "}":
644
+ depth -= 1
645
+ pos += 1
646
+
647
+ if depth != 0:
648
+ return None
649
+
650
+ return content[start_pos : pos - 1]
651
+
652
+
653
+ def _is_unity_serializable_class(base_class: str | None, content: str) -> bool:
654
+ """Check if a class is a Unity serializable class.
655
+
656
+ Args:
657
+ base_class: The base class name (if any)
658
+ content: Full source content for checking using statements
659
+
660
+ Returns:
661
+ True if this appears to be a Unity MonoBehaviour, ScriptableObject, etc.
662
+ """
663
+ unity_base_classes = {
664
+ "MonoBehaviour",
665
+ "ScriptableObject",
666
+ "StateMachineBehaviour",
667
+ "NetworkBehaviour", # Mirror/UNET
668
+ "SerializedObject",
669
+ }
670
+
671
+ if base_class and base_class in unity_base_classes:
672
+ return True
673
+
674
+ # Check for inheritance chain (simplified)
675
+ # Also check for [System.Serializable] attribute on the class
676
+ if re.search(r"\[\s*(?:System\.)?Serializable\s*\]", content):
677
+ return True
678
+
679
+ return base_class is not None # Assume any class with base could be Unity class
680
+
681
+
682
+ @dataclass
683
+ class ScriptFieldCache:
684
+ """Cache for script field order lookups."""
685
+
686
+ _cache: dict[str, list[str] | None] = field(default_factory=dict)
687
+ guid_index: GUIDIndex | None = None
688
+ project_root: Path | None = None
689
+
690
+ def get_field_order(self, script_guid: str) -> list[str] | None:
691
+ """Get field order for a script, using cache.
692
+
693
+ Args:
694
+ script_guid: The script GUID
695
+
696
+ Returns:
697
+ List of Unity field names in order, or None if not found
698
+ """
699
+ if script_guid in self._cache:
700
+ return self._cache[script_guid]
701
+
702
+ # Build index if needed
703
+ if self.guid_index is None and self.project_root:
704
+ self.guid_index = build_guid_index(self.project_root)
705
+
706
+ result = get_script_field_order(
707
+ script_guid,
708
+ guid_index=self.guid_index,
709
+ project_root=self.project_root,
710
+ )
711
+
712
+ self._cache[script_guid] = result
713
+ return result
714
+
715
+ def clear(self) -> None:
716
+ """Clear the cache."""
717
+ self._cache.clear()