gdharness 1.3.8 → 1.3.10

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 (78) hide show
  1. package/build/cli.js +4668 -3229
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +115 -4
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +272 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +115 -18
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +49 -2
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +89 -16
  7. package/build/godot/addons/gdharness_editor/property_values.gd +180 -121
  8. package/build/godot/addons/gdharness_editor/reading.gd +112 -6
  9. package/build/godot/addons/gdharness_editor/scene_file.gd +117 -174
  10. package/build/godot/addons/gdharness_editor/scene_uids.gd +107 -0
  11. package/build/godot/addons/gdharness_editor/scene_uids.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +19 -29
  13. package/build/godot/addons/gdharness_editor/setting_reads.gd +121 -0
  14. package/build/godot/addons/gdharness_editor/setting_reads.gd.uid +1 -0
  15. package/build/godot/addons/gdharness_editor/tool_executor.gd +39 -6
  16. package/build/godot/addons/gdharness_editor/tools/animation_states.gd +263 -0
  17. package/build/godot/addons/gdharness_editor/tools/animation_states.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +49 -414
  19. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +464 -0
  20. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd.uid +1 -0
  21. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +37 -0
  22. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +221 -0
  23. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd.uid +1 -0
  24. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +162 -483
  25. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +16 -0
  26. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +8 -4
  27. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +2 -1
  28. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +10 -0
  29. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +151 -8
  30. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +7 -30
  31. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +190 -38
  32. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd +485 -0
  33. package/build/godot/addons/gdharness_editor/tools/tileset_tools.gd.uid +1 -0
  34. package/build/godot/addons/gdharness_editor/value_shapes.gd +128 -1
  35. package/build/godot/addons/gdharness_runtime/call_errors.gd +8 -2
  36. package/build/godot/addons/gdharness_runtime/declared_values.gd +272 -0
  37. package/build/godot/addons/gdharness_runtime/reading.gd +112 -6
  38. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +35 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +5 -0
  40. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +188 -30
  41. package/build/godot/addons/gdharness_runtime/runtime_input.gd +107 -104
  42. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +17 -0
  43. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +13 -4
  44. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +320 -27
  45. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +165 -20
  46. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +49 -3
  47. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +31 -2
  48. package/build/godot/addons/gdharness_runtime/runtime_values.gd +250 -10
  49. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +279 -11
  50. package/build/godot/addons/gdharness_runtime/runtime_words.gd +27 -0
  51. package/build/godot/addons/gdharness_runtime/serialisation.gd +19 -29
  52. package/build/godot/addons/gdharness_runtime/value_shapes.gd +128 -1
  53. package/build/godot/operations/animation_reads.gd +115 -4
  54. package/build/godot/operations/audio_buses.gd +93 -7
  55. package/build/godot/operations/classdb_queries.gd +119 -7
  56. package/build/godot/operations/declared_values.gd +272 -0
  57. package/build/godot/operations/dependencies.gd +154 -41
  58. package/build/godot/operations/editor_tools.gd +29 -13
  59. package/build/godot/operations/export_presets.gd +602 -4
  60. package/build/godot/operations/file_reads.gd +115 -18
  61. package/build/godot/operations/gdscript_analysis.gd +14 -0
  62. package/build/godot/operations/gdscript_authoring.gd +12 -2
  63. package/build/godot/operations/godot_operations.gd +18 -2
  64. package/build/godot/operations/import_pipeline.gd +20 -221
  65. package/build/godot/operations/input_actions.gd +127 -51
  66. package/build/godot/operations/project_config.gd +113 -68
  67. package/build/godot/operations/project_diagnostics.gd +4 -0
  68. package/build/godot/operations/project_validation.gd +266 -0
  69. package/build/godot/operations/property_paths.gd +89 -16
  70. package/build/godot/operations/property_values.gd +180 -121
  71. package/build/godot/operations/reading.gd +112 -6
  72. package/build/godot/operations/scene_connections.gd +222 -0
  73. package/build/godot/operations/serialisation.gd +19 -29
  74. package/build/godot/operations/setting_reads.gd +121 -0
  75. package/build/godot/operations/value_shapes.gd +128 -1
  76. package/build/godot/tests/listed.gd +20 -13
  77. package/build/keeper.js +1402 -707
  78. package/package.json +1 -1
@@ -6,6 +6,36 @@ extends RefCounted
6
6
 
7
7
  const GdhRead = preload("reading.gd")
8
8
 
9
+ ## The section of layer_names in project.godot naming the layers of each layers hint.
10
+ const LAYER_NAMES: Dictionary[int, String] = {
11
+ PROPERTY_HINT_LAYERS_2D_PHYSICS: "2d_physics",
12
+ PROPERTY_HINT_LAYERS_2D_RENDER: "2d_render",
13
+ PROPERTY_HINT_LAYERS_2D_NAVIGATION: "2d_navigation",
14
+ PROPERTY_HINT_LAYERS_3D_PHYSICS: "3d_physics",
15
+ PROPERTY_HINT_LAYERS_3D_RENDER: "3d_render",
16
+ PROPERTY_HINT_LAYERS_3D_NAVIGATION: "3d_navigation",
17
+ PROPERTY_HINT_LAYERS_AVOIDANCE: "avoidance",
18
+ }
19
+ ## How many layers a mask holds, one bit each, whatever the editor names.
20
+ const MASK_LAYERS: int = 32
21
+
22
+
23
+ ## The layers [param mask] holds under a layers [param hint], each with the name project.godot
24
+ ## gives it where it gives one.
25
+ static func layers_in(hint: int, mask: int) -> Array[Dictionary]:
26
+ var layers: Array[Dictionary] = []
27
+ for layer: int in range(1, MASK_LAYERS + 1):
28
+ if mask & (1 << (layer - 1)) == 0:
29
+ continue
30
+ var entry: Dictionary = {"layer": layer}
31
+ var name: String = str(
32
+ ProjectSettings.get_setting("layer_names/%s/layer_%d" % [LAYER_NAMES[hint], layer], "")
33
+ )
34
+ if not name.is_empty():
35
+ entry["name"] = name
36
+ layers.append(entry)
37
+ return layers
38
+
9
39
 
10
40
  ## The values an enum hint names, by name, from its hint string: "SWORD:0,SHIELD:1" as a script
11
41
  ## declares one, or "Add,Subtract" as an engine property does, each name then taking the number
@@ -127,6 +157,77 @@ static func enum_refusal(
127
157
  return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
128
158
 
129
159
 
160
+ ## Why [param word], given as argument [param index] of [param called] for the enum [param named],
161
+ ## names none of its [param choices].
162
+ static func enum_argument_refusal(
163
+ called: String, index: int, named: String, word: String, choices: Dictionary
164
+ ) -> String:
165
+ var said: String = (
166
+ "%s takes the enum %s as argument %d, and %s is none of its values: send %s by name or by number"
167
+ % [called, named, index + 1, word, _listed(choices)]
168
+ )
169
+ var close: PackedStringArray = GdhRead.nearest(word, PackedStringArray(choices.keys()))
170
+ if close.is_empty():
171
+ return said + "."
172
+ return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
173
+
174
+
175
+ ## Why [param value], given as argument [param index] for the enum [param named], is none of its
176
+ ## [param choices], or "" when it is one. The engine passes any number on.
177
+ static func outside_enum_argument(index: int, named: String, value: Variant, choices: Dictionary) -> String:
178
+ if typeof(value) != TYPE_INT or choices.values().has(value):
179
+ return ""
180
+ return (
181
+ "argument %d, %d, is none of the values %s declares: %s."
182
+ % [index + 1, value, named, _listed(choices)]
183
+ )
184
+
185
+
186
+ ## The values of the enum the engine names [param qualified], by name: an engine class's, as
187
+ ## Node.ProcessMode, or a script's, as Named.Size for a class_name, res://main.gd.Mood for a script
188
+ ## without one, and either followed by inner classes. {} when it names nothing this can read.
189
+ static func enum_declared_as(qualified: String) -> Dictionary:
190
+ var cut: int = qualified.rfind(".")
191
+ if cut <= 0:
192
+ return {}
193
+ var owner: String = qualified.substr(0, cut)
194
+ var named: String = qualified.substr(cut + 1)
195
+ if ClassDB.class_exists(owner):
196
+ var constants: Dictionary = {}
197
+ for constant: String in ClassDB.class_get_enum_constants(owner, named):
198
+ constants[constant] = ClassDB.class_get_integer_constant(owner, constant)
199
+ return constants
200
+ var script: Script = _script_named_as(owner)
201
+ if script == null:
202
+ return {}
203
+ var found: Variant = script.get_script_constant_map().get(named)
204
+ if not found is Dictionary:
205
+ return {}
206
+ var values: Dictionary = found
207
+ return values
208
+
209
+
210
+ ## The script [param owner] names: a class_name, a res:// path, or an inner class of either after a
211
+ ## dot, or null.
212
+ static func _script_named_as(owner: String) -> Script:
213
+ var by_name: Script = script_class(owner)
214
+ if by_name != null:
215
+ return by_name
216
+ if owner.begins_with("res://") and ResourceLoader.exists(owner):
217
+ return load(owner) as Script
218
+ var cut: int = owner.rfind(".")
219
+ if cut <= 0:
220
+ return null
221
+ var outer: Script = _script_named_as(owner.substr(0, cut))
222
+ if outer == null:
223
+ return null
224
+ var inner: Variant = outer.get_script_constant_map().get(owner.substr(cut + 1))
225
+ if not inner is Script:
226
+ return null
227
+ var inner_script: Script = inner
228
+ return inner_script
229
+
230
+
130
231
  ## Why [param value], shown as [param shown], lies outside what [param declared] allows, an export
131
232
  ## range or an enum's values, or "" when it does not. A range marked or_greater or or_less is open
132
233
  ## on that side.
@@ -153,6 +254,19 @@ static func outside(target: Object, declared: Dictionary, value: Variant, shown:
153
254
  if choices.is_empty() or choices.values().has(value):
154
255
  return ""
155
256
  return "%d is none of the values %s declares: %s." % [value, property, _listed(choices)]
257
+ if hint == PROPERTY_HINT_FLAGS and typeof(value) == TYPE_INT:
258
+ var flags: Dictionary = flags_of(hint_string)
259
+ var known: int = 0
260
+ for bit: int in flags.values():
261
+ known |= bit
262
+ var whole: int = value
263
+ var stray: int = whole & ~known
264
+ if flags.is_empty() or stray == 0:
265
+ return ""
266
+ return (
267
+ "%d holds bits no flag of %s has (%d): its flags are %s; the engine stores it as given."
268
+ % [whole, property, stray, _listed(flags)]
269
+ )
156
270
  if hint != PROPERTY_HINT_RANGE:
157
271
  return ""
158
272
  var parts: PackedStringArray = []
@@ -173,6 +287,20 @@ static func outside(target: Object, declared: Dictionary, value: Variant, shown:
173
287
  )
174
288
 
175
289
 
290
+ ## The flags a flags hint names, each with its bit: "Fill:1,Expand:2" gives its values, and a name
291
+ ## with none takes the bit of its place, as @export_flags("A", "B") does.
292
+ static func flags_of(hint_string: String) -> Dictionary:
293
+ var flags: Dictionary = {}
294
+ var parts: PackedStringArray = hint_string.split(",", false)
295
+ for index: int in parts.size():
296
+ var part: String = parts[index].strip_edges()
297
+ if ":" in part:
298
+ flags[part.get_slice(":", 0)] = part.get_slice(":", 1).to_int()
299
+ else:
300
+ flags[part] = 1 << index
301
+ return flags
302
+
303
+
176
304
  static func _listed(choices: Dictionary) -> String:
177
305
  var listed: PackedStringArray = []
178
306
  for name: String in choices:
@@ -291,3 +419,147 @@ static func is_a(value: Object, name: String) -> bool:
291
419
  return true
292
420
  script = script.get_base_script()
293
421
  return false
422
+
423
+
424
+ ## Whether writing [param name] on [param target] runs code of its script: the script itself, a
425
+ ## property it declares, or any property once it takes sets through [code]_set[/code].
426
+ static func runs_script_code(target: Object, name: String) -> bool:
427
+ var script: Script = target.get_script()
428
+ if name == "script":
429
+ return true
430
+ if script == null:
431
+ return false
432
+ for method: Dictionary in script.get_script_method_list():
433
+ if str(method.get("name", "")) == "_set":
434
+ return true
435
+ for declared: Dictionary in script.get_script_property_list():
436
+ if str(declared.get("name", "")) == name:
437
+ return true
438
+ return false
439
+
440
+
441
+ ## Why [param property] of a [param holder], an [param engine_class], declared as [param declared],
442
+ ## holds [param held], as shown, after it was given [param given], the value and how it is shown. A
443
+ ## text property with a list of names holds only one of them, and its getter answers its default
444
+ ## for any other. A player's bus is one, though the engine fills its list only in the inspector, so
445
+ ## the buses are read from the AudioServer, which holds the project's layout; "the engine changed or
446
+ ## refused the value" left a caller to guess the bus did not exist.
447
+ static func not_held(
448
+ holder: String, engine_class: String, property: String, declared: Dictionary, given: Array, held: String
449
+ ) -> String:
450
+ var value: Variant = given[0]
451
+ var said: String = "%s.%s was given %s and holds %s after it was set" % [holder, property, given[1], held]
452
+ var type: int = GdhRead.as_int(declared.get("type", TYPE_NIL))
453
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
454
+ if hint in LAYER_NAMES and typeof(value) == TYPE_INT:
455
+ return (
456
+ (
457
+ "%s: it is a mask of the %s layers, one bit each from layer 1 (1) to layer %d (%d), so it "
458
+ + "takes 0 to %d and the engine keeps only those bits. Nothing was written."
459
+ )
460
+ % [
461
+ said,
462
+ LAYER_NAMES[hint],
463
+ MASK_LAYERS,
464
+ 1 << (MASK_LAYERS - 1),
465
+ (1 << MASK_LAYERS) - 1,
466
+ ]
467
+ )
468
+ var names: PackedStringArray = []
469
+ if hint == PROPERTY_HINT_ENUM and (type == TYPE_STRING or type == TYPE_STRING_NAME):
470
+ names = PackedStringArray(enum_values(str(declared.get("hint_string", ""))).keys())
471
+ var bus: bool = property == "bus" and engine_class.begins_with("AudioStreamPlayer")
472
+ if bus:
473
+ names = []
474
+ for index: int in AudioServer.bus_count:
475
+ var _added: bool = names.append(AudioServer.get_bus_name(index))
476
+ if names.is_empty() or names.has(str(value)):
477
+ return said + ": the engine changed or refused the value, so nothing was written."
478
+ var close: PackedStringArray = GdhRead.nearest(str(value), names)
479
+ var nearest: String = (
480
+ "%s %s" % ["is" if close.size() == 1 else "are", ", ".join(close)] if not close.is_empty() else ""
481
+ )
482
+ if bus:
483
+ # Not "holds Master after it was set": the getter answers the first bus for any name the
484
+ # layout lacks, and nothing was kept.
485
+ return (
486
+ (
487
+ "%s.bus takes the name of one of the project's audio buses, and %s is none of them, so it "
488
+ + "would play on %s; %s. project_info include audio_buses lists them, and "
489
+ + "project_settings add_audio_bus adds one. Nothing was written."
490
+ )
491
+ % [
492
+ holder,
493
+ given[1],
494
+ AudioServer.get_bus_name(0),
495
+ "the buses are " + ", ".join(names) if nearest.is_empty() else "the nearest " + nearest
496
+ ]
497
+ )
498
+ return (
499
+ "%s: it takes one of the names it lists, and %s is none of them; %s. Nothing was written."
500
+ % [
501
+ said,
502
+ given[1],
503
+ "it lists " + ", ".join(names) if nearest.is_empty() else "the nearest it lists " + nearest
504
+ ]
505
+ )
506
+
507
+
508
+ ## The sentence naming the children of [param node] that have [param property], or "" when none
509
+ ## does. A property of a helper node set on the body it serves, as navigation_layers on the
510
+ ## CharacterBody2D rather than its NavigationAgent2D, was refused with the nearest physics layer.
511
+ static func held_by_children(node: Node, property: String) -> String:
512
+ var root: Node = node.owner if node.owner != null else node
513
+ var holders: PackedStringArray = []
514
+ for child: Node in node.get_children():
515
+ if property in child:
516
+ var _added: bool = holders.append("%s (a %s)" % [root.get_path_to(child), child.get_class()])
517
+ if holders.is_empty():
518
+ return ""
519
+ if holders.size() == 1:
520
+ return " The node at %s has it." % holders[0]
521
+ var last: String = holders[holders.size() - 1]
522
+ return " The nodes at %s and %s have it." % [", ".join(holders.slice(0, -1)), last]
523
+
524
+
525
+ ## A sentence for each node path in [param given], by property, that names no node from
526
+ ## [param node]. A path is written whatever it names, as the inspector writes one, since the node may
527
+ ## be added next; a joint whose node_a named nothing then joined nothing, and no answer said so.
528
+ static func naming_nothing(node: Node, given: Dictionary) -> Dictionary:
529
+ var said: Dictionary = {}
530
+ for property: String in given:
531
+ if "/" in property or ":" in property:
532
+ continue
533
+ # What the node holds, since a path given as text is held as text until the engine stores it.
534
+ var path: Variant = node.get(property)
535
+ if not path is NodePath:
536
+ continue
537
+ var named: NodePath = path
538
+ if named.is_empty() or node.get_node_or_null(named) != null:
539
+ continue
540
+ said[property] = (
541
+ (
542
+ "%s names no node from %s. It is written, as the editor writes one, since that node may "
543
+ + "be added next; until it is, %s finds nothing."
544
+ )
545
+ % [str(named), node.name, property]
546
+ )
547
+ return said
548
+
549
+
550
+ ## Why [param material] has no [param property]: a ShaderMaterial's parameters are its shader's
551
+ ## uniforms, so the shader is what lacks one, and "ShaderMaterial has no property" named the
552
+ ## material.
553
+ static func no_uniform(material: ShaderMaterial, property: String) -> String:
554
+ var uniform: String = property.trim_prefix("shader_parameter/")
555
+ if material.shader == null:
556
+ return "The ShaderMaterial has no shader, so it has no parameter %s yet; set shader first." % uniform
557
+ var shader: String = material.shader.resource_path
558
+ var named: String = shader if not shader.is_empty() else "Its shader"
559
+ var uniforms: Array = []
560
+ for entry: Dictionary in material.shader.get_shader_uniform_list():
561
+ uniforms.append(str(entry["name"]))
562
+ var said: String = "%s declares no uniform %s, which %s would set" % [named, uniform, property]
563
+ if uniforms.is_empty():
564
+ return said + "; it declares no uniforms."
565
+ return GdhRead.offered(said, uniform, uniforms) + "."
@@ -9,7 +9,13 @@ const GdhResourceFiles = preload("resource_files.gd")
9
9
  ## What a reverse search reads unless told otherwise: the resource and code files, and the settings
10
10
  ## files that name resources, "godot" for project.godot, "cfg" for plugin.cfg and export_presets.cfg,
11
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"]
12
+ const PROJECT_FILE: String = "res://project.godot"
13
+ const DEFAULT_USAGE_KINDS: Array[String] = [
14
+ "tscn", "tres", "gd", "gdshader", "gdshaderinc", "godot", "cfg", "import"
15
+ ]
16
+ ## The files written in the shader language, whose comments are // and /* */ rather than #, which in a
17
+ ## shader starts a preprocessor directive.
18
+ const SHADER_KINDS: Array[String] = ["gdshader", "gdshaderinc"]
13
19
  ## One string literal of either quote, its contents captured by the one it opened with.
14
20
  const STRING_LITERAL: String = "\"((?:[^\"\\\\]|\\\\.)*)\"|'((?:[^'\\\\]|\\\\.)*)'"
15
21
 
@@ -19,6 +25,9 @@ const REFERENCE_PATTERNS: Array[String] = [
19
25
  'preload\\("([^"]+)"\\)',
20
26
  'load\\("([^"]+)"\\)',
21
27
  'ext_resource.*path="([^"]+)"',
28
+ # The shader preprocessor reads a relative include against the including file, and only the bare
29
+ # res:// form was read, so a shader including its sibling depended on nothing.
30
+ '#include\\s+"([^"]+)"',
22
31
  ]
23
32
 
24
33
  var _log: GdhLog
@@ -39,11 +48,17 @@ class GdhDependencyWalk:
39
48
  var visited: Dictionary = {}
40
49
  var path_stack: Array[String] = []
41
50
  var circular_references: Array
51
+ ## Whether a file the depth stops at is read for how many files it names, which the walk doing
52
+ ## that reading does not do again for those.
53
+ var counts_beyond: bool
42
54
 
43
- func _init(p_max_depth: int, p_include_built_in: bool, p_circular_references: Array) -> void:
55
+ func _init(
56
+ p_max_depth: int, p_include_built_in: bool, p_circular_references: Array, p_counts_beyond: bool = true
57
+ ) -> void:
44
58
  max_depth = p_max_depth
45
59
  include_built_in = p_include_built_in
46
60
  circular_references = p_circular_references
61
+ counts_beyond = p_counts_beyond
47
62
 
48
63
 
49
64
  func _init(p_log: GdhLog) -> void:
@@ -127,6 +142,10 @@ func missing_references() -> Array[Dictionary]:
127
142
  var naming: Array = missing.get_or_add(path, [])
128
143
  if not file in naming:
129
144
  naming.append(file)
145
+ for path: String in _named_by_settings():
146
+ var naming: Array = missing.get_or_add(path, [])
147
+ if not PROJECT_FILE in naming:
148
+ naming.append(PROJECT_FILE)
130
149
  # Sorted, since the walk meets files in the order the filesystem lists them, which differs
131
150
  # between platforms.
132
151
  var paths: Array = missing.keys()
@@ -139,6 +158,23 @@ func missing_references() -> Array[Dictionary]:
139
158
  return found
140
159
 
141
160
 
161
+ ## The files project.godot's settings name that are not on disk: an autoload, a registered
162
+ ## translation, an icon, an enabled plugin. Not the main scene, which has a check of its own, and
163
+ ## the engine reports each of the rest on every boot.
164
+ static func _named_by_settings() -> Array[String]:
165
+ var named: Array[String] = []
166
+ for line: String in FileAccess.get_file_as_string(PROJECT_FILE).split("\n"):
167
+ var held: String = line.strip_edges()
168
+ if held.begins_with(";") or held.begins_with("run/main_scene="):
169
+ continue
170
+ for found: RegExMatch in GdhPatterns.compiled('"\\*?(res://[^"]+)"').search_all(held):
171
+ var path: String = found.get_string(1)
172
+ if FileAccess.file_exists(path) or DirAccess.dir_exists_absolute(path):
173
+ continue
174
+ named.append(path)
175
+ return named
176
+
177
+
142
178
  ## One line for [param entry], one of [method missing_references].
143
179
  static func missing_line(entry: Dictionary) -> String:
144
180
  var referring: Array = entry.get("referenced_by", [])
@@ -197,11 +233,18 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
197
233
  var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
198
234
  if not file:
199
235
  continue
200
- var lines: PackedStringArray = file.get_as_text().split("\n")
236
+ var text: String = file.get_as_text()
201
237
  file.close()
202
- # A class is used by name only in code. In a scene or a resource the same word is a node's
203
- # name or a caption, and each was counted as a use of the class.
204
- var code: bool = file_path.get_extension() in ["gd", "gdshader"]
238
+ var lines: PackedStringArray = text.split("\n")
239
+ # A class is used by name only in a script's code. In a scene or a resource the same word is a
240
+ # node's name or a caption, and each was counted as a use of the class; a shader has no use
241
+ # for one at all.
242
+ var code: bool = file_path.get_extension() == "gd"
243
+ # In a shader # opens a directive, so an #include was answered as a comment, and its
244
+ # comments are // and /* */.
245
+ var shader_lines: PackedStringArray = []
246
+ if file_path.get_extension() in SHADER_KINDS:
247
+ shader_lines = _shader_code(text).split("\n")
205
248
 
206
249
  var references: Array[Dictionary] = []
207
250
  for i: int in range(lines.size()):
@@ -222,6 +265,10 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
222
265
  continue
223
266
  var trimmed: String = line.strip_edges()
224
267
  var kind: String = ""
268
+ var extra: Dictionary = {}
269
+ var loaded: String = _loaded_by_uid(line) if trimmed.begins_with("[ext_resource") else ""
270
+ var written: RegExMatch = GdhPatterns.compiled('path="([^"]+)"').search(line)
271
+ var written_path: String = "" if written == null else _resolved(written.get_string(1), file_path)
225
272
  # A mention in a comment is not a use, and both were answered as one. The question a
226
273
  # reverse walk is asked is whether anything still uses a class, and a project that
227
274
  # documents itself names its collaborators in `##` links: four references to one class,
@@ -232,8 +279,16 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
232
279
  kind = "doc"
233
280
  elif (code and trimmed.begins_with("#")) or name_in_comment:
234
281
  kind = "comment"
282
+ elif not shader_lines.is_empty() and not _names_target(shader_lines[i], file_path, target):
283
+ kind = "comment"
284
+ elif by_its_path and not loaded.is_empty() and loaded != target["path"]:
285
+ # The engine loads the file the uid names, so the path written here is used by nothing.
286
+ kind = "stale_path"
287
+ extra["loads"] = loaded
235
288
  elif by_its_path:
236
289
  kind = _path_reference_kind(line, file_path)
290
+ if not loaded.is_empty() and written_path != loaded:
291
+ extra["stale_path"] = written_path
237
292
  elif by_its_autoload:
238
293
  kind = "autoload"
239
294
  else:
@@ -242,9 +297,11 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
242
297
  if GdhPatterns.without_annotations(line).begins_with("extends ")
243
298
  else "class_name"
244
299
  )
245
- references.append({"line": i + 1, "kind": kind, "text": trimmed})
300
+ var found: Dictionary = {"line": i + 1, "kind": kind, "text": trimmed}
301
+ found.merge(extra)
302
+ references.append(found)
246
303
  by_kind[kind] = GdhRead.as_int(by_kind.get(kind, 0)) + 1
247
- if kind != "doc" and kind != "comment":
304
+ if not kind in ["doc", "comment", "stale_path"]:
248
305
  in_code += 1
249
306
 
250
307
  if not references.is_empty():
@@ -388,6 +445,8 @@ func _path_reference_kind(line: String, file_path: String) -> String:
388
445
  return "extends"
389
446
  if trimmed.begins_with("[ext_resource"):
390
447
  return "ext_resource"
448
+ if trimmed.begins_with("#include"):
449
+ return "include"
391
450
  if "preload(" in trimmed:
392
451
  return "preload"
393
452
  if "load(" in trimmed:
@@ -429,13 +488,10 @@ func _gather_missing(deps: Array[Dictionary], parent: String, into: Dictionary)
429
488
  _gather_missing(deeper, path, into)
430
489
 
431
490
 
432
- # A circular entry is not counted: it marks that the entry holding it was reached again, which that
433
- # entry already counts as one reference.
434
491
  func _count_recursive(deps: Array[Dictionary]) -> int:
435
492
  var count: int = 0
436
493
  for dep: Dictionary in deps:
437
- if not dep.get("circular", false):
438
- count += 1
494
+ count += 1
439
495
  if dep.has("dependencies"):
440
496
  var deeper: Array[Dictionary] = dep["dependencies"]
441
497
  count += _count_recursive(deeper)
@@ -448,13 +504,6 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
448
504
  if current_depth >= walk.max_depth:
449
505
  return deps
450
506
 
451
- if path in walk.path_stack:
452
- var cycle: Array[String] = walk.path_stack.slice(walk.path_stack.find(path))
453
- cycle.append(path)
454
- if not cycle in walk.circular_references:
455
- walk.circular_references.append(cycle)
456
- return [{"path": path, "circular": true}]
457
-
458
507
  # Reused only when it was walked with as much depth left as now. A resource first reached deep in
459
508
  # the walk has a list cut short at the limit, and handing that to a nearer reach answered with a
460
509
  # chain that stopped where nothing stopped it.
@@ -476,13 +525,15 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
476
525
  # about `["res://addons/x/plugin.cfg"]` was a dependency of every project with the addon.
477
526
  if path.get_extension() == "gd":
478
527
  content = _code_lines(content)
528
+ elif path.get_extension() in SHADER_KINDS:
529
+ content = _shader_code(content)
479
530
  var moved: Dictionary = _moved_by_uid(content)
480
531
 
481
532
  for pattern: String in REFERENCE_PATTERNS:
482
533
  var regex: RegEx = GdhPatterns.compiled(pattern)
483
534
  for m: RegExMatch in regex.search_all(content):
484
- var dep_path: String = _resolved(_referenced_path(m.get_string()), path)
485
- dep_path = str(moved.get(dep_path, dep_path))
535
+ var written: String = _resolved(_referenced_path(m.get_string()), path)
536
+ var dep_path: String = str(moved.get(written, written))
486
537
 
487
538
  if not dep_path.begins_with("res://"):
488
539
  continue
@@ -497,7 +548,14 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
497
548
  if not walk.include_built_in and dep_path.begins_with("res://."):
498
549
  continue
499
550
 
500
- _depend(deps, path, dep_path, {}, current_depth, walk)
551
+ _depend(
552
+ deps,
553
+ path,
554
+ dep_path,
555
+ {"stale_path": written} if dep_path != written else {},
556
+ current_depth,
557
+ walk
558
+ )
501
559
 
502
560
  # A script uses another by its class_name as surely as by a preload, and only the reverse walk
503
561
  # looked for that: asked what a script depends on, the answer left out the script it typed a
@@ -518,7 +576,8 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
518
576
 
519
577
  # Adds [param dep_path] to [param deps], what [param path] depends on, walked as far as the depth left
520
578
  # allows, once however many times it is named. [param named_by] is the class or autoload name it was
521
- # named by, as by_class or by_autoload, or empty for a path.
579
+ # named by, as by_class or by_autoload, and the path written when the engine loads another, as
580
+ # stale_path; empty for a path that is the file.
522
581
  func _depend(
523
582
  deps: Array[Dictionary],
524
583
  path: String,
@@ -534,10 +593,27 @@ func _depend(
534
593
  return
535
594
  var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
536
595
  dep_info.merge(named_by)
596
+ # Marked where the reference closing the cycle is. Marked inside the file reached again, the tree
597
+ # answered that file as a dependency of itself.
598
+ if dep_path in walk.path_stack:
599
+ var cycle: Array[String] = walk.path_stack.slice(walk.path_stack.find(dep_path))
600
+ cycle.append(dep_path)
601
+ if not cycle in walk.circular_references:
602
+ walk.circular_references.append(cycle)
603
+ dep_info["circular"] = true
604
+ deps.append(dep_info)
605
+ return
537
606
  if dep_info["exists"] and current_depth + 1 < walk.max_depth:
538
607
  var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
539
608
  if sub_deps.size() > 0:
540
609
  dep_info["dependencies"] = sub_deps
610
+ elif dep_info["exists"] and walk.counts_beyond:
611
+ # A file the depth stopped at read the same as one naming nothing, and a missing file past it
612
+ # went unsaid.
613
+ var beyond: GdhDependencyWalk = GdhDependencyWalk.new(1, walk.include_built_in, [], false)
614
+ var named: int = _analyze_resource(dep_path, 0, beyond).size()
615
+ if named > 0:
616
+ dep_info["beyond_depth"] = named
541
617
  deps.append(dep_info)
542
618
 
543
619
 
@@ -611,34 +687,71 @@ static func _resolved(written: String, from: String) -> String:
611
687
  return from.get_base_dir().path_join(written).simplify_path()
612
688
 
613
689
 
614
- # The path inside a preload(), load() or ext_resource match; a bare res:// match is the path.
615
- # The ext_resource paths in [param content] that nothing is at, each with the file its uid names
616
- # instead. The engine loads an ext_resource by its uid first, so a file moved outside the editor
617
- # still loads, and the walk called it missing; every pattern reads the old path, the bare res://
618
- # one included, so the paths are mapped before any of them runs.
690
+ # The ext_resource paths in [param content] whose uid leads elsewhere, each with the file the uid
691
+ # names. The engine loads an ext_resource by its uid whenever the uid is known, whatever the path
692
+ # says, so a file moved outside the editor still loads, and the walk called it missing; every pattern
693
+ # reads the old path, the bare res:// one included, so the paths are mapped before any of them runs.
619
694
  static func _moved_by_uid(content: String) -> Dictionary:
620
695
  var moved: Dictionary = {}
621
696
  for line: RegExMatch in GdhPatterns.compiled("\\[ext_resource[^\\]]*\\]").search_all(content):
622
- var entry: String = line.get_string()
623
- var at: RegExMatch = GdhPatterns.compiled('path="([^"]+)"').search(entry)
624
- var uid: RegExMatch = GdhPatterns.compiled('uid="(uid://[^"]+)"').search(entry)
625
- if at == null or uid == null or FileAccess.file_exists(at.get_string(1)):
626
- continue
627
- var id: int = ResourceUID.text_to_id(uid.get_string(1))
628
- if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id):
629
- continue
630
- var found: String = ResourceUID.get_id_path(id)
631
- if FileAccess.file_exists(found):
697
+ var found: String = _loaded_by_uid(line.get_string())
698
+ var at: RegExMatch = GdhPatterns.compiled('path="([^"]+)"').search(line.get_string())
699
+ if at != null and not found.is_empty() and found != at.get_string(1):
632
700
  moved[at.get_string(1)] = found
633
701
  return moved
634
702
 
635
703
 
704
+ # The file an ext_resource [param entry] is loaded from by its uid, or empty when it carries none
705
+ # the project knows, and the engine falls back to the path.
706
+ static func _loaded_by_uid(entry: String) -> String:
707
+ var uid: RegExMatch = GdhPatterns.compiled('uid="(uid://[^"]+)"').search(entry)
708
+ if uid == null:
709
+ return ""
710
+ var id: int = ResourceUID.text_to_id(uid.get_string(1))
711
+ if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id):
712
+ return ""
713
+ var found: String = ResourceUID.get_id_path(id)
714
+ return found if FileAccess.file_exists(found) else ""
715
+
716
+
717
+ # [param content], a shader's code, with its // and /* */ comments emptied and its lines kept, so a
718
+ # path written in one is no include.
719
+ static func _shader_code(content: String) -> String:
720
+ var kept: Array[String] = []
721
+ var length: int = content.length()
722
+ var copied: int = 0
723
+ var at: int = 0
724
+ while at < length:
725
+ var pair: String = content.substr(at, 2)
726
+ if content[at] == '"':
727
+ # Stepped over whole, since an include's res:// holds the // that opens a comment.
728
+ var close: int = content.find('"', at + 1)
729
+ var line_end: int = content.find("\n", at + 1)
730
+ var ends: int = close + 1 if close != -1 else length
731
+ at = line_end if line_end != -1 and line_end < ends else ends
732
+ elif pair == "//" or pair == "/*":
733
+ kept.append(content.substr(copied, at - copied))
734
+ var block: bool = pair == "/*"
735
+ var close: int = content.find("*/" if block else "\n", at + 2)
736
+ var resumes: int = length if close == -1 else (close + 2 if block else close)
737
+ if block:
738
+ kept.append("\n".repeat(content.substr(at, resumes - at).count("\n")))
739
+ at = resumes
740
+ copied = at
741
+ else:
742
+ at += 1
743
+ kept.append(content.substr(copied))
744
+ return "".join(PackedStringArray(kept))
745
+
746
+
747
+ # The path inside a preload(), load(), #include or ext_resource match; a bare res:// match is the path.
748
+ # An ext_resource is read first, since its type or its path can hold the word load.
636
749
  func _referenced_path(matched: String) -> String:
637
750
  var expression: String = ""
638
- if "preload" in matched or "load" in matched:
639
- expression = '"([^"]+)"'
640
- elif "ext_resource" in matched:
751
+ if matched.begins_with("ext_resource"):
641
752
  expression = 'path="([^"]+)"'
753
+ elif "load" in matched or matched.begins_with("#include"):
754
+ expression = '"([^"]+)"'
642
755
  else:
643
756
  return matched
644
757
  var inner: RegEx = GdhPatterns.compiled(expression)