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
@@ -10,9 +10,9 @@ extends RefCounted
10
10
  ## without making a node. Loading the file still does what loading does in the game: static
11
11
  ## initialisers run, and a resource with a script is made with it.
12
12
 
13
- const PropertyValues = preload("property_values.gd")
14
- const Read = preload("reading.gd")
15
- const Serialisation = preload("serialisation.gd")
13
+ const GdhPropertyValues = preload("property_values.gd")
14
+ const GdhRead = preload("reading.gd")
15
+ const GdhSerialisation = preload("serialisation.gd")
16
16
 
17
17
 
18
18
  ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf and
@@ -42,13 +42,43 @@ static func scene_node(scene_path: String, node_path: String) -> Dictionary:
42
42
  return {"ok": true, "name": found["name"], "type": found["type"], "properties": found["properties"]}
43
43
 
44
44
 
45
- ## [param said], the refusal for [param node_path], which [param scene_path] does not have, with
46
- ## where the path leaves the scene as [method Read.missing_below] says it.
47
- static func missing_from(said: String, scene_path: String, node_path: String) -> String:
45
+ ## The node at [param node_path] in [param scene_path] as [method scene_node] answers it, when it is
46
+ ## a [param kind]; otherwise a refusal saying what the scene has there and where its nodes of that
47
+ ## kind are, so the caller has the path to pass. The file is read once, because every load prints
48
+ ## the engine's warnings about it again.
49
+ static func scene_node_of_kind(scene_path: String, node_path: String, kind: String) -> Dictionary:
48
50
  var laid: Dictionary = _laid(scene_path)
49
51
  if not laid.get("ok", false):
50
- return said
52
+ return laid
51
53
  var nodes: Dictionary = laid["nodes"]
54
+ var path: String = _joined(".", node_path)
55
+ var said: String = "%s not found at: %s" % [kind, node_path]
56
+ var found: String = ""
57
+ if nodes.has(path):
58
+ found = str(nodes[path]["type"])
59
+ if ClassDB.class_exists(found) and ClassDB.is_parent_class(found, kind):
60
+ return {
61
+ "ok": true,
62
+ "name": nodes[path]["name"],
63
+ "type": found,
64
+ "properties": nodes[path]["properties"]
65
+ }
66
+ else:
67
+ var hinted: String = said
68
+ if not nodes.has(_joined(".", node_path.get_slice("/", 0))):
69
+ hinted = GdhRead.from_the_root(said, node_path, str(nodes["."]["name"]))
70
+ said = hinted if hinted != said else _missing_in(nodes, said, node_path)
71
+ var held: PackedStringArray = []
72
+ for at: Variant in nodes:
73
+ var type: String = str(nodes[at]["type"])
74
+ if ClassDB.class_exists(type) and ClassDB.is_parent_class(type, kind):
75
+ var _held: bool = held.append(str(at))
76
+ return {"ok": false, "error": GdhRead.not_of_kind(said, node_path, found, kind, held)}
77
+
78
+
79
+ ## [param said], the refusal for [param node_path], which the scene laid as [param nodes] does not
80
+ ## have, with where the path leaves the scene as [method GdhRead.missing_below] says it.
81
+ static func _missing_in(nodes: Dictionary, said: String, node_path: String) -> String:
52
82
  var parts: PackedStringArray = node_path.trim_prefix("./").split("/", false)
53
83
  var at: String = "."
54
84
  var reached: PackedStringArray = []
@@ -64,7 +94,7 @@ static func missing_from(said: String, scene_path: String, node_path: String) ->
64
94
  var children: Array[String] = nodes[at]["children"]
65
95
  for child: String in children:
66
96
  var _named: bool = names.append(str(nodes[child]["name"]))
67
- return Read.missing_below(said, str(nodes["."]["name"]), reached, parts[reached.size()], names)
97
+ return GdhRead.missing_below(said, str(nodes["."]["name"]), reached, parts[reached.size()], names)
68
98
 
69
99
 
70
100
  ## Every node of [param scene_path] as {ok, nodes}, keyed by path, or a refusal.
@@ -77,15 +107,14 @@ static func _laid(scene_path: String) -> Dictionary:
77
107
  if loaded == null:
78
108
  return {"ok": false, "error": "Failed to load %s as a scene" % scene_path}
79
109
  if not loaded is PackedScene:
80
- var kind: String = loaded.get_class()
81
- var sort: String = ("an " if kind.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + kind
110
+ var sort: String = GdhRead.with_article(loaded.get_class())
82
111
  return {
83
112
  "ok": false,
84
113
  "error": "%s is %s, not a scene: resource_edit read reads its properties." % [scene_path, sort]
85
114
  }
86
115
  var packed: PackedScene = loaded
87
116
  var nodes: Dictionary = {}
88
- var refused: String = _lay(packed.get_state(), ".", nodes, [scene_path])
117
+ var refused: String = _lay(packed.get_state(), ".", nodes, [scene_path], packed)
89
118
  if not refused.is_empty():
90
119
  return {"ok": false, "error": refused}
91
120
  if not nodes.has("."):
@@ -99,8 +128,11 @@ static func _laid(scene_path: String) -> Dictionary:
99
128
  ##
100
129
  ## [param within] is the files being laid, outermost first, so a scene that instances one of them
101
130
  ## is refused rather than laid forever; the engine refuses to load such a scene, so this is only
102
- ## reached through a state built some other way.
103
- static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: Array) -> String:
131
+ ## reached through a state built some other way. [param loaded] is the scene [param state] is read
132
+ ## from, when the caller has it loaded already.
133
+ static func _lay(
134
+ state: SceneState, prefix: String, nodes: Dictionary, within: Array, loaded: PackedScene = null
135
+ ) -> String:
104
136
  var base: SceneState = state.get_base_scene_state()
105
137
  if base != null and not nodes.has(prefix):
106
138
  var refused: String = _lay(base, prefix, nodes, within)
@@ -164,7 +196,7 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
164
196
  # Only the scene being read and the scenes it inherits share its root, the owner whose editable
165
197
  # instances a write here sets; a file instanced below makes its own owner's editable.
166
198
  if prefix == ".":
167
- for editable: String in _editable_in(state.get_path()):
199
+ for editable: String in _editable_in(state.get_path(), loaded):
168
200
  var at: String = _joined(prefix, editable)
169
201
  var marked: Dictionary = nodes.get(at, {})
170
202
  if marked.has("editableChildren"):
@@ -173,10 +205,13 @@ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: A
173
205
 
174
206
 
175
207
  ## The paths of the instances [param scene_path] gives editable children, from its root. SceneState
176
- ## does not give them to a script, so they are read off the scene's bundle.
177
- static func _editable_in(scene_path: String) -> PackedStringArray:
208
+ ## does not give them to a script, so they are read off the scene's bundle: [param given_scene]'s,
209
+ ## when the caller has the file loaded, since loading it again prints its warnings again.
210
+ static func _editable_in(scene_path: String, given_scene: PackedScene = null) -> PackedStringArray:
178
211
  var found: PackedStringArray = []
179
- var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
212
+ var loaded: Resource = given_scene
213
+ if loaded == null:
214
+ loaded = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
180
215
  if not loaded is PackedScene:
181
216
  return found
182
217
  var given: Variant = loaded.get("_bundled")
@@ -236,7 +271,7 @@ static func _by_id(nodes: Dictionary, owner: String, id: int) -> String:
236
271
  if path == owner or not (owner == "." or path.begins_with(owner + "/")):
237
272
  continue
238
273
  var entry: Dictionary = nodes[path]
239
- if Read.as_int(entry.get("uniqueId", -1), -1) != id:
274
+ if GdhRead.as_int(entry.get("uniqueId", -1), -1) != id:
240
275
  continue
241
276
  var above: String = "." if not path.contains("/") else path.get_base_dir()
242
277
  if _owner_of(nodes, above, owner) == owner:
@@ -311,7 +346,7 @@ static func _stored(type: String, given: Dictionary) -> Dictionary:
311
346
  if script != null:
312
347
  for declared: Dictionary in script.get_script_property_list():
313
348
  var property: String = str(declared.get("name", ""))
314
- if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
349
+ if not property.is_empty() and GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
315
350
  scripted[property] = script.get_property_default_value(property)
316
351
  var values: Dictionary = {}
317
352
  if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
@@ -321,7 +356,7 @@ static func _stored(type: String, given: Dictionary) -> Dictionary:
321
356
  bare.set(str(property), given[property])
322
357
  for declared: Dictionary in bare.get_property_list():
323
358
  var property: String = str(declared.get("name", ""))
324
- if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
359
+ if not property.is_empty() and GdhRead.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
325
360
  values[property] = bare.get(property)
326
361
  if bare is Node:
327
362
  bare.free()
@@ -330,7 +365,7 @@ static func _stored(type: String, given: Dictionary) -> Dictionary:
330
365
  # Given last, which keeps what a bare node cannot hold: the script itself, and a path to a node
331
366
  # for a property that holds the node once it is in a tree.
332
367
  values.merge(given, true)
333
- var serialiser: Serialisation = Serialisation.new()
368
+ var serialiser: GdhSerialisation = GdhSerialisation.new()
334
369
  var answered: Dictionary = {}
335
370
  for property: Variant in values:
336
371
  answered[property] = serialiser.serialize_value(values[property])
@@ -369,13 +404,13 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
369
404
  if script != null:
370
405
  for declared: Dictionary in script.get_script_property_list():
371
406
  scripted[str(declared.get("name", ""))] = true
372
- var serialiser: Serialisation = Serialisation.new()
407
+ var serialiser: GdhSerialisation = GdhSerialisation.new()
373
408
  var properties: Dictionary = {}
374
409
  for p: Dictionary in loaded.get_property_list():
375
410
  var property: String = str(p.get("name", ""))
376
411
  if property.is_empty() or property == "script":
377
412
  continue
378
- if not (Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
413
+ if not (GdhRead.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
379
414
  continue
380
415
  var value: Variant = loaded.get(property)
381
416
  if not include_defaults:
@@ -384,7 +419,7 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
384
419
  if scripted.has(property)
385
420
  else ClassDB.class_get_property_default_value(loaded.get_class(), property)
386
421
  )
387
- if PropertyValues.same(value, default):
422
+ if GdhPropertyValues.same(value, default):
388
423
  continue
389
424
  properties[property] = serialiser.serialize_value(value)
390
425
  var answer: Dictionary = {
@@ -392,6 +427,11 @@ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
392
427
  }
393
428
  if script != null:
394
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
395
435
  return answer
396
436
 
397
437
 
@@ -412,7 +452,7 @@ static func _kept_in(value: Variant, file: String, id: String, seen: Dictionary)
412
452
  if held.resource_scene_unique_id == id:
413
453
  return held
414
454
  for p: Dictionary in held.get_property_list():
415
- if Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
455
+ if GdhRead.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
416
456
  var found: Resource = _kept_in(held.get(str(p.get("name", ""))), file, id, seen)
417
457
  if found != null:
418
458
  return found
@@ -1,5 +1,7 @@
1
1
  extends RefCounted
2
2
 
3
+ const GdhRead = preload("reading.gd")
4
+
3
5
  # Two walks rather than one because they disagree about hidden entries on purpose: a suffix
4
6
  # walk descends into every visible directory and matches whole names, while the extension walk
5
7
  # skips anything beginning with a dot, including .import sidecars that would otherwise answer
@@ -30,6 +32,38 @@ static func missing_reason(path: String) -> String:
30
32
  return "" if FileAccess.file_exists(path) else path + " does not exist"
31
33
 
32
34
 
35
+ ## [param said], the refusal for [param path], a res:// path nothing is at, with the files of its
36
+ ## extension whose names are nearest its own, or every one when there are few and none is near, so
37
+ ## the caller has a path to send instead.
38
+ func with_nearest_files(said: String, path: String) -> String:
39
+ var extension: String = path.get_extension()
40
+ if extension.is_empty():
41
+ return said
42
+ var files: Array[String] = find_files_with_extensions("res://", [extension])
43
+ # The walk lists a directory in the order the filesystem keeps it, which differs between
44
+ # platforms, so the same project was answered with its files in another order on Linux.
45
+ files.sort()
46
+ if files.is_empty():
47
+ return "%s. The project holds no .%s file." % [said, extension]
48
+ var names: PackedStringArray = []
49
+ for file: String in files:
50
+ var _named: bool = names.append(file.get_file().get_basename())
51
+ var close: PackedStringArray = GdhRead.nearest(path.get_file().get_basename(), names)
52
+ var offered: PackedStringArray = []
53
+ for file: String in files:
54
+ if file.get_file().get_basename() in close:
55
+ var _offered: bool = offered.append(file)
56
+ if not offered.is_empty():
57
+ var one: bool = offered.size() == 1
58
+ return (
59
+ "%s. The nearest .%s %s %s."
60
+ % [said, extension, "file is" if one else "files are", ", ".join(offered)]
61
+ )
62
+ if files.size() <= GdhRead.NAMED_CHILDREN:
63
+ return "%s. The project's .%s files are %s." % [said, extension, ", ".join(files)]
64
+ return "%s. None of the project's %d .%s files is named like it." % [said, files.size(), extension]
65
+
66
+
33
67
  # A directory the engine steps over, marker file and all.
34
68
  func is_stepped_over(path: String) -> bool:
35
69
  return FileAccess.file_exists(path + ".gdignore")
@@ -1,16 +1,17 @@
1
1
  extends RefCounted
2
2
 
3
- const Dependencies = preload("dependencies.gd")
4
- const Patterns = preload("patterns.gd")
5
- const Read = preload("reading.gd")
6
- const Log = preload("logger.gd")
3
+ const GdhDependencies = preload("dependencies.gd")
4
+ const GdhFileWalk = preload("file_walk.gd")
5
+ const GdhPatterns = preload("patterns.gd")
6
+ const GdhRead = preload("reading.gd")
7
+ const GdhLog = preload("logger.gd")
7
8
 
8
- var _log: Log
9
+ var _log: GdhLog
9
10
  ## Kept for the call, so the project's class names are read once however many bases are walked.
10
- var _dependencies: Dependencies = null
11
+ var _dependencies: GdhDependencies = null
11
12
 
12
13
 
13
- func _init(p_log: Log) -> void:
14
+ func _init(p_log: GdhLog) -> void:
14
15
  _log = p_log
15
16
 
16
17
 
@@ -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:
@@ -62,7 +67,7 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
62
67
  if _triple_quotes(raw) % 2 == 1:
63
68
  in_multiline_string = false
64
69
  continue
65
- var stripped: String = Patterns.without_comment(raw)
70
+ var stripped: String = GdhPatterns.without_comment(raw)
66
71
 
67
72
  if stripped.is_empty():
68
73
  continue
@@ -85,7 +90,7 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
85
90
  # on the line with them off. `@abstract class_name X` was reported as no declared class,
86
91
  # and `@abstract func x()` and `@warning_ignore("...") func x()` as no function at all:
87
92
  # gdUnit4 alone declares 248 methods that way and this answered with none of them.
88
- var header: String = Patterns.without_annotations(stripped)
93
+ var header: String = GdhPatterns.without_annotations(stripped)
89
94
 
90
95
  var at: int = i
91
96
  # A declaration wrapped across lines is one declaration, and reading the first line alone
@@ -104,9 +109,9 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
104
109
  if _can_wrap(header):
105
110
  while _bracket_depth(stripped) > 0 and i + 1 < lines.size():
106
111
  i += 1
107
- stripped = (stripped + " " + Patterns.without_comment(lines[i])).strip_edges()
108
- header = Patterns.without_annotations(stripped)
109
- var declaration: RegExMatch = Patterns.declared_class(header)
112
+ stripped = (stripped + " " + GdhPatterns.without_comment(lines[i])).strip_edges()
113
+ header = GdhPatterns.without_annotations(stripped)
114
+ var declaration: RegExMatch = GdhPatterns.declared_class(header)
110
115
  if declaration != null:
111
116
  declared_class_name = declaration.get_string(1)
112
117
  # `class_name X extends Y` is also one line, and taking the rest of it as the name
@@ -144,9 +149,9 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
144
149
  # named it. The same reading as that tool's, so the two agree.
145
150
  var classes_used: Array[Dictionary] = []
146
151
  if _dependencies == null:
147
- _dependencies = Dependencies.new(_log)
152
+ _dependencies = GdhDependencies.new(_log)
148
153
  var declared_by: Dictionary = _dependencies.classes_declared()
149
- for used: String in Dependencies.classes_used_in(content, declared_by):
154
+ for used: String in GdhDependencies.classes_used_in(content, declared_by):
150
155
  var declaring: String = str(declared_by[used])
151
156
  if declaring == full_script_path:
152
157
  continue
@@ -165,7 +170,7 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
165
170
  if value.begins_with("*"):
166
171
  global_autoloads[setting.trim_prefix("autoload/")] = value.trim_prefix("*")
167
172
  var autoloads_used: Array[Dictionary] = []
168
- for used: String in Dependencies.classes_used_in(content, global_autoloads):
173
+ for used: String in GdhDependencies.classes_used_in(content, global_autoloads):
169
174
  if str(global_autoloads[used]) != full_script_path:
170
175
  autoloads_used.append({"name": used, "path": str(global_autoloads[used])})
171
176
 
@@ -183,9 +188,9 @@ 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
- if Read.as_bool(params.get("include_inherited", false)):
193
+ if GdhRead.as_bool(params.get("include_inherited", false)):
189
194
  _add_inherited(answer, [full_script_path])
190
195
  return answer
191
196
 
@@ -202,7 +207,7 @@ func _mark_virtuals(functions: Array[Dictionary], extends_name: String, from_pat
202
207
  var virtuals: Dictionary = {}
203
208
  if not native.is_empty():
204
209
  for m: Dictionary in ClassDB.class_get_method_list(native, false):
205
- var flags: int = Read.as_int(m.get("flags", 0))
210
+ var flags: int = GdhRead.as_int(m.get("flags", 0))
206
211
  if flags & METHOD_FLAG_VIRTUAL:
207
212
  virtuals[str(m.get("name", ""))] = true
208
213
  for one: Dictionary in functions:
@@ -366,7 +371,7 @@ func _parse_variable(line: String, line_num: int) -> Dictionary:
366
371
 
367
372
  # From the annotations themselves rather than from where they sit. `@onready @export var x` put
368
373
  # the export second and so read as not exported, and a hint with a space in it was cut at it.
369
- for one: String in Patterns.annotations_on(line):
374
+ for one: String in GdhPatterns.annotations_on(line):
370
375
  if one.begins_with("export"):
371
376
  is_export = true
372
377
  var after: String = one.substr(6)
@@ -457,7 +462,7 @@ func _parse_function(line: String, line_num: int, annotated: String = "") -> Dic
457
462
  "is_static": is_static,
458
463
  # Said rather than left to be inferred: an abstract method has no body, so a caller that
459
464
  # saw only the declaration would otherwise take it for one whose body it failed to read.
460
- "is_abstract": "abstract" in Patterns.annotations_on(annotated),
465
+ "is_abstract": "abstract" in GdhPatterns.annotations_on(annotated),
461
466
  "line": line_num
462
467
  }
463
468
 
@@ -624,7 +629,7 @@ static func _triple_quotes(line: String) -> int:
624
629
 
625
630
  func _extract_dependencies(line: String) -> Array[String]:
626
631
  var deps: Array[String] = []
627
- var regex: RegEx = Patterns.compiled("(?:preload|load)\\s*\\(\\s*[\"']([^\"']+)[\"']\\s*\\)")
632
+ var regex: RegEx = GdhPatterns.compiled("(?:preload|load)\\s*\\(\\s*[\"']([^\"']+)[\"']\\s*\\)")
628
633
 
629
634
  for m: RegExMatch in regex.search_all(line):
630
635
  deps.append(m.get_string(1))
@@ -1,16 +1,18 @@
1
1
  extends RefCounted
2
2
 
3
- const Read = preload("reading.gd")
4
- const Log = preload("logger.gd")
5
- const Patterns = preload("patterns.gd")
3
+ const GdhFileWalk = preload("file_walk.gd")
4
+ const GdhRead = preload("reading.gd")
5
+ const GdhLog = preload("logger.gd")
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
9
11
 
10
- var _log: Log
12
+ var _log: GdhLog
11
13
 
12
14
 
13
- func _init(p_log: Log) -> void:
15
+ func _init(p_log: GdhLog) -> void:
14
16
  _log = p_log
15
17
 
16
18
 
@@ -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
@@ -231,14 +329,14 @@ static func _modification_problems(modifications: Array, declared: Dictionary) -
231
329
  # one: variables, constants, signals, functions, enums and inner classes, which share one namespace.
232
330
  static func _declared(lines: Array[String]) -> Dictionary:
233
331
  var found: Dictionary = {}
234
- var declaration: RegEx = Patterns.compiled(
332
+ var declaration: RegEx = GdhPatterns.compiled(
235
333
  "^(?:static\\s+)?(var|const|signal|func|enum|class)\\s+([A-Za-z_][A-Za-z0-9_]*)"
236
334
  )
237
335
  for i: int in range(lines.size()):
238
336
  var raw: String = lines[i]
239
337
  if raw.is_empty() or raw[0] == "\t" or raw[0] == " ":
240
338
  continue
241
- var matched: RegExMatch = declaration.search(Patterns.without_annotations(raw))
339
+ var matched: RegExMatch = declaration.search(GdhPatterns.without_annotations(raw))
242
340
  if matched != null and not found.has(matched.get_string(2)):
243
341
  found[matched.get_string(2)] = {"kind": matched.get_string(1), "line": i + 1}
244
342
  return found
@@ -286,7 +384,7 @@ func _add_constant(lines: Array[String], mod: Dictionary, placed: Array[Dictiona
286
384
  var const_line: String = "const %s: %s = %s" % [str(mod.get("name", "")), declared_type, value]
287
385
  var after: int = 0
288
386
  for i: int in range(lines.size()):
289
- var line: String = Patterns.without_annotations(lines[i])
387
+ var line: String = GdhPatterns.without_annotations(lines[i])
290
388
  if (
291
389
  line.begins_with("extends ")
292
390
  or line.begins_with("class_name ")
@@ -310,9 +408,9 @@ func _add_variable(lines: Array[String], mod: Dictionary, placed: Array[Dictiona
310
408
  var var_name: String = str(mod.get("name", ""))
311
409
  var var_type: String = str(mod.get("varType", ""))
312
410
  var default_value: String = str(mod.get("defaultValue", ""))
313
- var is_export: bool = Read.as_bool(mod.get("isExport", false))
411
+ var is_export: bool = GdhRead.as_bool(mod.get("isExport", false))
314
412
  var export_hint: String = str(mod.get("exportHint", ""))
315
- var is_onready: bool = Read.as_bool(mod.get("isOnready", false))
413
+ var is_onready: bool = GdhRead.as_bool(mod.get("isOnready", false))
316
414
 
317
415
  var var_line: String = ""
318
416
 
@@ -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
 
@@ -433,7 +536,7 @@ func _variable_insertion_point(lines: Array[String]) -> int:
433
536
  # takes the new line above the `class_name`, where it does not parse; one that declares
434
537
  # `extends` first takes it between the two, which does not parse either. Stripping also
435
538
  # turns `@export var x` into `var x`, which is why the annotation names are gone below.
436
- var line: String = Patterns.without_annotations(lines[i])
539
+ var line: String = GdhPatterns.without_annotations(lines[i])
437
540
  if line.begins_with("extends ") or line.begins_with("class_name "):
438
541
  after_header = i + 1
439
542
  elif line.begins_with("signal "):
@@ -443,7 +546,7 @@ func _variable_insertion_point(lines: Array[String]) -> int:
443
546
  break
444
547
 
445
548
  for i: int in range(after_header, before_func):
446
- if Patterns.without_annotations(lines[i]).begins_with("var "):
549
+ if GdhPatterns.without_annotations(lines[i]).begins_with("var "):
447
550
  after_header = i + 1
448
551
 
449
552
  return after_header
@@ -455,7 +558,7 @@ func _signal_insertion_point(lines: Array[String]) -> int:
455
558
  for i: int in range(lines.size()):
456
559
  # As above: the annotations come off before the line is read, so an abstract class has a
457
560
  # header to find and `@export var x` reads as the var it is.
458
- var line: String = Patterns.without_annotations(lines[i])
561
+ var line: String = GdhPatterns.without_annotations(lines[i])
459
562
  if line.begins_with("extends ") or line.begins_with("class_name "):
460
563
  after_header = i + 1
461
564
  elif line.begins_with("signal "):
@@ -485,7 +588,7 @@ func _line_after_function(lines: Array[String], prefix: String) -> int:
485
588
  # ends of this. An annotated `_ready` was never found, so `after_ready` placed the new
486
589
  # function at the end of the file, and an annotated function after `_ready` was not the
487
590
  # boundary it is, so the new one went in after whichever later function was plain.
488
- var line: String = Patterns.without_annotations(lines[i])
591
+ var line: String = GdhPatterns.without_annotations(lines[i])
489
592
  if line.begins_with(prefix):
490
593
  inside = true
491
594
  elif inside and (line.begins_with("func ") or line.begins_with("static func ")):
@@ -502,7 +605,9 @@ static func _start_of_declaration(lines: Array[String], at: int) -> int:
502
605
  var start: int = at
503
606
  while start > 0 and _indentation(lines[start - 1]) == indent:
504
607
  var above: String = lines[start - 1].strip_edges()
505
- var annotation_only: bool = above.begins_with("@") and Patterns.without_annotations(above).is_empty()
608
+ var annotation_only: bool = (
609
+ above.begins_with("@") and GdhPatterns.without_annotations(above).is_empty()
610
+ )
506
611
  if not above.begins_with("#") and not annotation_only:
507
612
  break
508
613
  start -= 1