gdharness 1.1.21 → 1.1.22
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 +1036 -525
- package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
- package/build/godot/addons/gdharness_editor/reading.gd +17 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
- package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
- package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
- package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
- package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
- package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
- package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
- package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
- package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
- package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
- package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
- package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +8 -4
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
- package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
- package/build/godot/addons/gdharness_runtime/runtime_paths.gd +28 -8
- package/build/godot/addons/gdharness_runtime/runtime_queries.gd +7 -299
- package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
- package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
- package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
- package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
- package/build/godot/addons/gdharness_runtime/runtime_waits.gd +20 -9
- package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
- package/build/godot/operations/audio_buses.gd +47 -11
- package/build/godot/operations/class_cache.gd +18 -2
- package/build/godot/operations/dependencies.gd +128 -17
- package/build/godot/operations/file_walk.gd +20 -0
- package/build/godot/operations/gdscript_analysis.gd +59 -20
- package/build/godot/operations/gdscript_authoring.gd +127 -60
- package/build/godot/operations/godot_operations.gd +24 -5
- package/build/godot/operations/import_pipeline.gd +179 -19
- package/build/godot/operations/logger.gd +2 -2
- package/build/godot/operations/plugins.gd +88 -70
- package/build/godot/operations/project_config.gd +41 -6
- package/build/godot/operations/project_diagnostics.gd +25 -10
- package/build/godot/operations/reading.gd +17 -0
- package/build/godot/operations/resource_files.gd +60 -17
- package/build/godot/operations/serialisation.gd +116 -7
- package/build/index.js +949 -435
- package/package.json +1 -1
|
@@ -53,6 +53,9 @@ const LIST_TYPES: Array[int] = [
|
|
|
53
53
|
# type it wanted.
|
|
54
54
|
const SERIALISERS: Dictionary = {
|
|
55
55
|
TYPE_NIL: "_serialize_nil",
|
|
56
|
+
TYPE_FLOAT: "_serialize_float",
|
|
57
|
+
TYPE_PACKED_FLOAT32_ARRAY: "_serialize_floats",
|
|
58
|
+
TYPE_PACKED_FLOAT64_ARRAY: "_serialize_floats",
|
|
56
59
|
TYPE_VECTOR2: "_serialize_vector2",
|
|
57
60
|
TYPE_VECTOR2I: "_serialize_vector2i",
|
|
58
61
|
TYPE_VECTOR3: "_serialize_vector3",
|
|
@@ -105,6 +108,8 @@ const BUILDERS: Dictionary = {
|
|
|
105
108
|
"Color": "_build_color",
|
|
106
109
|
"NodePath": "_build_node_path",
|
|
107
110
|
"PackedByteArray": "_build_bytes",
|
|
111
|
+
"float": "_build_float",
|
|
112
|
+
"Dictionary": "_build_dictionary",
|
|
108
113
|
}
|
|
109
114
|
|
|
110
115
|
# What each tag has to carry before it is built. A tag naming a type whose keys are not there is a
|
|
@@ -130,6 +135,8 @@ const REQUIRED: Dictionary = {
|
|
|
130
135
|
"Color": [["r", "g", "b"]],
|
|
131
136
|
"NodePath": [["path"]],
|
|
132
137
|
"PackedByteArray": [["base64"]],
|
|
138
|
+
"float": [["value"]],
|
|
139
|
+
"Dictionary": [["entries"]],
|
|
133
140
|
}
|
|
134
141
|
|
|
135
142
|
|
|
@@ -145,7 +152,58 @@ func serialize_value(value: Variant) -> Variant:
|
|
|
145
152
|
if typeof(value) == TYPE_OBJECT and not is_instance_valid(value):
|
|
146
153
|
return null
|
|
147
154
|
var serialiser: String = SERIALISERS.get(typeof(value), "")
|
|
148
|
-
|
|
155
|
+
if serialiser.is_empty():
|
|
156
|
+
return value
|
|
157
|
+
var carried: Variant = call(serialiser, value)
|
|
158
|
+
# The fixed shapes carry their components as bare floats, so an infinite one in a Vector2 is
|
|
159
|
+
# tagged here; containers reach their members through this function already.
|
|
160
|
+
return carried if typeof(value) in [TYPE_ARRAY, TYPE_DICTIONARY] else _finite(carried)
|
|
161
|
+
|
|
162
|
+
|
|
163
|
+
# [param carried] with every non-finite float in it tagged. JSON has no spelling for infinity or
|
|
164
|
+
# NaN: the engine writes 1e99999 and null, and the server, reading 1e99999 as Infinity, writes it
|
|
165
|
+
# back out as null, so a property holding INF was answered as holding nothing.
|
|
166
|
+
func _finite(carried: Variant) -> Variant:
|
|
167
|
+
if carried is float:
|
|
168
|
+
var number: float = carried
|
|
169
|
+
return _serialize_float(number)
|
|
170
|
+
if carried is Dictionary:
|
|
171
|
+
var fields: Dictionary = carried
|
|
172
|
+
var tagged: Dictionary = {}
|
|
173
|
+
for key: Variant in fields:
|
|
174
|
+
tagged[key] = _finite(fields[key])
|
|
175
|
+
return tagged
|
|
176
|
+
if carried is Array:
|
|
177
|
+
var items: Array = carried
|
|
178
|
+
var tagged_items: Array = []
|
|
179
|
+
for item: Variant in items:
|
|
180
|
+
tagged_items.append(_finite(item))
|
|
181
|
+
return tagged_items
|
|
182
|
+
return carried
|
|
183
|
+
|
|
184
|
+
|
|
185
|
+
func _serialize_float(value: float) -> Variant:
|
|
186
|
+
if is_finite(value):
|
|
187
|
+
return value
|
|
188
|
+
return {"_type": "float", "value": "nan" if is_nan(value) else ("inf" if value > 0 else "-inf")}
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# A packed float array stays the JSON list it is unless something in it has no JSON spelling.
|
|
192
|
+
func _serialize_floats(value: Variant) -> Variant:
|
|
193
|
+
var numbers: Array = []
|
|
194
|
+
if value is PackedFloat32Array:
|
|
195
|
+
var single: PackedFloat32Array = value
|
|
196
|
+
numbers = Array(single)
|
|
197
|
+
else:
|
|
198
|
+
var double: PackedFloat64Array = value
|
|
199
|
+
numbers = Array(double)
|
|
200
|
+
for number: float in numbers:
|
|
201
|
+
if not is_finite(number):
|
|
202
|
+
var items: Array = []
|
|
203
|
+
for each: float in numbers:
|
|
204
|
+
items.append(_serialize_float(each))
|
|
205
|
+
return items
|
|
206
|
+
return value
|
|
149
207
|
|
|
150
208
|
|
|
151
209
|
# Rebuilds a Godot value from the shape serialize_value gave it.
|
|
@@ -181,9 +239,27 @@ func deserialize_value(value: Variant) -> Variant:
|
|
|
181
239
|
var needed: Array = REQUIRED.get(tag, [])
|
|
182
240
|
if builder.is_empty() or not _carries(fields, needed):
|
|
183
241
|
return fields
|
|
242
|
+
if tag == "float" and str(fields["value"]) not in ["inf", "-inf", "nan"]:
|
|
243
|
+
return fields
|
|
244
|
+
if tag == "Dictionary" and not pairs(fields["entries"]):
|
|
245
|
+
return fields
|
|
184
246
|
return call(builder, fields)
|
|
185
247
|
|
|
186
248
|
|
|
249
|
+
## Whether [param entries] is a list of [key, value] pairs, as a Dictionary tag carries them.
|
|
250
|
+
static func pairs(entries: Variant) -> bool:
|
|
251
|
+
if not entries is Array:
|
|
252
|
+
return false
|
|
253
|
+
var listed: Array = entries
|
|
254
|
+
for entry: Variant in listed:
|
|
255
|
+
if not entry is Array:
|
|
256
|
+
return false
|
|
257
|
+
var pair: Array = entry
|
|
258
|
+
if pair.size() != 2:
|
|
259
|
+
return false
|
|
260
|
+
return true
|
|
261
|
+
|
|
262
|
+
|
|
187
263
|
## Whether two values can be compared without the comparison itself failing.
|
|
188
264
|
##
|
|
189
265
|
## GDScript's `==` is not total. An Object against a String is a hard error rather than false, and
|
|
@@ -193,16 +269,19 @@ func deserialize_value(value: Variant) -> Variant:
|
|
|
193
269
|
## nothing anywhere naming the argument that did it. A wrong argument costs a refusal, never the
|
|
194
270
|
## session's game.
|
|
195
271
|
##
|
|
196
|
-
##
|
|
197
|
-
##
|
|
198
|
-
##
|
|
272
|
+
## Measured on 4.7.2 over every pair of built-in types: two values of different types compare only
|
|
273
|
+
## as an int against a float, a String against a StringName, or anything against null. Every other
|
|
274
|
+
## pair is that error, a word against a number as much as an object against a word, and so is a bool
|
|
275
|
+
## against an int and a NodePath against a String. An int against a float is kept because it is the
|
|
276
|
+
## same number to a caller waiting for 1 on a property holding 1.0.
|
|
199
277
|
static func comparable(one: Variant, other: Variant) -> bool:
|
|
200
278
|
var left: int = typeof(one)
|
|
201
279
|
var right: int = typeof(other)
|
|
202
|
-
if left
|
|
280
|
+
if left == right or left == TYPE_NIL or right == TYPE_NIL:
|
|
203
281
|
return true
|
|
204
|
-
|
|
205
|
-
|
|
282
|
+
var numbers: Array[int] = [TYPE_INT, TYPE_FLOAT]
|
|
283
|
+
var texts: Array[int] = [TYPE_STRING, TYPE_STRING_NAME]
|
|
284
|
+
return (left in numbers and right in numbers) or (left in texts and right in texts)
|
|
206
285
|
|
|
207
286
|
|
|
208
287
|
## Whether [param value] can be given to something declared as [param type] without damage.
|
|
@@ -400,7 +479,16 @@ func _serialize_signal(value: Signal) -> Dictionary:
|
|
|
400
479
|
return described
|
|
401
480
|
|
|
402
481
|
|
|
482
|
+
## A dictionary keyed by text as itself, and any other as its entries. JSON keys are text, so an
|
|
483
|
+
## int or Vector2 key was answered as its text, and a dictionary read and written back had its keys
|
|
484
|
+
## replaced by words: `{1: "a"}` came back as `{"1": "a"}`.
|
|
403
485
|
func _serialize_dictionary(value: Dictionary) -> Dictionary:
|
|
486
|
+
var texts: bool = value.keys().all(func(key: Variant) -> bool: return key is String or key is StringName)
|
|
487
|
+
if not texts:
|
|
488
|
+
var entries: Array = []
|
|
489
|
+
for key: Variant in value:
|
|
490
|
+
entries.append([serialize_value(key), serialize_value(value[key])])
|
|
491
|
+
return {"_type": "Dictionary", "entries": entries}
|
|
404
492
|
var serialised: Dictionary = {}
|
|
405
493
|
for key: Variant in value:
|
|
406
494
|
serialised[str(key)] = serialize_value(value[key])
|
|
@@ -621,6 +709,27 @@ func _build_bytes(fields: Dictionary) -> PackedByteArray:
|
|
|
621
709
|
return Marshalls.base64_to_raw(str(fields.get("base64", "")))
|
|
622
710
|
|
|
623
711
|
|
|
712
|
+
func _build_float(fields: Dictionary) -> float:
|
|
713
|
+
return Read.as_float(fields)
|
|
714
|
+
|
|
715
|
+
|
|
716
|
+
## The entries of a dictionary whose keys are not all text. JSON carries every number as a float,
|
|
717
|
+
## and a dictionary keys 1.0 apart from 1, so a whole-number key is built as an int: the key a
|
|
718
|
+
## game uses far more often, and one JSON cannot tell from the float.
|
|
719
|
+
func _build_dictionary(fields: Dictionary) -> Dictionary:
|
|
720
|
+
var built: Dictionary = {}
|
|
721
|
+
var entries: Array = fields["entries"]
|
|
722
|
+
for entry: Variant in entries:
|
|
723
|
+
var pair: Array = entry
|
|
724
|
+
var key: Variant = deserialize_value(pair[0])
|
|
725
|
+
if key is float:
|
|
726
|
+
var number: float = key
|
|
727
|
+
if is_finite(number) and number == floorf(number):
|
|
728
|
+
key = int(number)
|
|
729
|
+
built[key] = deserialize_value(pair[1])
|
|
730
|
+
return built
|
|
731
|
+
|
|
732
|
+
|
|
624
733
|
## One component of a compound value, as the type its constructor wants.
|
|
625
734
|
##
|
|
626
735
|
## Their own functions because a constructor refuses a Variant under `unsafe_call_argument`, and
|
|
@@ -20,17 +20,28 @@ func create_audio_bus(params: Dictionary) -> Dictionary:
|
|
|
20
20
|
var parent_idx: int = Read.as_int(params.get("parent_bus_index", 0))
|
|
21
21
|
if parent_idx < 0 or parent_idx >= AudioServer.bus_count:
|
|
22
22
|
return _log.failure("No bus at index " + str(parent_idx))
|
|
23
|
-
|
|
24
|
-
AudioServer.
|
|
25
|
-
|
|
23
|
+
# Sends are by name, so a second bus with one would leave every send to it meaning either.
|
|
24
|
+
var existing: int = AudioServer.get_bus_index(bus_name)
|
|
25
|
+
if existing != -1:
|
|
26
|
+
return _log.failure("A bus named " + bus_name + " already exists, at index " + str(existing))
|
|
27
|
+
|
|
28
|
+
# Right after the bus it sends to, because a bus can only send to one before it. The engine
|
|
29
|
+
# inserts there and moves every later bus down one, so the new bus is at that index: taking the
|
|
30
|
+
# last index named and rewired whichever bus had been last, and answered with it.
|
|
31
|
+
var new_idx: int = parent_idx + 1
|
|
32
|
+
AudioServer.add_bus(new_idx)
|
|
26
33
|
AudioServer.set_bus_name(new_idx, bus_name)
|
|
27
|
-
|
|
28
|
-
AudioServer.set_bus_send(new_idx, AudioServer.get_bus_name(parent_idx))
|
|
34
|
+
AudioServer.set_bus_send(new_idx, AudioServer.get_bus_name(parent_idx))
|
|
29
35
|
|
|
30
36
|
var layout: String = _save_layout()
|
|
31
37
|
if layout.is_empty():
|
|
32
38
|
return {}
|
|
33
|
-
|
|
39
|
+
# Every bus, because the ones after the new one have moved and a caller holding their old
|
|
40
|
+
# indices would address the wrong bus next.
|
|
41
|
+
var buses: Array[Dictionary] = []
|
|
42
|
+
for i: int in range(AudioServer.bus_count):
|
|
43
|
+
buses.append(_bus(i))
|
|
44
|
+
return {"bus": _bus(new_idx), "buses": buses, "layout": layout}
|
|
34
45
|
|
|
35
46
|
|
|
36
47
|
func get_audio_buses(_params: Dictionary) -> Dictionary:
|
|
@@ -52,17 +63,42 @@ func set_audio_bus_effect(params: Dictionary) -> Dictionary:
|
|
|
52
63
|
if effect == null:
|
|
53
64
|
return _log.failure("Unknown effect type: " + effect_type)
|
|
54
65
|
|
|
55
|
-
# The slot
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
66
|
+
# The slot holds this effect afterwards: the one there is replaced, and one past the last is
|
|
67
|
+
# added. The engine's add inserts, so the effect in the slot was pushed along rather than
|
|
68
|
+
# replaced, and a slot past the end was reached by padding the bus with Amplify effects nobody
|
|
69
|
+
# asked for.
|
|
70
|
+
var count: int = AudioServer.get_bus_effect_count(bus_idx)
|
|
71
|
+
if effect_idx < 0 or effect_idx > count:
|
|
72
|
+
return _log.failure(
|
|
73
|
+
(
|
|
74
|
+
"Bus "
|
|
75
|
+
+ str(bus_idx)
|
|
76
|
+
+ " has "
|
|
77
|
+
+ str(count)
|
|
78
|
+
+ (" effect" if count == 1 else " effects")
|
|
79
|
+
+ ", so effect_index can be 0 to "
|
|
80
|
+
+ str(count)
|
|
81
|
+
+ ", where "
|
|
82
|
+
+ str(count)
|
|
83
|
+
+ " adds one after the last"
|
|
84
|
+
)
|
|
85
|
+
)
|
|
86
|
+
var replaced: String = ""
|
|
87
|
+
if effect_idx < count:
|
|
88
|
+
replaced = AudioServer.get_bus_effect(bus_idx, effect_idx).get_class()
|
|
89
|
+
AudioServer.remove_bus_effect(bus_idx, effect_idx)
|
|
59
90
|
AudioServer.add_bus_effect(bus_idx, effect, effect_idx)
|
|
60
91
|
AudioServer.set_bus_effect_enabled(bus_idx, effect_idx, enabled)
|
|
61
92
|
|
|
62
93
|
var layout: String = _save_layout()
|
|
63
94
|
if layout.is_empty():
|
|
64
95
|
return {}
|
|
65
|
-
|
|
96
|
+
var answer: Dictionary = {
|
|
97
|
+
"bus": _bus(bus_idx), "effect_index": effect_idx, "effect_type": effect.get_class(), "layout": layout
|
|
98
|
+
}
|
|
99
|
+
if not replaced.is_empty():
|
|
100
|
+
answer["replaced"] = replaced
|
|
101
|
+
return answer
|
|
66
102
|
|
|
67
103
|
|
|
68
104
|
func set_audio_bus_volume(params: Dictionary) -> Dictionary:
|
|
@@ -21,7 +21,8 @@ func _init(p_log: Log) -> void:
|
|
|
21
21
|
|
|
22
22
|
|
|
23
23
|
func refresh_class_cache(_params: Dictionary) -> Dictionary:
|
|
24
|
-
var
|
|
24
|
+
var cached: Array = _read_cache()
|
|
25
|
+
var before: Dictionary = _entries_by_class(cached)
|
|
25
26
|
|
|
26
27
|
var entries: Array = []
|
|
27
28
|
var skipped: Array[Dictionary] = []
|
|
@@ -29,6 +30,18 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
|
|
|
29
30
|
var entry: Dictionary = _entry_for(path, skipped)
|
|
30
31
|
if not entry.is_empty():
|
|
31
32
|
entries.append(entry)
|
|
33
|
+
# Another language's classes are kept while their files are there. Only GDScript is read here and
|
|
34
|
+
# the list is written whole, so a C# project's global classes were dropped from it and answered as
|
|
35
|
+
# removed, and nothing started next could resolve them.
|
|
36
|
+
var carried: Array[String] = []
|
|
37
|
+
for entry: Variant in cached:
|
|
38
|
+
if not entry is Dictionary:
|
|
39
|
+
continue
|
|
40
|
+
var listed: Dictionary = entry
|
|
41
|
+
var language: String = str(listed.get("language", "GDScript"))
|
|
42
|
+
if language != "GDScript" and FileAccess.file_exists(str(listed.get("path", ""))):
|
|
43
|
+
entries.append(listed)
|
|
44
|
+
carried.append(str(listed.get("class", "")))
|
|
32
45
|
# By name as text: StringName's own order is by identity, not by spelling.
|
|
33
46
|
entries.sort_custom(func(a: Dictionary, b: Dictionary) -> bool: return str(a["class"]) < str(b["class"]))
|
|
34
47
|
|
|
@@ -56,7 +69,7 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
|
|
|
56
69
|
if not after.has(name):
|
|
57
70
|
removed.append(name)
|
|
58
71
|
|
|
59
|
-
|
|
72
|
+
var answer: Dictionary = {
|
|
60
73
|
"path": CACHE_PATH,
|
|
61
74
|
"classes": entries.size(),
|
|
62
75
|
"added": added,
|
|
@@ -64,6 +77,9 @@ func refresh_class_cache(_params: Dictionary) -> Dictionary:
|
|
|
64
77
|
"changed": changed,
|
|
65
78
|
"skipped": skipped,
|
|
66
79
|
}
|
|
80
|
+
if not carried.is_empty():
|
|
81
|
+
answer["carried"] = carried
|
|
82
|
+
return answer
|
|
67
83
|
|
|
68
84
|
|
|
69
85
|
# The cache entry for one script, or empty for a script with no class_name.
|
|
@@ -4,6 +4,14 @@ const Patterns = preload("patterns.gd")
|
|
|
4
4
|
const Read = preload("reading.gd")
|
|
5
5
|
const FileWalk = preload("file_walk.gd")
|
|
6
6
|
const Log = preload("logger.gd")
|
|
7
|
+
const ResourceFiles = preload("resource_files.gd")
|
|
8
|
+
|
|
9
|
+
## What a reverse search reads unless told otherwise: the resource and code files, and the settings
|
|
10
|
+
## files that name resources, "godot" for project.godot, "cfg" for plugin.cfg and export_presets.cfg,
|
|
11
|
+
## and "import" for the sidecars that name import scripts and external materials.
|
|
12
|
+
const DEFAULT_USAGE_KINDS: Array[String] = ["tscn", "tres", "gd", "gdshader", "godot", "cfg", "import"]
|
|
13
|
+
## One string literal of either quote, its contents captured by the one it opened with.
|
|
14
|
+
const STRING_LITERAL: String = "\"((?:[^\"\\\\]|\\\\.)*)\"|'((?:[^'\\\\]|\\\\.)*)'"
|
|
7
15
|
|
|
8
16
|
# What a dependency reference looks like in source, and how the path is read out of each form.
|
|
9
17
|
const REFERENCE_PATTERNS: Array[String] = [
|
|
@@ -106,7 +114,7 @@ func get_dependencies(params: Dictionary) -> Dictionary:
|
|
|
106
114
|
# preload it, and for a script with a class_name, every use of that name.
|
|
107
115
|
func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
108
116
|
var resource_path: String = str(params.get("resource_path", ""))
|
|
109
|
-
var file_types: Array = params.get("file_types",
|
|
117
|
+
var file_types: Array = params.get("file_types", DEFAULT_USAGE_KINDS)
|
|
110
118
|
|
|
111
119
|
if not resource_path.begins_with("res://"):
|
|
112
120
|
resource_path = "res://" + resource_path
|
|
@@ -116,14 +124,14 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
116
124
|
_log.info("Finding usages of: " + resource_path)
|
|
117
125
|
|
|
118
126
|
var class_name_declared: String = _declared_class_name(resource_path)
|
|
119
|
-
var by_path: RegEx = Patterns.compiled('"(res://)?' + _regex_escaped(resource_path.substr(6)) + '"')
|
|
120
127
|
var by_class: RegEx = null
|
|
121
128
|
if not class_name_declared.is_empty():
|
|
122
129
|
by_class = Patterns.compiled("\\b" + _regex_escaped(class_name_declared) + "\\b")
|
|
130
|
+
var target: Dictionary = {
|
|
131
|
+
"path": resource_path, "uid": str(ResourceFiles.uid_of(resource_path).get("uid", ""))
|
|
132
|
+
}
|
|
123
133
|
|
|
124
|
-
var all_files: Array[String] =
|
|
125
|
-
for ext: Variant in file_types:
|
|
126
|
-
all_files.append_array(_files.find_files("res://", "." + str(ext)))
|
|
134
|
+
var all_files: Array[String] = _usage_files(file_types)
|
|
127
135
|
|
|
128
136
|
var usages: Array[Dictionary] = []
|
|
129
137
|
var by_kind: Dictionary = {}
|
|
@@ -131,20 +139,29 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
131
139
|
var in_code: int = 0
|
|
132
140
|
|
|
133
141
|
for file_path: String in all_files:
|
|
134
|
-
|
|
142
|
+
# Its own sidecar names it as the file it was imported from, which is not a use of it.
|
|
143
|
+
if file_path == resource_path or file_path == resource_path + ".import":
|
|
135
144
|
continue
|
|
136
145
|
var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
|
|
137
146
|
if not file:
|
|
138
147
|
continue
|
|
139
148
|
var lines: PackedStringArray = file.get_as_text().split("\n")
|
|
140
149
|
file.close()
|
|
150
|
+
# A class is used by name only in code. In a scene or a resource the same word is a node's
|
|
151
|
+
# name or a caption, and each was counted as a use of the class.
|
|
152
|
+
var code: bool = file_path.get_extension() in ["gd", "gdshader"]
|
|
141
153
|
|
|
142
154
|
var references: Array[Dictionary] = []
|
|
143
155
|
for i: int in range(lines.size()):
|
|
144
156
|
var line: String = lines[i]
|
|
145
|
-
var by_its_path: bool =
|
|
146
|
-
var by_its_name: bool =
|
|
147
|
-
|
|
157
|
+
var by_its_path: bool = _names_target(line, file_path, target)
|
|
158
|
+
var by_its_name: bool = false
|
|
159
|
+
var name_in_comment: bool = false
|
|
160
|
+
if not by_its_path and code and by_class != null:
|
|
161
|
+
# Strings are text, so a class named in one is not a use of it.
|
|
162
|
+
by_its_name = by_class.search(_code_of(line)) != null
|
|
163
|
+
name_in_comment = not by_its_name and by_class.search(_comment_of(line)) != null
|
|
164
|
+
if not by_its_path and not by_its_name and not name_in_comment:
|
|
148
165
|
continue
|
|
149
166
|
var trimmed: String = line.strip_edges()
|
|
150
167
|
var kind: String = ""
|
|
@@ -154,12 +171,12 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
|
|
|
154
171
|
# three of them prose, all counted as code. The more carefully a project is documented
|
|
155
172
|
# the worse that ratio gets. `##` and `#` are kept apart because renaming the class
|
|
156
173
|
# breaks Godot's generated documentation and leaves the prose merely stale.
|
|
157
|
-
if trimmed.begins_with("##"):
|
|
174
|
+
if code and trimmed.begins_with("##"):
|
|
158
175
|
kind = "doc"
|
|
159
|
-
elif trimmed.begins_with("#"):
|
|
176
|
+
elif (code and trimmed.begins_with("#")) or name_in_comment:
|
|
160
177
|
kind = "comment"
|
|
161
178
|
elif by_its_path:
|
|
162
|
-
kind = _path_reference_kind(line)
|
|
179
|
+
kind = _path_reference_kind(line, file_path)
|
|
163
180
|
else:
|
|
164
181
|
kind = (
|
|
165
182
|
"extends" if Patterns.without_annotations(line).begins_with("extends ") else "class_name"
|
|
@@ -210,8 +227,81 @@ func _declared_class_name(path: String) -> String:
|
|
|
210
227
|
return ""
|
|
211
228
|
|
|
212
229
|
|
|
230
|
+
# The files a reverse search reads, for the kinds asked for. The settings files are kinds of their
|
|
231
|
+
# own because what they name is used as surely as a preload is: an autoload script, the main scene
|
|
232
|
+
# and a plugin's script are named only in project.godot and plugin.cfg, and a search that left those
|
|
233
|
+
# out answered that each was used by nothing, which is the answer a caller deletes a file on.
|
|
234
|
+
func _usage_files(kinds: Array) -> Array[String]:
|
|
235
|
+
var found: Array[String] = []
|
|
236
|
+
for kind: Variant in kinds:
|
|
237
|
+
match str(kind):
|
|
238
|
+
"godot":
|
|
239
|
+
found.append("res://project.godot")
|
|
240
|
+
"cfg":
|
|
241
|
+
found.append_array(_files.find_files("res://", "plugin.cfg"))
|
|
242
|
+
if FileAccess.file_exists("res://export_presets.cfg"):
|
|
243
|
+
found.append("res://export_presets.cfg")
|
|
244
|
+
_:
|
|
245
|
+
found.append_array(_files.find_files("res://", "." + str(kind)))
|
|
246
|
+
return found
|
|
247
|
+
|
|
248
|
+
|
|
249
|
+
# Whether [param line], in [param file_path], names the target by path or by UID. Every string on the
|
|
250
|
+
# line is read as a path the way the engine reads it: a res:// path as it is, a uid:// by what it
|
|
251
|
+
# names, and anything else relative to the file it is in, which is how `preload("sibling.gd")` and a
|
|
252
|
+
# plugin.cfg's `script="plugin.gd"` name their files. Only the quoted form of the project path was
|
|
253
|
+
# looked for, so neither of those was found, and nor was a scene named by its UID.
|
|
254
|
+
func _names_target(line: String, file_path: String, target: Dictionary) -> bool:
|
|
255
|
+
var path: String = target["path"]
|
|
256
|
+
var uid: String = target["uid"]
|
|
257
|
+
for quoted: String in _string_literals(line):
|
|
258
|
+
# An autoload is written "*res://path" when its name is global.
|
|
259
|
+
var literal: String = quoted.trim_prefix("*")
|
|
260
|
+
if not uid.is_empty() and literal == uid:
|
|
261
|
+
return true
|
|
262
|
+
if literal.get_extension() != path.get_extension():
|
|
263
|
+
continue
|
|
264
|
+
var resolved: String = literal
|
|
265
|
+
if not literal.begins_with("res://"):
|
|
266
|
+
resolved = file_path.get_base_dir().path_join(literal).simplify_path()
|
|
267
|
+
if resolved == path:
|
|
268
|
+
return true
|
|
269
|
+
return false
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
# The contents of each string literal on [param line], of either quote.
|
|
273
|
+
static func _string_literals(line: String) -> Array[String]:
|
|
274
|
+
var literals: Array[String] = []
|
|
275
|
+
for m: RegExMatch in Patterns.compiled(STRING_LITERAL).search_all(line):
|
|
276
|
+
literals.append(m.get_string(1) if not m.get_string(1).is_empty() else m.get_string(2))
|
|
277
|
+
return literals
|
|
278
|
+
|
|
279
|
+
|
|
280
|
+
# [param line] with its strings emptied and its comment taken off: the part a class can be used in.
|
|
281
|
+
static func _code_of(line: String) -> String:
|
|
282
|
+
var bare: String = Patterns.compiled(STRING_LITERAL).sub(line, '""', true)
|
|
283
|
+
var mark: int = bare.find("#")
|
|
284
|
+
return bare if mark == -1 else bare.substr(0, mark)
|
|
285
|
+
|
|
286
|
+
|
|
287
|
+
# The comment on [param line], strings emptied first so a # inside one does not start it.
|
|
288
|
+
static func _comment_of(line: String) -> String:
|
|
289
|
+
var bare: String = Patterns.compiled(STRING_LITERAL).sub(line, '""', true)
|
|
290
|
+
var mark: int = bare.find("#")
|
|
291
|
+
return "" if mark == -1 else bare.substr(mark)
|
|
292
|
+
|
|
293
|
+
|
|
213
294
|
# How a line that names the resource by path uses it.
|
|
214
|
-
func _path_reference_kind(line: String) -> String:
|
|
295
|
+
func _path_reference_kind(line: String, file_path: String) -> String:
|
|
296
|
+
match file_path.get_file():
|
|
297
|
+
"project.godot":
|
|
298
|
+
return "setting"
|
|
299
|
+
"plugin.cfg":
|
|
300
|
+
return "plugin"
|
|
301
|
+
"export_presets.cfg":
|
|
302
|
+
return "export_preset"
|
|
303
|
+
if file_path.ends_with(".import"):
|
|
304
|
+
return "import"
|
|
215
305
|
var trimmed: String = Patterns.without_annotations(line)
|
|
216
306
|
if trimmed.begins_with("extends "):
|
|
217
307
|
return "extends"
|
|
@@ -255,9 +345,15 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
|
|
|
255
345
|
walk.circular_references.append(cycle)
|
|
256
346
|
return [{"path": path, "circular": true}]
|
|
257
347
|
|
|
348
|
+
# Reused only when it was walked with as much depth left as now. A resource first reached deep in
|
|
349
|
+
# the walk has a list cut short at the limit, and handing that to a nearer reach answered with a
|
|
350
|
+
# chain that stopped where nothing stopped it.
|
|
351
|
+
var remaining: int = walk.max_depth - current_depth
|
|
258
352
|
if walk.visited.has(path):
|
|
259
|
-
var cached:
|
|
260
|
-
|
|
353
|
+
var cached: Dictionary = walk.visited[path]
|
|
354
|
+
if Read.as_int(cached["remaining"]) == remaining:
|
|
355
|
+
var cached_deps: Array[Dictionary] = cached["deps"]
|
|
356
|
+
return cached_deps
|
|
261
357
|
|
|
262
358
|
walk.path_stack.append(path)
|
|
263
359
|
|
|
@@ -269,7 +365,7 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
|
|
|
269
365
|
for pattern: String in REFERENCE_PATTERNS:
|
|
270
366
|
var regex: RegEx = Patterns.compiled(pattern)
|
|
271
367
|
for m: RegExMatch in regex.search_all(content):
|
|
272
|
-
var dep_path: String = _referenced_path(m.get_string())
|
|
368
|
+
var dep_path: String = _resolved(_referenced_path(m.get_string()), path)
|
|
273
369
|
|
|
274
370
|
if not dep_path.begins_with("res://"):
|
|
275
371
|
continue
|
|
@@ -299,10 +395,25 @@ func _analyze_resource(path: String, current_depth: int, walk: DependencyWalk) -
|
|
|
299
395
|
deps.append(dep_info)
|
|
300
396
|
|
|
301
397
|
walk.path_stack.pop_back()
|
|
302
|
-
walk.visited[path] = deps
|
|
398
|
+
walk.visited[path] = {"deps": deps, "remaining": remaining}
|
|
303
399
|
return deps
|
|
304
400
|
|
|
305
401
|
|
|
402
|
+
# [param written] as the res:// path the engine would load from [param from]: a uid:// by what the
|
|
403
|
+
# project's uid cache says it names, and a relative path against the referring file's directory.
|
|
404
|
+
# Both were passed over as not being res:// paths, so a script's `preload("sibling.gd")` was no
|
|
405
|
+
# dependency at all.
|
|
406
|
+
static func _resolved(written: String, from: String) -> String:
|
|
407
|
+
if written.begins_with("res://"):
|
|
408
|
+
return written
|
|
409
|
+
if written.begins_with("uid://"):
|
|
410
|
+
var id: int = ResourceUID.text_to_id(written)
|
|
411
|
+
return ResourceUID.get_id_path(id) if ResourceUID.has_id(id) else written
|
|
412
|
+
if written.contains("://"):
|
|
413
|
+
return written
|
|
414
|
+
return from.get_base_dir().path_join(written).simplify_path()
|
|
415
|
+
|
|
416
|
+
|
|
306
417
|
# The path inside a preload(), load() or ext_resource match; a bare res:// match is the path.
|
|
307
418
|
func _referenced_path(matched: String) -> String:
|
|
308
419
|
var expression: String = ""
|
|
@@ -10,6 +10,26 @@ extends RefCounted
|
|
|
10
10
|
# every caller files the engine will never answer for.
|
|
11
11
|
|
|
12
12
|
|
|
13
|
+
# Why [param path], a res:// or uid:// path a setting names, is not a file the engine can load, or
|
|
14
|
+
# empty when it is. A uid:// path resolves through the project's uid cache, which the import writes,
|
|
15
|
+
# so one that does not resolve is a file that has gone or a project not imported yet, and saying
|
|
16
|
+
# only that the file does not exist sent a caller looking for a scene that was there.
|
|
17
|
+
static func missing_reason(path: String) -> String:
|
|
18
|
+
if path.begins_with("uid://"):
|
|
19
|
+
var id: int = ResourceUID.text_to_id(path)
|
|
20
|
+
if id == ResourceUID.INVALID_ID or not ResourceUID.has_id(id):
|
|
21
|
+
return (
|
|
22
|
+
path
|
|
23
|
+
+ " does not resolve: no file in the project's uid cache has it, so the file has gone or the"
|
|
24
|
+
+ " project has not been imported yet (project_import refresh_uids imports it)"
|
|
25
|
+
)
|
|
26
|
+
var resolved: String = ResourceUID.get_id_path(id)
|
|
27
|
+
return (
|
|
28
|
+
"" if FileAccess.file_exists(resolved) else path + " names " + resolved + ", which does not exist"
|
|
29
|
+
)
|
|
30
|
+
return "" if FileAccess.file_exists(path) else path + " does not exist"
|
|
31
|
+
|
|
32
|
+
|
|
13
33
|
# A directory the engine steps over, marker file and all.
|
|
14
34
|
func is_stepped_over(path: String) -> bool:
|
|
15
35
|
return FileAccess.file_exists(path + ".gdignore")
|