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.
- unityflow/__init__.py +167 -0
- unityflow/asset_resolver.py +636 -0
- unityflow/asset_tracker.py +1687 -0
- unityflow/cli.py +2317 -0
- unityflow/data/__init__.py +1 -0
- unityflow/data/class_ids.json +336 -0
- unityflow/diff.py +234 -0
- unityflow/fast_parser.py +676 -0
- unityflow/formats.py +1558 -0
- unityflow/git_utils.py +307 -0
- unityflow/hierarchy.py +1672 -0
- unityflow/merge.py +226 -0
- unityflow/meta_generator.py +1291 -0
- unityflow/normalizer.py +529 -0
- unityflow/parser.py +698 -0
- unityflow/query.py +406 -0
- unityflow/script_parser.py +717 -0
- unityflow/sprite.py +378 -0
- unityflow/validator.py +783 -0
- unityflow-0.3.4.dist-info/METADATA +293 -0
- unityflow-0.3.4.dist-info/RECORD +25 -0
- unityflow-0.3.4.dist-info/WHEEL +5 -0
- unityflow-0.3.4.dist-info/entry_points.txt +2 -0
- unityflow-0.3.4.dist-info/licenses/LICENSE +21 -0
- unityflow-0.3.4.dist-info/top_level.txt +1 -0
|
@@ -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()
|