gdharness 1.1.21 → 1.1.23

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 (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
@@ -21,7 +21,8 @@ func _init(p_log: Log) -> void:
21
21
 
22
22
 
23
23
  func refresh_class_cache(_params: Dictionary) -> Dictionary:
24
- var before: Dictionary = _entries_by_class(_read_cache())
24
+ var cached: Array = _read_cache()
25
+ var before: Dictionary = _entries_by_class(cached)
25
26
 
26
27
  var entries: Array = []
27
28
  var skipped: Array[Dictionary] = []
@@ -29,6 +30,18 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
29
30
  var entry: Dictionary = _entry_for(path, skipped)
30
31
  if not entry.is_empty():
31
32
  entries.append(entry)
33
+ # Another language's classes are kept while their files are there. Only GDScript is read here and
34
+ # the list is written whole, so a C# project's global classes were dropped from it and answered as
35
+ # removed, and nothing started next could resolve them.
36
+ var carried: Array[String] = []
37
+ for entry: Variant in cached:
38
+ if not entry is Dictionary:
39
+ continue
40
+ var listed: Dictionary = entry
41
+ var language: String = str(listed.get("language", "GDScript"))
42
+ if language != "GDScript" and FileAccess.file_exists(str(listed.get("path", ""))):
43
+ entries.append(listed)
44
+ carried.append(str(listed.get("class", "")))
32
45
  # By name as text: StringName's own order is by identity, not by spelling.
33
46
  entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a["class"]) < str(b["class"]))
34
47
 
@@ -56,7 +69,7 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
56
69
  if not after.has(name):
57
70
  removed.append(name)
58
71
 
59
- return {
72
+ var answer: Dictionary = {
60
73
  "path": CACHE_PATH,
61
74
  "classes": entries.size(),
62
75
  "added": added,
@@ -64,6 +77,9 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
64
77
  "changed": changed,
65
78
  "skipped": skipped,
66
79
  }
80
+ if not carried.is_empty():
81
+ answer["carried"] = carried
82
+ return answer
67
83
 
68
84
 
69
85
  # The cache entry for one script, or empty for a script with no class_name.
@@ -3,18 +3,22 @@ extends RefCounted
3
3
  const Read = preload("reading.gd")
4
4
  const Log = preload("logger.gd")
5
5
 
6
- # The base class each category name stands for.
6
+ # The base classes each category name stands for. Kept to the list the tool's schema offers, which
7
+ # a case holds: the schema offered physics3d and ui, which this refused, and physics answered the 3D
8
+ # bodies alone beside physics2d and physics3d.
7
9
  const CATEGORY_BASES: Dictionary = {
8
- "node": "Node",
9
- "node2d": "Node2D",
10
- "node3d": "Node3D",
11
- "control": "Control",
12
- "resource": "Resource",
13
- "physics": "PhysicsBody3D",
14
- "physics2d": "PhysicsBody2D",
15
- "audio": "AudioStream",
16
- "visual": "VisualInstance3D",
17
- "animation": "AnimationMixer",
10
+ "node": ["Node"],
11
+ "node2d": ["Node2D"],
12
+ "node3d": ["Node3D"],
13
+ "control": ["Control"],
14
+ "ui": ["Control"],
15
+ "resource": ["Resource"],
16
+ "physics": ["PhysicsBody2D", "PhysicsBody3D"],
17
+ "physics2d": ["PhysicsBody2D"],
18
+ "physics3d": ["PhysicsBody3D"],
19
+ "audio": ["AudioStream"],
20
+ "visual": ["VisualInstance3D"],
21
+ "animation": ["AnimationMixer"],
18
22
  }
19
23
 
20
24
  var _log: Log
@@ -24,6 +28,14 @@ func _init(p_log: Log) -> void:
24
28
  _log = p_log
25
29
 
26
30
 
31
+ static func _under_any(name: String, bases: Array) -> bool:
32
+ for base: Variant in bases:
33
+ var base_name: String = str(base)
34
+ if name == base_name or ClassDB.is_parent_class(name, base_name):
35
+ return true
36
+ return false
37
+
38
+
27
39
  # Query available classes from ClassDB with optional filtering
28
40
  func query_classes(params: Dictionary) -> Dictionary:
29
41
  var filter: String = str(params.get("filter", ""))
@@ -42,10 +54,10 @@ func query_classes(params: Dictionary) -> Dictionary:
42
54
  )
43
55
  )
44
56
 
45
- var base_class: String = ""
57
+ var bases: Array = []
46
58
  if not category.is_empty():
47
- base_class = CATEGORY_BASES.get(category.to_lower(), "")
48
- if base_class.is_empty():
59
+ bases = CATEGORY_BASES.get(category.to_lower(), [])
60
+ if bases.is_empty():
49
61
  return _log.failure("Unknown category: " + category + ". Valid: " + str(CATEGORY_BASES.keys()))
50
62
 
51
63
  var all_classes: PackedStringArray = ClassDB.get_class_list()
@@ -60,9 +72,8 @@ func query_classes(params: Dictionary) -> Dictionary:
60
72
  if not filter.is_empty() and not class_name_str.to_lower().contains(filter.to_lower()):
61
73
  continue
62
74
 
63
- if not base_class.is_empty():
64
- if not ClassDB.is_parent_class(class_name_str, base_class) and class_name_str != base_class:
65
- continue
75
+ if not bases.is_empty() and not _under_any(class_name_str, bases):
76
+ continue
66
77
 
67
78
  filtered_classes.append(class_name_str)
68
79
 
@@ -4,6 +4,14 @@ const Patterns = preload("patterns.gd")
4
4
  const Read = preload("reading.gd")
5
5
  const FileWalk = preload("file_walk.gd")
6
6
  const Log = preload("logger.gd")
7
+ const ResourceFiles = preload("resource_files.gd")
8
+
9
+ ## What a reverse search reads unless told otherwise: the resource and code files, and the settings
10
+ ## files that name resources, "godot" for project.godot, "cfg" for plugin.cfg and export_presets.cfg,
11
+ ## and "import" for the sidecars that name import scripts and external materials.
12
+ const DEFAULT_USAGE_KINDS: Array[String] = ["tscn", "tres", "gd", "gdshader", "godot", "cfg", "import"]
13
+ ## One string literal of either quote, its contents captured by the one it opened with.
14
+ const STRING_LITERAL: String = "\"((?:[^\"\\\\]|\\\\.)*)\"|'((?:[^'\\\\]|\\\\.)*)'"
7
15
 
8
16
  # What a dependency reference looks like in source, and how the path is read out of each form.
9
17
  const REFERENCE_PATTERNS: Array[String] = [
@@ -106,7 +114,7 @@ func get_dependencies(params: Dictionary) -> Dictionary:
106
114
  # preload it, and for a script with a class_name, every use of that name.
107
115
  func find_resource_usages(params: Dictionary) -> Dictionary:
108
116
  var resource_path: String = str(params.get("resource_path", ""))
109
- var file_types: Array = params.get("file_types", ["tscn", "tres", "gd", "gdshader"])
117
+ var file_types: Array = params.get("file_types", DEFAULT_USAGE_KINDS)
110
118
 
111
119
  if not resource_path.begins_with("res://"):
112
120
  resource_path = "res://" + resource_path
@@ -116,14 +124,14 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
116
124
  _log.info("Finding usages of: " + resource_path)
117
125
 
118
126
  var class_name_declared: String = _declared_class_name(resource_path)
119
- var by_path: RegEx = Patterns.compiled('"(res://)?' + _regex_escaped(resource_path.substr(6)) + '"')
120
127
  var by_class: RegEx = null
121
128
  if not class_name_declared.is_empty():
122
129
  by_class = Patterns.compiled("\\b" + _regex_escaped(class_name_declared) + "\\b")
130
+ var target: Dictionary = {
131
+ "path": resource_path, "uid": str(ResourceFiles.uid_of(resource_path).get("uid", ""))
132
+ }
123
133
 
124
- var all_files: Array[String] = []
125
- for ext: Variant in file_types:
126
- all_files.append_array(_files.find_files("res://", "." + str(ext)))
134
+ var all_files: Array[String] = _usage_files(file_types)
127
135
 
128
136
  var usages: Array[Dictionary] = []
129
137
  var by_kind: Dictionary = {}
@@ -131,20 +139,29 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
131
139
  var in_code: int = 0
132
140
 
133
141
  for file_path: String in all_files:
134
- if file_path == resource_path:
142
+ # Its own sidecar names it as the file it was imported from, which is not a use of it.
143
+ if file_path == resource_path or file_path == resource_path + ".import":
135
144
  continue
136
145
  var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
137
146
  if not file:
138
147
  continue
139
148
  var lines: PackedStringArray = file.get_as_text().split("\n")
140
149
  file.close()
150
+ # A class is used by name only in code. In a scene or a resource the same word is a node's
151
+ # name or a caption, and each was counted as a use of the class.
152
+ var code: bool = file_path.get_extension() in ["gd", "gdshader"]
141
153
 
142
154
  var references: Array[Dictionary] = []
143
155
  for i: int in range(lines.size()):
144
156
  var line: String = lines[i]
145
- var by_its_path: bool = by_path.search(line) != null
146
- var by_its_name: bool = not by_its_path and by_class != null and by_class.search(line) != null
147
- if not by_its_path and not by_its_name:
157
+ var by_its_path: bool = _names_target(line, file_path, target)
158
+ var by_its_name: bool = false
159
+ var name_in_comment: bool = false
160
+ if not by_its_path and code and by_class != null:
161
+ # Strings are text, so a class named in one is not a use of it.
162
+ by_its_name = by_class.search(_code_of(line)) != null
163
+ name_in_comment = not by_its_name and by_class.search(_comment_of(line)) != null
164
+ if not by_its_path and not by_its_name and not name_in_comment:
148
165
  continue
149
166
  var trimmed: String = line.strip_edges()
150
167
  var kind: String = ""
@@ -154,12 +171,12 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
154
171
  # three of them prose, all counted as code. The more carefully a project is documented
155
172
  # the worse that ratio gets. `##` and `#` are kept apart because renaming the class
156
173
  # breaks Godot's generated documentation and leaves the prose merely stale.
157
- if trimmed.begins_with("##"):
174
+ if code and trimmed.begins_with("##"):
158
175
  kind = "doc"
159
- elif trimmed.begins_with("#"):
176
+ elif (code and trimmed.begins_with("#")) or name_in_comment:
160
177
  kind = "comment"
161
178
  elif by_its_path:
162
- kind = _path_reference_kind(line)
179
+ kind = _path_reference_kind(line, file_path)
163
180
  else:
164
181
  kind = (
165
182
  "extends" if Patterns.without_annotations(line).begins_with("extends ") else "class_name"
@@ -210,8 +227,81 @@ func _declared_class_name(path: String) -> String:
210
227
  return ""
211
228
 
212
229
 
230
+ # The files a reverse search reads, for the kinds asked for. The settings files are kinds of their
231
+ # own because what they name is used as surely as a preload is: an autoload script, the main scene
232
+ # and a plugin's script are named only in project.godot and plugin.cfg, and a search that left those
233
+ # out answered that each was used by nothing, which is the answer a caller deletes a file on.
234
+ func _usage_files(kinds: Array) -> Array[String]:
235
+ var found: Array[String] = []
236
+ for kind: Variant in kinds:
237
+ match str(kind):
238
+ "godot":
239
+ found.append("res://project.godot")
240
+ "cfg":
241
+ found.append_array(_files.find_files("res://", "plugin.cfg"))
242
+ if FileAccess.file_exists("res://export_presets.cfg"):
243
+ found.append("res://export_presets.cfg")
244
+ _:
245
+ found.append_array(_files.find_files("res://", "." + str(kind)))
246
+ return found
247
+
248
+
249
+ # Whether [param line], in [param file_path], names the target by path or by UID. Every string on the
250
+ # line is read as a path the way the engine reads it: a res:// path as it is, a uid:// by what it
251
+ # names, and anything else relative to the file it is in, which is how `preload("sibling.gd")` and a
252
+ # plugin.cfg's `script="plugin.gd"` name their files. Only the quoted form of the project path was
253
+ # looked for, so neither of those was found, and nor was a scene named by its UID.
254
+ func _names_target(line: String, file_path: String, target: Dictionary) -> bool:
255
+ var path: String = target["path"]
256
+ var uid: String = target["uid"]
257
+ for quoted: String in _string_literals(line):
258
+ # An autoload is written "*res://path" when its name is global.
259
+ var literal: String = quoted.trim_prefix("*")
260
+ if not uid.is_empty() and literal == uid:
261
+ return true
262
+ if literal.get_extension() != path.get_extension():
263
+ continue
264
+ var resolved: String = literal
265
+ if not literal.begins_with("res://"):
266
+ resolved = file_path.get_base_dir().path_join(literal).simplify_path()
267
+ if resolved == path:
268
+ return true
269
+ return false
270
+
271
+
272
+ # The contents of each string literal on [param line], of either quote.
273
+ static func _string_literals(line: String) -> Array[String]:
274
+ var literals: Array[String] = []
275
+ for m: RegExMatch in Patterns.compiled(STRING_LITERAL).search_all(line):
276
+ literals.append(m.get_string(1) if not m.get_string(1).is_empty() else m.get_string(2))
277
+ return literals
278
+
279
+
280
+ # [param line] with its strings emptied and its comment taken off: the part a class can be used in.
281
+ static func _code_of(line: String) -> String:
282
+ var bare: String = Patterns.compiled(STRING_LITERAL).sub(line, '""', true)
283
+ var mark: int = bare.find("#")
284
+ return bare if mark == -1 else bare.substr(0, mark)
285
+
286
+
287
+ # The comment on [param line], strings emptied first so a # inside one does not start it.
288
+ static func _comment_of(line: String) -> String:
289
+ var bare: String = Patterns.compiled(STRING_LITERAL).sub(line, '""', true)
290
+ var mark: int = bare.find("#")
291
+ return "" if mark == -1 else bare.substr(mark)
292
+
293
+
213
294
  # How a line that names the resource by path uses it.
214
- func _path_reference_kind(line: String) -> String:
295
+ func _path_reference_kind(line: String, file_path: String) -> String:
296
+ match file_path.get_file():
297
+ "project.godot":
298
+ return "setting"
299
+ "plugin.cfg":
300
+ return "plugin"
301
+ "export_presets.cfg":
302
+ return "export_preset"
303
+ if file_path.ends_with(".import"):
304
+ return "import"
215
305
  var trimmed: String = Patterns.without_annotations(line)
216
306
  if trimmed.begins_with("extends "):
217
307
  return "extends"
@@ -255,9 +345,15 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
255
345
  walk.circular_references.append(cycle)
256
346
  return [{"path": path, "circular": true}]
257
347
 
348
+ # Reused only when it was walked with as much depth left as now. A resource first reached deep in
349
+ # the walk has a list cut short at the limit, and handing that to a nearer reach answered with a
350
+ # chain that stopped where nothing stopped it.
351
+ var remaining: int = walk.max_depth - current_depth
258
352
  if walk.visited.has(path):
259
- var cached: Array[Dictionary] = walk.visited[path]
260
- return cached
353
+ var cached: Dictionary = walk.visited[path]
354
+ if Read.as_int(cached["remaining"]) == remaining:
355
+ var cached_deps: Array[Dictionary] = cached["deps"]
356
+ return cached_deps
261
357
 
262
358
  walk.path_stack.append(path)
263
359
 
@@ -269,7 +365,7 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
269
365
  for pattern: String in REFERENCE_PATTERNS:
270
366
  var regex: RegEx = Patterns.compiled(pattern)
271
367
  for m: RegExMatch in regex.search_all(content):
272
- var dep_path: String = _referenced_path(m.get_string())
368
+ var dep_path: String = _resolved(_referenced_path(m.get_string()), path)
273
369
 
274
370
  if not dep_path.begins_with("res://"):
275
371
  continue
@@ -299,10 +395,25 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
299
395
  deps.append(dep_info)
300
396
 
301
397
  walk.path_stack.pop_back()
302
- walk.visited[path] = deps
398
+ walk.visited[path] = {"deps": deps, "remaining": remaining}
303
399
  return deps
304
400
 
305
401
 
402
+ # [param written] as the res:// path the engine would load from [param from]: a uid:// by what the
403
+ # project's uid cache says it names, and a relative path against the referring file's directory.
404
+ # Both were passed over as not being res:// paths, so a script's `preload("sibling.gd")` was no
405
+ # dependency at all.
406
+ static func _resolved(written: String, from: String) -> String:
407
+ if written.begins_with("res://"):
408
+ return written
409
+ if written.begins_with("uid://"):
410
+ var id: int = ResourceUID.text_to_id(written)
411
+ return ResourceUID.get_id_path(id) if ResourceUID.has_id(id) else written
412
+ if written.contains("://"):
413
+ return written
414
+ return from.get_base_dir().path_join(written).simplify_path()
415
+
416
+
306
417
  # The path inside a preload(), load() or ext_resource match; a bare res:// match is the path.
307
418
  func _referenced_path(matched: String) -> String:
308
419
  var expression: String = ""
@@ -10,6 +10,26 @@ extends RefCounted
10
10
  # every caller files the engine will never answer for.
11
11
 
12
12
 
13
+ # Why [param path], a res:// or uid:// path a setting names, is not a file the engine can load, or
14
+ # empty when it is. A uid:// path resolves through the project's uid cache, which the import writes,
15
+ # so one that does not resolve is a file that has gone or a project not imported yet, and saying
16
+ # only that the file does not exist sent a caller looking for a scene that was there.
17
+ static func missing_reason(path: String) -> String:
18
+ if path.begins_with("uid://"):
19
+ var id: int = ResourceUID.text_to_id(path)
20
+ if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id):
21
+ return (
22
+ path
23
+ + " does not resolve: no file in the project's uid cache has it, so the file has gone or the"
24
+ + " project has not been imported yet (project_import refresh_uids imports it)"
25
+ )
26
+ var resolved: String = ResourceUID.get_id_path(id)
27
+ return (
28
+ "" if FileAccess.file_exists(resolved) else path + " names " + resolved + ", which does not exist"
29
+ )
30
+ return "" if FileAccess.file_exists(path) else path + " does not exist"
31
+
32
+
13
33
  # A directory the engine steps over, marker file and all.
14
34
  func is_stepped_over(path: String) -> bool:
15
35
  return FileAccess.file_exists(path + ".gdignore")
@@ -52,16 +52,30 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
52
52
  # answer: a `var x: int = 5 # note` carried `5 # note` as its default, and correcting the
53
53
  # end of a parameter list put the comment into the return type, where `rfind(")")` had been
54
54
  # hiding it by stopping inside the comment instead.
55
- var stripped: String = _without_comment(lines[i])
56
-
57
- if stripped.is_empty():
55
+ var raw: String = lines[i]
56
+ # Inside a string the raw line is counted, because a `#` in it is text and taking it for a
57
+ # comment would cut off the quotes that close the string.
58
+ if in_multiline_string:
59
+ if _triple_quotes(raw) % 2 == 1:
60
+ in_multiline_string = false
58
61
  continue
62
+ var stripped: String = _without_comment(raw)
59
63
 
60
- if '"""' in stripped or "'''" in stripped:
61
- in_multiline_string = not in_multiline_string
64
+ if stripped.is_empty():
62
65
  continue
63
66
 
64
- if in_multiline_string:
67
+ # An odd count opens a string that runs on; an even one is a string that closed on the line
68
+ # it opened on. Toggling on any count lost `const HELP := """Usage"""`, and everything after
69
+ # it up to the next triple quote, with the state inverted from there to the end of the file.
70
+ # The line that opens one is still read: it can be the declaration the string belongs to.
71
+ if _triple_quotes(stripped) % 2 == 1:
72
+ in_multiline_string = true
73
+
74
+ # Only a line at the left margin declares one of the script's own members. Indented, it is a
75
+ # function's local or an inner class's member, and each was answered as the script's own:
76
+ # every `var` in every body, and an inner class's `extends` taken for the script's base.
77
+ if raw.begins_with(" ") or raw.begins_with("\t"):
78
+ _collect_dependencies(stripped, dependencies)
65
79
  continue
66
80
 
67
81
  # Annotations may share the line with anything they annotate, so every branch below decides
@@ -116,14 +130,11 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
116
130
  elif header.begins_with("class "):
117
131
  inner_classes.append(header.substr(6).split(":")[0].split(" ")[0].strip_edges())
118
132
 
119
- if "preload(" in stripped or "load(" in stripped:
120
- for dep: String in _extract_dependencies(stripped):
121
- if dep not in dependencies:
122
- dependencies.append(dep)
133
+ _collect_dependencies(stripped, dependencies)
123
134
 
124
135
  # After the loop, because it needs the base the loop reads, and a file that declared one after
125
136
  # its first function would otherwise be judged against the default.
126
- _mark_virtuals(functions, extends_name)
137
+ _mark_virtuals(functions, extends_name, full_script_path)
127
138
 
128
139
  var answer: Dictionary = {
129
140
  "path": script_path,
@@ -151,8 +162,8 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
151
162
  # with a private helper in it. The virtuals are the ones the native ancestor declares with
152
163
  # `METHOD_FLAG_VIRTUAL`, which is a question `ClassDB` answers exactly, and `class_has_method` does
153
164
  # not: it says false for `_ready` on `Node`, so the method list is what has to be walked.
154
- func _mark_virtuals(functions: Array[Dictionary], extends_name: String) -> void:
155
- var native: String = _native_ancestor(extends_name)
165
+ func _mark_virtuals(functions: Array[Dictionary], extends_name: String, from_path: String) -> void:
166
+ var native: String = _native_ancestor(extends_name, from_path)
156
167
  var virtuals: Dictionary = {}
157
168
  if not native.is_empty():
158
169
  for m: Dictionary in ClassDB.class_get_method_list(native, false):
@@ -168,11 +179,12 @@ func _mark_virtuals(functions: Array[Dictionary], extends_name: String) -> void:
168
179
  # A base named by a script is followed; a base that is neither a script nor a class the engine knows
169
180
  # leaves nothing to ask, and then no function is called virtual rather than every underscore being
170
181
  # guessed at.
171
- func _native_ancestor(base: String) -> String:
182
+ func _native_ancestor(base: String, from_path: String) -> String:
172
183
  var seen: Array[String] = []
173
184
  var current: String = base
185
+ var declared_in: String = from_path
174
186
  while true:
175
- var path: String = _script_named(current)
187
+ var path: String = _script_named(current, declared_in)
176
188
  if path.is_empty():
177
189
  break
178
190
  if path in seen:
@@ -182,6 +194,7 @@ func _native_ancestor(base: String) -> String:
182
194
  if above.is_empty():
183
195
  return ""
184
196
  current = str(above.get("extends", ""))
197
+ declared_in = path
185
198
  return current if ClassDB.class_exists(current) else ""
186
199
 
187
200
 
@@ -196,8 +209,9 @@ func _native_ancestor(base: String) -> String:
196
209
  func _add_inherited(answer: Dictionary, seen: Array[String]) -> void:
197
210
  var inherits_from: Array[String] = []
198
211
  var base: String = str(answer.get("extends", ""))
212
+ var declared_in: String = str(answer.get("full_path", ""))
199
213
  while true:
200
- var base_path: String = _script_named(base)
214
+ var base_path: String = _script_named(base, declared_in)
201
215
  if base_path.is_empty() or base_path in seen:
202
216
  break
203
217
  seen.append(base_path)
@@ -216,16 +230,28 @@ func _add_inherited(answer: Dictionary, seen: Array[String]) -> void:
216
230
  carried["inherited_from"] = base_path
217
231
  mine.append(carried)
218
232
  base = str(above.get("extends", ""))
233
+ declared_in = base_path
219
234
  answer["inherits_from"] = inherits_from
220
235
 
221
236
 
222
237
  # The file a base names, whether it named a path or a class, or empty for a native class.
223
- func _script_named(base: String) -> String:
238
+ #
239
+ # A quoted path is resolved the way the engine resolves it: `extends "base.gd"` is relative to the
240
+ # script [param declared_in], and asking whether `base.gd` exists asked about nothing, so such a
241
+ # script had no ancestor and none of its engine callbacks were virtual.
242
+ func _script_named(base: String, declared_in: String) -> String:
224
243
  if base.is_empty():
225
244
  return ""
226
- if base.begins_with('"') and base.ends_with('"'):
227
- var quoted: String = base.substr(1, base.length() - 2)
228
- return quoted if FileAccess.file_exists(quoted) else ""
245
+ var quoted: String = ""
246
+ if base.length() >= 2 and (base[0] == '"' or base[0] == "'") and base.ends_with(base[0]):
247
+ quoted = base.substr(1, base.length() - 2)
248
+ if not quoted.is_empty():
249
+ if quoted.begins_with("uid://"):
250
+ var id: int = ResourceUID.text_to_id(quoted)
251
+ quoted = ResourceUID.get_id_path(id) if ResourceUID.has_id(id) else ""
252
+ elif not quoted.begins_with("res://"):
253
+ quoted = declared_in.get_base_dir().path_join(quoted).simplify_path()
254
+ return quoted if not quoted.is_empty() and FileAccess.file_exists(quoted) else ""
229
255
  for entry: Dictionary in ProjectSettings.get_global_class_list():
230
256
  if str(entry.get("class", "")) == base:
231
257
  var path: String = str(entry.get("path", ""))
@@ -562,6 +588,19 @@ func _parse_param(param_text: String) -> Dictionary:
562
588
  return {"name": name, "type": type_hint, "default": default_value, "is_rest": is_rest}
563
589
 
564
590
 
591
+ func _collect_dependencies(line: String, into: Array[String]) -> void:
592
+ if "load(" not in line:
593
+ return
594
+ for dep: String in _extract_dependencies(line):
595
+ if dep not in into:
596
+ into.append(dep)
597
+
598
+
599
+ # How many triple quotes of either kind [param line] holds.
600
+ static func _triple_quotes(line: String) -> int:
601
+ return line.count('"""') + line.count("'''")
602
+
603
+
565
604
  func _extract_dependencies(line: String) -> Array[String]:
566
605
  var deps: Array[String] = []
567
606
  var regex: RegEx = Patterns.compiled("(?:preload|load)\\s*\\(\\s*[\"']([^\"']+)[\"']\\s*\\)")