gdharness 1.3.1 → 1.3.3

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 (43) hide show
  1. package/build/cli.js +369 -97
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +11 -9
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
  4. package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
  5. package/build/godot/addons/gdharness_editor/file_reads.gd +5 -0
  6. package/build/godot/addons/gdharness_editor/file_walk.gd +134 -0
  7. package/build/godot/addons/gdharness_editor/file_walk.gd.uid +1 -0
  8. package/build/godot/addons/gdharness_editor/property_values.gd +161 -17
  9. package/build/godot/addons/gdharness_editor/reading.gd +37 -0
  10. package/build/godot/addons/gdharness_editor/scene_file.gd +3 -1
  11. package/build/godot/addons/gdharness_editor/serialisation.gd +4 -0
  12. package/build/godot/addons/gdharness_editor/tool_executor.gd +3 -0
  13. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +367 -23
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +133 -39
  15. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +20 -3
  16. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +17 -13
  17. package/build/godot/addons/gdharness_editor/value_shapes.gd +7 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +37 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_values.gd +27 -1
  20. package/build/godot/addons/gdharness_runtime/serialisation.gd +4 -0
  21. package/build/godot/addons/gdharness_runtime/value_shapes.gd +7 -0
  22. package/build/godot/operations/animation_reads.gd +11 -9
  23. package/build/godot/operations/classdb_queries.gd +13 -3
  24. package/build/godot/operations/declared_values.gd +116 -0
  25. package/build/godot/operations/dependencies.gd +109 -5
  26. package/build/godot/operations/editor_tools.gd +3 -0
  27. package/build/godot/operations/export_presets.gd +4 -6
  28. package/build/godot/operations/file_reads.gd +5 -0
  29. package/build/godot/operations/file_walk.gd +35 -0
  30. package/build/godot/operations/gdscript_analysis.gd +7 -2
  31. package/build/godot/operations/gdscript_authoring.gd +105 -2
  32. package/build/godot/operations/godot_operations.gd +2 -0
  33. package/build/godot/operations/import_pipeline.gd +39 -0
  34. package/build/godot/operations/input_actions.gd +73 -1
  35. package/build/godot/operations/project_config.gd +59 -3
  36. package/build/godot/operations/project_diagnostics.gd +27 -2
  37. package/build/godot/operations/property_values.gd +161 -17
  38. package/build/godot/operations/reading.gd +37 -0
  39. package/build/godot/operations/resource_files.gd +4 -1
  40. package/build/godot/operations/script_check.gd +16 -0
  41. package/build/godot/operations/serialisation.gd +4 -0
  42. package/build/godot/operations/value_shapes.gd +7 -0
  43. 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)
@@ -75,7 +75,9 @@ 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
82
  var walk: GdhDependencyWalk = GdhDependencyWalk.new(max_depth, include_built_in, circular_references)
81
83
  dependencies[full_path] = _analyze_resource(full_path, 0, walk)
@@ -100,12 +102,19 @@ func get_dependencies(params: Dictionary) -> Dictionary:
100
102
 
101
103
  var references: int = 0
102
104
  var distinct: Dictionary = {}
105
+ var missing: Dictionary = {}
103
106
  for key: String in dependencies:
104
107
  # Through a typed local: what comes out of a Dictionary is Variant, and a project that
105
108
  # errors on handing one to a typed parameter will not compile this script at all.
106
109
  var walked: Array[Dictionary] = dependencies[key]
107
110
  references += _count_recursive(walked)
108
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]})
109
118
 
110
119
  return {
111
120
  "dependencies": dependencies,
@@ -117,11 +126,54 @@ func get_dependencies(params: Dictionary) -> Dictionary:
117
126
  # loads it, and counting its entries answered 11 for a scene with six dependencies.
118
127
  "total_dependencies": distinct.size(),
119
128
  "total_references": references,
120
- "circular_count": circular_references.size()
129
+ "circular_count": circular_references.size(),
130
+ "missing": missing_files
121
131
  }
122
132
  }
123
133
 
124
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
+
125
177
  # What refers to a resource, and how: the scenes that instance it, the scripts that extend or
126
178
  # preload it, and for a script with a class_name, every use of that name.
127
179
  func find_resource_usages(params: Dictionary) -> Dictionary:
@@ -130,8 +182,10 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
130
182
 
131
183
  if not resource_path.begins_with("res://"):
132
184
  resource_path = "res://" + resource_path
133
- if not FileAccess.file_exists(resource_path):
134
- 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)
135
189
 
136
190
  _log.info("Finding usages of: " + resource_path)
137
191
 
@@ -140,7 +194,8 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
140
194
  if not class_name_declared.is_empty():
141
195
  by_class = GdhPatterns.compiled("\\b" + _regex_escaped(class_name_declared) + "\\b")
142
196
  var target: Dictionary = {
143
- "path": resource_path, "uid": str(GdhResourceFiles.uid_of(resource_path).get("uid", ""))
197
+ "path": resource_path,
198
+ "uid": str(GdhResourceFiles.uid_of(resource_path).get("uid", "")) if exists else ""
144
199
  }
145
200
 
146
201
  var all_files: Array[String] = _usage_files(file_types)
@@ -204,11 +259,19 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
204
259
  usages.append({"file": file_path, "references": references})
205
260
  total += references.size()
206
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
+ )
207
269
  var declared: Variant = null
208
270
  if not class_name_declared.is_empty():
209
271
  declared = class_name_declared
210
272
  return {
211
273
  "resource_path": resource_path,
274
+ "exists": exists,
212
275
  "class_name": declared,
213
276
  "usages": usages,
214
277
  "summary":
@@ -357,6 +420,20 @@ func _gather_paths(deps: Array[Dictionary], root: String, into: Dictionary) -> v
357
420
  _gather_paths(deeper, root, into)
358
421
 
359
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
+
360
437
  # A circular entry is not counted: it marks that the entry holding it was reached again, which that
361
438
  # entry already counts as one reference.
362
439
  func _count_recursive(deps: Array[Dictionary]) -> int:
@@ -404,14 +481,20 @@ func _analyze_resource(path: String, current_depth: int, walk: GdhDependencyWalk
404
481
  # about `["res://addons/x/plugin.cfg"]` was a dependency of every project with the addon.
405
482
  if path.get_extension() == "gd":
406
483
  content = _code_lines(content)
484
+ var moved: Dictionary = _moved_by_uid(content)
407
485
 
408
486
  for pattern: String in REFERENCE_PATTERNS:
409
487
  var regex: RegEx = GdhPatterns.compiled(pattern)
410
488
  for m: RegExMatch in regex.search_all(content):
411
489
  var dep_path: String = _resolved(_referenced_path(m.get_string()), path)
490
+ dep_path = str(moved.get(dep_path, dep_path))
412
491
 
413
492
  if not dep_path.begins_with("res://"):
414
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
415
498
 
416
499
  # Skip engine-internal resources unless requested. `addons/` is not one of
417
500
  # them: it is ordinary project content, and often shipping content, so
@@ -503,6 +586,27 @@ static func _resolved(written: String, from: String) -> String:
503
586
 
504
587
 
505
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
+
506
610
  func _referenced_path(matched: String) -> String:
507
611
  var expression: String = ""
508
612
  if "preload" in matched or "load" in matched:
@@ -37,6 +37,9 @@ const RESOURCE_COMMANDS: Array[String] = [
37
37
  const ANIMATION_COMMANDS: Array[String] = [
38
38
  "create_animation",
39
39
  "add_animation_track",
40
+ "set_animation",
41
+ "rename_animation",
42
+ "remove_animation",
40
43
  "add_animation_state",
41
44
  "connect_animation_states",
42
45
  ]
@@ -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
@@ -427,6 +427,11 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
427
427
  }
428
428
  if script != null:
429
429
  answer["script"] = script.resource_path
430
+ # resourceType is the engine class under the script, which for a class the project declares
431
+ # is Resource whatever create was given.
432
+ var declared_class: String = str(script.get_global_name())
433
+ if not declared_class.is_empty():
434
+ answer["scriptClass"] = declared_class
430
435
  return answer
431
436
 
432
437
 
@@ -1,5 +1,8 @@
1
+ @tool
1
2
  extends RefCounted
2
3
 
4
+ const GdhRead = preload("reading.gd")
5
+
3
6
  # Two walks rather than one because they disagree about hidden entries on purpose: a suffix
4
7
  # walk descends into every visible directory and matches whole names, while the extension walk
5
8
  # skips anything beginning with a dot, including .import sidecars that would otherwise answer
@@ -30,6 +33,38 @@ static func missing_reason(path: String) -> String:
30
33
  return "" if FileAccess.file_exists(path) else path + " does not exist"
31
34
 
32
35
 
36
+ ## [param said], the refusal for [param path], a res:// path nothing is at, with the files of its
37
+ ## extension whose names are nearest its own, or every one when there are few and none is near, so
38
+ ## the caller has a path to send instead.
39
+ func with_nearest_files(said: String, path: String) -> String:
40
+ var extension: String = path.get_extension()
41
+ if extension.is_empty():
42
+ return said
43
+ var files: Array[String] = find_files_with_extensions("res://", [extension])
44
+ # The walk lists a directory in the order the filesystem keeps it, which differs between
45
+ # platforms, so the same project was answered with its files in another order on Linux.
46
+ files.sort()
47
+ if files.is_empty():
48
+ return "%s. The project holds no .%s file." % [said, extension]
49
+ var names: PackedStringArray = []
50
+ for file: String in files:
51
+ var _named: bool = names.append(file.get_file().get_basename())
52
+ var close: PackedStringArray = GdhRead.nearest(path.get_file().get_basename(), names)
53
+ var offered: PackedStringArray = []
54
+ for file: String in files:
55
+ if file.get_file().get_basename() in close:
56
+ var _offered: bool = offered.append(file)
57
+ if not offered.is_empty():
58
+ var one: bool = offered.size() == 1
59
+ return (
60
+ "%s. The nearest .%s %s %s."
61
+ % [said, extension, "file is" if one else "files are", ", ".join(offered)]
62
+ )
63
+ if files.size() <= GdhRead.NAMED_CHILDREN:
64
+ return "%s. The project's .%s files are %s." % [said, extension, ", ".join(files)]
65
+ return "%s. None of the project's %d .%s files is named like it." % [said, files.size(), extension]
66
+
67
+
33
68
  # A directory the engine steps over, marker file and all.
34
69
  func is_stepped_over(path: String) -> bool:
35
70
  return FileAccess.file_exists(path + ".gdignore")
@@ -1,6 +1,7 @@
1
1
  extends RefCounted
2
2
 
3
3
  const GdhDependencies = preload("dependencies.gd")
4
+ const GdhFileWalk = preload("file_walk.gd")
4
5
  const GdhPatterns = preload("patterns.gd")
5
6
  const GdhRead = preload("reading.gd")
6
7
  const GdhLog = preload("logger.gd")
@@ -25,7 +26,11 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
25
26
  full_script_path = "res://" + full_script_path
26
27
 
27
28
  if not FileAccess.file_exists(full_script_path):
28
- return _log.failure("Script file does not exist: " + full_script_path)
29
+ return _log.failure(
30
+ GdhFileWalk.new().with_nearest_files(
31
+ "Script file does not exist: " + full_script_path, full_script_path
32
+ )
33
+ )
29
34
 
30
35
  var file: FileAccess = FileAccess.open(full_script_path, FileAccess.READ)
31
36
  if not file:
@@ -183,7 +188,7 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
183
188
  "dependencies": dependencies,
184
189
  "classes_used": classes_used,
185
190
  "autoloads_used": autoloads_used,
186
- "line_count": lines.size()
191
+ "line_count": lines.size() - 1 if content.is_empty() or content.ends_with("\n") else lines.size()
187
192
  }
188
193
  if GdhRead.as_bool(params.get("include_inherited", false)):
189
194
  _add_inherited(answer, [full_script_path])
@@ -1,8 +1,10 @@
1
1
  extends RefCounted
2
2
 
3
+ const GdhFileWalk = preload("file_walk.gd")
3
4
  const GdhRead = preload("reading.gd")
4
5
  const GdhLog = preload("logger.gd")
5
6
  const GdhPatterns = preload("patterns.gd")
7
+ const GdhScriptCheck = preload("script_check.gd")
6
8
 
7
9
  const MODIFICATION_TYPES: Array[String] = ["add_constant", "add_variable", "add_signal", "add_function"]
8
10
  const BLANKS_AROUND_FUNCTIONS: int = 2
@@ -56,6 +58,13 @@ func create_gdscript(params: Dictionary) -> Dictionary:
56
58
  "Script file already exists: " + full_script_path + ". script_edit modify adds to it."
57
59
  )
58
60
 
61
+ # Content is left to the parse below: two scripts that name each other are written one at a time,
62
+ # and the first does not compile until the second exists.
63
+ if content.is_empty():
64
+ var header: String = _header_refusal(cls_name_param, extends_class)
65
+ if not header.is_empty():
66
+ return _log.failure(header + " Nothing was written.")
67
+
59
68
  var dir: DirAccess = DirAccess.open("res://")
60
69
  var script_dir: String = full_script_path.get_base_dir()
61
70
  if script_dir != "res://" and not dir.dir_exists(script_dir.substr(6)):
@@ -111,7 +120,11 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
111
120
  full_script_path = "res://" + full_script_path
112
121
 
113
122
  if not FileAccess.file_exists(full_script_path):
114
- return _log.failure("Script file does not exist: " + full_script_path)
123
+ return _log.failure(
124
+ GdhFileWalk.new().with_nearest_files(
125
+ "Script file does not exist: " + full_script_path, full_script_path
126
+ )
127
+ )
115
128
 
116
129
  var file: FileAccess = FileAccess.open(full_script_path, FileAccess.READ)
117
130
  if not file:
@@ -122,6 +135,8 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
122
135
 
123
136
  var lines: Array[String] = []
124
137
  lines.assign(original_content.split("\n"))
138
+ var before: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
139
+ var parsed_before: bool = _parses(before)
125
140
 
126
141
  # All of them checked before any is made, so a call is made whole or not at all: one skipped for a
127
142
  # missing name used to leave the rest written and the answer saying success over an empty list.
@@ -166,16 +181,99 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
166
181
  if not rewrote:
167
182
  return _log.failure("Failed to write to script file: " + full_script_path)
168
183
 
184
+ var caught: GdhScriptCheck.GdhCaught = GdhScriptCheck.GdhCaught.new()
185
+ OS.add_logger(caught)
169
186
  var made: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
187
+ OS.remove_logger(caught)
188
+ var parses: bool = _parses(made)
189
+ # The names checked above are the script's own, and a member the base class declares, or a body
190
+ # that does not compile, is found only by the engine; a script left broken by an addition breaks
191
+ # every scene that uses it, so it goes back as it was. One already broken is written, as the
192
+ # addition can be what mends it.
193
+ if parsed_before and not parses:
194
+ file = FileAccess.open(full_script_path, FileAccess.WRITE)
195
+ var restored: bool = file != null and file.store_string(original_content)
196
+ if file != null:
197
+ file.close()
198
+ if not restored:
199
+ return _log.failure(
200
+ "%s would not parse with these additions, and could not be put back as it was" % script_path
201
+ )
202
+ return _log.failure(
203
+ (
204
+ "Nothing was changed: %s parses as it is and would not with these additions: %s"
205
+ % [script_path, _reasons(caught.raised, full_script_path)]
206
+ )
207
+ )
170
208
  return {
171
209
  "success": true,
172
210
  "script_path": script_path,
173
211
  "modifications_applied": modifications_applied,
174
212
  "total_modifications": modifications_applied.size(),
175
- "parses": _parses(made),
213
+ "parses": parses,
176
214
  }
177
215
 
178
216
 
217
+ # The engine's errors in [param raised] for [param path], each with its line, as one sentence part.
218
+ static func _reasons(raised: Array[Dictionary], path: String) -> String:
219
+ var reasons: PackedStringArray = []
220
+ for error: Dictionary in raised:
221
+ var reason: String = "line %d, %s" % [GdhRead.as_int(error["line"]), str(error["message"])]
222
+ if str(error["path"]) == path and not reason in reasons:
223
+ var _added: bool = reasons.append(reason)
224
+ if reasons.is_empty():
225
+ return "the engine would not load it as a script."
226
+ var last: String = reasons[-1]
227
+ return "; ".join(reasons) + ("" if last.ends_with(".") or last.ends_with(".)") else ".")
228
+
229
+
230
+ # Why a script with [param named] as its class_name and extending [param base] cannot be written,
231
+ # or "". A class_name another script holds does not compile ("hides a global script class"), and nor
232
+ # does a base no class answers to; both were written and answered as made. Classes are read off the
233
+ # scripts as well as the class list, which a fresh clone has not written yet.
234
+ static func _header_refusal(named: String, base: String) -> String:
235
+ var declared: Dictionary = _declared_classes()
236
+ if not named.is_empty():
237
+ if ClassDB.class_exists(named):
238
+ return "class_name %s is the name of an engine class, which a script cannot take." % named
239
+ if declared.has(named):
240
+ return (
241
+ (
242
+ "class_name %s is taken: %s declares it, and a second script of that name does not "
243
+ + "compile. Give this one another class_name, or script_edit rename the first."
244
+ )
245
+ % [named, declared[named]]
246
+ )
247
+ if base.begins_with('"') or base.begins_with("'") or "." in base:
248
+ return ""
249
+ if ClassDB.class_exists(base) or declared.has(base):
250
+ return ""
251
+ var said: String = (
252
+ "extends names %s, which is neither an engine class nor a class_name this project declares" % base
253
+ )
254
+ var close: PackedStringArray = GdhRead.nearest(base, PackedStringArray(declared.keys()))
255
+ if close.is_empty():
256
+ close = GdhRead.nearest(base, ClassDB.get_class_list())
257
+ if close.is_empty():
258
+ return said + "."
259
+ return said + "; the nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
260
+
261
+
262
+ # The class_name each script of the project declares, by name, with its path.
263
+ static func _declared_classes() -> Dictionary:
264
+ var declared: Dictionary = {}
265
+ for entry: Dictionary in ProjectSettings.get_global_class_list():
266
+ var path: String = str(entry.get("path", ""))
267
+ if FileAccess.file_exists(path):
268
+ declared[str(entry.get("class", ""))] = path
269
+ for path: String in GdhFileWalk.new().find_files("res://", ".gd"):
270
+ for line: String in FileAccess.get_file_as_string(path).split("\n"):
271
+ var found: RegExMatch = GdhPatterns.declared_class(GdhPatterns.without_annotations(line))
272
+ if found != null and not declared.has(found.get_string(1)):
273
+ declared[found.get_string(1)] = path
274
+ return declared
275
+
276
+
179
277
  # Every reason [param modifications] cannot be made as asked, each naming the entry by position.
180
278
  #
181
279
  # A name the script already declares is one of them, as is one the call adds twice. GDScript gives a
@@ -341,6 +439,11 @@ func _add_variable(lines: Array[String], mod: Dictionary, placed: Array[Dictiona
341
439
  var_line += ": Variant"
342
440
 
343
441
  var insert_line: int = _variable_insertion_point(lines)
442
+ # The first variable below the header or the signals is set apart from them by a blank line, as
443
+ # the style guide and gdformat lay a script out.
444
+ var above: String = lines[insert_line - 1].strip_edges() if insert_line > 0 else ""
445
+ if not above.is_empty() and not GdhPatterns.without_annotations(above).begins_with("var "):
446
+ return insert_line + 1 if _insert(lines, insert_line, ["", var_line], placed) else -1
344
447
  return insert_line if _insert(lines, insert_line, [var_line], placed) else -1
345
448
 
346
449
 
@@ -277,6 +277,8 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
277
277
  payload = GdhInputActions.new(_log).add_input_action(params)
278
278
  "remove_input_action":
279
279
  payload = GdhInputActions.new(_log).remove_input_action(params)
280
+ "list_input_actions":
281
+ payload = GdhInputActions.new(_log).list_input_actions(params)
280
282
 
281
283
  # Audio
282
284
  "create_audio_bus":
@@ -1,6 +1,8 @@
1
1
  extends RefCounted
2
2
 
3
+ const GdhDependencies = preload("dependencies.gd")
3
4
  const GdhExportPresets = preload("export_presets.gd")
5
+ const GdhScriptCheck = preload("script_check.gd")
4
6
  const GdhRead = preload("reading.gd")
5
7
  const GdhSerialisation = preload("serialisation.gd")
6
8
  const GdhFileWalk = preload("file_walk.gd")
@@ -85,6 +87,14 @@ func get_import_status(params: Dictionary) -> Dictionary:
85
87
  if not full_path.begins_with("res://"):
86
88
  full_path = "res://" + full_path
87
89
 
90
+ # missing_source is a sidecar whose source has gone; with neither, the path names nothing the
91
+ # engine ever imported, and answering it as a lost source sent a caller to restore a file.
92
+ if not FileAccess.file_exists(full_path) and not FileAccess.file_exists(full_path + ".import"):
93
+ return _log.failure(
94
+ _files.with_nearest_files(
95
+ "Nothing is at %s, and no import sidecar names it" % full_path, full_path
96
+ )
97
+ )
88
98
  var status: Dictionary = _import_status_of(full_path, full_path + ".import")
89
99
  resources.append(status)
90
100
  _tally(summary, status)
@@ -457,6 +467,35 @@ func validate_project(params: Dictionary) -> Dictionary:
457
467
  if "pass # TODO" in content:
458
468
  script_issues.append({"path": script_path, "issue": "Contains unimplemented functions"})
459
469
 
470
+ # Compiled by the server and handed in, as for the health check: a script the engine refuses
471
+ # stops the export's game at the first scene using it, which the comment check above never saw.
472
+ var compiled: Dictionary = params.get("compiled", {})
473
+ if compiled.has("checked"):
474
+ checks_performed.append("compiles")
475
+ var failures: Array = compiled.get("failures", [])
476
+ for failure: Dictionary in failures:
477
+ issues.append(
478
+ _finding(
479
+ "error",
480
+ "compiles",
481
+ GdhScriptCheck.failure_line(failure),
482
+ "script_diagnostics gives every error in it with its line",
483
+ include_suggestions
484
+ )
485
+ )
486
+
487
+ checks_performed.append("references")
488
+ for entry: Dictionary in GdhDependencies.new(_log).missing_references():
489
+ issues.append(
490
+ _finding(
491
+ "error",
492
+ "references",
493
+ GdhDependencies.missing_line(entry),
494
+ "Restore the file, or point the reference at the file that replaced it",
495
+ include_suggestions
496
+ )
497
+ )
498
+
460
499
  if script_issues.size() > 0:
461
500
  var warning: Dictionary = _finding(
462
501
  "warning",