gdharness 1.1.21 → 1.1.23

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 (51) hide show
  1. package/build/cli.js +1470 -726
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +12 -2
  3. package/build/godot/addons/gdharness_editor/property_values.gd +484 -65
  4. package/build/godot/addons/gdharness_editor/reading.gd +17 -0
  5. package/build/godot/addons/gdharness_editor/scene_file.gd +229 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd.uid +1 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +116 -7
  8. package/build/godot/addons/gdharness_editor/tool_executor.gd +10 -3
  9. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +374 -242
  10. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +47 -9
  11. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +206 -0
  12. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +90 -15
  14. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +373 -140
  15. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +566 -472
  16. package/build/godot/addons/gdharness_runtime/call_errors.gd +42 -0
  17. package/build/godot/addons/gdharness_runtime/call_errors.gd.uid +1 -0
  18. package/build/godot/addons/gdharness_runtime/reading.gd +17 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +15 -5
  20. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +51 -5
  21. package/build/godot/addons/gdharness_runtime/runtime_changes.gd +440 -0
  22. package/build/godot/addons/gdharness_runtime/runtime_changes.gd.uid +1 -0
  23. package/build/godot/addons/gdharness_runtime/runtime_input.gd +184 -338
  24. package/build/godot/addons/gdharness_runtime/runtime_paths.gd +89 -16
  25. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +107 -343
  26. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +13 -1
  27. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +90 -24
  28. package/build/godot/addons/gdharness_runtime/runtime_typing.gd +351 -0
  29. package/build/godot/addons/gdharness_runtime/runtime_typing.gd.uid +1 -0
  30. package/build/godot/addons/gdharness_runtime/runtime_values.gd +92 -33
  31. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +61 -28
  32. package/build/godot/addons/gdharness_runtime/serialisation.gd +116 -7
  33. package/build/godot/operations/audio_buses.gd +47 -11
  34. package/build/godot/operations/class_cache.gd +18 -2
  35. package/build/godot/operations/classdb_queries.gd +28 -17
  36. package/build/godot/operations/dependencies.gd +128 -17
  37. package/build/godot/operations/file_walk.gd +20 -0
  38. package/build/godot/operations/gdscript_analysis.gd +59 -20
  39. package/build/godot/operations/gdscript_authoring.gd +127 -60
  40. package/build/godot/operations/godot_operations.gd +24 -5
  41. package/build/godot/operations/import_pipeline.gd +179 -19
  42. package/build/godot/operations/logger.gd +2 -2
  43. package/build/godot/operations/plugins.gd +88 -70
  44. package/build/godot/operations/project_config.gd +41 -6
  45. package/build/godot/operations/project_diagnostics.gd +27 -11
  46. package/build/godot/operations/reading.gd +17 -0
  47. package/build/godot/operations/resource_files.gd +60 -17
  48. package/build/godot/operations/serialisation.gd +116 -7
  49. package/build/index.js +1385 -553
  50. package/build/keeper.js +4 -0
  51. package/package.json +1 -1
@@ -24,48 +24,63 @@ func list_plugins(_params: Dictionary) -> Dictionary:
24
24
  "message": "No addons directory found in the project"
25
25
  }
26
26
 
27
- var enabled_plugins: Array[String] = _enabled_plugin_names()
28
- _log.debug("Enabled plugins: " + str(enabled_plugins))
27
+ var enabled_paths: Array[String] = _enabled_paths()
28
+ _log.debug("Enabled plugins: " + str(enabled_paths))
29
29
 
30
30
  var plugins: Array[Dictionary] = []
31
31
  var enabled_count: int = 0
32
-
33
- var dir: DirAccess = DirAccess.open(addons_path)
34
- if dir and dir.list_dir_begin() == OK:
35
- var folder_name: String = dir.get_next()
36
-
37
- while folder_name != "":
38
- if dir.current_is_dir() and not folder_name.begins_with("."):
39
- var plugin_cfg_path: String = addons_path + folder_name + "/plugin.cfg"
40
-
41
- if FileAccess.file_exists(plugin_cfg_path):
42
- var enabled: bool = folder_name in enabled_plugins
43
- var plugin_info: Dictionary = {
44
- "name": folder_name, "path": addons_path + folder_name, "enabled": enabled
45
- }
46
-
47
- var plugin_config: ConfigFile = ConfigFile.new()
48
- if plugin_config.load(plugin_cfg_path) == OK:
49
- plugin_info["display_name"] = plugin_config.get_value("plugin", "name", folder_name)
50
- plugin_info["description"] = plugin_config.get_value("plugin", "description", "")
51
- plugin_info["author"] = plugin_config.get_value("plugin", "author", "")
52
- plugin_info["version"] = plugin_config.get_value("plugin", "version", "")
53
- plugin_info["script"] = plugin_config.get_value("plugin", "script", "")
54
-
55
- plugins.append(plugin_info)
56
- if enabled:
57
- enabled_count += 1
58
-
59
- folder_name = dir.get_next()
60
-
61
- dir.list_dir_end()
62
-
63
- return {
32
+ for folder: String in _plugin_folders(addons_path):
33
+ var plugin_cfg_path: String = folder.path_join("plugin.cfg")
34
+ var name: String = folder.trim_prefix(addons_path)
35
+ var enabled: bool = plugin_cfg_path in enabled_paths
36
+ var plugin_info: Dictionary = {"name": name, "path": folder, "enabled": enabled}
37
+
38
+ var plugin_config: ConfigFile = ConfigFile.new()
39
+ if plugin_config.load(plugin_cfg_path) == OK:
40
+ plugin_info["display_name"] = plugin_config.get_value("plugin", "name", name)
41
+ plugin_info["description"] = plugin_config.get_value("plugin", "description", "")
42
+ plugin_info["author"] = plugin_config.get_value("plugin", "author", "")
43
+ plugin_info["version"] = plugin_config.get_value("plugin", "version", "")
44
+ plugin_info["script"] = plugin_config.get_value("plugin", "script", "")
45
+
46
+ plugins.append(plugin_info)
47
+ if enabled:
48
+ enabled_count += 1
49
+
50
+ var answer: Dictionary = {
64
51
  "plugins": plugins,
65
52
  "addons_directory_exists": true,
66
53
  "enabled_count": enabled_count,
67
54
  "disabled_count": plugins.size() - enabled_count
68
55
  }
56
+ # Enabled in the project with no plugin.cfg where the entry points: the editor reports each one
57
+ # as an error at every start and loads nothing for it.
58
+ var missing: Array[String] = []
59
+ for path: String in enabled_paths:
60
+ if not FileAccess.file_exists(path):
61
+ missing.append(path)
62
+ if not missing.is_empty():
63
+ answer["enabled_but_missing"] = missing
64
+ return answer
65
+
66
+
67
+ # Every folder under [param root] holding a plugin.cfg, at any depth, the way the editor's plugin
68
+ # list finds them: a folder with one is a plugin and is not looked inside, and one without is
69
+ # searched. Only the top level was read, so a plugin one folder down was never listed.
70
+ func _plugin_folders(root: String) -> Array[String]:
71
+ var found: Array[String] = []
72
+ var dir: DirAccess = DirAccess.open(root)
73
+ if dir == null:
74
+ return found
75
+ for folder_name: String in dir.get_directories():
76
+ if folder_name.begins_with("."):
77
+ continue
78
+ var folder: String = root.path_join(folder_name)
79
+ if FileAccess.file_exists(folder.path_join("plugin.cfg")):
80
+ found.append(folder)
81
+ else:
82
+ found.append_array(_plugin_folders(folder))
83
+ return found
69
84
 
70
85
 
71
86
  # Enable a plugin
@@ -79,19 +94,19 @@ func enable_plugin(params: Dictionary) -> Dictionary:
79
94
  _log.error("Plugin not found: " + plugin_name)
80
95
  return _log.failure("Expected plugin.cfg at: " + plugin_cfg_path)
81
96
 
82
- var enabled_plugins: Array[String] = _enabled_plugin_names()
97
+ var enabled_paths: Array[String] = _enabled_paths()
83
98
 
84
- if plugin_name in enabled_plugins:
99
+ if plugin_cfg_path in enabled_paths:
85
100
  return {
86
101
  "plugin_name": plugin_name, "action": "already_enabled", "message": "Plugin is already enabled"
87
102
  }
88
103
 
89
- enabled_plugins.append(plugin_name)
90
- var err: Error = _save_enabled_plugins(enabled_plugins)
104
+ enabled_paths.append(plugin_cfg_path)
105
+ var err: Error = _save_enabled_paths(enabled_paths)
91
106
  if err != OK:
92
107
  return _log.failure("Failed to save project.godot: " + error_string(err))
93
108
 
94
- return {"plugin_name": plugin_name, "action": "enabled", "enabled_plugins": enabled_plugins}
109
+ return {"plugin_name": plugin_name, "action": "enabled", "enabled_plugins": _names_of(enabled_paths)}
95
110
 
96
111
 
97
112
  # Disable a plugin
@@ -100,42 +115,50 @@ func disable_plugin(params: Dictionary) -> Dictionary:
100
115
 
101
116
  _log.info("Disabling plugin: " + plugin_name)
102
117
 
103
- var enabled_plugins: Array[String] = _enabled_plugin_names()
118
+ var enabled_paths: Array[String] = _enabled_paths()
119
+ var plugin_cfg_path: String = "res://addons/" + plugin_name + "/plugin.cfg"
104
120
 
105
- if not plugin_name in enabled_plugins:
121
+ if not plugin_cfg_path in enabled_paths:
106
122
  return {
107
123
  "plugin_name": plugin_name,
108
124
  "action": "already_disabled",
109
125
  "message": "Plugin is not currently enabled"
110
126
  }
111
127
 
112
- enabled_plugins.erase(plugin_name)
113
- var err: Error = _save_enabled_plugins(enabled_plugins)
128
+ enabled_paths.erase(plugin_cfg_path)
129
+ var err: Error = _save_enabled_paths(enabled_paths)
114
130
  if err != OK:
115
131
  return _log.failure("Failed to save project.godot: " + error_string(err))
116
132
 
117
- return {"plugin_name": plugin_name, "action": "disabled", "enabled_plugins": enabled_plugins}
133
+ return {"plugin_name": plugin_name, "action": "disabled", "enabled_plugins": _names_of(enabled_paths)}
118
134
 
119
135
 
120
- # The addon directory names in [editor_plugins]. The editor stores them as the engine
121
- # expression PackedStringArray("res://addons/<name>/plugin.cfg", ...), which loads as that
122
- # type; the String form is what an older hand-edited file can carry.
123
- func _enabled_plugin_names() -> Array[String]:
124
- var names: Array[String] = []
125
- var regex: RegEx = Patterns.compiled("res://addons/([^/]+)/plugin.cfg")
126
-
136
+ # Every entry in [editor_plugins], as the plugin.cfg path the editor stores. Paths rather than
137
+ # folder names, because the list is written back whole: reading names off a pattern for
138
+ # `res://addons/<name>/plugin.cfg` dropped a plugin one folder down, and the next enable or disable
139
+ # wrote the list without it, disabling a plugin nobody named. The editor stores the engine
140
+ # expression PackedStringArray(...), which loads as that type; the String form is what an older
141
+ # hand-edited file can carry.
142
+ func _enabled_paths() -> Array[String]:
143
+ var paths: Array[String] = []
127
144
  var enabled_value: Variant = ProjectSettings.get_setting("editor_plugins/enabled", PackedStringArray())
128
145
  if enabled_value is PackedStringArray:
129
- var paths: PackedStringArray = enabled_value
130
- for path: String in paths:
131
- var m: RegExMatch = regex.search(path)
132
- if m:
133
- names.append(m.get_string(1))
146
+ var packed: PackedStringArray = enabled_value
147
+ paths.assign(packed)
134
148
  elif enabled_value is String:
135
149
  var listed: String = enabled_value
136
- for m: RegExMatch in regex.search_all(listed):
137
- names.append(m.get_string(1))
150
+ for m: RegExMatch in Patterns.compiled("res://[^\"',)]+?plugin\\.cfg").search_all(listed):
151
+ paths.append(m.get_string())
152
+ return paths
138
153
 
154
+
155
+ # The names callers use for [param paths]: the folder under addons/, or the path itself for an
156
+ # entry somewhere else.
157
+ static func _names_of(paths: Array[String]) -> Array[String]:
158
+ var names: Array[String] = []
159
+ for path: String in paths:
160
+ var inside: bool = path.begins_with("res://addons/") and path.ends_with("/plugin.cfg")
161
+ names.append(path.trim_prefix("res://addons/").trim_suffix("/plugin.cfg") if inside else path)
139
162
  return names
140
163
 
141
164
 
@@ -145,19 +168,14 @@ func _enabled_plugin_names() -> Array[String]:
145
168
  # the unquoted expression the editor reads, whereas the same text handed over as a String is
146
169
  # written quoted and loads as a String the editor does not recognise as a plugin list. An
147
170
  # empty list is the setting removed, which is what the editor writes for no plugins.
148
- func _save_enabled_plugins(names: Array[String]) -> Error:
149
- if names.is_empty():
171
+ func _save_enabled_paths(paths: Array[String]) -> Error:
172
+ if paths.is_empty():
150
173
  ProjectSettings.set_setting("editor_plugins/enabled", null)
151
174
  return ProjectSettings.save()
152
175
 
153
- # Gathered in an Array[String] and converted at the end, because appending to a packed array
154
- # answers with whether it worked and a project holding return_value_discarded at error level
155
- # will not compile a script that drops that answer. The conversion is what matters: an
156
- # Array[String] handed to set_setting is written as Array[String]([...]), which the editor does
157
- # not read as a plugin list at all.
158
- var enabled_paths: Array[String] = []
159
- for name: String in names:
160
- enabled_paths.append("res://addons/" + name + "/plugin.cfg")
161
-
162
- ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(enabled_paths))
176
+ # Kept in an Array[String] and converted here, because appending to a packed array answers with
177
+ # whether it worked and a project holding return_value_discarded at error level will not compile
178
+ # a script that drops that answer. The conversion is what matters: an Array[String] handed to
179
+ # set_setting is written as Array[String]([...]), which the editor does not read as a plugin list.
180
+ ProjectSettings.set_setting("editor_plugins/enabled", PackedStringArray(paths))
163
181
  return ProjectSettings.save()
@@ -34,6 +34,19 @@ func get_project_setting(params: Dictionary) -> Dictionary:
34
34
  result["value"] = null
35
35
  result["message"] = "Setting does not exist"
36
36
 
37
+ # The value under this name is not the one every build reads: `name.windows` replaces it on
38
+ # Windows, `name.editor` in the editor. Read alone, a caller saw the plain value and not the one
39
+ # their game showed.
40
+ var overrides: Dictionary = {}
41
+ for info: Dictionary in ProjectSettings.get_property_list():
42
+ var name: String = str(info.get("name", ""))
43
+ if name.begins_with(setting_path + "."):
44
+ overrides[name.substr(setting_path.length() + 1)] = _values.serialize_value(
45
+ ProjectSettings.get_setting(name)
46
+ )
47
+ if not overrides.is_empty():
48
+ result["overrides"] = overrides
49
+
37
50
  return result
38
51
 
39
52
 
@@ -139,7 +152,19 @@ func _converts_faithfully(value: Variant, to: int) -> bool:
139
152
  func add_autoload(params: Dictionary) -> Dictionary:
140
153
  var name: String = str(params.get("name", ""))
141
154
  var path: String = str(params.get("path", ""))
142
- var enabled: bool = Read.as_bool(params.get("enabled", true), true)
155
+ var global: bool = Read.as_bool(params.get("global", true), true)
156
+
157
+ # There is no disabled autoload in Godot 4: every entry in the list is loaded under the root at
158
+ # every start. `enabled: false` used to write the entry without its asterisk and answer that it
159
+ # was disabled, and the autoload ran all the same.
160
+ if not Read.as_bool(params.get("enabled", true), true):
161
+ return _log.failure(
162
+ (
163
+ "An autoload cannot be added disabled: Godot loads every autoload in the list at every"
164
+ + " start. global: false keeps its name out of the global scope and still loads it;"
165
+ + " remove_autoload is what stops it loading"
166
+ )
167
+ )
143
168
 
144
169
  if not path.begins_with("res://"):
145
170
  path = "res://" + path
@@ -154,14 +179,21 @@ func add_autoload(params: Dictionary) -> Dictionary:
154
179
  var setting: String = "autoload/" + name
155
180
  var was_updated: bool = ProjectSettings.has_setting(setting)
156
181
 
157
- # An asterisk in front of the path makes the autoload a global name as well. Without it the
158
- # node still comes up under the root; only the name stops resolving.
159
- ProjectSettings.set_setting(setting, ("*" if enabled else "") + path)
182
+ # An asterisk in front of the path makes the autoload's name a global variable, the editor's
183
+ # "Global Variable" column. Without it the node still comes up under the root; only the name
184
+ # stops resolving.
185
+ ProjectSettings.set_setting(setting, ("*" if global else "") + path)
160
186
  var err: Error = ProjectSettings.save()
161
187
  if err != OK:
162
188
  return _log.failure("Failed to save project.godot: " + error_string(err))
163
189
 
164
- return {"name": name, "path": path, "enabled": enabled, "action": "updated" if was_updated else "added"}
190
+ return {
191
+ "name": name,
192
+ "path": path,
193
+ "enabled": true,
194
+ "global": global,
195
+ "action": "updated" if was_updated else "added",
196
+ }
165
197
 
166
198
 
167
199
  # Remove an autoload singleton
@@ -196,10 +228,13 @@ func list_autoloads(_params: Dictionary) -> Dictionary:
196
228
  var value: String = str(ProjectSettings.get_setting(setting))
197
229
  var path: String = value.trim_prefix("*")
198
230
 
231
+ # Enabled whatever the asterisk says, because every autoload listed is loaded; the asterisk
232
+ # is whether its name is a global variable.
199
233
  var autoload_info: Dictionary = {
200
234
  "name": setting.trim_prefix("autoload/"),
201
235
  "path": path,
202
- "enabled": value.begins_with("*"),
236
+ "enabled": true,
237
+ "global": value.begins_with("*"),
203
238
  "file_exists": FileAccess.file_exists(path),
204
239
  }
205
240
  autoloads.append(autoload_info)
@@ -25,11 +25,9 @@ func get_project_health(params: Dictionary) -> Dictionary:
25
25
 
26
26
  # 1. Project Structure Check
27
27
  if "structure" in check_categories:
28
- var passed: bool = true
29
28
  var details: Array[String] = []
30
29
 
31
30
  if not FileAccess.file_exists("res://project.godot"):
32
- passed = false
33
31
  details.append("Missing project.godot file")
34
32
  deductions += 30
35
33
 
@@ -43,7 +41,7 @@ func get_project_health(params: Dictionary) -> Dictionary:
43
41
  details.append("Consider organizing with directories: " + ", ".join(missing_dirs))
44
42
  deductions += 2 * missing_dirs.size()
45
43
 
46
- checks["structure"] = {"name": "Project Structure", "passed": passed, "details": details}
44
+ checks["structure"] = {"name": "Project Structure", "passed": details.is_empty(), "details": details}
47
45
 
48
46
  # 2. Resource Check
49
47
  if "resources" in check_categories:
@@ -64,7 +62,10 @@ func get_project_health(params: Dictionary) -> Dictionary:
64
62
  deductions += missing_imports
65
63
 
66
64
  checks["resources"] = {
67
- "name": "Resources", "passed": true, "details": details, "total_resources": resources.size()
65
+ "name": "Resources",
66
+ "passed": details.is_empty(),
67
+ "details": details,
68
+ "total_resources": resources.size()
68
69
  }
69
70
 
70
71
  # 3. Scripts Check
@@ -95,20 +96,27 @@ func get_project_health(params: Dictionary) -> Dictionary:
95
96
  deductions += empty_functions
96
97
 
97
98
  checks["scripts"] = {
98
- "name": "Scripts", "passed": true, "details": details, "total_scripts": script_files.size()
99
+ "name": "Scripts",
100
+ "passed": details.is_empty(),
101
+ "details": details,
102
+ "total_scripts": script_files.size()
99
103
  }
100
104
 
101
105
  # 4. Scenes Check
102
106
  if "scenes" in check_categories:
103
107
  var details: Array[String] = []
104
108
 
105
- var scene_files: Array[String] = _files.find_files("res://", ".tscn")
109
+ # Binary scenes too, which a project may save instead and which counted as none.
110
+ var scene_files: Array[String] = _files.find_files_with_extensions("res://", ["tscn", "scn"])
106
111
  if scene_files.size() == 0:
107
112
  details.append("No scene files found in project")
108
113
  deductions += 5
109
114
 
110
115
  checks["scenes"] = {
111
- "name": "Scenes", "passed": true, "details": details, "total_scenes": scene_files.size()
116
+ "name": "Scenes",
117
+ "passed": details.is_empty(),
118
+ "details": details,
119
+ "total_scenes": scene_files.size()
112
120
  }
113
121
 
114
122
  # 5. Configuration Check
@@ -119,8 +127,8 @@ func get_project_health(params: Dictionary) -> Dictionary:
119
127
  if main_scene.is_empty():
120
128
  details.append("No main scene configured")
121
129
  deductions += 10
122
- elif not FileAccess.file_exists(main_scene):
123
- details.append("Main scene file does not exist: " + main_scene)
130
+ elif not FileWalk.missing_reason(main_scene).is_empty():
131
+ details.append("Main scene " + FileWalk.missing_reason(main_scene))
124
132
  deductions += 15
125
133
 
126
134
  var project_name: String = str(ProjectSettings.get_setting("application/config/name", ""))
@@ -132,7 +140,7 @@ func get_project_health(params: Dictionary) -> Dictionary:
132
140
  details.append("No export presets configured")
133
141
  deductions += 3
134
142
 
135
- checks["config"] = {"name": "Configuration", "passed": true, "details": details}
143
+ checks["config"] = {"name": "Configuration", "passed": details.is_empty(), "details": details}
136
144
 
137
145
  var score: int = maxi(0, 100 - deductions)
138
146
 
@@ -154,6 +162,14 @@ func get_project_health(params: Dictionary) -> Dictionary:
154
162
  if not FileAccess.file_exists("res://export_presets.cfg"):
155
163
  recommendations.append("Configure export presets for your target platforms")
156
164
 
165
+ # Every detail costs points, so each is an issue; `issues` was always empty beside them, and a
166
+ # category said passed next to the failure it had just found.
167
+ var issues: Array[Dictionary] = []
168
+ for category: String in checks:
169
+ var check: Dictionary = checks[category]
170
+ var found: Array[String] = check["details"]
171
+ for detail: String in found:
172
+ issues.append({"check": category, "detail": detail})
157
173
  return {
158
- "score": score, "grade": grade, "checks": checks, "issues": [], "recommendations": recommendations
174
+ "score": score, "grade": grade, "checks": checks, "issues": issues, "recommendations": recommendations
159
175
  }
@@ -52,6 +52,23 @@ static func as_float(value: Variant, fallback: float = 0.0) -> float:
52
52
  if value is String:
53
53
  var text: String = value
54
54
  return text.to_float()
55
+ if value is Dictionary:
56
+ var tagged: Dictionary = value
57
+ if str(tagged.get("_type", "")) == "float":
58
+ return non_finite(str(tagged.get("value", "")), fallback)
59
+ return fallback
60
+
61
+
62
+ ## The float a non-finite tag names, "inf", "-inf" or "nan", or [param fallback] for anything else.
63
+ ## JSON has no spelling for these, so the serialiser tags them rather than let them arrive as null.
64
+ static func non_finite(word: String, fallback: float = 0.0) -> float:
65
+ match word:
66
+ "inf":
67
+ return INF
68
+ "-inf":
69
+ return -INF
70
+ "nan":
71
+ return NAN
55
72
  return fallback
56
73
 
57
74
 
@@ -1,6 +1,14 @@
1
1
  extends RefCounted
2
2
 
3
3
  const Log = preload("logger.gd")
4
+ const Patterns = preload("patterns.gd")
5
+
6
+ ## Where the engine keeps a file's UID, by what kind of file it is. Measured on 4.7.2: a script or
7
+ ## shader has a `.uid` file beside it, an imported file has it in its `.import`, and a text scene or
8
+ ## resource has it in its own header. `ResourceLoader.get_resource_uid` answers the first two in a
9
+ ## headless run and not the third, so each place is read here rather than asking it.
10
+ const SIDECAR_KINDS: Array[String] = ["gd", "gdshader", "gdshaderinc", "cs"]
11
+ const TEXT_RESOURCE_KINDS: Array[String] = ["tscn", "tres", "escn"]
4
12
 
5
13
  var _log: Log
6
14
 
@@ -9,7 +17,7 @@ func _init(p_log: Log) -> void:
9
17
  _log = p_log
10
18
 
11
19
 
12
- # The UID the engine gave a file, from the .uid sidecar it writes beside scripts and shaders.
20
+ # The UID the engine gave a file, read from wherever that kind of file keeps it.
13
21
  func get_uid(params: Dictionary) -> Dictionary:
14
22
  var file_path: String = str(params.get("resource_path", ""))
15
23
  if file_path.is_empty():
@@ -23,19 +31,54 @@ func get_uid(params: Dictionary) -> Dictionary:
23
31
  if not FileAccess.file_exists(file_path):
24
32
  return _log.failure("File does not exist: " + file_path)
25
33
 
26
- var uid_path: String = file_path + ".uid"
27
- var f: FileAccess = FileAccess.open(uid_path, FileAccess.READ)
28
- if not f:
29
- return {
30
- "file": file_path,
31
- "absolute_path": absolute_path,
32
- "exists": false,
33
- "message": "No .uid sidecar. refresh_uids writes one for every script and shader."
34
- }
35
-
36
- var uid_content: String = f.get_as_text()
37
- f.close()
38
-
39
- return {
40
- "file": file_path, "absolute_path": absolute_path, "uid": uid_content.strip_edges(), "exists": true
41
- }
34
+ var answer: Dictionary = {"file": file_path, "absolute_path": absolute_path}
35
+ var found: Dictionary = uid_of(file_path)
36
+ if found.is_empty():
37
+ answer["exists"] = false
38
+ answer["message"] = _why_none(file_path)
39
+ return answer
40
+ answer["exists"] = true
41
+ answer.merge(found)
42
+ return answer
43
+
44
+
45
+ # The UID and where it was read, or empty when the file has none anywhere the engine keeps one.
46
+ static func uid_of(file_path: String) -> Dictionary:
47
+ var sidecar: String = file_path + ".uid"
48
+ if FileAccess.file_exists(sidecar):
49
+ var text: String = FileAccess.get_file_as_string(sidecar).strip_edges()
50
+ if not text.is_empty():
51
+ return {"uid": text, "from": sidecar}
52
+ var import_file: String = file_path + ".import"
53
+ if FileAccess.file_exists(import_file):
54
+ var config: ConfigFile = ConfigFile.new()
55
+ if config.load(import_file) == OK and config.has_section_key("remap", "uid"):
56
+ return {"uid": str(config.get_value("remap", "uid")), "from": import_file}
57
+ if file_path.get_extension().to_lower() in TEXT_RESOURCE_KINDS:
58
+ var file: FileAccess = FileAccess.open(file_path, FileAccess.READ)
59
+ if file != null:
60
+ var header: String = file.get_line()
61
+ file.close()
62
+ var matched: RegExMatch = Patterns.compiled('\\buid="(uid://[^"]+)"').search(header)
63
+ if matched != null:
64
+ return {"uid": matched.get_string(1), "from": "the file's header"}
65
+ var id: int = ResourceLoader.get_resource_uid(file_path)
66
+ if id != ResourceUID.INVALID_ID:
67
+ return {"uid": ResourceUID.id_to_text(id), "from": "the engine"}
68
+ return {}
69
+
70
+
71
+ # Why [param file_path] has no UID, and what gives it one, which depends on what kind of file it is:
72
+ # refresh_uids writes a .uid for scripts and shaders only, and was named for every file.
73
+ func _why_none(file_path: String) -> String:
74
+ var kind: String = file_path.get_extension().to_lower()
75
+ if kind in SIDECAR_KINDS:
76
+ return "No .uid file beside it. refresh_uids writes one for every script and shader."
77
+ if kind in TEXT_RESOURCE_KINDS:
78
+ return (
79
+ "Its header carries no uid. The editor writes one when it saves the file; refresh_uids does not,"
80
+ + " and a headless save does not either."
81
+ )
82
+ if ResourceLoader.exists(file_path):
83
+ return "The engine keeps no UID for it."
84
+ return "It has not been imported, and the import is what gives it a UID: project_import reimport imports it."