gdharness 1.2.34 → 1.2.36

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.
@@ -12,8 +12,10 @@ const REFUSED: String = "Error calling method from 'callv': "
12
12
 
13
13
  ## The engine's reason for refusing the call, without [constant REFUSED], or "" when it ran.
14
14
  var refused: String = ""
15
- ## What was reported while it ran, a line each, warnings marked as such.
15
+ ## The errors reported while it ran, a line each.
16
16
  var raised: Array[String] = []
17
+ ## The warnings reported while it ran, a line each.
18
+ var warned: Array[String] = []
17
19
 
18
20
 
19
21
  func _log_message(_message: String, _error: bool) -> void:
@@ -39,4 +41,7 @@ func _log_error(
39
41
  if function == "callv" and text.begins_with(REFUSED) and refused.is_empty():
40
42
  refused = text.trim_prefix(REFUSED)
41
43
  return
42
- raised.append(("WARNING: " if error_type == ERROR_TYPE_WARNING else "") + text)
44
+ if error_type == ERROR_TYPE_WARNING:
45
+ warned.append(text)
46
+ else:
47
+ raised.append(text)
@@ -108,6 +108,9 @@ try {
108
108
  }
109
109
  $p = Patterned $element 'RangeValue'
110
110
  if ($p) {
111
+ # A reader speaks the Value pattern's words, such as "75%" for a slider whose game sets them,
112
+ # and the number would otherwise replace them.
113
+ if ($node.Contains('value') -and "$($node.value)") { $node.value_text = $node.value }
111
114
  $node.value = $p.Current.Value
112
115
  $node.range = @($p.Current.Minimum, $p.Current.Maximum)
113
116
  if (-not $p.Current.IsReadOnly) { $actions += 'set_value' }
@@ -389,17 +392,18 @@ static func _narrow(result: Dictionary, params: Dictionary) -> void:
389
392
 
390
393
 
391
394
  ## Whether [param element] is made by the node at [param under] or one below it, and says
392
- ## [param said], each where asked: words as find matches them, in the element's name or value.
395
+ ## [param said], each where asked: words as find matches them, in the element's name, value or
396
+ ## value_text.
393
397
  static func _matches(element: Dictionary, under: String, said: String) -> bool:
394
398
  if not under.is_empty():
395
399
  var path: String = str(element.get("node", element.get("part_of", "")))
396
400
  if path != under and not path.begins_with(under + "/"):
397
401
  return false
398
402
  if not said.is_empty():
399
- var name: String = str(element.get("name", ""))
400
- var value: String = str(element.get("value", ""))
401
- if not Says.matches(name, said) and not Says.matches(value, said):
402
- return false
403
+ for field: String in ["name", "value", "value_text"]:
404
+ if element.has(field) and Says.matches(str(element[field]), said):
405
+ return true
406
+ return false
403
407
  return true
404
408
 
405
409
 
@@ -18,6 +18,7 @@ const InputCommands = preload("runtime_input.gd")
18
18
  const Pad = preload("runtime_pad.gd")
19
19
  const Queries = preload("runtime_queries.gd")
20
20
  const Read = preload("reading.gd")
21
+ const Targets = preload("runtime_targets.gd")
21
22
  const Typing = preload("runtime_typing.gd")
22
23
  const Values = preload("runtime_values.gd")
23
24
  const Waits = preload("runtime_waits.gd")
@@ -153,6 +154,7 @@ func _init() -> void:
153
154
  "choose": _typing.choose,
154
155
  "wait_frames": _waits.wait_frames,
155
156
  "wait_signal": _waits.wait_signal,
157
+ "watch_signal": _waits.watch_signal,
156
158
  "wait_until": _waits.wait_until,
157
159
  "send_profile": _send_profile_now,
158
160
  "close_game": _close_game,
@@ -160,6 +162,36 @@ func _init() -> void:
160
162
  _wait_for_the_profiler()
161
163
 
162
164
 
165
+ ## Gives a game with no window the size the project opens its window at. Measured: a
166
+ ## headless engine's root viewport is 64 by 64 whatever the project settings or --resolution say, so
167
+ ## a headless game laid its interface out in 64 pixels, and a button every windowed run shows was
168
+ ## refused as off screen. The root takes a size set once the autoloads are in the tree, before the
169
+ ## main scene enters it, and the scene lays out at it from its first frame. A size set at the
170
+ ## autoload's construction does not hold: the root is a 100 by 100 placeholder then, and the engine
171
+ ## gives it the 64 by 64 afterwards. A script run is not a game and keeps the viewport its own code
172
+ ## gives it, and a release build is left as the engine made it.
173
+ func _size_a_windowless_game() -> void:
174
+ if DisplayServer.get_name() != "headless" or _script_run() or not OS.is_debug_build():
175
+ return
176
+ var root: Window = get_tree().root
177
+ if Vector2(root.size) != Targets.HEADLESS_VIEWPORT:
178
+ return
179
+ root.size = Vector2i(_window_dimension("width", 1152), _window_dimension("height", 648))
180
+
181
+
182
+ ## The window's [param axis] as the project opens it: the override when one is set, otherwise the
183
+ ## viewport's size, whose engine default is [param fallback].
184
+ static func _window_dimension(axis: String, fallback: int) -> int:
185
+ var override: int = Read.as_int(
186
+ ProjectSettings.get_setting("display/window/size/window_%s_override" % axis, 0)
187
+ )
188
+ if override > 0:
189
+ return override
190
+ return Read.as_int(
191
+ ProjectSettings.get_setting("display/window/size/viewport_%s" % axis, fallback), fallback
192
+ )
193
+
194
+
163
195
  ## Holds the game's start until the profiler is on, when whatever started the game asked for a
164
196
  ## profile from its start. The debugger switches the profiler on with a message the game reads only
165
197
  ## on a poll, which comes every [constant POLLS_PER_READ] lines of script, so the main scene's _ready
@@ -197,6 +229,7 @@ func _ready() -> void:
197
229
  # even be un-paused over the socket. A debug server has to stay responsive while the game
198
230
  # is frozen, to inspect, capture, inject or resume it.
199
231
  process_mode = Node.PROCESS_MODE_ALWAYS
232
+ _size_a_windowless_game()
200
233
  var watched: int = get_tree().root.tree_exiting.connect(_send_the_profile)
201
234
  if watched != OK:
202
235
  push_error("gdharness could not watch for the game quitting: %d" % watched)
@@ -441,16 +441,24 @@ func call_method(params: Dictionary) -> Dictionary:
441
441
  "result": _values.serialize(result),
442
442
  "elapsed_usec": elapsed
443
443
  }
444
- if not caught.raised.is_empty():
445
- answer["errors"] = caught.raised.slice(0, ERRORS_KEPT)
446
- if caught.raised.size() > ERRORS_KEPT:
447
- answer["errors_not_shown"] = caught.raised.size() - ERRORS_KEPT
444
+ _keep_reported(answer, "errors", caught.raised)
445
+ _keep_reported(answer, "warnings", caught.warned)
448
446
  var asked: Variant = params.get("properties")
449
447
  if _reads_the_result(asked):
450
448
  answer.merge(_read_off_result(result, asked))
451
449
  return answer
452
450
 
453
451
 
452
+ ## Puts the first [constant ERRORS_KEPT] of [param reported] in [param answer] under [param field],
453
+ ## with how many more there were, when anything was reported.
454
+ static func _keep_reported(answer: Dictionary, field: String, reported: Array[String]) -> void:
455
+ if reported.is_empty():
456
+ return
457
+ answer[field] = reported.slice(0, ERRORS_KEPT)
458
+ if reported.size() > ERRORS_KEPT:
459
+ answer[field + "_not_shown"] = reported.size() - ERRORS_KEPT
460
+
461
+
454
462
  ## Why [param named], the last step of a call's method, names no method of [param holder] to call, or
455
463
  ## "" when it does. Brackets on it holding arguments are refused rather than read: the arguments are
456
464
  ## taken from args alone, so a call is never made with half of them in one place and half in the
@@ -412,17 +412,19 @@ static func _outside_note(outside: int, root: Node) -> String:
412
412
  ## headless game's button twenty pixels in from the corner was refused as off screen, and the
413
413
  ## refusal read as though the button were not there.
414
414
  ##
415
- ## The size is only claimed where it is true: the rect is printed beside this, so naming 64 by 64
416
- ## over a viewport somebody has resized says two different things in one sentence.
415
+ ## The size named is the one in force, which the rect printed beside it agrees with. A game the
416
+ ## runtime started headless has the size the project opens its window at, and a window of that size
417
+ ## would not reach the control either, so only the engine's own 64 by 64 is answered with a window.
417
418
  static func no_window_note(viewport: Viewport, root: Window) -> String:
418
419
  if viewport != root or root.can_draw():
419
420
  return ""
420
- if viewport.get_visible_rect().size == HEADLESS_VIEWPORT:
421
+ var size: Vector2 = viewport.get_visible_rect().size
422
+ if size == HEADLESS_VIEWPORT:
421
423
  return (
422
424
  ". This game has no window, and a game with no window has a 64 by 64 viewport whatever "
423
425
  + "the project settings say: run it with a window to reach this control"
424
426
  )
425
- return ". This game has no window: run it with a window to reach this control"
427
+ return ". This game has no window, and its viewport is %d by %d" % [roundi(size.x), roundi(size.y)]
426
428
 
427
429
 
428
430
  ## What to add to a refusal that found nothing on screen, when the controls saying it are covered.
@@ -771,7 +773,7 @@ static func _where_it_lands(target: Control, point: Vector2) -> Dictionary[Scrol
771
773
  ## Whether a click could not reach [param target] however it scrolled: its centre, where the click
772
774
  ## would bring it, outside its viewport or the one showing that on the screen, or clipped away by a
773
775
  ## container the click does not scroll. A drawer parked off the side of the screen, or anything
774
- ## outside the 64 by 64 viewport of a game with no window, was listed among the controls on screen.
776
+ ## outside the viewport of a game with no window, was listed among the controls on screen.
775
777
  static func _off_screen(target: Control) -> bool:
776
778
  var moves: Dictionary[ScrollContainer, Vector2] = _scrolls_for(target)
777
779
  var point: Vector2 = _centre_of(target) + _moved_by(target, moves)
@@ -26,8 +26,13 @@ const LOOKS_APART: int = 9
26
26
  ## two looks and a wait for 32 never sees it.
27
27
  const COMPARES: Array[String] = ["==", "!=", ">=", "<=", ">", "<"]
28
28
 
29
+ ## The most signal watches kept at once; arming one past this lets the oldest go.
30
+ const MOST_WATCHES: int = 16
31
+
29
32
  var _host: Node
30
33
  var _values: Values
34
+ var _watches: Dictionary[int, Watch] = {}
35
+ var _next_watch: int = 1
31
36
 
32
37
 
33
38
  func _init(host: Node, values: Values) -> void:
@@ -56,13 +61,16 @@ func wait_frames(params: Dictionary) -> Dictionary:
56
61
  return {"type": "waited", "frames": frames, "elapsed_ms": Time.get_ticks_msec() - started}
57
62
 
58
63
 
59
- ## Waits for a signal to fire, and answers with what it carried, or that the time ran out.
64
+ ## Waits for a signal to fire, and answers with what it carried, or that the time ran out. Given a
65
+ ## watch, collects that instead: see [method watch_signal].
60
66
  func wait_signal(params: Dictionary) -> Dictionary:
61
67
  var node_path: String = str(params.get("path", ""))
62
68
  var signal_name: String = str(params.get("signal", ""))
63
69
  var timeout_ms: int = Read.as_int(params.get("timeout_ms", 5000), 5000)
64
70
  if timeout_ms < 1 or timeout_ms > CEILING_MSEC:
65
71
  return _out_of_range("timeout_ms", timeout_ms, 1, CEILING_MSEC)
72
+ if params.has("watch"):
73
+ return await _collect(Read.as_int(params["watch"], 0), timeout_ms)
66
74
  if node_path.is_empty() or signal_name.is_empty():
67
75
  return {"type": "error", "message": "Node path and signal name required"}
68
76
 
@@ -84,7 +92,7 @@ func wait_signal(params: Dictionary) -> Dictionary:
84
92
  if not catcher.fired and is_instance_valid(node) and node.is_connected(signal_name, callable):
85
93
  node.disconnect(signal_name, callable)
86
94
 
87
- return {
95
+ var answer: Dictionary = {
88
96
  "type": "signal",
89
97
  "path": node_path,
90
98
  "signal": signal_name,
@@ -92,6 +100,90 @@ func wait_signal(params: Dictionary) -> Dictionary:
92
100
  "args": _values.serialize(catcher.args),
93
101
  "elapsed_ms": Time.get_ticks_msec() - started,
94
102
  }
103
+ if not catcher.fired:
104
+ answer["note"] = (
105
+ "If a call meant to set the signal off was sent beside this wait, it may have run only after"
106
+ + " the wait answered, as a client can run its calls in turn: arm runtime_wait watch first,"
107
+ + " make the call, then wait with signal and that watch"
108
+ )
109
+ return answer
110
+
111
+
112
+ ## Starts recording a signal and answers at once with the watch to collect it by.
113
+ ##
114
+ ## A wait sent beside the call meant to set its signal off does not see it when the client runs the
115
+ ## two in turn: Claude Code ran a key press only after the signal wait beside it had answered, so the
116
+ ## wait ran out and the press landed after it. A watch is armed before the call and collected after.
117
+ func watch_signal(params: Dictionary) -> Dictionary:
118
+ var node_path: String = str(params.get("path", ""))
119
+ var signal_name: String = str(params.get("signal", ""))
120
+ if node_path.is_empty() or signal_name.is_empty():
121
+ return {"type": "error", "message": "Node path and signal name required"}
122
+ var standing: Dictionary = Values.node_at(_host.get_tree().root, node_path)
123
+ if standing.has("message"):
124
+ return standing
125
+ var node: Node = standing["node"]
126
+ if not node.has_signal(signal_name):
127
+ return {"type": "error", "message": "%s has no signal %s" % [node_path, signal_name]}
128
+ var watch: Watch = Watch.new(node, node_path, signal_name)
129
+ var listening: Error = node.connect(signal_name, watch.catcher._on_fired)
130
+ if listening != OK:
131
+ return {"type": "error", "message": "Could not listen to signal: " + signal_name}
132
+ while _watches.size() >= MOST_WATCHES:
133
+ var oldest: int = _watches.keys().min()
134
+ _let_go(oldest)
135
+ var id: int = _next_watch
136
+ _next_watch += 1
137
+ _watches[id] = watch
138
+ return {"type": "watching", "watch": id, "path": node_path, "signal": signal_name}
139
+
140
+
141
+ ## Answers at once with the first emission recorded since watch [param id] was armed, otherwise
142
+ ## waits for the next until the time runs out. The watch goes either way.
143
+ func _collect(id: int, timeout_ms: int) -> Dictionary:
144
+ if not _watches.has(id):
145
+ return {
146
+ "type": "error",
147
+ "message":
148
+ (
149
+ (
150
+ "No watch %d is armed in this game: it was collected already, %d armed after it let it"
151
+ + " go, or the game started again since"
152
+ )
153
+ % [id, MOST_WATCHES]
154
+ ),
155
+ }
156
+ var watch: Watch = _watches[id]
157
+ var started: int = Time.get_ticks_msec()
158
+ var before: bool = watch.catcher.fired
159
+ while not watch.catcher.fired and Time.get_ticks_msec() - started < timeout_ms:
160
+ await _host.get_tree().process_frame
161
+ _let_go(id)
162
+ var answer: Dictionary = {
163
+ "type": "signal",
164
+ "path": watch.node_path,
165
+ "signal": watch.signal_name,
166
+ "watch": id,
167
+ "fired": watch.catcher.fired,
168
+ "times": watch.catcher.times,
169
+ "args": _values.serialize(watch.catcher.args),
170
+ "elapsed_ms": Time.get_ticks_msec() - started,
171
+ }
172
+ if before:
173
+ answer["fired_before_wait"] = true
174
+ return answer
175
+
176
+
177
+ func _let_go(id: int) -> void:
178
+ var watch: Watch = _watches.get(id)
179
+ var _gone: bool = _watches.erase(id)
180
+ if watch == null:
181
+ return
182
+ var held: Object = watch.node.get_ref()
183
+ var node: Node = held as Node
184
+ var callable: Callable = watch.catcher._on_fired
185
+ if node != null and node.is_connected(watch.signal_name, callable):
186
+ node.disconnect(watch.signal_name, callable)
95
187
 
96
188
 
97
189
  ## Waits until a property reads as the value given, or until something under the path says what
@@ -492,8 +584,26 @@ func _anything_says(node_path: String, said: String, include_hidden: bool) -> bo
492
584
  class SignalCatcher:
493
585
  extends RefCounted
494
586
  var fired: bool = false
587
+ ## How many times it fired; [member args] holds what the first carried.
588
+ var times: int = 0
495
589
  var args: Array = []
496
590
 
497
591
  func _on_fired(...carried: Array) -> void:
592
+ if not fired:
593
+ args = carried
498
594
  fired = true
499
- args = carried
595
+ times += 1
596
+
597
+
598
+ ## A signal recorded from when it was armed until it is collected.
599
+ class Watch:
600
+ extends RefCounted
601
+ var node: WeakRef
602
+ var node_path: String
603
+ var signal_name: String
604
+ var catcher: SignalCatcher = SignalCatcher.new()
605
+
606
+ func _init(watched: Node, path: String, named: String) -> void:
607
+ node = weakref(watched)
608
+ node_path = path
609
+ signal_name = named
@@ -21,6 +21,17 @@ const CATEGORY_BASES: Dictionary = {
21
21
  "visual": ["VisualInstance3D"],
22
22
  "animation": ["AnimationMixer"],
23
23
  }
24
+ ## The engine's MethodFlags, each by the name an answer gives it.
25
+ const METHOD_FLAG_NAMES: Dictionary[int, String] = {
26
+ METHOD_FLAG_NORMAL: "normal",
27
+ METHOD_FLAG_EDITOR: "editor",
28
+ METHOD_FLAG_CONST: "const",
29
+ METHOD_FLAG_VIRTUAL: "virtual",
30
+ METHOD_FLAG_VARARG: "vararg",
31
+ METHOD_FLAG_STATIC: "static",
32
+ METHOD_FLAG_OBJECT_CORE: "object_core",
33
+ METHOD_FLAG_VIRTUAL_REQUIRED: "virtual_required",
34
+ }
24
35
 
25
36
  var _log: Log
26
37
  var _values: Serialisation = Serialisation.new()
@@ -333,6 +344,7 @@ func _methods_of(class_name_str: String, include_inherited: bool) -> Array[Dicti
333
344
  "class_name": return_info.get("class_name", "")
334
345
  },
335
346
  "flags": m.get("flags", 0),
347
+ "flag_names": _flag_names(Read.as_int(m.get("flags", 0))),
336
348
  # As values, tagged the way every other answer tags one: JSON writes a Vector2i it is
337
349
  # handed as its text, so TileMapLayer.set_cell's default atlas coords read "(-1, -1)".
338
350
  "default_args": _values.serialize_value(m.get("default_args", []))
@@ -343,6 +355,16 @@ func _methods_of(class_name_str: String, include_inherited: bool) -> Array[Dicti
343
355
  return methods
344
356
 
345
357
 
358
+ # The MethodFlags set in [param flags] by name, beside the number the engine reports, which reads as
359
+ # nothing without its enum: 1 is normal, and a const method is 5.
360
+ static func _flag_names(flags: int) -> Array[String]:
361
+ var named: Array[String] = []
362
+ for flag: int in METHOD_FLAG_NAMES:
363
+ if flags & flag:
364
+ named.append(METHOD_FLAG_NAMES[flag])
365
+ return named
366
+
367
+
346
368
  # What [param info]'s type is called: its class for an object of one, and otherwise the engine's own
347
369
  # name for the type, beside the number the engine reports, which reads as nothing without its enum.
348
370
  # Nil is a value that can be anything when the usage says so, and [param nil_name] otherwise: void
@@ -23,6 +23,11 @@ const REFERENCE_PATTERNS: Array[String] = [
23
23
 
24
24
  var _log: Log
25
25
  var _files: FileWalk = FileWalk.new()
26
+ ## The project's class names and the scripts declaring them, read the first time a script is walked
27
+ ## and kept for the call: walking every resource starts a walk per root, and each reading every
28
+ ## script again made the whole-project answer quadratic in the number of scripts.
29
+ var _classes_declared: Dictionary = {}
30
+ var _classes_read: bool = false
26
31
 
27
32
 
28
33
  # Everything one dependency walk carries: the settings it was started with, and the state it
@@ -91,12 +96,14 @@ func get_dependencies(params: Dictionary) -> Dictionary:
91
96
 
92
97
  total_resources = all_resources.size()
93
98
 
94
- var dep_count: int = 0
99
+ var references: int = 0
100
+ var distinct: Dictionary = {}
95
101
  for key: String in dependencies:
96
102
  # Through a typed local: what comes out of a Dictionary is Variant, and a project that
97
103
  # errors on handing one to a typed parameter will not compile this script at all.
98
104
  var walked: Array[Dictionary] = dependencies[key]
99
- dep_count += _count_recursive(walked)
105
+ references += _count_recursive(walked)
106
+ _gather_paths(walked, key, distinct)
100
107
 
101
108
  return {
102
109
  "dependencies": dependencies,
@@ -104,7 +111,10 @@ func get_dependencies(params: Dictionary) -> Dictionary:
104
111
  "summary":
105
112
  {
106
113
  "total_resources": total_resources,
107
- "total_dependencies": dep_count,
114
+ # The files depended on, each once. The tree names a file again under everything that
115
+ # loads it, and counting its entries answered 11 for a scene with six dependencies.
116
+ "total_dependencies": distinct.size(),
117
+ "total_references": references,
108
118
  "circular_count": circular_references.size()
109
119
  }
110
120
  }
@@ -332,9 +342,24 @@ func _regex_escaped(text: String) -> String:
332
342
  return escaped
333
343
 
334
344
 
345
+ # [param root] is left out: a cycle leads back to it, and it is not one of its own dependencies.
346
+ func _gather_paths(deps: Array[Dictionary], root: String, into: Dictionary) -> void:
347
+ for dep: Dictionary in deps:
348
+ var path: String = str(dep.get("path", ""))
349
+ if path != root:
350
+ into[path] = true
351
+ if dep.has("dependencies"):
352
+ var deeper: Array[Dictionary] = dep["dependencies"]
353
+ _gather_paths(deeper, root, into)
354
+
355
+
356
+ # A circular entry is not counted: it marks that the entry holding it was reached again, which that
357
+ # entry already counts as one reference.
335
358
  func _count_recursive(deps: Array[Dictionary]) -> int:
336
- var count: int = deps.size()
359
+ var count: int = 0
337
360
  for dep: Dictionary in deps:
361
+ if not dep.get("circular", false):
362
+ count += 1
338
363
  if dep.has("dependencies"):
339
364
  var deeper: Array[Dictionary] = dep["dependencies"]
340
365
  count += _count_recursive(deeper)
@@ -390,29 +415,74 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
390
415
  if not walk.include_built_in and dep_path.begins_with("res://."):
391
416
  continue
392
417
 
393
- if dep_path == path:
394
- continue
395
-
396
- var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
397
-
398
- if dep_info["exists"] and current_depth + 1 < walk.max_depth:
399
- var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
400
- if sub_deps.size() > 0:
401
- dep_info["dependencies"] = sub_deps
418
+ _depend(deps, path, dep_path, "", current_depth, walk)
402
419
 
403
- var already_added: bool = false
404
- for existing: Dictionary in deps:
405
- if existing.get("path", "") == dep_path:
406
- already_added = true
407
- break
408
- if not already_added:
409
- deps.append(dep_info)
420
+ # A script uses another by its class_name as surely as by a preload, and only the reverse walk
421
+ # looked for that: asked what a script depends on, the answer left out the script it typed a
422
+ # variable with, while asking what used that script named this one.
423
+ if path.get_extension() == "gd":
424
+ var classes: Dictionary = classes_declared()
425
+ for used: String in classes_used_in(content, classes):
426
+ _depend(deps, path, str(classes[used]), used, current_depth, walk)
410
427
 
411
428
  walk.path_stack.pop_back()
412
429
  walk.visited[path] = {"deps": deps, "remaining": remaining}
413
430
  return deps
414
431
 
415
432
 
433
+ # Adds [param dep_path] to [param deps], what [param path] depends on, walked as far as the depth left
434
+ # allows, once however many times it is named. [param by_class] is the class name it was named by,
435
+ # or empty for a path.
436
+ func _depend(
437
+ deps: Array[Dictionary],
438
+ path: String,
439
+ dep_path: String,
440
+ by_class: String,
441
+ current_depth: int,
442
+ walk: DependencyWalk
443
+ ) -> void:
444
+ if dep_path == path:
445
+ return
446
+ for existing: Dictionary in deps:
447
+ if existing.get("path", "") == dep_path:
448
+ return
449
+ var dep_info: Dictionary = {"path": dep_path, "exists": FileAccess.file_exists(dep_path)}
450
+ if not by_class.is_empty():
451
+ dep_info["by_class"] = by_class
452
+ if dep_info["exists"] and current_depth + 1 < walk.max_depth:
453
+ var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
454
+ if sub_deps.size() > 0:
455
+ dep_info["dependencies"] = sub_deps
456
+ deps.append(dep_info)
457
+
458
+
459
+ ## Every class_name the project's scripts declare, with the script declaring it.
460
+ func classes_declared() -> Dictionary:
461
+ if _classes_read:
462
+ return _classes_declared
463
+ _classes_read = true
464
+ for script: String in _files.find_files("res://", ".gd"):
465
+ var declared: String = _declared_class_name(script)
466
+ if not declared.is_empty():
467
+ _classes_declared[declared] = script
468
+ return _classes_declared
469
+
470
+
471
+ ## The names in [param classes] that the code in [param content] uses, in the order first used: words
472
+ ## outside strings and comments, so a class named in prose or in a message is not a use of it.
473
+ static func classes_used_in(content: String, classes: Dictionary) -> Array[String]:
474
+ var used: Array[String] = []
475
+ if classes.is_empty():
476
+ return used
477
+ var word: RegEx = Patterns.compiled("[A-Za-z_][A-Za-z0-9_]*")
478
+ for line: String in content.split("\n"):
479
+ for m: RegExMatch in word.search_all(_code_of(line)):
480
+ var name: String = m.get_string()
481
+ if classes.has(name) and not name in used:
482
+ used.append(name)
483
+ return used
484
+
485
+
416
486
  # [param written] as the res:// path the engine would load from [param from]: a uid:// by what the
417
487
  # project's uid cache says it names, and a relative path against the referring file's directory.
418
488
  # Both were passed over as not being res:// paths, so a script's `preload("sibling.gd")` was no