gdharness 1.2.35 → 1.2.37

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 (28) hide show
  1. package/build/cli.js +478 -100
  2. package/build/godot/addons/gdharness_editor/headless_check.gd +374 -0
  3. package/build/godot/addons/gdharness_editor/headless_check.gd.uid +1 -0
  4. package/build/godot/addons/gdharness_editor/property_values.gd +3 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +345 -10
  6. package/build/godot/addons/gdharness_editor/serialisation.gd +103 -5
  7. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -18
  8. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +24 -13
  9. package/build/godot/addons/gdharness_runtime/call_errors.gd +7 -2
  10. package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +9 -5
  11. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +33 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +21 -4
  13. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +7 -5
  14. package/build/godot/addons/gdharness_runtime/runtime_values.gd +5 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +116 -3
  16. package/build/godot/addons/gdharness_runtime/serialisation.gd +103 -5
  17. package/build/godot/operations/classdb_queries.gd +22 -0
  18. package/build/godot/operations/dependencies.gd +90 -20
  19. package/build/godot/operations/editor_tools.gd +57 -0
  20. package/build/godot/operations/gdscript_analysis.gd +45 -2
  21. package/build/godot/operations/gdscript_authoring.gd +5 -2
  22. package/build/godot/operations/godot_operations.gd +53 -2
  23. package/build/godot/operations/property_values.gd +3 -0
  24. package/build/godot/operations/rename_checks.gd +0 -19
  25. package/build/godot/operations/script_check.gd +98 -0
  26. package/build/godot/operations/serialisation.gd +103 -5
  27. package/build/index.js +466 -90
  28. package/package.json +1 -1
@@ -0,0 +1,374 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## What a write made by a headless engine has to put back so the file holds what an editor would have
5
+ ## written.
6
+ ##
7
+ ## The editor runs only tool scripts and gives every other script a placeholder that holds values and
8
+ ## runs nothing. A headless engine runs them all, so loading a file runs each script's
9
+ ## [code]_init[/code] and every setter of a stored property, and a tool setting one runs its setter.
10
+ ## Whatever those change is saved with the write unless it is put back. Godot's own setters run in
11
+ ## both, so a property a built-in setter derives (a Control's offsets from its size) is left alone
12
+ ## unless a script that is not a tool is involved.
13
+
14
+ const PropertyValues = preload("property_values.gd")
15
+ const Read = preload("reading.gd")
16
+
17
+ const REMEDY: String = (
18
+ "Open the project's editor (editor_launch with hidden: true) and ask again: the write then goes "
19
+ + "through it, and the editor runs no script that is not a tool."
20
+ )
21
+
22
+
23
+ ## Every stored property of [param object] with its value, copied so a later change to an array or
24
+ ## dictionary held there does not reach this record.
25
+ static func stored(object: Object) -> Dictionary:
26
+ var values: Dictionary = {}
27
+ for declared: Dictionary in object.get_property_list():
28
+ if not (Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
29
+ continue
30
+ var name: String = str(declared.get("name", ""))
31
+ if name.is_empty():
32
+ continue
33
+ var value: Variant = object.get(name)
34
+ if value is Array:
35
+ var items: Array = value
36
+ value = items.duplicate(true)
37
+ elif value is Dictionary:
38
+ var entries_held: Dictionary = value
39
+ value = entries_held.duplicate(true)
40
+ values[name] = value
41
+ return values
42
+
43
+
44
+ ## Whether [param script] is a script an editor would not run.
45
+ static func untooled(script: Variant) -> bool:
46
+ if not script is Script:
47
+ return false
48
+ var held: Script = script
49
+ return not held.is_tool()
50
+
51
+
52
+ ## Whether setting [param names] on [param object] can run a script an editor would not: one that is
53
+ ## not a tool, declaring one of the names, taking every set through [code]_set[/code], or being the
54
+ ## script set.
55
+ static func through_script(object: Object, names: Dictionary) -> bool:
56
+ var script: Script = object.get_script()
57
+ if script == null or script.is_tool():
58
+ return false
59
+ if names.has("script"):
60
+ return true
61
+ for method: Dictionary in script.get_script_method_list():
62
+ if str(method.get("name", "")) == "_set":
63
+ return true
64
+ for declared: Dictionary in script.get_script_property_list():
65
+ if names.has(str(declared.get("name", ""))):
66
+ return true
67
+ return false
68
+
69
+
70
+ ## The stored properties of [param object] that are not in [param names] and no longer hold what
71
+ ## [param before] recorded. One the object did not have before, which a script set on it declares,
72
+ ## counts once it holds anything but its default; one it no longer has went with the old script.
73
+ static func differing(object: Object, before: Dictionary, names: Dictionary) -> Array[String]:
74
+ var changed: Array[String] = []
75
+ var now: Dictionary = stored(object)
76
+ for key: Variant in now:
77
+ var name: String = str(key)
78
+ if names.has(name):
79
+ continue
80
+ var was: Variant = before[name] if before.has(name) else default_of(object, name)
81
+ if not PropertyValues.same(now[name], was):
82
+ changed.append(name)
83
+ return changed
84
+
85
+
86
+ ## The value [param object] holds for [param name] when nothing sets it: its script's default for a
87
+ ## property the script declares, its class's otherwise.
88
+ static func default_of(object: Object, name: String) -> Variant:
89
+ var script: Script = object.get_script()
90
+ if script != null:
91
+ for declared: Dictionary in script.get_script_property_list():
92
+ if str(declared.get("name", "")) == name:
93
+ return script.get_property_default_value(name)
94
+ return ClassDB.class_get_property_default_value(object.get_class(), name)
95
+
96
+
97
+ ## The ids, in the file, of the embedded resources [param property] reaches into on [param object],
98
+ ## such as the material of [code]material:albedo_color[/code], as a set.
99
+ static func reached(object: Object, property: String) -> Dictionary:
100
+ var ids: Dictionary = {}
101
+ var parts: PackedStringArray = property.split(":")
102
+ var current: Variant = object
103
+ for index: int in parts.size() - 1:
104
+ if not current is Object:
105
+ break
106
+ var holder: Object = current
107
+ current = holder.get(parts[index])
108
+ if current is Resource:
109
+ var resource: Resource = current
110
+ var id: String = resource.get_scene_unique_id()
111
+ if not id.is_empty():
112
+ ids[id] = true
113
+ return ids
114
+
115
+
116
+ ## Whether [param object] holds, at any depth, an embedded resource with a script that is not a tool.
117
+ static func holds_scripted_embedded(object: Object, seen: Dictionary) -> bool:
118
+ for value: Variant in stored(object).values():
119
+ var items: Array = value if value is Array else [value]
120
+ for item: Variant in items:
121
+ if not item is Resource or seen.has(item):
122
+ continue
123
+ var resource: Resource = item
124
+ if not resource.is_built_in():
125
+ continue
126
+ seen[resource] = true
127
+ var script: Script = resource.get_script()
128
+ if script != null and not script.is_tool():
129
+ return true
130
+ if holds_scripted_embedded(resource, seen):
131
+ return true
132
+ return false
133
+
134
+
135
+ ## Puts the file at [param file_path] back as [param previous], or removes it when there was none,
136
+ ## answering "" or a sentence saying that failed too.
137
+ static func put_back(file_path: String, previous: Variant) -> String:
138
+ if previous is PackedByteArray:
139
+ var bytes: PackedByteArray = previous
140
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.WRITE)
141
+ if file == null or not file.store_buffer(bytes):
142
+ return " Putting the file back as it was failed too."
143
+ file.close()
144
+ if ResourceLoader.has_cached(file_path):
145
+ var _replaced: Resource = ResourceLoader.load(file_path, "", ResourceLoader.CACHE_MODE_REPLACE)
146
+ elif DirAccess.remove_absolute(ProjectSettings.globalize_path(file_path)) != OK:
147
+ return " Removing the file it wrote failed too."
148
+ return ""
149
+
150
+
151
+ ## Each section of a text scene or resource, by "resource" for the main one and "sub:<id>" for an
152
+ ## embedded one, as {"head": its first line, "lines": the rest}.
153
+ static func sections(text: String) -> Dictionary:
154
+ var found: Dictionary = {}
155
+ var lines: PackedStringArray = []
156
+ var key: String = ""
157
+ var head: String = ""
158
+ for line: String in text.split("\n"):
159
+ if line.begins_with("["):
160
+ if not key.is_empty():
161
+ found[key] = {"head": head, "lines": lines}
162
+ lines = []
163
+ head = line
164
+ key = _section_key(line)
165
+ continue
166
+ var _kept: bool = lines.append(line)
167
+ if not key.is_empty():
168
+ found[key] = {"head": head, "lines": lines}
169
+ return found
170
+
171
+
172
+ static func _section_key(head: String) -> String:
173
+ if head.begins_with("[resource]"):
174
+ return "resource"
175
+ if head.begins_with("[sub_resource"):
176
+ var id: RegExMatch = RegEx.create_from_string('\\bid="([^"]+)"').search(head)
177
+ return "sub:" + (id.get_string(1) if id != null else head)
178
+ if head.begins_with("[ext_resource"):
179
+ return "ext:" + head
180
+ return ""
181
+
182
+
183
+ ## Each property a section sets, by name, to the text of its value. A value running over several lines
184
+ ## is kept whole.
185
+ static func entries_of(section: Dictionary) -> Dictionary:
186
+ var lines: PackedStringArray = section.get("lines", PackedStringArray())
187
+ return entries(lines)
188
+
189
+
190
+ static func entries(lines: PackedStringArray) -> Dictionary:
191
+ var found: Dictionary = {}
192
+ var key: String = ""
193
+ var at_start: RegEx = RegEx.create_from_string("^([^\\s=][^=]*?) = (.*)$")
194
+ for line: String in lines:
195
+ var started: RegExMatch = at_start.search(line)
196
+ if started != null:
197
+ key = started.get_string(1)
198
+ found[key] = started.get_string(2)
199
+ elif not key.is_empty() and not line.is_empty():
200
+ found[key] = str(found[key]) + "\n" + line
201
+ return found
202
+
203
+
204
+ ## Each property the main resource of [param after] sets differently from [param before], leaving
205
+ ## out [param asked], as [name, whether before sets it, the text before gives it].
206
+ static func main_changes(before: String, after: String, asked: Dictionary) -> Array[Array]:
207
+ var was: Dictionary = _main_entries(before)
208
+ var now: Dictionary = _main_entries(after)
209
+ var all: Dictionary = was.duplicate()
210
+ all.merge(now)
211
+ var changed: Array[Array] = []
212
+ for key: Variant in all:
213
+ var name: String = str(key)
214
+ if asked.has(name) or was.get(name) == now.get(name):
215
+ continue
216
+ changed.append([name, was.has(name), str(was.get(name, ""))])
217
+ return changed
218
+
219
+
220
+ static func _main_entries(text: String) -> Dictionary:
221
+ var main: Dictionary = sections(text).get("resource", {})
222
+ return entries_of(main)
223
+
224
+
225
+ ## The ids of the embedded resources in [param text] that carry a script that is not a tool, as a set.
226
+ static func scripted_sections(text: String) -> Dictionary:
227
+ var all: Dictionary = sections(text)
228
+ var scripts: Dictionary = {}
229
+ var ext: RegEx = RegEx.create_from_string('path="([^"]+)"[^\\]]*\\bid="([^"]+)"')
230
+ for key: Variant in all:
231
+ var name: String = str(key)
232
+ if not name.begins_with("ext:") or not 'type="Script"' in name:
233
+ continue
234
+ var found: RegExMatch = ext.search(name)
235
+ if found != null:
236
+ scripts[found.get_string(2)] = found.get_string(1)
237
+ var scripted: Dictionary = {}
238
+ var uses: RegEx = RegEx.create_from_string('^ExtResource\\("([^"]+)"\\)$')
239
+ for key: Variant in all:
240
+ var name: String = str(key)
241
+ var held: Dictionary = all[key]
242
+ var set_there: Dictionary = entries_of(held)
243
+ if not set_there.has("script"):
244
+ continue
245
+ var used: RegExMatch = uses.search(str(set_there["script"]))
246
+ if used == null or not scripts.has(used.get_string(1)):
247
+ continue
248
+ var script: Script = ResourceLoader.load(str(scripts[used.get_string(1)])) as Script
249
+ if script != null and not script.is_tool():
250
+ scripted[name] = true
251
+ return scripted
252
+
253
+
254
+ ## Each property an embedded resource of [param after] sets differently from [param before], as
255
+ ## [its id, name, whether before sets it, the text before gives it]. Only those with a script that
256
+ ## is not a tool are compared, since only a script runs differently here, and those in
257
+ ## [param reached_ids], which the tool changed, are left out.
258
+ static func embedded_changes(before: String, after: String, reached_ids: Dictionary) -> Array[Array]:
259
+ var changed: Array[Array] = []
260
+ if before.is_empty():
261
+ return changed
262
+ var old: Dictionary = sections(before)
263
+ var new: Dictionary = sections(after)
264
+ for key: Variant in scripted_sections(before):
265
+ var section: String = str(key)
266
+ var id: String = section.trim_prefix("sub:")
267
+ if not section.begins_with("sub:") or reached_ids.has(id) or not new.has(section):
268
+ continue
269
+ var was_section: Dictionary = old[section]
270
+ var now_section: Dictionary = new[section]
271
+ var was: Dictionary = entries_of(was_section)
272
+ var now: Dictionary = entries_of(now_section)
273
+ var all: Dictionary = was.duplicate()
274
+ all.merge(now)
275
+ for entry: Variant in all:
276
+ var name: String = str(entry)
277
+ if was.get(name) != now.get(name):
278
+ changed.append([id, name, was.has(name), str(was.get(name, ""))])
279
+ return changed
280
+
281
+
282
+ ## Compares the file just saved at [param path] with [param before], what it held, and puts back
283
+ ## what a script that is not a tool changed in it: in its embedded resources, which [param values]
284
+ ## reach, and in [param main], its main resource, unless that is null. [param asked] and
285
+ ## [param reached_ids] are what the call changed. Saved again through [param resave], which answers
286
+ ## an Error, and answers "" or why the file still does not hold what it should.
287
+ static func settle_saved(
288
+ path: String,
289
+ before: String,
290
+ values: Array,
291
+ main: Object,
292
+ asked: Dictionary,
293
+ reached_ids: Dictionary,
294
+ resave: Callable
295
+ ) -> String:
296
+ for attempt: int in 2:
297
+ var after: String = FileAccess.get_file_as_string(path)
298
+ var embedded: Array[Array] = embedded_changes(before, after, reached_ids)
299
+ var top: Array[Array] = []
300
+ if main != null:
301
+ top = main_changes(before, after, asked)
302
+ if embedded.is_empty() and top.is_empty():
303
+ return ""
304
+ if attempt == 1:
305
+ if not embedded.is_empty():
306
+ return unasked("The embedded resource " + str(embedded[0][0]), str(embedded[0][1]))
307
+ return unasked(path, str(top[0][0]))
308
+ var by_id: Dictionary = embedded_in(values, {})
309
+ for found: Array in embedded:
310
+ var id: String = found[0]
311
+ var name: String = found[1]
312
+ var was_set: bool = found[2]
313
+ var held: String = found[3]
314
+ var resource: Resource = by_id.get(id)
315
+ var refused: String = "it was not found to be put back."
316
+ if resource != null:
317
+ refused = put_back_value(resource, name, was_set, held)
318
+ if not refused.is_empty():
319
+ return unasked("The embedded resource " + id, name, refused)
320
+ for found: Array in top:
321
+ var name: String = found[0]
322
+ var was_set: bool = found[1]
323
+ var held: String = found[2]
324
+ var refused: String = put_back_value(main, name, was_set, held)
325
+ if not refused.is_empty():
326
+ return unasked(path, name, refused)
327
+ var saving: Error = resave.call()
328
+ if saving != OK:
329
+ return "Failed to save %s again: %s." % [path, error_string(saving)]
330
+ return ""
331
+
332
+
333
+ ## Every embedded resource [param values] reach, at any depth, by its id in the file.
334
+ static func embedded_in(values: Array, found: Dictionary) -> Dictionary:
335
+ for value: Variant in values:
336
+ var items: Array = value if value is Array else [value]
337
+ for item: Variant in items:
338
+ if not item is Resource:
339
+ continue
340
+ var resource: Resource = item
341
+ var id: String = resource.get_scene_unique_id()
342
+ if not resource.is_built_in() or id.is_empty() or found.has(id):
343
+ continue
344
+ found[id] = resource
345
+ var _deeper: Dictionary = embedded_in(stored(resource).values(), found)
346
+ return found
347
+
348
+
349
+ ## Sets [param name] on [param object] back to [param held], the text a file gave it, or to its
350
+ ## default when the file set nothing ([param was_set] false), answering "" or why it cannot be.
351
+ ## A value naming another resource is not read back from text here.
352
+ static func put_back_value(object: Object, name: String, was_set: bool, held: String) -> String:
353
+ if not was_set:
354
+ object.set(name, default_of(object, name))
355
+ return ""
356
+ if "Resource(" in held:
357
+ return "its value there names another resource, which cannot be restored from the text here."
358
+ object.set(name, str_to_var(held))
359
+ return ""
360
+
361
+
362
+ ## Why [param name] on [param what] cannot be saved as the file held it.
363
+ static func unasked(what: String, name: String, why: String = "") -> String:
364
+ return (
365
+ (
366
+ (
367
+ "%s.%s would be saved differently from what the file holds, and the call did not ask for "
368
+ + "it: its script ran when a headless engine loaded or set it, as it does in the game and not "
369
+ + "in an editor%s. "
370
+ )
371
+ % [what, name, "" if why.is_empty() else "; " + why.trim_suffix(".")]
372
+ )
373
+ + REMEDY
374
+ )
@@ -0,0 +1 @@
1
+ uid://bj5rkwnfa6cep
@@ -272,6 +272,9 @@ func value_for(target: Object, declared: Dictionary, raw: Variant) -> Array:
272
272
  var elements: Array = elements_of(declared)
273
273
  if _fraction(raw, expected_type, elements):
274
274
  return [false, _fraction_refusal(target, property, raw)]
275
+ var rounded: String = Serialisation.inexact(raw, expected_type)
276
+ if not rounded.is_empty():
277
+ return [false, "%s.%s holds int: %s." % [target.get_class(), property, rounded]]
275
278
  _refused = ""
276
279
  value = parse(raw, expected_type, elements)
277
280
  if not _refused.is_empty():