gdharness 1.3.0 → 1.3.2

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 (72) hide show
  1. package/build/cli.js +322 -96
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +7 -19
  3. package/build/godot/addons/gdharness_editor/bridge_client.gd +8 -8
  4. package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
  5. package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
  6. package/build/godot/addons/gdharness_editor/file_reads.gd +65 -25
  7. package/build/godot/addons/gdharness_editor/headless_check.gd +7 -7
  8. package/build/godot/addons/gdharness_editor/plugin.gd +6 -6
  9. package/build/godot/addons/gdharness_editor/property_values.gd +222 -59
  10. package/build/godot/addons/gdharness_editor/reading.gd +65 -0
  11. package/build/godot/addons/gdharness_editor/scene_file.gd +28 -23
  12. package/build/godot/addons/gdharness_editor/serialisation.gd +49 -94
  13. package/build/godot/addons/gdharness_editor/tool_executor.gd +24 -24
  14. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +52 -41
  15. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +4 -4
  16. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +2 -2
  17. package/build/godot/addons/gdharness_editor/tools/profile_tools.gd +14 -14
  18. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +175 -74
  19. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +17 -17
  20. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +14 -12
  21. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +7 -7
  22. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +88 -84
  23. package/build/godot/addons/gdharness_editor/tools/settings_tools.gd +4 -4
  24. package/build/godot/addons/gdharness_editor/value_shapes.gd +118 -0
  25. package/build/godot/addons/gdharness_editor/value_shapes.gd.uid +1 -0
  26. package/build/godot/addons/gdharness_runtime/reading.gd +65 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +18 -18
  28. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +29 -29
  29. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +21 -18
  30. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +86 -80
  31. package/build/godot/addons/gdharness_runtime/runtime_input.gd +65 -64
  32. package/build/godot/addons/gdharness_runtime/runtime_items.gd +6 -6
  33. package/build/godot/addons/gdharness_runtime/runtime_menus.gd +8 -8
  34. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +6 -6
  35. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +10 -10
  36. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +7 -7
  37. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +41 -41
  38. package/build/godot/addons/gdharness_runtime/runtime_says.gd +3 -3
  39. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +33 -33
  40. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +20 -20
  41. package/build/godot/addons/gdharness_runtime/runtime_values.gd +52 -25
  42. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +56 -50
  43. package/build/godot/addons/gdharness_runtime/serialisation.gd +49 -94
  44. package/build/godot/addons/gdharness_runtime/value_shapes.gd +118 -0
  45. package/build/godot/addons/gdharness_runtime/value_shapes.gd.uid +1 -0
  46. package/build/godot/operations/animation_reads.gd +7 -19
  47. package/build/godot/operations/audio_buses.gd +10 -10
  48. package/build/godot/operations/class_cache.gd +16 -16
  49. package/build/godot/operations/classdb_queries.gd +25 -15
  50. package/build/godot/operations/declared_values.gd +116 -0
  51. package/build/godot/operations/dependencies.gd +141 -33
  52. package/build/godot/operations/editor_tools.gd +12 -12
  53. package/build/godot/operations/export_presets.gd +14 -14
  54. package/build/godot/operations/file_reads.gd +65 -25
  55. package/build/godot/operations/file_walk.gd +34 -0
  56. package/build/godot/operations/gdscript_analysis.gd +27 -22
  57. package/build/godot/operations/gdscript_authoring.gd +122 -17
  58. package/build/godot/operations/godot_operations.gd +66 -63
  59. package/build/godot/operations/import_pipeline.gd +59 -20
  60. package/build/godot/operations/input_actions.gd +90 -18
  61. package/build/godot/operations/logger.gd +1 -1
  62. package/build/godot/operations/plugins.gd +8 -8
  63. package/build/godot/operations/project_config.gd +83 -20
  64. package/build/godot/operations/project_diagnostics.gd +36 -11
  65. package/build/godot/operations/property_values.gd +222 -59
  66. package/build/godot/operations/reading.gd +65 -0
  67. package/build/godot/operations/rename_checks.gd +3 -3
  68. package/build/godot/operations/resource_files.gd +9 -6
  69. package/build/godot/operations/script_check.gd +23 -7
  70. package/build/godot/operations/serialisation.gd +49 -94
  71. package/build/godot/operations/value_shapes.gd +118 -0
  72. package/package.json +1 -1
@@ -0,0 +1,116 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## What a property declares beyond its type: the values of its enum and the range it is exported
5
+ ## with, for taking a value by name and for saying when a value written lies outside them.
6
+
7
+ const GdhRead = preload("reading.gd")
8
+
9
+
10
+ ## The values an enum hint names, by name, from its hint string: "SWORD:0,SHIELD:1" as a script
11
+ ## declares one, or "Add,Subtract" as an engine property does, each name then taking the number
12
+ ## after the one before it.
13
+ static func enum_values(hint_string: String) -> Dictionary:
14
+ var values: Dictionary = {}
15
+ var next: int = 0
16
+ for entry: String in hint_string.split(",", false):
17
+ var name: String = entry.get_slice(":", 0).strip_edges()
18
+ var number: String = entry.get_slice(":", 1).strip_edges() if ":" in entry else ""
19
+ var value: int = number.to_int() if number.is_valid_int() else next
20
+ values[name] = value
21
+ next = value + 1
22
+ return values
23
+
24
+
25
+ ## The values of the enum [param declared] declares, by name, spelt as the script of [param target]
26
+ ## spells them when it declares that enum. The engine's hint capitalises each name, "SWORD" as
27
+ ## "Sword", and a refusal listing those named values the script never spells.
28
+ static func enum_of(target: Object, declared: Dictionary) -> Dictionary:
29
+ var hinted: Dictionary = enum_values(str(declared.get("hint_string", "")))
30
+ var attached: Variant = target.get_script()
31
+ if not attached is Script:
32
+ return hinted
33
+ var script: Script = attached
34
+ for constant: Variant in script.get_script_constant_map().values():
35
+ if not constant is Dictionary:
36
+ continue
37
+ var values: Dictionary = constant
38
+ if values.size() != hinted.size():
39
+ continue
40
+ var spelt: Dictionary = {}
41
+ for name: Variant in values:
42
+ if hinted.get(str(name).capitalize()) == values[name]:
43
+ spelt[str(name)] = values[name]
44
+ if spelt.size() == hinted.size():
45
+ return spelt
46
+ return hinted
47
+
48
+
49
+ ## The number [param word] names among [param choices], matched as written and then ignoring case,
50
+ ## or null when it names none.
51
+ static func enum_named(choices: Dictionary, word: String) -> Variant:
52
+ var wanted: String = word.strip_edges()
53
+ if choices.has(wanted):
54
+ return choices[wanted]
55
+ for name: String in choices:
56
+ if name.to_lower() == wanted.to_lower():
57
+ return choices[name]
58
+ return null
59
+
60
+
61
+ static func enum_refusal(holder: String, property: String, word: String, choices: Dictionary) -> String:
62
+ var said: String = (
63
+ "%s.%s is an enum, and %s is none of its values: send %s by name or by number"
64
+ % [holder, property, word, _listed(choices)]
65
+ )
66
+ var close: PackedStringArray = GdhRead.nearest(word, PackedStringArray(choices.keys()))
67
+ if close.is_empty():
68
+ return said + "."
69
+ return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
70
+
71
+
72
+ ## Why [param value], shown as [param shown], lies outside what [param declared] allows, an export
73
+ ## range or an enum's values, or "" when it does not. A range marked or_greater or or_less is open
74
+ ## on that side.
75
+ static func outside(target: Object, declared: Dictionary, value: Variant, shown: String) -> String:
76
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
77
+ var hint_string: String = str(declared.get("hint_string", ""))
78
+ var property: String = str(declared.get("name", ""))
79
+ if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT:
80
+ return ""
81
+ var number: float = GdhRead.as_float(value)
82
+ if hint == PROPERTY_HINT_ENUM and typeof(value) == TYPE_INT:
83
+ var choices: Dictionary = enum_of(target, declared)
84
+ if choices.is_empty() or choices.values().has(value):
85
+ return ""
86
+ return "%d is none of the values %s declares: %s." % [value, property, _listed(choices)]
87
+ if hint != PROPERTY_HINT_RANGE:
88
+ return ""
89
+ var parts: PackedStringArray = []
90
+ for part: String in hint_string.split(","):
91
+ var _added: bool = parts.append(part.strip_edges())
92
+ if parts.size() < 2 or not parts[0].is_valid_float() or not parts[1].is_valid_float():
93
+ return ""
94
+ var low: float = parts[0].to_float()
95
+ var high: float = parts[1].to_float()
96
+ if (number <= high or parts.has("or_greater")) and (number >= low or parts.has("or_less")):
97
+ return ""
98
+ return (
99
+ (
100
+ "%s is outside the range %s declares, %s to %s, which the inspector holds it to; the "
101
+ + "engine stores it as given."
102
+ )
103
+ % [shown, property, _bound(low), _bound(high)]
104
+ )
105
+
106
+
107
+ static func _listed(choices: Dictionary) -> String:
108
+ var listed: PackedStringArray = []
109
+ for name: String in choices:
110
+ var _added: bool = listed.append("%s (%d)" % [name, choices[name]])
111
+ return ", ".join(listed)
112
+
113
+
114
+ ## A range's end as the script wrote it: the engine's hint gives @export_range(0, 10) as 0.0 and 10.0.
115
+ static func _bound(end: float) -> String:
116
+ return str(int(end)) if end == floorf(end) and absf(end) < 1e15 else str(end)
@@ -1,10 +1,10 @@
1
1
  extends RefCounted
2
2
 
3
- const Patterns = preload("patterns.gd")
4
- const Read = preload("reading.gd")
5
- const FileWalk = preload("file_walk.gd")
6
- const Log = preload("logger.gd")
7
- const ResourceFiles = preload("resource_files.gd")
3
+ const GdhPatterns = preload("patterns.gd")
4
+ const GdhRead = preload("reading.gd")
5
+ const GdhFileWalk = preload("file_walk.gd")
6
+ const GdhLog = preload("logger.gd")
7
+ const GdhResourceFiles = preload("resource_files.gd")
8
8
 
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,
@@ -21,8 +21,8 @@ const REFERENCE_PATTERNS: Array[String] = [
21
21
  'ext_resource.*path="([^"]+)"',
22
22
  ]
23
23
 
24
- var _log: Log
25
- var _files: FileWalk = FileWalk.new()
24
+ var _log: GdhLog
25
+ var _files: GdhFileWalk = GdhFileWalk.new()
26
26
  ## The project's class names and the scripts declaring them, read the first time a script is walked
27
27
  ## and kept for the call: walking every resource starts a walk per root, and each reading every
28
28
  ## script again made the whole-project answer quadratic in the number of scripts.
@@ -33,7 +33,7 @@ var _classes_read: bool = false
33
33
  # Everything one dependency walk carries: the settings it was started with, and the state it
34
34
  # accumulates as it recurses. Kept together so the recursion passes one object rather than
35
35
  # six positional arguments that have to stay in the same order at every call site.
36
- class DependencyWalk:
36
+ class GdhDependencyWalk:
37
37
  var max_depth: int
38
38
  var include_built_in: bool
39
39
  var visited: Dictionary = {}
@@ -46,7 +46,7 @@ class DependencyWalk:
46
46
  circular_references = p_circular_references
47
47
 
48
48
 
49
- func _init(p_log: Log) -> void:
49
+ func _init(p_log: GdhLog) -> void:
50
50
  _log = p_log
51
51
 
52
52
 
@@ -54,9 +54,9 @@ func _init(p_log: Log) -> void:
54
54
  func get_dependencies(params: Dictionary) -> Dictionary:
55
55
  var resource_path: String = str(params.get("resource_path", ""))
56
56
  # No depth, or a depth of zero or less, means the whole chain; the walk stops at cycles.
57
- var depth: int = Read.as_int(params.get("depth", 0))
57
+ var depth: int = GdhRead.as_int(params.get("depth", 0))
58
58
  var max_depth: int = depth if depth > 0 else 1000
59
- var include_built_in: bool = Read.as_bool(params.get("include_builtin", false))
59
+ var include_built_in: bool = GdhRead.as_bool(params.get("include_builtin", false))
60
60
 
61
61
  _log.info(
62
62
  (
@@ -75,9 +75,11 @@ func get_dependencies(params: Dictionary) -> Dictionary:
75
75
  full_path = "res://" + full_path
76
76
 
77
77
  if not FileAccess.file_exists(full_path):
78
- return _log.failure("Resource file does not exist: " + full_path)
78
+ return _log.failure(
79
+ _files.with_nearest_files("Resource file does not exist: " + full_path, full_path)
80
+ )
79
81
 
80
- var walk: DependencyWalk = DependencyWalk.new(max_depth, include_built_in, circular_references)
82
+ var walk: GdhDependencyWalk = GdhDependencyWalk.new(max_depth, include_built_in, circular_references)
81
83
  dependencies[full_path] = _analyze_resource(full_path, 0, walk)
82
84
  total_resources = 1
83
85
  else:
@@ -89,7 +91,9 @@ func get_dependencies(params: Dictionary) -> Dictionary:
89
91
  # Each root gets a fresh walk so a cycle is reported from every resource it passes
90
92
  # through, rather than only from whichever one happened to be walked first.
91
93
  for res_path: String in all_resources:
92
- var walk: DependencyWalk = DependencyWalk.new(max_depth, include_built_in, circular_references)
94
+ var walk: GdhDependencyWalk = GdhDependencyWalk.new(
95
+ max_depth, include_built_in, circular_references
96
+ )
93
97
  var deps: Array[Dictionary] = _analyze_resource(res_path, 0, walk)
94
98
  if deps.size() > 0:
95
99
  dependencies[res_path] = deps
@@ -98,12 +102,19 @@ func get_dependencies(params: Dictionary) -> Dictionary:
98
102
 
99
103
  var references: int = 0
100
104
  var distinct: Dictionary = {}
105
+ var missing: Dictionary = {}
101
106
  for key: String in dependencies:
102
107
  # Through a typed local: what comes out of a Dictionary is Variant, and a project that
103
108
  # errors on handing one to a typed parameter will not compile this script at all.
104
109
  var walked: Array[Dictionary] = dependencies[key]
105
110
  references += _count_recursive(walked)
106
111
  _gather_paths(walked, key, distinct)
112
+ _gather_missing(walked, key, missing)
113
+ # Said in the summary as well as in the tree, where a file that is not there was one
114
+ # `exists: false` among the entries, which a caller reading the summary never met.
115
+ var missing_files: Array[Dictionary] = []
116
+ for path: String in missing:
117
+ missing_files.append({"path": path, "referenced_by": missing[path]})
107
118
 
108
119
  return {
109
120
  "dependencies": dependencies,
@@ -115,11 +126,54 @@ func get_dependencies(params: Dictionary) -> Dictionary:
115
126
  # loads it, and counting its entries answered 11 for a scene with six dependencies.
116
127
  "total_dependencies": distinct.size(),
117
128
  "total_references": references,
118
- "circular_count": circular_references.size()
129
+ "circular_count": circular_references.size(),
130
+ "missing": missing_files
119
131
  }
120
132
  }
121
133
 
122
134
 
135
+ ## Every file a scene or resource of the project loads through an ext_resource that is not there,
136
+ ## by its path or its uid, each with the files loading it. Only those: a script naming a path in
137
+ ## its code may be asking whether the file exists, and the shipped addons check for files they
138
+ ## write, so every project was told its addons referred to files that are missing. A script's
139
+ ## preloads are the compile's to judge.
140
+ func missing_references() -> Array[Dictionary]:
141
+ var missing: Dictionary = {}
142
+ for file: String in _files.find_files_with_extensions("res://", GdhResourceFiles.TEXT_RESOURCE_KINDS):
143
+ var content: String = FileAccess.get_file_as_string(file)
144
+ var moved: Dictionary = _moved_by_uid(content)
145
+ for line: RegExMatch in GdhPatterns.compiled("\\[ext_resource[^\\]]*\\]").search_all(content):
146
+ var at: RegExMatch = GdhPatterns.compiled('path="([^"]+)"').search(line.get_string())
147
+ if at == null or moved.has(at.get_string(1)):
148
+ continue
149
+ var path: String = _resolved(at.get_string(1), file)
150
+ if FileAccess.file_exists(path):
151
+ continue
152
+ var naming: Array = missing.get_or_add(path, [])
153
+ if not file in naming:
154
+ naming.append(file)
155
+ # Sorted, since the walk meets files in the order the filesystem lists them, which differs
156
+ # between platforms.
157
+ var paths: Array = missing.keys()
158
+ paths.sort()
159
+ var found: Array[Dictionary] = []
160
+ for path: String in paths:
161
+ var naming: Array = missing[path]
162
+ naming.sort()
163
+ found.append({"path": path, "referenced_by": naming})
164
+ return found
165
+
166
+
167
+ ## One line for [param entry], one of [method missing_references].
168
+ static func missing_line(entry: Dictionary) -> String:
169
+ var referring: Array = entry.get("referenced_by", [])
170
+ var naming: PackedStringArray = PackedStringArray(referring)
171
+ return (
172
+ "%s does not exist, and %s %s to it"
173
+ % [str(entry.get("path", "")), ", ".join(naming), "refers" if naming.size() == 1 else "refer"]
174
+ )
175
+
176
+
123
177
  # What refers to a resource, and how: the scenes that instance it, the scripts that extend or
124
178
  # preload it, and for a script with a class_name, every use of that name.
125
179
  func find_resource_usages(params: Dictionary) -> Dictionary:
@@ -128,17 +182,20 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
128
182
 
129
183
  if not resource_path.begins_with("res://"):
130
184
  resource_path = "res://" + resource_path
131
- if not FileAccess.file_exists(resource_path):
132
- return _log.failure("Resource file does not exist: " + resource_path)
185
+ # A file that is gone is searched for all the same, since what still refers to it is what a
186
+ # caller asks about one, and the forward walk of a scene had just named it missing. Refused only
187
+ # when nothing refers to it either, which is a path written wrong.
188
+ var exists: bool = FileAccess.file_exists(resource_path)
133
189
 
134
190
  _log.info("Finding usages of: " + resource_path)
135
191
 
136
192
  var class_name_declared: String = _declared_class_name(resource_path)
137
193
  var by_class: RegEx = null
138
194
  if not class_name_declared.is_empty():
139
- by_class = Patterns.compiled("\\b" + _regex_escaped(class_name_declared) + "\\b")
195
+ by_class = GdhPatterns.compiled("\\b" + _regex_escaped(class_name_declared) + "\\b")
140
196
  var target: Dictionary = {
141
- "path": resource_path, "uid": str(ResourceFiles.uid_of(resource_path).get("uid", ""))
197
+ "path": resource_path,
198
+ "uid": str(GdhResourceFiles.uid_of(resource_path).get("uid", "")) if exists else ""
142
199
  }
143
200
 
144
201
  var all_files: Array[String] = _usage_files(file_types)
@@ -189,10 +246,12 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
189
246
  kind = _path_reference_kind(line, file_path)
190
247
  else:
191
248
  kind = (
192
- "extends" if Patterns.without_annotations(line).begins_with("extends ") else "class_name"
249
+ "extends"
250
+ if GdhPatterns.without_annotations(line).begins_with("extends ")
251
+ else "class_name"
193
252
  )
194
253
  references.append({"line": i + 1, "kind": kind, "text": trimmed})
195
- by_kind[kind] = Read.as_int(by_kind.get(kind, 0)) + 1
254
+ by_kind[kind] = GdhRead.as_int(by_kind.get(kind, 0)) + 1
196
255
  if kind != "doc" and kind != "comment":
197
256
  in_code += 1
198
257
 
@@ -200,11 +259,19 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
200
259
  usages.append({"file": file_path, "references": references})
201
260
  total += references.size()
202
261
 
262
+ if not exists and usages.is_empty():
263
+ return _log.failure(
264
+ _files.with_nearest_files(
265
+ "Resource file does not exist: %s, and nothing in the project refers to it" % resource_path,
266
+ resource_path
267
+ )
268
+ )
203
269
  var declared: Variant = null
204
270
  if not class_name_declared.is_empty():
205
271
  declared = class_name_declared
206
272
  return {
207
273
  "resource_path": resource_path,
274
+ "exists": exists,
208
275
  "class_name": declared,
209
276
  "usages": usages,
210
277
  "summary":
@@ -229,7 +296,7 @@ func _declared_class_name(path: String) -> String:
229
296
  return ""
230
297
  while not file.eof_reached():
231
298
  # Annotations first, since `@abstract class_name X` is one line and this read it as none.
232
- var found: RegExMatch = Patterns.declared_class(Patterns.without_annotations(file.get_line()))
299
+ var found: RegExMatch = GdhPatterns.declared_class(GdhPatterns.without_annotations(file.get_line()))
233
300
  if found != null:
234
301
  file.close()
235
302
  return found.get_string(1)
@@ -282,14 +349,14 @@ func _names_target(line: String, file_path: String, target: Dictionary) -> bool:
282
349
  # The contents of each string literal on [param line], of either quote.
283
350
  static func _string_literals(line: String) -> Array[String]:
284
351
  var literals: Array[String] = []
285
- for m: RegExMatch in Patterns.compiled(STRING_LITERAL).search_all(line):
352
+ for m: RegExMatch in GdhPatterns.compiled(STRING_LITERAL).search_all(line):
286
353
  literals.append(m.get_string(1) if not m.get_string(1).is_empty() else m.get_string(2))
287
354
  return literals
288
355
 
289
356
 
290
357
  # [param line] with its strings emptied and its comment taken off: the part a class can be used in.
291
358
  static func _code_of(line: String) -> String:
292
- var bare: String = Patterns.compiled(STRING_LITERAL).sub(line, '""', true)
359
+ var bare: String = GdhPatterns.compiled(STRING_LITERAL).sub(line, '""', true)
293
360
  var mark: int = bare.find("#")
294
361
  return bare if mark == -1 else bare.substr(0, mark)
295
362
 
@@ -299,13 +366,13 @@ static func _code_of(line: String) -> String:
299
366
  static func _code_lines(content: String) -> String:
300
367
  var kept: Array[String] = []
301
368
  for line: String in content.split("\n"):
302
- kept.append(Patterns.without_comment(line))
369
+ kept.append(GdhPatterns.without_comment(line))
303
370
  return "\n".join(kept)
304
371
 
305
372
 
306
373
  # The comment on [param line], strings emptied first so a # inside one does not start it.
307
374
  static func _comment_of(line: String) -> String:
308
- var bare: String = Patterns.compiled(STRING_LITERAL).sub(line, '""', true)
375
+ var bare: String = GdhPatterns.compiled(STRING_LITERAL).sub(line, '""', true)
309
376
  var mark: int = bare.find("#")
310
377
  return "" if mark == -1 else bare.substr(mark)
311
378
 
@@ -321,7 +388,7 @@ func _path_reference_kind(line: String, file_path: String) -> String:
321
388
  return "export_preset"
322
389
  if file_path.ends_with(".import"):
323
390
  return "import"
324
- var trimmed: String = Patterns.without_annotations(line)
391
+ var trimmed: String = GdhPatterns.without_annotations(line)
325
392
  if trimmed.begins_with("extends "):
326
393
  return "extends"
327
394
  if trimmed.begins_with("[ext_resource"):
@@ -353,6 +420,20 @@ func _gather_paths(deps: Array[Dictionary], root: String, into: Dictionary) -> v
353
420
  _gather_paths(deeper, root, into)
354
421
 
355
422
 
423
+ # Each file in [param deps] that does not exist, into [param into] with the files naming it, once
424
+ # each however often the tree reaches them; [param parent] names the entries of [param deps].
425
+ func _gather_missing(deps: Array[Dictionary], parent: String, into: Dictionary) -> void:
426
+ for dep: Dictionary in deps:
427
+ var path: String = str(dep.get("path", ""))
428
+ if not dep.get("exists", true):
429
+ var naming: Array = into.get_or_add(path, [])
430
+ if not parent in naming:
431
+ naming.append(parent)
432
+ if dep.has("dependencies"):
433
+ var deeper: Array[Dictionary] = dep["dependencies"]
434
+ _gather_missing(deeper, path, into)
435
+
436
+
356
437
  # A circular entry is not counted: it marks that the entry holding it was reached again, which that
357
438
  # entry already counts as one reference.
358
439
  func _count_recursive(deps: Array[Dictionary]) -> int:
@@ -366,7 +447,7 @@ func _count_recursive(deps: Array[Dictionary]) -> int:
366
447
  return count
367
448
 
368
449
 
369
- func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -> Array[Dictionary]:
450
+ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk) -> Array[Dictionary]:
370
451
  var deps: Array[Dictionary] = []
371
452
 
372
453
  if current_depth >= walk.max_depth:
@@ -385,7 +466,7 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
385
466
  var remaining: int = walk.max_depth - current_depth
386
467
  if walk.visited.has(path):
387
468
  var cached: Dictionary = walk.visited[path]
388
- if Read.as_int(cached["remaining"]) == remaining:
469
+ if GdhRead.as_int(cached["remaining"]) == remaining:
389
470
  var cached_deps: Array[Dictionary] = cached["deps"]
390
471
  return cached_deps
391
472
 
@@ -400,14 +481,20 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
400
481
  # about `["res://addons/x/plugin.cfg"]` was a dependency of every project with the addon.
401
482
  if path.get_extension() == "gd":
402
483
  content = _code_lines(content)
484
+ var moved: Dictionary = _moved_by_uid(content)
403
485
 
404
486
  for pattern: String in REFERENCE_PATTERNS:
405
- var regex: RegEx = Patterns.compiled(pattern)
487
+ var regex: RegEx = GdhPatterns.compiled(pattern)
406
488
  for m: RegExMatch in regex.search_all(content):
407
489
  var dep_path: String = _resolved(_referenced_path(m.get_string()), path)
490
+ dep_path = str(moved.get(dep_path, dep_path))
408
491
 
409
492
  if not dep_path.begins_with("res://"):
410
493
  continue
494
+ # A literal naming a directory, or the start of a path a script builds as it runs
495
+ # ("res://levels/" + name), names no file, and walked as one it was a missing dependency.
496
+ if dep_path.get_extension().is_empty():
497
+ continue
411
498
 
412
499
  # Skip engine-internal resources unless requested. `addons/` is not one of
413
500
  # them: it is ordinary project content, and often shipping content, so
@@ -439,7 +526,7 @@ func _depend(
439
526
  dep_path: String,
440
527
  by_class: String,
441
528
  current_depth: int,
442
- walk: DependencyWalk
529
+ walk: GdhDependencyWalk
443
530
  ) -> void:
444
531
  if dep_path == path:
445
532
  return
@@ -474,7 +561,7 @@ static func classes_used_in(content: String, classes: Dictionary) -> Array[Strin
474
561
  var used: Array[String] = []
475
562
  if classes.is_empty():
476
563
  return used
477
- var word: RegEx = Patterns.compiled("[A-Za-z_][A-Za-z0-9_]*")
564
+ var word: RegEx = GdhPatterns.compiled("[A-Za-z_][A-Za-z0-9_]*")
478
565
  for line: String in content.split("\n"):
479
566
  for m: RegExMatch in word.search_all(_code_of(line)):
480
567
  var name: String = m.get_string()
@@ -499,6 +586,27 @@ static func _resolved(written: String, from: String) -> String:
499
586
 
500
587
 
501
588
  # The path inside a preload(), load() or ext_resource match; a bare res:// match is the path.
589
+ # The ext_resource paths in [param content] that nothing is at, each with the file its uid names
590
+ # instead. The engine loads an ext_resource by its uid first, so a file moved outside the editor
591
+ # still loads, and the walk called it missing; every pattern reads the old path, the bare res://
592
+ # one included, so the paths are mapped before any of them runs.
593
+ static func _moved_by_uid(content: String) -> Dictionary:
594
+ var moved: Dictionary = {}
595
+ for line: RegExMatch in GdhPatterns.compiled("\\[ext_resource[^\\]]*\\]").search_all(content):
596
+ var entry: String = line.get_string()
597
+ var at: RegExMatch = GdhPatterns.compiled('path="([^"]+)"').search(entry)
598
+ var uid: RegExMatch = GdhPatterns.compiled('uid="(uid://[^"]+)"').search(entry)
599
+ if at == null or uid == null or FileAccess.file_exists(at.get_string(1)):
600
+ continue
601
+ var id: int = ResourceUID.text_to_id(uid.get_string(1))
602
+ if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id):
603
+ continue
604
+ var found: String = ResourceUID.get_id_path(id)
605
+ if FileAccess.file_exists(found):
606
+ moved[at.get_string(1)] = found
607
+ return moved
608
+
609
+
502
610
  func _referenced_path(matched: String) -> String:
503
611
  var expression: String = ""
504
612
  if "preload" in matched or "load" in matched:
@@ -507,6 +615,6 @@ func _referenced_path(matched: String) -> String:
507
615
  expression = 'path="([^"]+)"'
508
616
  else:
509
617
  return matched
510
- var inner: RegEx = Patterns.compiled(expression)
618
+ var inner: RegEx = GdhPatterns.compiled(expression)
511
619
  var inner_match: RegExMatch = inner.search(matched)
512
620
  return inner_match.get_string(1) if inner_match else matched
@@ -7,10 +7,10 @@ extends RefCounted
7
7
  ## made here passes the same checks and answers in the same shape. What the engine does that an
8
8
  ## editor does not, running every script, is put back in scene_file.gd and headless_check.gd.
9
9
 
10
- const AnimationTools = preload("../addons/gdharness_editor/tools/animation_tools.gd")
11
- const ResourceTools = preload("../addons/gdharness_editor/tools/resource_tools.gd")
12
- const SceneTools = preload("../addons/gdharness_editor/tools/scene_tools.gd")
13
- const SceneFile = preload("../addons/gdharness_editor/scene_file.gd")
10
+ const GdhAnimationTools = preload("../addons/gdharness_editor/tools/animation_tools.gd")
11
+ const GdhResourceTools = preload("../addons/gdharness_editor/tools/resource_tools.gd")
12
+ const GdhSceneTools = preload("../addons/gdharness_editor/tools/scene_tools.gd")
13
+ const GdhSceneFile = preload("../addons/gdharness_editor/scene_file.gd")
14
14
 
15
15
  const SCENE_COMMANDS: Array[String] = [
16
16
  "create_scene",
@@ -51,18 +51,18 @@ static func run(params: Dictionary) -> Dictionary:
51
51
  var arguments: Dictionary = given
52
52
  var tools: Node = null
53
53
  if command in SCENE_COMMANDS:
54
- tools = SceneTools.new()
54
+ tools = GdhSceneTools.new()
55
55
  elif command in RESOURCE_COMMANDS:
56
- tools = ResourceTools.new()
56
+ tools = GdhResourceTools.new()
57
57
  elif command in ANIMATION_COMMANDS:
58
- tools = AnimationTools.new()
58
+ tools = GdhAnimationTools.new()
59
59
  else:
60
60
  return {"ok": false, "error": "No editor tool runs headless as " + command}
61
61
  var answer: Variant = tools.call(command, arguments)
62
62
  tools.free()
63
63
  if answer is Dictionary:
64
64
  var answered: Dictionary = answer
65
- if not SceneFile.by_path.is_empty():
65
+ if not GdhSceneFile.by_path.is_empty():
66
66
  answered["namedByPath"] = (
67
67
  (
68
68
  "%s %s named by path rather than by uid. A uid in a file's own header is resolved "
@@ -72,10 +72,10 @@ static func run(params: Dictionary) -> Dictionary:
72
72
  + "it name %s by uid."
73
73
  )
74
74
  % [
75
- ", ".join(SceneFile.by_path),
76
- "is" if SceneFile.by_path.size() == 1 else "are",
77
- "it" if SceneFile.by_path.size() == 1 else "them",
78
- "it" if SceneFile.by_path.size() == 1 else "them",
75
+ ", ".join(GdhSceneFile.by_path),
76
+ "is" if GdhSceneFile.by_path.size() == 1 else "are",
77
+ "it" if GdhSceneFile.by_path.size() == 1 else "them",
78
+ "it" if GdhSceneFile.by_path.size() == 1 else "them",
79
79
  ]
80
80
  )
81
81
  return answered
@@ -2,8 +2,8 @@ extends RefCounted
2
2
 
3
3
  ## export_presets.cfg, read and written in the format the editor's Project > Export saves it in.
4
4
 
5
- const Read = preload("reading.gd")
6
- const Log = preload("logger.gd")
5
+ const GdhRead = preload("reading.gd")
6
+ const GdhLog = preload("logger.gd")
7
7
 
8
8
  const PRESETS_FILE: String = "res://export_presets.cfg"
9
9
  ## Where the editor names the one preset of each platform it runs on a device.
@@ -19,10 +19,10 @@ const PLATFORMS: Dictionary[String, String] = {
19
19
  "Web": "web_",
20
20
  }
21
21
 
22
- var _log: Log
22
+ var _log: GdhLog
23
23
 
24
24
 
25
- func _init(p_log: Log) -> void:
25
+ func _init(p_log: GdhLog) -> void:
26
26
  _log = p_log
27
27
 
28
28
 
@@ -88,7 +88,7 @@ static func _runnable(config: ConfigFile, section: String) -> bool:
88
88
  var platform: String = str(config.get_value(section, "platform", ""))
89
89
  if config.has_section_key(RUNNABLE_SECTION, platform):
90
90
  return str(config.get_value(RUNNABLE_SECTION, platform)) == str(config.get_value(section, "name", ""))
91
- return Read.as_bool(config.get_value(section, "runnable", false))
91
+ return GdhRead.as_bool(config.get_value(section, "runnable", false))
92
92
 
93
93
 
94
94
  ## Adds an export preset to export_presets.cfg, written with the keys the editor's Project > Export
@@ -113,17 +113,15 @@ func add_export_preset(params: Dictionary) -> Dictionary:
113
113
  while config.has_section("preset." + str(index)):
114
114
  var held: String = "preset." + str(index)
115
115
  if str(config.get_value(held, "name", "")) == preset_name:
116
+ var path: String = str(config.get_value(held, "export_path", ""))
117
+ var to: String = "with no export path set" if path.is_empty() else "exporting to '%s'" % path
116
118
  return _log.failure(
117
119
  (
118
120
  (
119
- "There is already an export preset named '%s', for %s, exporting to '%s'. "
121
+ "There is already an export preset named '%s', for %s, %s. "
120
122
  + "project_export run exports with it; give another name to add a second."
121
123
  )
122
- % [
123
- preset_name,
124
- config.get_value(held, "platform", ""),
125
- config.get_value(held, "export_path", "")
126
- ]
124
+ % [preset_name, config.get_value(held, "platform", ""), to]
127
125
  )
128
126
  )
129
127
  index += 1
@@ -188,9 +186,11 @@ func add_export_preset(params: Dictionary) -> Dictionary:
188
186
  ## self-contained one, otherwise in the user's data directory, as the editor's own paths have it.
189
187
  static func _templates_dir() -> String:
190
188
  var version: Dictionary = Engine.get_version_info()
191
- var named: String = "%d.%d" % [Read.as_int(version.get("major", 0)), Read.as_int(version.get("minor", 0))]
192
- if Read.as_int(version.get("patch", 0)) != 0:
193
- named += "." + str(Read.as_int(version.get("patch", 0)))
189
+ var named: String = (
190
+ "%d.%d" % [GdhRead.as_int(version.get("major", 0)), GdhRead.as_int(version.get("minor", 0))]
191
+ )
192
+ if GdhRead.as_int(version.get("patch", 0)) != 0:
193
+ named += "." + str(GdhRead.as_int(version.get("patch", 0)))
194
194
  named += "." + str(version.get("status", ""))
195
195
  if ClassDB.class_exists("CSharpScript"):
196
196
  named += ".mono"