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.
- package/build/cli.js +478 -100
- package/build/godot/addons/gdharness_editor/headless_check.gd +374 -0
- package/build/godot/addons/gdharness_editor/headless_check.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/property_values.gd +3 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +345 -10
- package/build/godot/addons/gdharness_editor/serialisation.gd +103 -5
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +68 -18
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +24 -13
- package/build/godot/addons/gdharness_runtime/call_errors.gd +7 -2
- package/build/godot/addons/gdharness_runtime/runtime_accessibility.gd +9 -5
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +33 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +21 -4
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +7 -5
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +5 -0
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +116 -3
- package/build/godot/addons/gdharness_runtime/serialisation.gd +103 -5
- package/build/godot/operations/classdb_queries.gd +22 -0
- package/build/godot/operations/dependencies.gd +90 -20
- package/build/godot/operations/editor_tools.gd +57 -0
- package/build/godot/operations/gdscript_analysis.gd +45 -2
- package/build/godot/operations/gdscript_authoring.gd +5 -2
- package/build/godot/operations/godot_operations.gd +53 -2
- package/build/godot/operations/property_values.gd +3 -0
- package/build/godot/operations/rename_checks.gd +0 -19
- package/build/godot/operations/script_check.gd +98 -0
- package/build/godot/operations/serialisation.gd +103 -5
- package/build/index.js +466 -90
- 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():
|