gdharness 1.3.43 → 1.3.45

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.
@@ -312,16 +312,25 @@ static func _bare(word: String) -> String:
312
312
  return word.to_lower().replace("_", "").replace(" ", "")
313
313
 
314
314
 
315
- ## The signals [param object] has that a caller can connect, its own class's first: the engine
316
- ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
317
- ## starting with an underscore are the engine's own.
315
+ ## The signals [param object] has that a caller can connect, its own class's first: its script's,
316
+ ## as declared, then the engine's from the class down to Object. The engine lists its own from
317
+ ## Object down, which puts a Button's pressed after thirty a Control has, and its script's ahead of
318
+ ## those, so reversing the whole list put a game's own two signals after twenty-four of the
319
+ ## engine's. Names starting with an underscore are the engine's own.
318
320
  static func signals_of(object: Object) -> Array:
321
+ var declared: Array = []
322
+ var script: Script = object.get_script()
323
+ if script != null:
324
+ for entry: Dictionary in script.get_script_signal_list():
325
+ var name: String = str(entry["name"])
326
+ if not name.begins_with("_") and not name in declared:
327
+ declared.append(name)
319
328
  var names: Array = []
320
329
  for entry: Dictionary in object.get_signal_list():
321
330
  var name: String = str(entry["name"])
322
- if not name.begins_with("_"):
331
+ if not name.begins_with("_") and not name in declared:
323
332
  names.push_front(name)
324
- return names
333
+ return declared + names
325
334
 
326
335
 
327
336
  ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
@@ -526,10 +526,27 @@ func unusable_name(parent: Node, child_name: String, moving: Node = null) -> Str
526
526
  if taken != null and taken != moving and taken.get_parent() == parent:
527
527
  # The root by its name, since its path is ".", and ". already has a child" read as a typo.
528
528
  var holder: String = "The root, %s," % parent.name if parent == root else path_of(parent)
529
- return "%s already has a child named %s." % [holder, child_name]
529
+ return (
530
+ "%s already has a child named %s; %s is free there."
531
+ % [holder, child_name, _free_name(parent, child_name)]
532
+ )
530
533
  return ""
531
534
 
532
535
 
536
+ ## The first name after [param taken] that no child of [param parent] has, numbered as Godot numbers
537
+ ## a duplicate: Crate2 after Crate, Crate4 after Crate3 when that is taken too. The refusal alone
538
+ ## left a caller guessing at a name that might be taken as well.
539
+ static func _free_name(parent: Node, taken: String) -> String:
540
+ var digits: int = 0
541
+ while digits < taken.length() and taken[taken.length() - 1 - digits] in "0123456789":
542
+ digits += 1
543
+ var base: String = taken.left(taken.length() - digits)
544
+ var at: int = 2 if digits == 0 else taken.right(digits).to_int() + 1
545
+ while parent.has_node(NodePath(base + str(at))):
546
+ at += 1
547
+ return base + str(at)
548
+
549
+
533
550
  ## The properties this scene's own file sets on [param node], each with the value the file gives it.
534
551
  func set_here(node: Node) -> Dictionary:
535
552
  return set_at(path_of(node))
@@ -62,7 +62,7 @@ static func under(prefix: String, values: GdhSerialisation) -> Dictionary:
62
62
  var value: Variant = ProjectSettings.get_setting(name)
63
63
  var described: Dictionary = {
64
64
  "setting": name,
65
- "type": type_string(kind),
65
+ "type": type_named(kind, value),
66
66
  "value": values.serialize_value(value),
67
67
  }
68
68
  # As a single get says it, so a family read can be told apart into what the project sets and
@@ -81,6 +81,47 @@ static func under(prefix: String, values: GdhSerialisation) -> Dictionary:
81
81
  return { "prefix": prefix, "count": found.size(), "settings": found }
82
82
 
83
83
 
84
+ ## The type [param kind] names, with what [param value] holds when it is a typed array or
85
+ ## dictionary, as project.godot writes it: a `Dictionary[String, bool]` was answered as Dictionary,
86
+ ## which hides what its keys and values must be.
87
+ static func type_named(kind: int, value: Variant) -> String:
88
+ if kind == TYPE_ARRAY and value is Array:
89
+ var list: Array = value
90
+ if list.is_typed():
91
+ var element: String = _held(
92
+ list.get_typed_builtin(), list.get_typed_class_name(), list.get_typed_script()
93
+ )
94
+ return "Array[%s]" % element
95
+ elif kind == TYPE_DICTIONARY and value is Dictionary:
96
+ var table: Dictionary = value
97
+ if table.is_typed():
98
+ var key: String = _held(
99
+ table.get_typed_key_builtin(),
100
+ table.get_typed_key_class_name(),
101
+ table.get_typed_key_script()
102
+ )
103
+ var held: String = _held(
104
+ table.get_typed_value_builtin(),
105
+ table.get_typed_value_class_name(),
106
+ table.get_typed_value_script()
107
+ )
108
+ return "Dictionary[%s, %s]" % [key, held]
109
+ return type_string(kind)
110
+
111
+
112
+ ## One side of a typed collection: a built-in type, a class, or a script by its class_name.
113
+ static func _held(builtin: int, named: StringName, script: Variant) -> String:
114
+ if builtin == TYPE_NIL:
115
+ return "Variant"
116
+ if builtin != TYPE_OBJECT:
117
+ return type_string(builtin)
118
+ if script is Script:
119
+ var declared: Script = script
120
+ if not declared.get_global_name().is_empty():
121
+ return declared.get_global_name()
122
+ return str(named)
123
+
124
+
84
125
  ## The setting [param setting_path] overrides for builds with one feature, as
85
126
  ## `application/run/max_fps.mobile` does `application/run/max_fps`, or "" for a plain setting.
86
127
  static func override_base(setting_path: String) -> String:
@@ -51,7 +51,7 @@ func reload_script(args: Dictionary) -> Dictionary:
51
51
  var asked: String = str(args.get("scriptPath", ""))
52
52
  if asked.is_empty():
53
53
  return { "ok": false, "error": "scriptPath is required" }
54
- var path: String = _spelled_as_on_disk(asked)
54
+ var path: String = _spelled_as_on_disk(asked, {})
55
55
  if not ResourceLoader.exists(path):
56
56
  return { "ok": false, "error": "No script at " + path }
57
57
 
@@ -151,8 +151,9 @@ func scripts_behind(args: Dictionary) -> Dictionary:
151
151
  unsaved = EditorInterface.get_script_editor().get_unsaved_files()
152
152
  var behind: Array[String] = []
153
153
  var edited: Array[String] = []
154
+ var listings: Dictionary = {}
154
155
  for entry: Variant in asked:
155
- var path: String = _spelled_as_on_disk(str(entry))
156
+ var path: String = _spelled_as_on_disk(str(entry), listings)
156
157
  var script: GDScript = ResourceLoader.get_cached_ref(path) as GDScript
157
158
  if script == null:
158
159
  continue
@@ -196,28 +197,46 @@ func _reread_open_tabs(path: String, answer: Dictionary) -> void:
196
197
  ## `res://core/rules.gd`, but the editor holds its copy under the spelling on disk, and a load by
197
198
  ## another spelling misses that copy and compiles a new one: reloading that answers with a fresh
198
199
  ## compile's members and leaves the copy the editor holds as it was.
199
- static func _spelled_as_on_disk(path: String) -> String:
200
+ ##
201
+ ## [param listings] keeps each directory's entries for the length of one call that spells many
202
+ ## paths. Listed again for every path, a check of the 416 classes of one project listed a folder of
203
+ ## eight hundred files twice per class, and held an editor's main thread for close to a minute on a
204
+ ## CI runner, with every request behind it waiting.
205
+ static func _spelled_as_on_disk(path: String, listings: Dictionary) -> String:
200
206
  var local: String = ProjectSettings.localize_path(path)
201
207
  if not local.begins_with("res://"):
202
208
  return local
203
209
  var spelled: String = "res://"
204
210
  for step: String in local.trim_prefix("res://").split("/", false):
205
- var listing: DirAccess = DirAccess.open(spelled)
206
- if listing == null:
211
+ if not listings.has(spelled):
212
+ listings[spelled] = _entries_by_spelling(spelled)
213
+ var entries: Variant = listings[spelled]
214
+ if entries == null:
207
215
  return local
208
- var found: String = ""
209
- for entry: String in listing.get_directories() + listing.get_files():
210
- if entry == step:
211
- found = entry
212
- break
213
- if found.is_empty() and entry.nocasecmp_to(step) == 0:
214
- found = entry
216
+ var exact: Dictionary = entries[0]
217
+ var folded: Dictionary = entries[1]
218
+ var found: String = step if exact.has(step) else str(folded.get(step.to_lower(), ""))
215
219
  if found.is_empty():
216
220
  return local
217
221
  spelled = spelled.path_join(found)
218
222
  return spelled
219
223
 
220
224
 
225
+ ## The entries of [param directory] by their own spelling and by their spelling in lower case, the
226
+ ## first of those that fold together winning, or null for a directory that cannot be opened.
227
+ static func _entries_by_spelling(directory: String) -> Variant:
228
+ var listing: DirAccess = DirAccess.open(directory)
229
+ if listing == null:
230
+ return null
231
+ var exact: Dictionary = {}
232
+ var folded: Dictionary = {}
233
+ for entry: String in listing.get_directories() + listing.get_files():
234
+ exact[entry] = true
235
+ if not folded.has(entry.to_lower()):
236
+ folded[entry.to_lower()] = entry
237
+ return [exact, folded]
238
+
239
+
221
240
  func _method_names(script: GDScript) -> Array[String]:
222
241
  var names: Array[String] = []
223
242
  for entry: Dictionary in script.get_script_method_list():
@@ -312,16 +312,25 @@ static func _bare(word: String) -> String:
312
312
  return word.to_lower().replace("_", "").replace(" ", "")
313
313
 
314
314
 
315
- ## The signals [param object] has that a caller can connect, its own class's first: the engine
316
- ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
317
- ## starting with an underscore are the engine's own.
315
+ ## The signals [param object] has that a caller can connect, its own class's first: its script's,
316
+ ## as declared, then the engine's from the class down to Object. The engine lists its own from
317
+ ## Object down, which puts a Button's pressed after thirty a Control has, and its script's ahead of
318
+ ## those, so reversing the whole list put a game's own two signals after twenty-four of the
319
+ ## engine's. Names starting with an underscore are the engine's own.
318
320
  static func signals_of(object: Object) -> Array:
321
+ var declared: Array = []
322
+ var script: Script = object.get_script()
323
+ if script != null:
324
+ for entry: Dictionary in script.get_script_signal_list():
325
+ var name: String = str(entry["name"])
326
+ if not name.begins_with("_") and not name in declared:
327
+ declared.append(name)
319
328
  var names: Array = []
320
329
  for entry: Dictionary in object.get_signal_list():
321
330
  var name: String = str(entry["name"])
322
- if not name.begins_with("_"):
331
+ if not name.begins_with("_") and not name in declared:
323
332
  names.push_front(name)
324
- return names
333
+ return declared + names
325
334
 
326
335
 
327
336
  ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
@@ -395,6 +395,19 @@ static func class_at(root: Node, path: String) -> Dictionary:
395
395
  "type": "error",
396
396
  "message": "No script is at %s to call a static function of." % script_path,
397
397
  }
398
+ # A script that did not compile loads all the same, with no methods, and the call answered that
399
+ # it had no such method, which reads as a misspelt name. An abstract script can be instantiated
400
+ # as far as this asks, so it is not taken for one.
401
+ var compiled: Script = loaded as Script
402
+ if not compiled.can_instantiate():
403
+ return {
404
+ "type": "error",
405
+ "message":
406
+ (
407
+ "%s did not compile, so none of its functions can be called; script_diagnostics on "
408
+ + "it gives the error and its line."
409
+ ) % script_path,
410
+ }
398
411
  return { "object": loaded, "node": root }
399
412
 
400
413
 
@@ -36,6 +36,8 @@ var _files: GdhFileWalk = GdhFileWalk.new()
36
36
  ## script again made the whole-project answer quadratic in the number of scripts.
37
37
  var _classes_declared: Dictionary = {}
38
38
  var _classes_read: bool = false
39
+ var _uids_declared: Dictionary = {}
40
+ var _uids_read: bool = false
39
41
 
40
42
 
41
43
  func _init(p_log: GdhLog) -> void:
@@ -158,8 +160,7 @@ func missing_references() -> Array[Dictionary]:
158
160
  func _loaded_and_missing(script: String) -> Array[String]:
159
161
  var loaded: Array[String] = []
160
162
  var named: Dictionary = {}
161
- for line: String in FileAccess.get_file_as_string(script).split("\n"):
162
- var code: String = GdhPatterns.without_comment(line)
163
+ for code: String in code_by_line(FileAccess.get_file_as_string(script)):
163
164
  if not code.contains("load("):
164
165
  for literal: String in _string_literals(code):
165
166
  named[_resolved(literal, script)] = true
@@ -492,10 +493,32 @@ static func _code_of(line: String) -> String:
492
493
  # [param content] with each line's comment taken off and its strings kept, which is where the paths
493
494
  # a script loads are written.
494
495
  static func _code_lines(content: String) -> String:
495
- var kept: Array[String] = []
496
+ return "\n".join(code_by_line(content))
497
+
498
+
499
+ ## Each line of [param content] as code: its comment off, and the text of a string running across
500
+ ## lines left out. Read as code, a test suite template's example
501
+ ## `load("res://folder/my_class.gd")` was a call of a file that does not exist, which made the
502
+ ## project invalid.
503
+ static func code_by_line(content: String) -> Array[String]:
504
+ var lines: Array[String] = []
505
+ var in_string: bool = false
496
506
  for line: String in content.split("\n"):
497
- kept.append(GdhPatterns.without_comment(line))
498
- return "\n".join(kept)
507
+ if in_string:
508
+ if GdhPatterns.triple_quotes(line) % 2 == 1:
509
+ in_string = false
510
+ lines.append("")
511
+ continue
512
+ var code: String = GdhPatterns.without_comment(line)
513
+ if GdhPatterns.triple_quotes(code) % 2 == 1:
514
+ in_string = true
515
+ var opened: int = code.find('"""')
516
+ var apostrophes: int = code.find("'''")
517
+ if opened < 0 or (apostrophes >= 0 and apostrophes < opened):
518
+ opened = apostrophes
519
+ code = code.substr(0, opened)
520
+ lines.append(code)
521
+ return lines
499
522
 
500
523
 
501
524
  # The comment on [param line], strings emptied first so a # inside one does not start it.
@@ -578,21 +601,72 @@ func _analyze_resource(
578
601
  path: String, current_depth: int, walk: GdhDependencyWalk
579
602
  ) -> Array[Dictionary]:
580
603
  var deps: Array[Dictionary] = []
581
-
582
604
  if current_depth >= walk.max_depth:
583
605
  return deps
584
606
 
585
- # Reused only when it was walked with as much depth left as now. A resource first reached deep
586
- # in the walk has a list cut short at the limit, and handing that to a nearer reach answered
587
- # with a chain that stopped where nothing stopped it.
607
+ walk.path_stack.append(path)
608
+ var cut: bool = false
609
+ for named: Dictionary in _references(path, walk):
610
+ var dep_info: Dictionary = named.duplicate()
611
+ cut = _depend(dep_info, current_depth, walk) or cut
612
+ deps.append(dep_info)
613
+ walk.path_stack.pop_back()
614
+
588
615
  var remaining: int = walk.max_depth - current_depth
589
- if walk.visited.has(path):
590
- var cached: Dictionary = walk.visited[path]
591
- if GdhRead.as_int(cached["remaining"]) == remaining:
592
- var cached_deps: Array[Dictionary] = cached["deps"]
593
- return cached_deps
616
+ var shown: Dictionary = walk.shown.get(path, {})
617
+ if shown.is_empty() or remaining > GdhRead.as_int(shown["remaining"]):
618
+ walk.shown[path] = { "remaining": remaining, "cut": cut }
619
+ return deps
594
620
 
595
- walk.path_stack.append(path)
621
+
622
+ # Walks [param dep_info], one entry of what the file at [param current_depth] names, and answers
623
+ # whether the depth cut short what it shows. Its dependencies are listed at its first entry, which
624
+ # every later one says is above it: listed in full under every file loading it, a game of a
625
+ # hundred scripts typed by class_name answered half a megabyte at depth six and nothing at all
626
+ # unlimited, the tree growing about sixfold a level. A list the depth cut short is listed again
627
+ # where more depth is left, since pointing a nearer reach at it answered a chain that stopped
628
+ # where nothing stopped it. A list is recorded once walked, so what a later entry points at is
629
+ # always above it.
630
+ func _depend(dep_info: Dictionary, current_depth: int, walk: GdhDependencyWalk) -> bool:
631
+ var dep_path: String = dep_info["path"]
632
+ # Marked where the reference closing the cycle is. Marked inside the file reached again, the
633
+ # tree answered that file as a dependency of itself.
634
+ if dep_path in walk.path_stack:
635
+ var cycle: Array[String] = walk.path_stack.slice(walk.path_stack.find(dep_path))
636
+ cycle.append(dep_path)
637
+ if not cycle in walk.circular_references:
638
+ walk.circular_references.append(cycle)
639
+ dep_info["circular"] = true
640
+ return false
641
+ if not dep_info["exists"]:
642
+ return false
643
+ var named: int = _references(dep_path, walk).size()
644
+ if named == 0:
645
+ return false
646
+ if current_depth + 1 >= walk.max_depth:
647
+ # A file the depth stopped at read the same as one naming nothing, and a missing file past
648
+ # it went unsaid.
649
+ dep_info["beyond_depth"] = named
650
+ return true
651
+ var shown: Dictionary = walk.shown.get(dep_path, {})
652
+ if not shown.is_empty():
653
+ var shown_cut: bool = shown["cut"]
654
+ var remaining: int = walk.max_depth - current_depth - 1
655
+ if not shown_cut or remaining <= GdhRead.as_int(shown["remaining"]):
656
+ dep_info["dependencies_listed_above"] = true
657
+ return shown_cut
658
+ dep_info["dependencies"] = _analyze_resource(dep_path, current_depth + 1, walk)
659
+ var walked: Dictionary = walk.shown[dep_path]
660
+ return walked["cut"]
661
+
662
+
663
+ ## What [param path] names, each file once, as the entries the tree lists it by; read once a walk.
664
+ func _references(path: String, walk: GdhDependencyWalk) -> Array[Dictionary]:
665
+ if walk.references.has(path):
666
+ var known: Array[Dictionary] = walk.references[path]
667
+ return known
668
+ var deps: Array[Dictionary] = []
669
+ walk.references[path] = deps
596
670
 
597
671
  var file: FileAccess = FileAccess.open(path, FileAccess.READ)
598
672
  if file:
@@ -619,7 +693,7 @@ func _analyze_resource(
619
693
  # as no res:// path, a script's load of one went unsaid while the game printed
620
694
  # "Unrecognized UID" at that line.
621
695
  if dep_path.begins_with("uid://"):
622
- _depend(deps, path, dep_path, {}, current_depth, walk)
696
+ _name(deps, path, dep_path, {})
623
697
  continue
624
698
  if not dep_path.begins_with("res://"):
625
699
  continue
@@ -638,7 +712,7 @@ func _analyze_resource(
638
712
  var named_by: Dictionary = { "stale_path": written } if dep_path != written else {}
639
713
  if unknown.has(dep_path):
640
714
  named_by["unknown_uid"] = unknown[dep_path]
641
- _depend(deps, path, dep_path, named_by, current_depth, walk)
715
+ _name(deps, path, dep_path, named_by)
642
716
 
643
717
  # A script uses another by its class_name as surely as by a preload, and only the reverse
644
718
  # walk looked for that: asked what a script depends on, the answer left out the script it
@@ -647,30 +721,18 @@ func _analyze_resource(
647
721
  if path.get_extension() == "gd":
648
722
  var classes: Dictionary = classes_declared()
649
723
  for used: String in classes_used_in(content, classes):
650
- _depend(deps, path, str(classes[used]), { "by_class": used }, current_depth, walk)
724
+ _name(deps, path, str(classes[used]), { "by_class": used })
651
725
  var autoloads: Dictionary = autoloads_declared()
652
726
  for used: String in classes_used_in(content, autoloads):
653
- _depend(
654
- deps, path, str(autoloads[used]), { "by_autoload": used }, current_depth, walk
655
- )
656
-
657
- walk.path_stack.pop_back()
658
- walk.visited[path] = { "deps": deps, "remaining": remaining }
727
+ _name(deps, path, str(autoloads[used]), { "by_autoload": used })
659
728
  return deps
660
729
 
661
730
 
662
- # Adds [param dep_path] to [param deps], what [param path] depends on, walked as far as the depth
663
- # left allows, once however many times it is named. [param named_by] is the class or autoload name
664
- # it was named by, as by_class or by_autoload, and the path written when the engine loads another,
665
- # as stale_path; empty for a path that is the file.
666
- func _depend(
667
- deps: Array[Dictionary],
668
- path: String,
669
- dep_path: String,
670
- named_by: Dictionary,
671
- current_depth: int,
672
- walk: GdhDependencyWalk
673
- ) -> void:
731
+ # Adds [param dep_path] to [param deps], what [param path] depends on, once however many times it
732
+ # is named. [param named_by] is the class or autoload name it was named by, as by_class or
733
+ # by_autoload, and the path written when the engine loads another, as stale_path; empty for a path
734
+ # that is the file.
735
+ func _name(deps: Array[Dictionary], path: String, dep_path: String, named_by: Dictionary) -> void:
674
736
  if dep_path == path:
675
737
  return
676
738
  for existing: Dictionary in deps:
@@ -683,33 +745,12 @@ func _depend(
683
745
  "exists": not dep_path.begins_with("uid://") and FileAccess.file_exists(dep_path),
684
746
  }
685
747
  dep_info.merge(named_by)
686
- # Marked where the reference closing the cycle is. Marked inside the file reached again, the
687
- # tree answered that file as a dependency of itself.
688
- if dep_path in walk.path_stack:
689
- var cycle: Array[String] = walk.path_stack.slice(walk.path_stack.find(dep_path))
690
- cycle.append(dep_path)
691
- if not cycle in walk.circular_references:
692
- walk.circular_references.append(cycle)
693
- dep_info["circular"] = true
694
- deps.append(dep_info)
695
- return
696
- if dep_info["exists"] and current_depth + 1 < walk.max_depth:
697
- var sub_deps: Array[Dictionary] = _analyze_resource(dep_path, current_depth + 1, walk)
698
- if sub_deps.size() > 0:
699
- dep_info["dependencies"] = sub_deps
700
- elif dep_info["exists"] and walk.counts_beyond:
701
- # A file the depth stopped at read the same as one naming nothing, and a missing file past
702
- # it went unsaid.
703
- var beyond: GdhDependencyWalk = GdhDependencyWalk.new(1, walk.include_built_in, [], false)
704
- var named: int = _analyze_resource(dep_path, 0, beyond).size()
705
- if named > 0:
706
- dep_info["beyond_depth"] = named
707
748
  deps.append(dep_info)
708
749
 
709
750
 
710
751
  ## Every autoload the project registers as a global name, with its script. One that is not global
711
752
  ## is no name code can use.
712
- static func autoloads_declared() -> Dictionary:
753
+ func autoloads_declared() -> Dictionary:
713
754
  var autoloads: Dictionary = {}
714
755
  for property: Dictionary in ProjectSettings.get_property_list():
715
756
  var setting: String = str(property.get("name", ""))
@@ -762,27 +803,63 @@ static func classes_used_in(content: String, classes: Dictionary) -> Array[Strin
762
803
  return used
763
804
 
764
805
 
765
- # [param written] as the res:// path the engine would load from [param from]: a uid:// by what the
766
- # project's uid cache says it names, and a relative path against the referring file's directory.
767
- # Both were passed over as not being res:// paths, so a script's `preload("sibling.gd")` was no
768
- # dependency at all.
769
- static func _resolved(written: String, from: String) -> String:
806
+ # [param written] as the res:// path the engine would load from [param from]: a uid:// by the file
807
+ # it names, and a relative path against the referring file's directory. Both were passed over as not
808
+ # being res:// paths, so a script's `preload("sibling.gd")` was no dependency at all.
809
+ func _resolved(written: String, from: String) -> String:
770
810
  if written.begins_with("res://"):
771
811
  return written
772
812
  if written.begins_with("uid://"):
773
- var id: int = ResourceUID.text_to_id(written)
774
- return ResourceUID.get_id_path(id) if ResourceUID.has_id(id) else written
813
+ var found: String = _uid_path(written)
814
+ return written if found.is_empty() else found
775
815
  if written.contains("://"):
776
816
  return written
777
817
  return from.get_base_dir().path_join(written).simplify_path()
778
818
 
779
819
 
820
+ # The file the uid:// [param text] names, or empty when nothing does: the engine's uid cache first,
821
+ # then the uids the project's files declare. Only an import writes the cache, so on a fresh clone
822
+ # every uid was answered as naming no file, and a scene moved outside the editor was missing under
823
+ # its old path, although the first import loads it by its uid. A cached path with no file there
824
+ # gives way to a file declaring the uid, and is the answer when none does.
825
+ func _uid_path(text: String) -> String:
826
+ var id: int = ResourceUID.text_to_id(text)
827
+ if id == ResourceUID.INVALID_ID:
828
+ return ""
829
+ var cached: String = ResourceUID.get_id_path(id) if ResourceUID.has_id(id) else ""
830
+ if FileAccess.file_exists(cached):
831
+ return cached
832
+ return str(_files_by_uid().get(id, cached))
833
+
834
+
835
+ # Every file of the project that declares a uid, by its uid's id: in a .uid sidecar, an import
836
+ # sidecar or the header of a text resource, as GdhResourceFiles reads them. Read once for the call,
837
+ # the first time the cache does not know a uid.
838
+ func _files_by_uid() -> Dictionary:
839
+ if _uids_read:
840
+ return _uids_declared
841
+ _uids_read = true
842
+ var declaring: Dictionary = {}
843
+ for file: String in _files.find_files_with_extensions(
844
+ "res://", ["uid", "import"] + GdhResourceFiles.TEXT_RESOURCE_KINDS
845
+ ):
846
+ var sidecar: bool = file.get_extension() in ["uid", "import"]
847
+ declaring[file.get_basename() if sidecar else file] = true
848
+ for file: String in declaring:
849
+ if not FileAccess.file_exists(file):
850
+ continue
851
+ var id: int = ResourceUID.text_to_id(str(GdhResourceFiles.uid_of(file).get("uid", "")))
852
+ if id != ResourceUID.INVALID_ID:
853
+ _uids_declared[id] = file
854
+ return _uids_declared
855
+
856
+
780
857
  # The ext_resource paths in [param content] whose uid leads elsewhere, each with the file the uid
781
858
  # names. The engine loads an ext_resource by its uid whenever the uid is known, whatever the path
782
859
  # says, so a file moved outside the editor still loads, and the walk called it missing; every
783
860
  # pattern reads the old path, the bare res:// one included, so the paths are mapped before any of
784
861
  # them runs.
785
- static func _moved_by_uid(content: String) -> Dictionary:
862
+ func _moved_by_uid(content: String) -> Dictionary:
786
863
  var moved: Dictionary = {}
787
864
  for line: RegExMatch in GdhPatterns.compiled("\\[ext_resource[^\\]]*\\]").search_all(content):
788
865
  var found: String = _loaded_by_uid(line.get_string())
@@ -795,7 +872,7 @@ static func _moved_by_uid(content: String) -> Dictionary:
795
872
  # The ext_resource paths in [param content], read from [param from], whose uid names no file the
796
873
  # project has, each with that uid. The engine warns that the uid is invalid and loads the path, and
797
874
  # the walk followed the path saying nothing, so an export was the first to mention it.
798
- static func _unknown_uids(content: String, from: String) -> Dictionary:
875
+ func _unknown_uids(content: String, from: String) -> Dictionary:
799
876
  var unknown: Dictionary = {}
800
877
  for line: RegExMatch in GdhPatterns.compiled("\\[ext_resource[^\\]]*\\]").search_all(content):
801
878
  var uid: String = _unknown_uid_of(line.get_string())
@@ -806,21 +883,18 @@ static func _unknown_uids(content: String, from: String) -> Dictionary:
806
883
 
807
884
 
808
885
  # The uid an ext_resource [param entry] carries when it names no file the project has, or empty.
809
- static func _unknown_uid_of(entry: String) -> String:
886
+ func _unknown_uid_of(entry: String) -> String:
810
887
  var uid: RegExMatch = GdhPatterns.compiled('uid="(uid://[^"]+)"').search(entry)
811
888
  return "" if uid == null or not _loaded_by_uid(entry).is_empty() else uid.get_string(1)
812
889
 
813
890
 
814
891
  # The file an ext_resource [param entry] is loaded from by its uid, or empty when it carries none
815
892
  # the project knows, and the engine falls back to the path.
816
- static func _loaded_by_uid(entry: String) -> String:
893
+ func _loaded_by_uid(entry: String) -> String:
817
894
  var uid: RegExMatch = GdhPatterns.compiled('uid="(uid://[^"]+)"').search(entry)
818
895
  if uid == null:
819
896
  return ""
820
- var id: int = ResourceUID.text_to_id(uid.get_string(1))
821
- if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id):
822
- return ""
823
- var found: String = ResourceUID.get_id_path(id)
897
+ var found: String = _uid_path(uid.get_string(1))
824
898
  return found if FileAccess.file_exists(found) else ""
825
899
 
826
900
 
@@ -877,23 +951,18 @@ class GdhDependencyWalk:
877
951
 
878
952
  var include_built_in: bool
879
953
 
880
- var visited: Dictionary = {}
954
+ ## What each file read names, by its path.
955
+ var references: Dictionary = {}
956
+
957
+ ## For each file whose dependencies the tree lists, the depth left where they are listed and
958
+ ## whether the depth cut that list short.
959
+ var shown: Dictionary = {}
881
960
 
882
961
  var path_stack: Array[String] = []
883
962
 
884
963
  var circular_references: Array
885
964
 
886
- ## Whether a file the depth stops at is read for how many files it names, which the walk doing
887
- ## that reading does not do again for those.
888
- var counts_beyond: bool
889
-
890
- func _init(
891
- p_max_depth: int,
892
- p_include_built_in: bool,
893
- p_circular_references: Array,
894
- p_counts_beyond: bool = true
895
- ) -> void:
965
+ func _init(p_max_depth: int, p_include_built_in: bool, p_circular_references: Array) -> void:
896
966
  max_depth = p_max_depth
897
967
  include_built_in = p_include_built_in
898
968
  circular_references = p_circular_references
899
- counts_beyond = p_counts_beyond
@@ -85,15 +85,16 @@ static func run(params: Dictionary) -> Dictionary:
85
85
  answered["namedByPath"] = (
86
86
  (
87
87
  "What this saved names %s by path rather than by uid, which loads the same. A "
88
- + "uid in a file's own header is resolved through the project's uid cache, "
89
- + "which holds none for %s yet, as with a file made since the project was last "
90
- + "scanned, so a uid written now would warn on every load. project_import "
91
- + "refresh_uids scans the project, and writes after it name %s by uid."
88
+ + "reference by uid is resolved through the project's uid cache, which holds "
89
+ + "none for %s yet, as with a file made since the project was last scanned, so "
90
+ + "naming %s by uid now would warn on every load. project_import refresh_uids "
91
+ + "scans the project, and writes after it name %s by uid."
92
92
  )
93
93
  % [
94
94
  ", ".join(GdhSceneUids.by_path),
95
95
  "it" if GdhSceneUids.by_path.size() == 1 else "them",
96
96
  "it" if GdhSceneUids.by_path.size() == 1 else "them",
97
+ "it" if GdhSceneUids.by_path.size() == 1 else "them",
97
98
  ]
98
99
  )
99
100
  return answered