gdharness 1.3.3 → 1.3.5

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 (52) hide show
  1. package/build/cli.js +532 -456
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +3 -2
  3. package/build/godot/addons/gdharness_editor/declared_values.gd +181 -4
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +15 -3
  5. package/build/godot/addons/gdharness_editor/headless_check.gd +44 -23
  6. package/build/godot/addons/gdharness_editor/property_paths.gd +225 -0
  7. package/build/godot/addons/gdharness_editor/property_paths.gd.uid +1 -0
  8. package/build/godot/addons/gdharness_editor/property_values.gd +192 -231
  9. package/build/godot/addons/gdharness_editor/reading.gd +15 -0
  10. package/build/godot/addons/gdharness_editor/same_values.gd +177 -0
  11. package/build/godot/addons/gdharness_editor/same_values.gd.uid +1 -0
  12. package/build/godot/addons/gdharness_editor/scene_file.gd +97 -14
  13. package/build/godot/addons/gdharness_editor/serialisation.gd +75 -3
  14. package/build/godot/addons/gdharness_editor/tool_executor.gd +9 -3
  15. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +38 -35
  16. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +20 -12
  17. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +3 -2
  18. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd +91 -0
  19. package/build/godot/addons/gdharness_editor/tools/scene_groups.gd.uid +1 -0
  20. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +68 -2
  21. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +94 -333
  22. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd +363 -0
  23. package/build/godot/addons/gdharness_editor/tools/signal_tools.gd.uid +1 -0
  24. package/build/godot/addons/gdharness_runtime/declared_values.gd +293 -0
  25. package/build/godot/addons/gdharness_runtime/declared_values.gd.uid +1 -0
  26. package/build/godot/addons/gdharness_runtime/reading.gd +15 -0
  27. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +4 -0
  28. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +29 -1
  29. package/build/godot/addons/gdharness_runtime/runtime_input.gd +26 -39
  30. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +18 -0
  31. package/build/godot/addons/gdharness_runtime/runtime_pad.gd +3 -2
  32. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +2 -1
  33. package/build/godot/addons/gdharness_runtime/runtime_values.gd +62 -5
  34. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +48 -15
  35. package/build/godot/addons/gdharness_runtime/serialisation.gd +75 -3
  36. package/build/godot/operations/animation_reads.gd +3 -2
  37. package/build/godot/operations/classdb_queries.gd +112 -46
  38. package/build/godot/operations/declared_values.gd +181 -4
  39. package/build/godot/operations/dependencies.gd +14 -39
  40. package/build/godot/operations/editor_tools.gd +4 -3
  41. package/build/godot/operations/file_reads.gd +15 -3
  42. package/build/godot/operations/gdscript_analysis.gd +229 -75
  43. package/build/godot/operations/gdscript_authoring.gd +157 -36
  44. package/build/godot/operations/import_pipeline.gd +17 -6
  45. package/build/godot/operations/logger.gd +7 -0
  46. package/build/godot/operations/property_paths.gd +225 -0
  47. package/build/godot/operations/property_values.gd +192 -231
  48. package/build/godot/operations/reading.gd +15 -0
  49. package/build/godot/operations/same_values.gd +177 -0
  50. package/build/godot/operations/serialisation.gd +75 -3
  51. package/build/keeper.js +83 -69
  52. package/package.json +1 -1
@@ -7,6 +7,12 @@ const GdhPatterns = preload("patterns.gd")
7
7
  const GdhScriptCheck = preload("script_check.gd")
8
8
 
9
9
  const MODIFICATION_TYPES: Array[String] = ["add_constant", "add_variable", "add_signal", "add_function"]
10
+ ## The class each template's code is written for, and the base a script made from it extends
11
+ ## when given none: the resource template's code is a Resource's, and on the default of Node it
12
+ ## does not parse.
13
+ const TEMPLATE_BASES: Dictionary = {
14
+ "singleton": "Node", "state_machine": "Node", "component": "Node", "resource": "Resource"
15
+ }
10
16
  const BLANKS_AROUND_FUNCTIONS: int = 2
11
17
 
12
18
  var _log: GdhLog
@@ -20,11 +26,11 @@ func _init(p_log: GdhLog) -> void:
20
26
  func create_gdscript(params: Dictionary) -> Dictionary:
21
27
  var script_path: String = str(params.get("script_path", ""))
22
28
  var cls_name_param: String = str(params.get("class_name", ""))
23
- var extends_class: String = str(params.get("extends", "Node"))
24
- if extends_class.is_empty():
25
- extends_class = "Node"
26
29
  var content: String = str(params.get("content", ""))
27
30
  var template: String = str(params.get("template", ""))
31
+ var extends_class: String = str(params.get("extends", ""))
32
+ if extends_class.is_empty():
33
+ extends_class = str(TEMPLATE_BASES.get(template, "Node"))
28
34
 
29
35
  _log.info("Creating GDScript: " + script_path)
30
36
 
@@ -51,7 +57,7 @@ func create_gdscript(params: Dictionary) -> Dictionary:
51
57
  full_script_path = "res://" + full_script_path
52
58
 
53
59
  if not full_script_path.ends_with(".gd"):
54
- return _log.failure("Script path must end with .gd extension")
60
+ return _log.failure(_not_gdscript(full_script_path, "makes"))
55
61
 
56
62
  if FileAccess.file_exists(full_script_path):
57
63
  return _log.failure(
@@ -62,6 +68,8 @@ func create_gdscript(params: Dictionary) -> Dictionary:
62
68
  # and the first does not compile until the second exists.
63
69
  if content.is_empty():
64
70
  var header: String = _header_refusal(cls_name_param, extends_class)
71
+ if header.is_empty() and not template.is_empty():
72
+ header = _template_refusal(template, extends_class, full_script_path)
65
73
  if not header.is_empty():
66
74
  return _log.failure(header + " Nothing was written.")
67
75
 
@@ -80,8 +88,9 @@ func create_gdscript(params: Dictionary) -> Dictionary:
80
88
  script_content += "extends " + extends_class + "\n"
81
89
  # Only what was asked for: with no template the file was given an empty _ready beside a
82
90
  # template_used of "none", which a caller had then to delete or leave lying in the script.
91
+ # Each template opens with the class's doc comment, which belongs straight below the header.
83
92
  if not template.is_empty():
84
- script_content += "\n" + _script_template(template)
93
+ script_content += _script_template(template)
85
94
 
86
95
  var file: FileAccess = FileAccess.open(full_script_path, FileAccess.WRITE)
87
96
  if not file:
@@ -93,21 +102,56 @@ func create_gdscript(params: Dictionary) -> Dictionary:
93
102
  return _log.failure("Failed to write to script file: " + full_script_path)
94
103
 
95
104
  var written: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
105
+ var parses: bool = _parses(written)
96
106
  var answer: Dictionary = {
97
107
  "success": true,
98
108
  "script_path": script_path,
99
109
  "full_path": full_script_path,
100
110
  "absolute_path": ProjectSettings.globalize_path(full_script_path),
101
111
  "template_used": template if not template.is_empty() else "none",
102
- "parses": _parses(written),
112
+ "parses": parses,
103
113
  }
104
- # Read off the file as the engine parsed it rather than off the arguments, which content leaves out.
105
- if written != null:
114
+ # Read off the file as the engine parsed it rather than off the arguments, which content leaves
115
+ # out. A script that does not parse tells the engine nothing, and its empty base and name were
116
+ # answered for a file whose header says otherwise, so those are read off the text instead.
117
+ if parses:
106
118
  answer["extends"] = _base_named(written)
107
119
  answer["class_name"] = str(written.get_global_name())
120
+ else:
121
+ answer.merge(_header_written(script_content))
108
122
  return answer
109
123
 
110
124
 
125
+ ## The refusal for [param path], which is no GDScript file, naming what [param doing] it instead:
126
+ ## a shader is made with resource_edit, and once it exists its text is edited as a file's.
127
+ static func _not_gdscript(path: String, doing: String) -> String:
128
+ var refusal: String = "%s is not a .gd file, and script_edit %s GDScript only." % [path, doing]
129
+ if path.get_extension() != "gdshader":
130
+ return refusal
131
+ if doing == "makes":
132
+ return refusal + " resource_edit create_shader makes a shader, with its source as code."
133
+ return refusal + " A shader's text is edited as a file."
134
+
135
+
136
+ ## The class_name and extends [param text] declares at its margin, as written. A script with no
137
+ ## extends extends RefCounted.
138
+ static func _header_written(text: String) -> Dictionary:
139
+ var header: Dictionary = {"extends": "RefCounted", "class_name": ""}
140
+ for line: String in text.split("\n"):
141
+ # An inner class's extends is indented, and is not the script's.
142
+ if line.begins_with(" ") or line.begins_with("\t"):
143
+ continue
144
+ var bare: String = GdhPatterns.without_annotations(GdhPatterns.without_comment(line))
145
+ var found: RegExMatch = GdhPatterns.declared_class(bare)
146
+ if found != null:
147
+ header["class_name"] = found.get_string(1)
148
+ if not found.get_string(2).is_empty():
149
+ header["extends"] = found.get_string(2)
150
+ elif bare.begins_with("extends "):
151
+ header["extends"] = bare.substr(8).strip_edges()
152
+ return header
153
+
154
+
111
155
  # Modify an existing GDScript file by adding functions, variables, or signals
112
156
  func modify_gdscript(params: Dictionary) -> Dictionary:
113
157
  var script_path: String = str(params.get("script_path", ""))
@@ -119,6 +163,9 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
119
163
  if not full_script_path.begins_with("res://"):
120
164
  full_script_path = "res://" + full_script_path
121
165
 
166
+ if not full_script_path.ends_with(".gd"):
167
+ return _log.failure(_not_gdscript(full_script_path, "changes"))
168
+
122
169
  if not FileAccess.file_exists(full_script_path):
123
170
  return _log.failure(
124
171
  GdhFileWalk.new().with_nearest_files(
@@ -135,14 +182,22 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
135
182
 
136
183
  var lines: Array[String] = []
137
184
  lines.assign(original_content.split("\n"))
185
+ # Loaded only to learn whether it parsed as it was. What the engine says of that file carries its
186
+ # old line numbers, and beside the errors of the file written it named lines that had moved.
187
+ printerr(GdhLog.ASIDE)
138
188
  var before: Script = ResourceLoader.load(full_script_path, "Script", ResourceLoader.CACHE_MODE_IGNORE)
189
+ printerr(GdhLog.RESUMED)
139
190
  var parsed_before: bool = _parses(before)
140
191
 
141
192
  # All of them checked before any is made, so a call is made whole or not at all: one skipped for a
142
193
  # missing name used to leave the rest written and the answer saying success over an empty list.
143
- var problems: Array[String] = _modification_problems(modifications, _declared(lines))
194
+ var declared: Dictionary = _declared(lines)
195
+ var problems: Array[String] = _modification_problems(modifications, declared)
144
196
  if not problems.is_empty():
145
- return _log.failure("Nothing was changed: " + "; ".join(problems))
197
+ var refusal: String = "Nothing was changed: " + "; ".join(problems)
198
+ if _redeclares(modifications, declared):
199
+ refusal += ". modify only adds, so a member the script declares already is changed in the file itself"
200
+ return _log.failure(refusal)
146
201
  # Each placed line as a zero-based index that moves down whenever a later insertion lands above
147
202
  # it, so the numbers answered are where the lines are in the file written, not where each went
148
203
  # at the moment it was placed.
@@ -259,6 +314,44 @@ static func _header_refusal(named: String, base: String) -> String:
259
314
  return said + "; the nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
260
315
 
261
316
 
317
+ ## Why [param template] cannot be written on [param base], or "". Each template's code calls
318
+ ## methods of the class it is written for, so a base outside it makes a script that does not parse.
319
+ ## A base whose engine class cannot be read here is left to the engine's parse.
320
+ static func _template_refusal(template: String, base: String, script_path: String) -> String:
321
+ var wanted: String = str(TEMPLATE_BASES.get(template, ""))
322
+ var native: String = _engine_class_of(base, script_path)
323
+ if wanted.is_empty() or native.is_empty():
324
+ return ""
325
+ if native == wanted or ClassDB.is_parent_class(native, wanted):
326
+ return ""
327
+ var named: String = base if native == base else "%s, a %s," % [base, native]
328
+ return (
329
+ (
330
+ "The %s template is written for a %s, and extends %s is not one. Give extends a %s "
331
+ + "class, or leave it out for %s."
332
+ )
333
+ % [template, wanted, named, wanted, wanted]
334
+ )
335
+
336
+
337
+ ## The engine class [param base] comes down to: itself, the one beneath the script a class_name or
338
+ ## a quoted path names, or "" when that script cannot be read.
339
+ static func _engine_class_of(base: String, script_path: String) -> String:
340
+ if ClassDB.class_exists(base):
341
+ return base
342
+ var path: String = ""
343
+ if base.begins_with('"') or base.begins_with("'"):
344
+ path = base.substr(1, base.length() - 2)
345
+ if not path.begins_with("res://") and not path.begins_with("uid://"):
346
+ path = script_path.get_base_dir().path_join(path)
347
+ else:
348
+ path = str(_declared_classes().get(base, ""))
349
+ if path.is_empty() or not ResourceLoader.exists(path):
350
+ return ""
351
+ var script: Script = ResourceLoader.load(path, "Script") as Script
352
+ return "" if script == null else str(script.get_instance_base_type())
353
+
354
+
262
355
  # The class_name each script of the project declares, by name, with its path.
263
356
  static func _declared_classes() -> Dictionary:
264
357
  var declared: Dictionary = {}
@@ -325,6 +418,15 @@ static func _modification_problems(modifications: Array, declared: Dictionary) -
325
418
  return problems
326
419
 
327
420
 
421
+ static func _redeclares(modifications: Array, declared: Dictionary) -> bool:
422
+ for mod: Variant in modifications:
423
+ if mod is Dictionary:
424
+ var fields: Dictionary = mod
425
+ if declared.has(str(fields.get("name", ""))):
426
+ return true
427
+ return false
428
+
429
+
328
430
  # The members [param lines] declares at the top level, by name, as {"kind", "line"} counted from
329
431
  # one: variables, constants, signals, functions, enums and inner classes, which share one namespace.
330
432
  static func _declared(lines: Array[String]) -> Dictionary:
@@ -472,7 +574,18 @@ func _add_signal(lines: Array[String], mod: Dictionary, placed: Array[Dictionary
472
574
  signal_line += "(" + signal_params + ")"
473
575
 
474
576
  var insert_line: int = _signal_insertion_point(lines)
475
- return insert_line if _insert(lines, insert_line, [signal_line], placed) else -1
577
+ # Set apart by a blank line from the header above and from whatever follows that is not a
578
+ # signal, as the style guide and gdformat lay the signals out.
579
+ var block: Array[String] = [signal_line]
580
+ var above: String = lines[insert_line - 1].strip_edges() if insert_line > 0 else ""
581
+ var at: int = insert_line
582
+ if not above.is_empty() and not GdhPatterns.without_annotations(above).begins_with("signal "):
583
+ block.push_front("")
584
+ at += 1
585
+ var below: String = lines[insert_line].strip_edges() if insert_line < lines.size() else ""
586
+ if not below.is_empty() and not GdhPatterns.without_annotations(below).begins_with("signal "):
587
+ block.append("")
588
+ return at if _insert(lines, insert_line, block, placed) else -1
476
589
 
477
590
 
478
591
  # Answers with the zero-based index of the `func` line, below the blank lines that separate it.
@@ -545,6 +658,7 @@ func _variable_insertion_point(lines: Array[String]) -> int:
545
658
  before_func = i
546
659
  break
547
660
 
661
+ after_header = _past_class_doc(lines, after_header)
548
662
  for i: int in range(after_header, before_func):
549
663
  if GdhPatterns.without_annotations(lines[i]).begins_with("var "):
550
664
  after_header = i + 1
@@ -552,6 +666,20 @@ func _variable_insertion_point(lines: Array[String]) -> int:
552
666
  return after_header
553
667
 
554
668
 
669
+ ## [param at], or the line after the class's doc comment when [param at] follows the header and
670
+ ## one opens there. Godot reads the `##` lines straight below `extends` as the class's own
671
+ ## documentation, and a line put between them handed that documentation to the line put there.
672
+ static func _past_class_doc(lines: Array[String], at: int) -> int:
673
+ if at == 0 or at > lines.size():
674
+ return at
675
+ var above: String = GdhPatterns.without_annotations(lines[at - 1])
676
+ if not (above.begins_with("extends ") or above.begins_with("class_name ")):
677
+ return at
678
+ while at < lines.size() and lines[at].strip_edges().begins_with("##"):
679
+ at += 1
680
+ return at
681
+
682
+
555
683
  func _signal_insertion_point(lines: Array[String]) -> int:
556
684
  var after_header: int = 0
557
685
 
@@ -566,7 +694,7 @@ func _signal_insertion_point(lines: Array[String]) -> int:
566
694
  elif line.begins_with("var ") or line.begins_with("func "):
567
695
  break
568
696
 
569
- return after_header
697
+ return _past_class_doc(lines, after_header)
570
698
 
571
699
 
572
700
  func _function_insertion_point(lines: Array[String], position: String) -> int:
@@ -621,24 +749,15 @@ static func _indentation(line: String) -> String:
621
749
  func _script_template(template_name: String) -> String:
622
750
  match template_name:
623
751
  "singleton":
624
- return """## Singleton (Autoload) script
625
- ## Add to Project Settings -> Autoload to use as a global singleton
752
+ return """## One instance for the whole game, which the project makes at start once
753
+ ## project_settings add_autoload names it, and every script reaches by that name.
626
754
 
627
- var _instance: Object = null
628
-
629
- func _init() -> void:
630
- \tif _instance != null:
631
- \t\tpush_error("Singleton instance already exists!")
632
- \t\treturn
633
- \t_instance = self
634
755
 
635
756
  func _ready() -> void:
636
757
  \tpass
637
-
638
- # Add your singleton methods here
639
758
  """
640
759
  "state_machine":
641
- return """## Finite State Machine implementation
760
+ return """## A finite state machine: one state at a time, entered and left through change_state.
642
761
 
643
762
  signal state_changed(old_state: String, new_state: String)
644
763
 
@@ -647,22 +766,27 @@ var current_state: String = ""
647
766
  ## physics_process(delta); whichever it has are called.
648
767
  var states: Dictionary = {}
649
768
 
769
+
650
770
  func _ready() -> void:
651
771
  \t_setup_states()
652
772
  \tif states.size() > 0:
653
773
  \t\tchange_state(str(states.keys()[0]))
654
774
 
775
+
655
776
  func _setup_states() -> void:
656
777
  \t# Override this to add states
657
778
  \t# Example: states["idle"] = IdleState.new()
658
779
  \tpass
659
780
 
781
+
660
782
  func _process(delta: float) -> void:
661
783
  \t_call_state(current_state, "process", [delta])
662
784
 
785
+
663
786
  func _physics_process(delta: float) -> void:
664
787
  \t_call_state(current_state, "physics_process", [delta])
665
788
 
789
+
666
790
  func change_state(new_state: String) -> void:
667
791
  \tif not states.has(new_state):
668
792
  \t\tpush_error("State not found: " + new_state)
@@ -674,6 +798,7 @@ func change_state(new_state: String) -> void:
674
798
  \t_call_state(current_state, "enter", [])
675
799
  \tstate_changed.emit(old_state, new_state)
676
800
 
801
+
677
802
  ## Calls a method on the named state when it has one; a state without it is left alone.
678
803
  func _call_state(state_name: String, method: String, args: Array) -> void:
679
804
  \tif state_name.is_empty() or not states.has(state_name):
@@ -683,51 +808,47 @@ func _call_state(state_name: String, method: String, args: Array) -> void:
683
808
  \t\tstate.callv(method, args)
684
809
  """
685
810
  "component":
686
- return """## Component pattern - attach to nodes to add behavior
811
+ return """## A component: a child node that adds one behaviour to its parent, switched off with disable().
687
812
 
688
813
  @export var enabled: bool = true
689
814
 
815
+
690
816
  func _ready() -> void:
691
817
  \tif not enabled:
692
818
  \t\tset_process(false)
693
819
  \t\tset_physics_process(false)
694
820
 
821
+
695
822
  func _process(_delta: float) -> void:
696
823
  \tif not enabled:
697
824
  \t\treturn
698
825
  \t# Component logic here
699
826
  \tpass
700
827
 
828
+
701
829
  func enable() -> void:
702
830
  \tenabled = true
703
831
  \tset_process(true)
704
832
  \tset_physics_process(true)
705
833
 
834
+
706
835
  func disable() -> void:
707
836
  \tenabled = false
708
837
  \tset_process(false)
709
838
  \tset_physics_process(false)
710
839
  """
711
840
  "resource":
712
- return """## Custom Resource - save and load data
841
+ return """## Data kept in a .tres file, which resource_edit create makes and the game loads.
713
842
 
714
843
  @export var id: String = ""
715
844
  @export var display_name: String = ""
716
845
  @export var description: String = ""
717
846
 
847
+
718
848
  func _init(p_id: String = "", p_name: String = "", p_desc: String = "") -> void:
719
849
  \tid = p_id
720
850
  \tdisplay_name = p_name
721
851
  \tdescription = p_desc
722
-
723
- func duplicate_resource() -> Resource:
724
- \tvar new_resource: Resource = duplicate()
725
- \treturn new_resource
726
852
  """
727
853
  _:
728
- return """func _ready() -> void:
729
- \tpass
730
-
731
- func _process(_delta: float) -> void:
732
- \tpass
733
- """
854
+ return ""
@@ -224,18 +224,29 @@ func set_import_options(params: Dictionary) -> Dictionary:
224
224
  if not refused.is_empty():
225
225
  return _log.failure("Nothing was written: " + "; ".join(refused))
226
226
 
227
+ # One already holding the value given changes nothing, and listing it as updated said it had.
227
228
  var updated_keys: Array[String] = []
229
+ var unchanged_keys: Array[String] = []
228
230
  for name: String in fitted_values:
229
- config.set_value("params", name, fitted_values[name])
231
+ var held: Variant = config.get_value("params", name, null)
232
+ var value: Variant = fitted_values[name]
233
+ if held != null and typeof(held) == typeof(value) and held == value:
234
+ unchanged_keys.append(name)
235
+ continue
236
+ config.set_value("params", name, value)
230
237
  updated_keys.append(name)
231
- _log.debug("Set " + name + " = " + str(fitted_values[name]))
238
+ _log.debug("Set " + name + " = " + str(value))
232
239
 
233
- err = config.save(import_file_path)
234
- if err != OK:
235
- return _log.failure("Failed to save import file: " + str(err))
240
+ if not updated_keys.is_empty():
241
+ err = config.save(import_file_path)
242
+ if err != OK:
243
+ return _log.failure("Failed to save import file: " + str(err))
236
244
 
237
245
  # The reimport that applies them is the server's, through the editor or the engine's import.
238
- return {"resource_path": resource_path, "updated_options": updated_keys}
246
+ var answer: Dictionary = {"resource_path": resource_path, "updated_options": updated_keys}
247
+ if not unchanged_keys.is_empty():
248
+ answer["unchanged_options"] = unchanged_keys
249
+ return answer
239
250
 
240
251
 
241
252
  ## [param given] as the option holds it. Every JSON number arrives as a float, and an enum option
@@ -6,6 +6,13 @@ extends RefCounted
6
6
  # Every module preloads this as `GdhLog`. `Logger` is a native class from Godot 4.5 on, and a
7
7
  # constant of that name is a parse error rather than a shadowing warning.
8
8
 
9
+ ## Printed around engine output an operation provokes to answer a question of its own, such as
10
+ ## whether a script parsed before a change, which src/headless.ts leaves out of the answer: its
11
+ ## errors are about a file the answer does not describe. Kept in step with `ASIDE` and `RESUMED`
12
+ ## there.
13
+ const ASIDE: String = "[gdharness] aside"
14
+ const RESUMED: String = "[gdharness] resumed"
15
+
9
16
  var debug_mode: bool = false
10
17
 
11
18
 
@@ -0,0 +1,225 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## Where a property a tool writes lives: on the object given, on a resource built into it, or on an
5
+ ## element of one of its lists that the write has to make first.
6
+
7
+ const GdhRead = preload("reading.gd")
8
+
9
+ const HEADINGS: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
10
+
11
+
12
+ ## The entry [param target] declares for [param property], or {} when it declares none, headings
13
+ ## left out.
14
+ static func declared_on(target: Object, property: String) -> Dictionary:
15
+ for declared: Dictionary in target.get_property_list():
16
+ if str(declared.get("name", "")) != property:
17
+ continue
18
+ if GdhRead.as_int(declared.get("usage", 0)) & HEADINGS:
19
+ continue
20
+ return declared
21
+ return {}
22
+
23
+
24
+ ## Where [param key] is written: as [holder, name, through], the object holding the property, its
25
+ ## name there, and the resources passed on the way. A key that is none of [param target]'s own may
26
+ ## start with the name of one holding a resource built into it, then a slash and a key of that
27
+ ## resource: "sources/0/0:0/0/probability" is the probability of tile 0:0 in the atlas source a
28
+ ## TileSet keeps as sources/0. A colon path cannot reach it, since the tile's own name holds colons.
29
+ ## A resource kept in a file of its own is not reached, nor, given the file [param home] that
30
+ ## [param target] is saved in, one built into another file, since writing it here would leave that
31
+ ## file as it was.
32
+ static func holder_of(target: Object, key: String, home: String = "") -> Array:
33
+ var through: Array[Resource] = []
34
+ var holder: Object = target
35
+ var name: String = key
36
+ while declared_on(holder, name).is_empty():
37
+ var reached: Resource = null
38
+ var at: int = name.find("/")
39
+ while at > 0:
40
+ var held: Resource = built_in_at(holder, name.left(at), home)
41
+ if held != null:
42
+ reached = held
43
+ break
44
+ at = name.find("/", at + 1)
45
+ if reached == null:
46
+ break
47
+ through.append(reached)
48
+ holder = reached
49
+ name = name.substr(at + 1)
50
+ return [holder, name, through]
51
+
52
+
53
+ ## The resource [param target] holds under [param property] when it is built into a file, or null;
54
+ ## given [param home], only one built into that file or into none yet.
55
+ static func built_in_at(target: Object, property: String, home: String = "") -> Resource:
56
+ var held: Resource = held_at(target, property)
57
+ if held == null or not held.is_built_in():
58
+ return null
59
+ var file: String = held.resource_path.get_slice("::", 0)
60
+ return held if home.is_empty() or file.is_empty() or file == home else null
61
+
62
+
63
+ ## The file [param target] is saved in: its scene's for a node, as the nodes of an instance are
64
+ ## saved in the scene holding it, and its own for a resource; "" when it has none yet.
65
+ static func home_of(target: Object) -> String:
66
+ if target is Node:
67
+ var top: Node = target
68
+ while top.owner != null:
69
+ top = top.owner
70
+ return top.scene_file_path
71
+ if target is Resource:
72
+ var resource: Resource = target
73
+ return resource.resource_path.get_slice("::", 0)
74
+ return ""
75
+
76
+
77
+ ## The resource [param target] holds under [param property], built in or kept in a file, or null.
78
+ static func held_at(target: Object, property: String) -> Resource:
79
+ if declared_on(target, property).is_empty():
80
+ return null
81
+ var held: Variant = target.get(property)
82
+ return held if held is Resource else null
83
+
84
+
85
+ ## Makes the element of a list [param name] names on [param target] when it is the next one, as the
86
+ ## inspector's Add Element does, and answers "" (also when [param name] is in no list), or why not,
87
+ ## [param called] being what [param target] is called. The engine lists an element's properties
88
+ ## only once the element exists, so a TileSet with no physics layer had no
89
+ ## physics_layer_0/collision_layer to write. A list the engine declares as an array grows through
90
+ ## the method named after its elements, add_physics_layer for physics_layer_, given the indices of
91
+ ## the lists around it; a list counted by a property, as a tile's polygons are by polygons_count,
92
+ ## grows by raising the count.
93
+ static func grow(target: Object, name: String, called: String) -> String:
94
+ var steps: PackedStringArray = name.split("/")
95
+ for depth: int in steps.size() - 1:
96
+ var step: String = steps[depth]
97
+ var cut: int = step.rfind("_")
98
+ if cut < 1 or not step.substr(cut + 1).is_valid_int():
99
+ continue
100
+ var index: int = step.substr(cut + 1).to_int()
101
+ var above: String = "/".join(steps.slice(0, depth))
102
+ var under: String = above + "/" if not above.is_empty() else ""
103
+ var prefix: String = under + step.left(cut + 1)
104
+ var element: String = step.left(cut)
105
+ var counted: String = under + element + "s_count"
106
+ var count: int = -1
107
+ var adding: Callable
108
+ if not declared_on(target, counted).is_empty():
109
+ count = GdhRead.as_int(target.get(counted))
110
+ adding = func() -> void: target.set(counted, count + 1)
111
+ elif _listed_array(target, prefix):
112
+ count = _elements(target, prefix)
113
+ var method: String = "add_" + element
114
+ var outer: Array = []
115
+ for enclosing: String in steps.slice(0, depth):
116
+ var at: int = enclosing.rfind("_")
117
+ if at > 0 and enclosing.substr(at + 1).is_valid_int():
118
+ outer.append(enclosing.substr(at + 1).to_int())
119
+ if target.has_method(method):
120
+ adding = func() -> void: target.callv(method, outer)
121
+ if count < 0 or index < count:
122
+ continue
123
+ var held: String = (
124
+ "%s has %d %s%s"
125
+ % [
126
+ above if not above.is_empty() else called,
127
+ count,
128
+ element.replace("_", " "),
129
+ "" if count == 1 else "s"
130
+ ]
131
+ )
132
+ if index > count:
133
+ return (
134
+ "%s, and a new one goes after the last, so %s%d cannot be written yet: write %s%d first."
135
+ % [held, prefix, index, prefix, count]
136
+ )
137
+ if not adding.is_valid():
138
+ return "%s, and nothing makes another, so %s cannot be written." % [held, name]
139
+ adding.call()
140
+ return ""
141
+ return ""
142
+
143
+
144
+ ## Whether [param target] declares a list whose elements' properties start with [param prefix],
145
+ ## as TileSet declares physics_layers for physics_layer_0/collision_layer.
146
+ static func _listed_array(target: Object, prefix: String) -> bool:
147
+ for declared: Dictionary in target.get_property_list():
148
+ var usage: int = GdhRead.as_int(declared.get("usage", 0))
149
+ if usage & PROPERTY_USAGE_ARRAY and str(declared.get("class_name", "")) == prefix:
150
+ return true
151
+ return false
152
+
153
+
154
+ ## How many elements of the list whose properties start with [param prefix] [param target] holds.
155
+ static func _elements(target: Object, prefix: String) -> int:
156
+ var count: int = 0
157
+ for declared: Dictionary in target.get_property_list():
158
+ var name: String = str(declared.get("name", ""))
159
+ if not name.begins_with(prefix):
160
+ continue
161
+ var index: String = name.substr(prefix.length()).get_slice("/", 0)
162
+ if index.is_valid_int():
163
+ count = maxi(count, index.to_int() + 1)
164
+ return count
165
+
166
+
167
+ ## What [param target] is called in a refusal: the class_name its script declares, or else its
168
+ ## engine class. A resource made as a project class is a Resource to the engine, and "Resource.kind"
169
+ ## named a property no Resource has.
170
+ static func class_called(target: Object) -> String:
171
+ var attached: Variant = target.get_script()
172
+ if attached is Script:
173
+ var script: Script = attached
174
+ if not str(script.get_global_name()).is_empty():
175
+ return str(script.get_global_name())
176
+ return target.get_class()
177
+
178
+
179
+ ## The refusal for [param name], which [param target] does not declare, when it runs through a
180
+ ## resource saved in a file other than [param home], the one [param target] is saved in; or "". The
181
+ ## write belongs in that file, since this one would be saved and that one left as it was: through a
182
+ ## TileMapLayer's tile_set kept in a file of its own, or through the mesh of an instance's editable
183
+ ## child, which is built into the scene the instance comes from.
184
+ static func held_elsewhere(target: Object, name: String, home: String) -> String:
185
+ var at: int = name.find("/")
186
+ while at > 0:
187
+ var head: String = name.left(at)
188
+ var rest: String = name.substr(at + 1)
189
+ var held: Resource = held_at(target, head)
190
+ var file: String = "" if held == null else held.resource_path.get_slice("::", 0)
191
+ if held != null and not held.is_built_in():
192
+ return (
193
+ "%s.%s is %s, a file of its own, so %s is written there, with resource_edit modify on %s."
194
+ % [class_called(target), head, file, rest, file]
195
+ )
196
+ if held != null and not file.is_empty() and not home.is_empty() and file != home:
197
+ return _built_in_elsewhere(target, head, rest, held, file)
198
+ at = name.find("/", at + 1)
199
+ return ""
200
+
201
+
202
+ ## The refusal for writing [param rest] into [param held], which [param target] holds under
203
+ ## [param head] and which is built into [param file], the scene an instance in this one comes from.
204
+ static func _built_in_elsewhere(
205
+ target: Object, head: String, rest: String, held: Resource, file: String
206
+ ) -> String:
207
+ var node: String = ""
208
+ if target is Node:
209
+ var placed: Node = target
210
+ var from: Node = placed
211
+ while from != null and from.scene_file_path != file:
212
+ from = from.owner
213
+ if from != null:
214
+ node = " on %s" % from.get_path_to(placed)
215
+ var text: String = (
216
+ (
217
+ "%s.%s is built into %s, so %s is written there, with scene_node set%s in %s, which changes "
218
+ + "it wherever that scene is used. To change it here alone, give %s a %s of its own: a "
219
+ + 'value with "_type" set to its class and its properties beside it.'
220
+ )
221
+ % [class_called(target), head, file, rest, node, file, head, held.get_class()]
222
+ )
223
+ if target is GeometryInstance3D and (held is Material or rest.contains("material")):
224
+ text += " A material for this instance alone goes in material_override."
225
+ return text