gdharness 0.4.1

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.
Files changed (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +125 -0
  3. package/build/cli.js +30353 -0
  4. package/build/godot/addons/auto_reload/auto_reload.gd +89 -0
  5. package/build/godot/addons/auto_reload/plugin.cfg +6 -0
  6. package/build/godot/addons/gdharness_editor/bridge_client.gd +197 -0
  7. package/build/godot/addons/gdharness_editor/plugin.cfg +6 -0
  8. package/build/godot/addons/gdharness_editor/plugin.gd +88 -0
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +104 -0
  10. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +397 -0
  11. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +85 -0
  12. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +427 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +885 -0
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +292 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +77 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_input.gd +254 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +283 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_values.gd +169 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +119 -0
  20. package/build/godot/operations/audio_buses.gd +125 -0
  21. package/build/godot/operations/class_cache.gd +180 -0
  22. package/build/godot/operations/classdb_queries.gd +252 -0
  23. package/build/godot/operations/dependencies.gd +293 -0
  24. package/build/godot/operations/file_walk.gd +55 -0
  25. package/build/godot/operations/gdscript_analysis.gd +288 -0
  26. package/build/godot/operations/gdscript_authoring.gd +419 -0
  27. package/build/godot/operations/godot_operations.gd +186 -0
  28. package/build/godot/operations/import_pipeline.gd +390 -0
  29. package/build/godot/operations/input_actions.gd +237 -0
  30. package/build/godot/operations/logger.gd +33 -0
  31. package/build/godot/operations/plugins.gd +159 -0
  32. package/build/godot/operations/project_config.gd +153 -0
  33. package/build/godot/operations/project_diagnostics.gd +159 -0
  34. package/build/godot/operations/resource_files.gd +132 -0
  35. package/build/godot/operations/serialisation.gd +154 -0
  36. package/build/index.js +29252 -0
  37. package/package.json +57 -0
@@ -0,0 +1,293 @@
1
+ extends RefCounted
2
+
3
+ const FileWalk = preload("file_walk.gd")
4
+ const Log = preload("logger.gd")
5
+
6
+ # What a dependency reference looks like in source, and how the path is read out of each form.
7
+ const REFERENCE_PATTERNS: Array[String] = [
8
+ "res://[^\"'\\s\\]\\)]+",
9
+ 'preload\\("([^"]+)"\\)',
10
+ 'load\\("([^"]+)"\\)',
11
+ 'ext_resource.*path="([^"]+)"',
12
+ ]
13
+
14
+ var _log: Log
15
+ var _files: FileWalk = FileWalk.new()
16
+
17
+
18
+ # Everything one dependency walk carries: the settings it was started with, and the state it
19
+ # accumulates as it recurses. Kept together so the recursion passes one object rather than
20
+ # six positional arguments that have to stay in the same order at every call site.
21
+ class DependencyWalk:
22
+ var max_depth: int
23
+ var include_built_in: bool
24
+ var visited: Dictionary = {}
25
+ var path_stack: Array[String] = []
26
+ var circular_references: Array
27
+
28
+ func _init(p_max_depth: int, p_include_built_in: bool, p_circular_references: Array) -> void:
29
+ max_depth = p_max_depth
30
+ include_built_in = p_include_built_in
31
+ circular_references = p_circular_references
32
+
33
+
34
+ func _init(p_log: Log) -> void:
35
+ _log = p_log
36
+
37
+
38
+ # Get dependencies for a resource with circular reference detection
39
+ func get_dependencies(params: Dictionary) -> Dictionary:
40
+ var resource_path: String = str(params.get("resource_path", ""))
41
+ # No depth, or a depth of zero or less, means the whole chain; the walk stops at cycles.
42
+ var depth: int = int(params.get("depth", 0))
43
+ var max_depth: int = depth if depth > 0 else 1000
44
+ var include_built_in: bool = bool(params.get("include_builtin", false))
45
+
46
+ _log.info(
47
+ (
48
+ "Getting dependencies"
49
+ + (" for: " + resource_path if not resource_path.is_empty() else " for all resources")
50
+ )
51
+ )
52
+
53
+ var dependencies: Dictionary = {}
54
+ var circular_references: Array = []
55
+ var total_resources: int = 0
56
+
57
+ if not resource_path.is_empty():
58
+ var full_path: String = resource_path
59
+ if not full_path.begins_with("res://"):
60
+ full_path = "res://" + full_path
61
+
62
+ if not FileAccess.file_exists(full_path):
63
+ return _log.failure("Resource file does not exist: " + full_path)
64
+
65
+ var walk: DependencyWalk = DependencyWalk.new(max_depth, include_built_in, circular_references)
66
+ dependencies[full_path] = _analyze_resource(full_path, 0, walk)
67
+ total_resources = 1
68
+ else:
69
+ var resource_extensions: Array[String] = ["tscn", "tres", "gd", "gdshader", "shader"]
70
+ var all_resources: Array[String] = []
71
+ for ext: String in resource_extensions:
72
+ all_resources.append_array(_files.find_files("res://", "." + ext))
73
+
74
+ # Each root gets a fresh walk so a cycle is reported from every resource it passes
75
+ # through, rather than only from whichever one happened to be walked first.
76
+ for res_path: String in all_resources:
77
+ var walk: DependencyWalk = DependencyWalk.new(max_depth, include_built_in, circular_references)
78
+ var deps: Array[Dictionary] = _analyze_resource(res_path, 0, walk)
79
+ if deps.size() > 0:
80
+ dependencies[res_path] = deps
81
+
82
+ total_resources = all_resources.size()
83
+
84
+ var dep_count: int = 0
85
+ for key: String in dependencies:
86
+ dep_count += _count_recursive(dependencies[key])
87
+
88
+ return {
89
+ "dependencies": dependencies,
90
+ "circular_references": circular_references,
91
+ "summary":
92
+ {
93
+ "total_resources": total_resources,
94
+ "total_dependencies": dep_count,
95
+ "circular_count": circular_references.size()
96
+ }
97
+ }
98
+
99
+
100
+ # What refers to a resource, and how: the scenes that instance it, the scripts that extend or
101
+ # preload it, and for a script with a class_name, every use of that name.
102
+ func find_resource_usages(params: Dictionary) -> Dictionary:
103
+ var resource_path: String = str(params.get("resource_path", ""))
104
+ var file_types: Array = params.get("file_types", ["tscn", "tres", "gd", "gdshader"])
105
+
106
+ if not resource_path.begins_with("res://"):
107
+ resource_path = "res://" + resource_path
108
+ if not FileAccess.file_exists(resource_path):
109
+ return _log.failure("Resource file does not exist: " + resource_path)
110
+
111
+ _log.info("Finding usages of: " + resource_path)
112
+
113
+ var class_name_declared: String = _declared_class_name(resource_path)
114
+ var by_path: RegEx = RegEx.new()
115
+ by_path.compile('"(res://)?' + _regex_escaped(resource_path.substr(6)) + '"')
116
+ var by_class: RegEx = null
117
+ if not class_name_declared.is_empty():
118
+ by_class = RegEx.new()
119
+ by_class.compile("\\b" + _regex_escaped(class_name_declared) + "\\b")
120
+
121
+ var all_files: Array[String] = []
122
+ for ext: Variant in file_types:
123
+ all_files.append_array(_files.find_files("res://", "." + str(ext)))
124
+
125
+ var usages: Array[Dictionary] = []
126
+ var by_kind: Dictionary = {}
127
+ var total: int = 0
128
+
129
+ for file_path: String in all_files:
130
+ if file_path == resource_path:
131
+ continue
132
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
133
+ if not file:
134
+ continue
135
+ var lines: PackedStringArray = file.get_as_text().split("\n")
136
+ file.close()
137
+
138
+ var references: Array[Dictionary] = []
139
+ for i: int in range(lines.size()):
140
+ var line: String = lines[i]
141
+ var kind: String = ""
142
+ if by_path.search(line) != null:
143
+ kind = _path_reference_kind(line)
144
+ elif by_class != null and by_class.search(line) != null:
145
+ kind = "extends" if line.strip_edges().begins_with("extends ") else "class_name"
146
+ if kind.is_empty():
147
+ continue
148
+ references.append({"line": i + 1, "kind": kind, "text": line.strip_edges()})
149
+ by_kind[kind] = int(by_kind.get(kind, 0)) + 1
150
+
151
+ if not references.is_empty():
152
+ usages.append({"file": file_path, "references": references})
153
+ total += references.size()
154
+
155
+ var declared: Variant = null
156
+ if not class_name_declared.is_empty():
157
+ declared = class_name_declared
158
+ return {
159
+ "resource_path": resource_path,
160
+ "class_name": declared,
161
+ "usages": usages,
162
+ "summary":
163
+ {
164
+ "files_searched": all_files.size(),
165
+ "files_with_usages": usages.size(),
166
+ "total": total,
167
+ "by_kind": by_kind,
168
+ },
169
+ }
170
+
171
+
172
+ # The class_name a script declares, or empty for a scene, a resource or a script without one.
173
+ func _declared_class_name(path: String) -> String:
174
+ if not path.ends_with(".gd"):
175
+ return ""
176
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
177
+ if not file:
178
+ return ""
179
+ var declaration: RegEx = RegEx.new()
180
+ declaration.compile("^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)")
181
+ while not file.eof_reached():
182
+ var found: RegExMatch = declaration.search(file.get_line())
183
+ if found != null:
184
+ file.close()
185
+ return found.get_string(1)
186
+ file.close()
187
+ return ""
188
+
189
+
190
+ # How a line that names the resource by path uses it.
191
+ func _path_reference_kind(line: String) -> String:
192
+ var trimmed: String = line.strip_edges()
193
+ if trimmed.begins_with("extends "):
194
+ return "extends"
195
+ if trimmed.begins_with("[ext_resource"):
196
+ return "ext_resource"
197
+ if "preload(" in trimmed:
198
+ return "preload"
199
+ if "load(" in trimmed:
200
+ return "load"
201
+ return "path"
202
+
203
+
204
+ func _regex_escaped(text: String) -> String:
205
+ var escaped: String = ""
206
+ for character: String in text:
207
+ if character in "\\^$.|?*+()[]{}/":
208
+ escaped += "\\"
209
+ escaped += character
210
+ return escaped
211
+
212
+
213
+ func _count_recursive(deps: Array[Dictionary]) -> int:
214
+ var count: int = deps.size()
215
+ for dep: Dictionary in deps:
216
+ if dep.has("dependencies"):
217
+ count += _count_recursive(dep["dependencies"])
218
+ return count
219
+
220
+
221
+ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -> Array[Dictionary]:
222
+ var deps: Array[Dictionary] = []
223
+
224
+ if current_depth >= walk.max_depth:
225
+ return deps
226
+
227
+ if path in walk.path_stack:
228
+ var cycle: Array[String] = walk.path_stack.slice(walk.path_stack.find(path))
229
+ cycle.append(path)
230
+ if not cycle in walk.circular_references:
231
+ walk.circular_references.append(cycle)
232
+ return [{"path": path, "circular": true}]
233
+
234
+ if walk.visited.has(path):
235
+ var cached: Array[Dictionary] = walk.visited[path]
236
+ return cached
237
+
238
+ walk.path_stack.append(path)
239
+
240
+ var file: FileAccess = FileAccess.open(path, FileAccess.READ)
241
+ if file:
242
+ var content: String = file.get_as_text()
243
+ file.close()
244
+
245
+ for pattern: String in REFERENCE_PATTERNS:
246
+ var regex: RegEx = RegEx.new()
247
+ regex.compile(pattern)
248
+ for m: RegExMatch in regex.search_all(content):
249
+ var dep_path: String = _referenced_path(m.get_string())
250
+
251
+ if not dep_path.begins_with("res://"):
252
+ continue
253
+
254
+ # Skip engine-internal resources unless requested. `addons/` is not one of
255
+ # them: it is ordinary project content, and often shipping content, so
256
+ # treating it as built-in dropped every dependency of anything living there.
257
+ if not walk.include_built_in and dep_path.begins_with("res://."):
258
+ continue
259
+
260
+ if dep_path == path:
261
+ continue
262
+
263
+ var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
264
+
265
+ if dep_info["exists"] and current_depth + 1 < walk.max_depth:
266
+ var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
267
+ if sub_deps.size() > 0:
268
+ dep_info["dependencies"] = sub_deps
269
+
270
+ var already_added: bool = false
271
+ for existing: Dictionary in deps:
272
+ if existing.get("path", "") == dep_path:
273
+ already_added = true
274
+ break
275
+ if not already_added:
276
+ deps.append(dep_info)
277
+
278
+ walk.path_stack.pop_back()
279
+ walk.visited[path] = deps
280
+ return deps
281
+
282
+
283
+ # The path inside a preload(), load() or ext_resource match; a bare res:// match is the path.
284
+ func _referenced_path(matched: String) -> String:
285
+ var inner: RegEx = RegEx.new()
286
+ if "preload" in matched or "load" in matched:
287
+ inner.compile('"([^"]+)"')
288
+ elif "ext_resource" in matched:
289
+ inner.compile('path="([^"]+)"')
290
+ else:
291
+ return matched
292
+ var inner_match: RegExMatch = inner.search(matched)
293
+ return inner_match.get_string(1) if inner_match else matched
@@ -0,0 +1,55 @@
1
+ extends RefCounted
2
+
3
+ # Two walks rather than one because they disagree about hidden entries on purpose: a suffix
4
+ # walk descends into every visible directory and matches whole names, while the extension walk
5
+ # skips anything beginning with a dot, including .import sidecars that would otherwise answer
6
+ # for the resource they belong to.
7
+
8
+
9
+ # Files under `path` whose name ends with `extension`, which is passed with its dot.
10
+ func find_files(path: String, extension: String) -> Array[String]:
11
+ var files: Array[String] = []
12
+ var dir: DirAccess = DirAccess.open(path)
13
+
14
+ if dir:
15
+ dir.list_dir_begin()
16
+ var file_name: String = dir.get_next()
17
+
18
+ while file_name != "":
19
+ if dir.current_is_dir() and not file_name.begins_with("."):
20
+ files.append_array(find_files(path + file_name + "/", extension))
21
+ elif file_name.ends_with(extension):
22
+ files.append(path + file_name)
23
+
24
+ file_name = dir.get_next()
25
+
26
+ return files
27
+
28
+
29
+ # Files under `path` whose extension is in `extensions`, which are passed without their dot.
30
+ func find_files_with_extensions(path: String, extensions: Array) -> Array[String]:
31
+ var files: Array[String] = []
32
+ var dir: DirAccess = DirAccess.open(path)
33
+
34
+ if dir:
35
+ dir.list_dir_begin()
36
+ var file_name: String = dir.get_next()
37
+
38
+ while file_name != "":
39
+ if file_name.begins_with("."):
40
+ file_name = dir.get_next()
41
+ continue
42
+
43
+ var full_path: String = path + file_name
44
+ if dir.current_is_dir():
45
+ files.append_array(find_files_with_extensions(full_path + "/", extensions))
46
+ else:
47
+ var ext: String = file_name.get_extension().to_lower()
48
+ if ext in extensions:
49
+ files.append(full_path)
50
+
51
+ file_name = dir.get_next()
52
+
53
+ dir.list_dir_end()
54
+
55
+ return files
@@ -0,0 +1,288 @@
1
+ extends RefCounted
2
+
3
+ const Log = preload("logger.gd")
4
+
5
+ var _log: Log
6
+
7
+
8
+ func _init(p_log: Log) -> void:
9
+ _log = p_log
10
+
11
+
12
+ # Analyze a GDScript file and return its structure
13
+ func get_gdscript_info(params: Dictionary) -> Dictionary:
14
+ var script_path: String = str(params.get("script_path", ""))
15
+
16
+ _log.info("Analyzing GDScript: " + script_path)
17
+
18
+ var full_script_path: String = script_path
19
+ if not full_script_path.begins_with("res://"):
20
+ full_script_path = "res://" + full_script_path
21
+
22
+ if not FileAccess.file_exists(full_script_path):
23
+ return _log.failure("Script file does not exist: " + full_script_path)
24
+
25
+ var file: FileAccess = FileAccess.open(full_script_path, FileAccess.READ)
26
+ if not file:
27
+ return _log.failure("Failed to open script file: " + full_script_path)
28
+
29
+ var content: String = file.get_as_text()
30
+ file.close()
31
+
32
+ var lines: PackedStringArray = content.split("\n")
33
+
34
+ var declared_class_name: Variant = null
35
+ var extends_name: String = "RefCounted"
36
+ var signals: Array[Dictionary] = []
37
+ var variables: Array[Dictionary] = []
38
+ var functions: Array[Dictionary] = []
39
+ var constants: Array[Dictionary] = []
40
+ var enums: Array[Dictionary] = []
41
+ var inner_classes: Array[String] = []
42
+ var dependencies: Array[String] = []
43
+
44
+ var in_multiline_string: bool = false
45
+
46
+ for i: int in range(lines.size()):
47
+ var stripped: String = lines[i].strip_edges()
48
+
49
+ if stripped.is_empty() or stripped.begins_with("#"):
50
+ continue
51
+
52
+ if '"""' in stripped or "'''" in stripped:
53
+ in_multiline_string = not in_multiline_string
54
+ continue
55
+
56
+ if in_multiline_string:
57
+ continue
58
+
59
+ if stripped.begins_with("class_name "):
60
+ declared_class_name = stripped.substr(11).strip_edges()
61
+ elif stripped.begins_with("extends "):
62
+ extends_name = stripped.substr(8).strip_edges()
63
+ elif stripped.begins_with("signal "):
64
+ signals.append(_parse_signal(stripped, i + 1))
65
+ elif stripped.begins_with("const "):
66
+ constants.append(_parse_constant(stripped, i + 1))
67
+ elif stripped.begins_with("enum "):
68
+ enums.append(_parse_enum(stripped, i + 1))
69
+ elif (
70
+ stripped.begins_with("var ")
71
+ or stripped.begins_with("@export")
72
+ or stripped.begins_with("@onready")
73
+ ):
74
+ variables.append(_parse_variable(stripped, i + 1))
75
+ elif stripped.begins_with("func ") or stripped.begins_with("static func "):
76
+ functions.append(_parse_function(stripped, i + 1))
77
+ elif stripped.begins_with("class "):
78
+ inner_classes.append(stripped.substr(6).split(":")[0].split(" ")[0].strip_edges())
79
+
80
+ if "preload(" in stripped or "load(" in stripped:
81
+ for dep: String in _extract_dependencies(stripped):
82
+ if dep not in dependencies:
83
+ dependencies.append(dep)
84
+
85
+ return {
86
+ "path": script_path,
87
+ "full_path": full_script_path,
88
+ "class_name": declared_class_name,
89
+ "extends": extends_name,
90
+ "signals": signals,
91
+ "variables": variables,
92
+ "functions": functions,
93
+ "constants": constants,
94
+ "enums": enums,
95
+ "inner_classes": inner_classes,
96
+ "dependencies": dependencies,
97
+ "line_count": lines.size()
98
+ }
99
+
100
+
101
+ func _parse_signal(line: String, line_num: int) -> Dictionary:
102
+ var signal_text: String = line.substr(7).strip_edges()
103
+ var signal_name: String = ""
104
+ var params: Array[Dictionary] = []
105
+
106
+ if "(" in signal_text:
107
+ var parts: PackedStringArray = signal_text.split("(")
108
+ signal_name = parts[0].strip_edges()
109
+ if parts.size() > 1:
110
+ var params_text: String = parts[1].replace(")", "").strip_edges()
111
+ if not params_text.is_empty():
112
+ for p: String in params_text.split(","):
113
+ params.append(_parse_param(p.strip_edges()))
114
+ else:
115
+ signal_name = signal_text
116
+
117
+ return {"name": signal_name, "params": params, "line": line_num}
118
+
119
+
120
+ func _parse_constant(line: String, line_num: int) -> Dictionary:
121
+ var const_text: String = line.substr(6).strip_edges()
122
+ var name: String = ""
123
+ var value: String = ""
124
+ var type_hint: String = ""
125
+
126
+ if "=" in const_text:
127
+ var parts: PackedStringArray = const_text.split("=", true, 1)
128
+ var name_part: String = parts[0].strip_edges()
129
+ value = parts[1].strip_edges() if parts.size() > 1 else ""
130
+
131
+ if ":" in name_part:
132
+ var type_parts: PackedStringArray = name_part.split(":")
133
+ name = type_parts[0].strip_edges()
134
+ type_hint = type_parts[1].strip_edges()
135
+ else:
136
+ name = name_part
137
+ else:
138
+ name = const_text
139
+
140
+ return {"name": name, "value": value, "type": type_hint, "line": line_num}
141
+
142
+
143
+ func _parse_enum(line: String, line_num: int) -> Dictionary:
144
+ var enum_text: String = line.substr(5).strip_edges()
145
+ var enum_name: String = ""
146
+ var values: Array[String] = []
147
+
148
+ if "{" in enum_text:
149
+ var parts: PackedStringArray = enum_text.split("{")
150
+ enum_name = parts[0].strip_edges()
151
+ if parts.size() > 1:
152
+ var values_text: String = parts[1].replace("}", "").strip_edges()
153
+ if not values_text.is_empty():
154
+ for v: String in values_text.split(","):
155
+ var val: String = v.strip_edges()
156
+ if not val.is_empty():
157
+ values.append(val)
158
+ else:
159
+ enum_name = enum_text
160
+
161
+ return {"name": enum_name, "values": values, "line": line_num}
162
+
163
+
164
+ func _parse_variable(line: String, line_num: int) -> Dictionary:
165
+ var is_export: bool = line.begins_with("@export")
166
+ var is_onready: bool = "@onready" in line
167
+ var export_hint: String = ""
168
+
169
+ if is_export:
170
+ var export_match: int = line.find("@export")
171
+ var hint_end: int = line.find("var ")
172
+ if hint_end > export_match:
173
+ var hint_part: String = line.substr(export_match + 7, hint_end - export_match - 7).strip_edges()
174
+ if hint_part.begins_with("_"):
175
+ export_hint = hint_part.substr(1).split(" ")[0]
176
+
177
+ var var_pos: int = line.find("var ")
178
+ if var_pos == -1:
179
+ return {"name": "", "line": line_num}
180
+
181
+ var var_text: String = line.substr(var_pos + 4).strip_edges()
182
+ var name: String = ""
183
+ var type_hint: String = ""
184
+ var default_value: String = ""
185
+
186
+ if "=" in var_text:
187
+ var parts: PackedStringArray = var_text.split("=", true, 1)
188
+ var name_part: String = parts[0].strip_edges()
189
+ default_value = parts[1].strip_edges() if parts.size() > 1 else ""
190
+
191
+ if ":" in name_part:
192
+ var type_parts: PackedStringArray = name_part.split(":")
193
+ name = type_parts[0].strip_edges()
194
+ type_hint = type_parts[1].strip_edges()
195
+ else:
196
+ name = name_part
197
+ elif ":" in var_text:
198
+ var type_parts: PackedStringArray = var_text.split(":")
199
+ name = type_parts[0].strip_edges()
200
+ type_hint = type_parts[1].strip_edges()
201
+ else:
202
+ name = var_text.split(" ")[0].strip_edges()
203
+
204
+ return {
205
+ "name": name,
206
+ "type": type_hint,
207
+ "default_value": default_value,
208
+ "is_export": is_export,
209
+ "export_hint": export_hint,
210
+ "is_onready": is_onready,
211
+ "line": line_num
212
+ }
213
+
214
+
215
+ func _parse_function(line: String, line_num: int) -> Dictionary:
216
+ var is_static: bool = line.begins_with("static ")
217
+ var func_text: String = line
218
+
219
+ if is_static:
220
+ func_text = line.substr(7).strip_edges()
221
+
222
+ func_text = func_text.substr(5).strip_edges()
223
+
224
+ var name: String = ""
225
+ var params: Array[Dictionary] = []
226
+ var return_type: String = ""
227
+
228
+ if "(" in func_text:
229
+ var paren_start: int = func_text.find("(")
230
+ name = func_text.substr(0, paren_start).strip_edges()
231
+
232
+ var paren_end: int = func_text.rfind(")")
233
+ if paren_end > paren_start:
234
+ var params_text: String = func_text.substr(paren_start + 1, paren_end - paren_start - 1)
235
+ if not params_text.is_empty():
236
+ for p: String in params_text.split(","):
237
+ params.append(_parse_param(p.strip_edges()))
238
+
239
+ var after_paren: String = func_text.substr(paren_end + 1).strip_edges()
240
+ if after_paren.begins_with("->"):
241
+ return_type = after_paren.substr(2).replace(":", "").strip_edges()
242
+
243
+ return {
244
+ "name": name,
245
+ "params": params,
246
+ "return_type": return_type,
247
+ "is_virtual": name.begins_with("_"),
248
+ "is_static": is_static,
249
+ "line": line_num
250
+ }
251
+
252
+
253
+ func _parse_param(param_text: String) -> Dictionary:
254
+ var name: String = ""
255
+ var type_hint: String = ""
256
+ var default_value: String = ""
257
+
258
+ if "=" in param_text:
259
+ var parts: PackedStringArray = param_text.split("=", true, 1)
260
+ var name_part: String = parts[0].strip_edges()
261
+ default_value = parts[1].strip_edges() if parts.size() > 1 else ""
262
+
263
+ if ":" in name_part:
264
+ var type_parts: PackedStringArray = name_part.split(":")
265
+ name = type_parts[0].strip_edges()
266
+ type_hint = type_parts[1].strip_edges()
267
+ else:
268
+ name = name_part
269
+ elif ":" in param_text:
270
+ var type_parts: PackedStringArray = param_text.split(":")
271
+ name = type_parts[0].strip_edges()
272
+ type_hint = type_parts[1].strip_edges()
273
+ else:
274
+ name = param_text
275
+
276
+ return {"name": name, "type": type_hint, "default": default_value}
277
+
278
+
279
+ func _extract_dependencies(line: String) -> Array[String]:
280
+ var deps: Array[String] = []
281
+ var regex: RegEx = RegEx.new()
282
+
283
+ regex.compile("(?:preload|load)\\s*\\(\\s*[\"']([^\"']+)[\"']\\s*\\)")
284
+
285
+ for m: RegExMatch in regex.search_all(line):
286
+ deps.append(m.get_string(1))
287
+
288
+ return deps