gdharness 0.4.1

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 (37) hide show
  1. package/LICENSE +22 -0
  2. package/README.md +125 -0
  3. package/build/cli.js +30353 -0
  4. package/build/godot/addons/auto_reload/auto_reload.gd +89 -0
  5. package/build/godot/addons/auto_reload/plugin.cfg +6 -0
  6. package/build/godot/addons/gdharness_editor/bridge_client.gd +197 -0
  7. package/build/godot/addons/gdharness_editor/plugin.cfg +6 -0
  8. package/build/godot/addons/gdharness_editor/plugin.gd +88 -0
  9. package/build/godot/addons/gdharness_editor/tool_executor.gd +104 -0
  10. package/build/godot/addons/gdharness_editor/tools/animation_tools.gd +397 -0
  11. package/build/godot/addons/gdharness_editor/tools/play_tools.gd +85 -0
  12. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +427 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +885 -0
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +292 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_capture.gd +77 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_input.gd +254 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_queries.gd +283 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_values.gd +169 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +119 -0
  20. package/build/godot/operations/audio_buses.gd +125 -0
  21. package/build/godot/operations/class_cache.gd +180 -0
  22. package/build/godot/operations/classdb_queries.gd +252 -0
  23. package/build/godot/operations/dependencies.gd +293 -0
  24. package/build/godot/operations/file_walk.gd +55 -0
  25. package/build/godot/operations/gdscript_analysis.gd +288 -0
  26. package/build/godot/operations/gdscript_authoring.gd +419 -0
  27. package/build/godot/operations/godot_operations.gd +186 -0
  28. package/build/godot/operations/import_pipeline.gd +390 -0
  29. package/build/godot/operations/input_actions.gd +237 -0
  30. package/build/godot/operations/logger.gd +33 -0
  31. package/build/godot/operations/plugins.gd +159 -0
  32. package/build/godot/operations/project_config.gd +153 -0
  33. package/build/godot/operations/project_diagnostics.gd +159 -0
  34. package/build/godot/operations/resource_files.gd +132 -0
  35. package/build/godot/operations/serialisation.gd +154 -0
  36. package/build/index.js +29252 -0
  37. package/package.json +57 -0
@@ -0,0 +1,885 @@
1
+ @tool
2
+ extends Node
3
+
4
+ ## Scenes and their nodes, edited in the open editor and read back from what it holds.
5
+
6
+ var _editor_plugin: EditorPlugin = null
7
+
8
+
9
+ func set_editor_plugin(plugin: EditorPlugin) -> void:
10
+ _editor_plugin = plugin
11
+
12
+
13
+ func _refresh_and_reload(scene_path: String) -> void:
14
+ _refresh_filesystem()
15
+ _reload_scene_in_editor(scene_path)
16
+
17
+
18
+ func _refresh_filesystem() -> void:
19
+ if _editor_plugin:
20
+ EditorInterface.get_resource_filesystem().scan()
21
+
22
+
23
+ func _reload_scene_in_editor(scene_path: String) -> void:
24
+ if not _editor_plugin:
25
+ return
26
+ var edited: Node = EditorInterface.get_edited_scene_root()
27
+ if edited and edited.scene_file_path == scene_path:
28
+ EditorInterface.reload_scene_from_path(scene_path)
29
+
30
+
31
+ func _ensure_res_path(path: String) -> String:
32
+ if not path.begins_with("res://"):
33
+ return "res://" + path
34
+ return path
35
+
36
+
37
+ func _to_scene_res_path(project_path: String, scene_path: String) -> String:
38
+ var p: String = scene_path.strip_edges()
39
+ if p.begins_with("res://"):
40
+ return p
41
+
42
+ if project_path.strip_edges() != "":
43
+ var normalized_project: String = project_path.replace("\\", "/")
44
+ var normalized_scene: String = p.replace("\\", "/")
45
+ if normalized_scene.begins_with(normalized_project):
46
+ var rel: String = normalized_scene.substr(normalized_project.length())
47
+ if rel.begins_with("/"):
48
+ rel = rel.substr(1)
49
+ return _ensure_res_path(rel)
50
+
51
+ return _ensure_res_path(p)
52
+
53
+
54
+ func _load_scene(scene_path: String) -> Array:
55
+ if not FileAccess.file_exists(scene_path):
56
+ return [null, {"ok": false, "error": "Scene not found: " + scene_path}]
57
+ var packed: PackedScene = load(scene_path)
58
+ if not packed:
59
+ return [null, {"ok": false, "error": "Failed to load: " + scene_path}]
60
+ var root: Node = packed.instantiate()
61
+ if not root:
62
+ return [null, {"ok": false, "error": "Failed to instantiate: " + scene_path}]
63
+ return [root, {}]
64
+
65
+
66
+ func _save_scene(scene_root: Node, scene_path: String) -> Dictionary:
67
+ var packed: PackedScene = PackedScene.new()
68
+ if packed.pack(scene_root) != OK:
69
+ scene_root.queue_free()
70
+ return {"ok": false, "error": "Failed to pack scene"}
71
+ if ResourceSaver.save(packed, scene_path) != OK:
72
+ scene_root.queue_free()
73
+ return {"ok": false, "error": "Failed to save scene"}
74
+ scene_root.queue_free()
75
+ _refresh_and_reload(scene_path)
76
+ return {}
77
+
78
+
79
+ func _find_node(root: Node, path: String) -> Node:
80
+ if path == "." or path.is_empty():
81
+ return root
82
+ return root.get_node_or_null(path)
83
+
84
+
85
+ ## Turns what arrived over the wire into the Godot value a property wants.
86
+ ##
87
+ ## Three separate questions, asked in order, because a caller may say what it means in three
88
+ ## ways: a dictionary that names its own type, a dictionary shaped like the type the property
89
+ ## declares, or a bare array positional for a vector. Each is its own function; asking all three
90
+ ## in one was twenty-four exits deep and impossible to follow.
91
+ func _parse_value(value: Variant, expected_type: int = TYPE_NIL) -> Variant:
92
+ if typeof(value) == TYPE_DICTIONARY:
93
+ var tagged: Array = _parse_tagged_dictionary(value)
94
+ if tagged[0]:
95
+ return tagged[1]
96
+ return _parse_shaped_dictionary(value, expected_type)
97
+ if typeof(value) == TYPE_ARRAY:
98
+ return _parse_array(value, expected_type)
99
+ return value
100
+
101
+
102
+ ## A dictionary carrying its own type name, as the serialiser writes it.
103
+ ##
104
+ ## Answers [handled, value] rather than just the value, because a handled tag may legitimately
105
+ ## produce null: a Resource with no path is "this is nothing", not "this is not mine". A tag
106
+ ## that names a type but lacks the keys to build it is left unhandled on purpose, so the caller
107
+ ## can still read it against the type the property declares.
108
+ func _parse_tagged_dictionary(value: Dictionary) -> Array:
109
+ var type_tag: String = ""
110
+ if value.has("type"):
111
+ type_tag = str(value["type"])
112
+ elif value.has("_type"):
113
+ type_tag = str(value["_type"])
114
+
115
+ match type_tag:
116
+ "Vector2":
117
+ return [true, Vector2(value.get("x", 0), value.get("y", 0))]
118
+ "Vector3":
119
+ return [true, Vector3(value.get("x", 0), value.get("y", 0), value.get("z", 0))]
120
+ "Color":
121
+ return [true, Color(value.get("r", 1), value.get("g", 1), value.get("b", 1), value.get("a", 1))]
122
+ "Vector2i":
123
+ return [true, Vector2i(value.get("x", 0), value.get("y", 0))]
124
+ "Vector3i":
125
+ return [true, Vector3i(value.get("x", 0), value.get("y", 0), value.get("z", 0))]
126
+ "Rect2":
127
+ return [
128
+ true,
129
+ Rect2(value.get("x", 0), value.get("y", 0), value.get("width", 0), value.get("height", 0))
130
+ ]
131
+ "Transform2D":
132
+ return _parse_transform2d(value)
133
+ "Transform3D":
134
+ return _parse_transform3d(value)
135
+ "NodePath":
136
+ return [true, NodePath(value.get("path", ""))]
137
+ "Resource":
138
+ var resource_path: String = str(value.get("path", ""))
139
+ return [true, null if resource_path.is_empty() else load(resource_path)]
140
+ _:
141
+ return _parse_new_resource(type_tag, value)
142
+
143
+
144
+ ## A tag naming a Resource class builds a fresh one, its other keys set as properties, so a
145
+ ## NavigationRegion2D can arrive with its NavigationPolygon and an AnimationTree with its root
146
+ ## state machine in the same add as any other property.
147
+ func _parse_new_resource(type_tag: String, value: Dictionary) -> Array:
148
+ if (
149
+ type_tag.is_empty()
150
+ or not ClassDB.class_exists(type_tag)
151
+ or not ClassDB.is_parent_class(type_tag, "Resource")
152
+ or not ClassDB.can_instantiate(type_tag)
153
+ ):
154
+ return [false, null]
155
+
156
+ var built: Resource = ClassDB.instantiate(type_tag)
157
+ for key: Variant in value:
158
+ var property: String = str(key)
159
+ if property == "_type" or property == "type":
160
+ continue
161
+ built.set(property, _parse_value(value[key], typeof(built.get(property))))
162
+ return [true, built]
163
+
164
+
165
+ func _parse_transform2d(value: Dictionary) -> Array:
166
+ if not (value.has("x") and value.has("y") and value.has("origin")):
167
+ return [false, null]
168
+
169
+ var basis_x: Dictionary = value["x"]
170
+ var basis_y: Dictionary = value["y"]
171
+ var origin: Dictionary = value["origin"]
172
+ return [
173
+ true,
174
+ Transform2D(
175
+ Vector2(basis_x.get("x", 1), basis_x.get("y", 0)),
176
+ Vector2(basis_y.get("x", 0), basis_y.get("y", 1)),
177
+ Vector2(origin.get("x", 0), origin.get("y", 0))
178
+ )
179
+ ]
180
+
181
+
182
+ func _parse_transform3d(value: Dictionary) -> Array:
183
+ if not (value.has("basis") and value.has("origin")):
184
+ return [false, null]
185
+
186
+ var b: Dictionary = value["basis"]
187
+ var o: Dictionary = value["origin"]
188
+ var x: Dictionary = b.get("x", {})
189
+ var y: Dictionary = b.get("y", {})
190
+ var z: Dictionary = b.get("z", {})
191
+ var basis: Basis = Basis(
192
+ Vector3(x.get("x", 1), x.get("y", 0), x.get("z", 0)),
193
+ Vector3(y.get("x", 0), y.get("y", 1), y.get("z", 0)),
194
+ Vector3(z.get("x", 0), z.get("y", 0), z.get("z", 1))
195
+ )
196
+ return [true, Transform3D(basis, Vector3(o.get("x", 0), o.get("y", 0), o.get("z", 0)))]
197
+
198
+
199
+ ## A dictionary with no tag, read against the type the property declares. Falls back to the
200
+ ## dictionary itself, since a property may genuinely want one.
201
+ func _parse_shaped_dictionary(value: Dictionary, expected_type: int) -> Variant:
202
+ match expected_type:
203
+ TYPE_VECTOR2:
204
+ if value.has("x") and value.has("y"):
205
+ return Vector2(value.get("x", 0), value.get("y", 0))
206
+ TYPE_VECTOR2I:
207
+ if value.has("x") and value.has("y"):
208
+ return Vector2i(value.get("x", 0), value.get("y", 0))
209
+ TYPE_VECTOR3:
210
+ if value.has("x") and value.has("y") and value.has("z"):
211
+ return Vector3(value.get("x", 0), value.get("y", 0), value.get("z", 0))
212
+ TYPE_VECTOR3I:
213
+ if value.has("x") and value.has("y") and value.has("z"):
214
+ return Vector3i(value.get("x", 0), value.get("y", 0), value.get("z", 0))
215
+ TYPE_COLOR:
216
+ if value.has("r") and value.has("g") and value.has("b"):
217
+ return Color(value.get("r", 1), value.get("g", 1), value.get("b", 1), value.get("a", 1))
218
+ TYPE_RECT2:
219
+ if value.has("x") and value.has("y") and value.has("width") and value.has("height"):
220
+ return Rect2(
221
+ value.get("x", 0), value.get("y", 0), value.get("width", 0), value.get("height", 0)
222
+ )
223
+ TYPE_NODE_PATH:
224
+ if value.has("path"):
225
+ return NodePath(value.get("path", ""))
226
+ return value
227
+
228
+
229
+ ## An array, either positional for a vector the property declares, or a list to parse per item.
230
+ func _parse_array(value: Array, expected_type: int) -> Variant:
231
+ match expected_type:
232
+ TYPE_VECTOR2:
233
+ if value.size() >= 2:
234
+ return Vector2(value[0], value[1])
235
+ TYPE_VECTOR2I:
236
+ if value.size() >= 2:
237
+ return Vector2i(value[0], value[1])
238
+ TYPE_VECTOR3:
239
+ if value.size() >= 3:
240
+ return Vector3(value[0], value[1], value[2])
241
+ TYPE_VECTOR3I:
242
+ if value.size() >= 3:
243
+ return Vector3i(value[0], value[1], value[2])
244
+ return value.map(func(item: Variant) -> Variant: return _parse_value(item))
245
+
246
+
247
+ func _get_property_type(node: Node, prop_name: String) -> int:
248
+ for prop: Dictionary in node.get_property_list():
249
+ if str(prop.get("name", "")) == prop_name:
250
+ return int(prop.get("type", TYPE_NIL))
251
+ return TYPE_NIL
252
+
253
+
254
+ func _serialize_value(value: Variant) -> Variant:
255
+ match typeof(value):
256
+ TYPE_VECTOR2:
257
+ return {"type": "Vector2", "x": value.x, "y": value.y}
258
+ TYPE_VECTOR3:
259
+ return {"type": "Vector3", "x": value.x, "y": value.y, "z": value.z}
260
+ TYPE_COLOR:
261
+ return {"type": "Color", "r": value.r, "g": value.g, "b": value.b, "a": value.a}
262
+ TYPE_VECTOR2I:
263
+ return {"type": "Vector2i", "x": value.x, "y": value.y}
264
+ TYPE_VECTOR3I:
265
+ return {"type": "Vector3i", "x": value.x, "y": value.y, "z": value.z}
266
+ TYPE_RECT2:
267
+ return {
268
+ "type": "Rect2",
269
+ "x": value.position.x,
270
+ "y": value.position.y,
271
+ "width": value.size.x,
272
+ "height": value.size.y
273
+ }
274
+ TYPE_NODE_PATH:
275
+ return {"type": "NodePath", "path": str(value)}
276
+ TYPE_TRANSFORM2D:
277
+ return {
278
+ "type": "Transform2D",
279
+ "x": {"x": value.x.x, "y": value.x.y},
280
+ "y": {"x": value.y.x, "y": value.y.y},
281
+ "origin": {"x": value.origin.x, "y": value.origin.y}
282
+ }
283
+ TYPE_TRANSFORM3D:
284
+ return {
285
+ "type": "Transform3D",
286
+ "basis":
287
+ {
288
+ "x": {"x": value.basis.x.x, "y": value.basis.x.y, "z": value.basis.x.z},
289
+ "y": {"x": value.basis.y.x, "y": value.basis.y.y, "z": value.basis.y.z},
290
+ "z": {"x": value.basis.z.x, "y": value.basis.z.y, "z": value.basis.z.z}
291
+ },
292
+ "origin": {"x": value.origin.x, "y": value.origin.y, "z": value.origin.z}
293
+ }
294
+ TYPE_OBJECT:
295
+ if value and value is Resource and value.resource_path:
296
+ return {"type": "Resource", "path": value.resource_path}
297
+ return null
298
+ _:
299
+ return value
300
+
301
+
302
+ ## Set each property, answering with what went wrong or "" when nothing did.
303
+ ##
304
+ ## A property the node does not have, and a resource path nothing is at, are both refused: Object
305
+ ## .set ignores an unknown name and stores null for a resource that would not load, so either one
306
+ ## saves a scene that quietly did not change and reports it as a change that did.
307
+ func _set_node_properties(node: Node, properties: Dictionary) -> String:
308
+ for prop_name: Variant in properties:
309
+ var property: String = str(prop_name)
310
+ if not _has_property(node, property):
311
+ return "%s has no property %s" % [node.get_class(), property]
312
+
313
+ var expected_type: int = _get_property_type(node, property)
314
+ var raw: Variant = properties[prop_name]
315
+
316
+ # A resource-valued property takes the path of one, which is how a caller names a
317
+ # TileSet, a material or a theme: there is no other way to hand a tool a Resource.
318
+ if expected_type == TYPE_OBJECT and typeof(raw) == TYPE_STRING:
319
+ var path: String = String(raw)
320
+ # The project boundary is enforced here as well as on the server, because only the
321
+ # engine knows that this property is one holding a path: an absolute path and a
322
+ # user:// one both load, and neither names a file this project owns.
323
+ if not (path.begins_with("res://") or path.begins_with("uid://")):
324
+ return "%s takes a res:// or uid:// path, not %s" % [property, path]
325
+ if path.split("/").has(".."):
326
+ return "%s leaves the project: %s" % [property, path]
327
+ if not ResourceLoader.exists(path):
328
+ return "No resource at %s for %s" % [path, property]
329
+ node.set(property, load(path))
330
+ continue
331
+
332
+ node.set(property, _parse_value(raw, expected_type))
333
+ return ""
334
+
335
+
336
+ func _has_property(node: Node, prop_name: String) -> bool:
337
+ for prop: Dictionary in node.get_property_list():
338
+ if str(prop.get("name", "")) == prop_name:
339
+ return true
340
+ return false
341
+
342
+
343
+ func _parse_properties_arg(raw_properties: Variant) -> Dictionary:
344
+ if typeof(raw_properties) == TYPE_DICTIONARY:
345
+ return raw_properties
346
+ if typeof(raw_properties) == TYPE_STRING:
347
+ var text: String = String(raw_properties)
348
+ if text.strip_edges().is_empty():
349
+ return {}
350
+ var parsed: Variant = JSON.parse_string(text)
351
+ if typeof(parsed) == TYPE_DICTIONARY:
352
+ return parsed
353
+ return {}
354
+
355
+
356
+ func _ensure_parent_dir_for_scene(scene_path: String) -> void:
357
+ var base_dir: String = scene_path.get_base_dir()
358
+ if not DirAccess.dir_exists_absolute(base_dir):
359
+ DirAccess.make_dir_recursive_absolute(base_dir)
360
+
361
+
362
+ func _set_owner_recursive(node: Node, scene_owner: Node) -> void:
363
+ node.owner = scene_owner
364
+ for child: Node in node.get_children():
365
+ _set_owner_recursive(child, scene_owner)
366
+
367
+
368
+ func _build_node_tree(
369
+ node: Node, include_properties: bool, depth: int, current_depth: int, node_path: String
370
+ ) -> Dictionary:
371
+ var children: Array[Dictionary] = []
372
+ var data: Dictionary = {
373
+ "name": str(node.name), "type": node.get_class(), "path": node_path, "children": children
374
+ }
375
+
376
+ if include_properties:
377
+ var props: Dictionary = {}
378
+ for p: Dictionary in node.get_property_list():
379
+ if not (int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
380
+ continue
381
+ var pn: String = str(p.get("name", ""))
382
+ if pn.is_empty():
383
+ continue
384
+ props[pn] = _serialize_value(node.get(pn))
385
+ data["properties"] = props
386
+
387
+ if depth >= 0 and current_depth >= depth:
388
+ return data
389
+
390
+ for child: Node in node.get_children():
391
+ var child_path: String = str(child.name) if node_path == "." else node_path + "/" + str(child.name)
392
+ children.append(_build_node_tree(child, include_properties, depth, current_depth + 1, child_path))
393
+
394
+ return data
395
+
396
+
397
+ func _collect_nodes_recursive(node: Node, path: String, out_nodes: Array) -> void:
398
+ out_nodes.append({"path": path, "node": node})
399
+ for child: Node in node.get_children():
400
+ var child_path: String = str(child.name) if path == "." else path + "/" + str(child.name)
401
+ _collect_nodes_recursive(child, child_path, out_nodes)
402
+
403
+
404
+ func create_scene(args: Dictionary) -> Dictionary:
405
+ var project_path: String = str(args.get("projectPath", ""))
406
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
407
+ var root_node_type: String = str(args.get("rootNodeType", "Node"))
408
+ var script_path: String = str(args.get("scriptPath", ""))
409
+
410
+ if scene_path == "res://":
411
+ return {"ok": false, "error": "Missing scenePath"}
412
+ if not scene_path.ends_with(".tscn"):
413
+ scene_path += ".tscn"
414
+ if not ClassDB.class_exists(root_node_type):
415
+ return {"ok": false, "error": "Invalid rootNodeType: " + root_node_type}
416
+
417
+ _ensure_parent_dir_for_scene(scene_path)
418
+
419
+ var root: Node = ClassDB.instantiate(root_node_type)
420
+ if not root:
421
+ return {"ok": false, "error": "Failed to instantiate root node: " + root_node_type}
422
+ root.name = root_node_type
423
+
424
+ if not script_path.is_empty():
425
+ var full_script_path: String = _to_scene_res_path(project_path, script_path)
426
+ var script: Resource = load(full_script_path)
427
+ if not script:
428
+ root.queue_free()
429
+ return {"ok": false, "error": "Failed to load script: " + full_script_path}
430
+ root.set_script(script)
431
+
432
+ var err: Dictionary = _save_scene(root, scene_path)
433
+ if not err.is_empty():
434
+ return err
435
+
436
+ return {"ok": true, "scenePath": scene_path, "rootNodeType": root_node_type}
437
+
438
+
439
+ func list_scene_nodes(args: Dictionary) -> Dictionary:
440
+ var project_path: String = str(args.get("projectPath", ""))
441
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
442
+ var depth: int = int(args.get("depth", -1))
443
+ var include_properties: bool = bool(args.get("includeProperties", false))
444
+
445
+ var loaded: Array = _load_scene(scene_path)
446
+ var refused: Dictionary = loaded[1]
447
+ if not refused.is_empty():
448
+ return refused
449
+
450
+ var root: Node = loaded[0]
451
+ var tree: Dictionary = _build_node_tree(root, include_properties, depth, 0, ".")
452
+ root.queue_free()
453
+ return {"ok": true, "tree": tree}
454
+
455
+
456
+ func add_node(args: Dictionary) -> Dictionary:
457
+ var project_path: String = str(args.get("projectPath", ""))
458
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
459
+ var node_type: String = str(args.get("nodeType", ""))
460
+ var node_name: String = str(args.get("nodeName", ""))
461
+ var parent_node_path: String = str(args.get("parentNodePath", "."))
462
+ var properties: Dictionary = _parse_properties_arg(args.get("properties", {}))
463
+
464
+ if node_type.is_empty() or node_name.is_empty():
465
+ return {"ok": false, "error": "Missing nodeType or nodeName"}
466
+ if not ClassDB.class_exists(node_type):
467
+ return {"ok": false, "error": "Invalid nodeType: " + node_type}
468
+
469
+ var loaded: Array = _load_scene(scene_path)
470
+ var refused: Dictionary = loaded[1]
471
+ if not refused.is_empty():
472
+ return refused
473
+
474
+ var root: Node = loaded[0]
475
+ var parent: Node = _find_node(root, parent_node_path)
476
+ if not parent:
477
+ root.queue_free()
478
+ return {"ok": false, "error": "Parent node not found: " + parent_node_path}
479
+
480
+ var new_node: Node = ClassDB.instantiate(node_type)
481
+ if not new_node:
482
+ root.queue_free()
483
+ return {"ok": false, "error": "Failed to instantiate nodeType: " + node_type}
484
+
485
+ new_node.name = node_name
486
+ var refused_property: String = _set_node_properties(new_node, properties)
487
+ if not refused_property.is_empty():
488
+ new_node.queue_free()
489
+ root.queue_free()
490
+ return {"ok": false, "error": refused_property}
491
+
492
+ parent.add_child(new_node)
493
+ _set_owner_recursive(new_node, root)
494
+
495
+ var err: Dictionary = _save_scene(root, scene_path)
496
+ if not err.is_empty():
497
+ return err
498
+
499
+ return {"ok": true, "nodeName": node_name, "nodeType": node_type}
500
+
501
+
502
+ func delete_node(args: Dictionary) -> Dictionary:
503
+ var project_path: String = str(args.get("projectPath", ""))
504
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
505
+ var node_path: String = str(args.get("nodePath", ""))
506
+
507
+ if node_path.is_empty() or node_path == ".":
508
+ return {"ok": false, "error": "Cannot delete root node"}
509
+
510
+ var loaded: Array = _load_scene(scene_path)
511
+ var refused: Dictionary = loaded[1]
512
+ if not refused.is_empty():
513
+ return refused
514
+
515
+ var root: Node = loaded[0]
516
+ var node: Node = _find_node(root, node_path)
517
+ if not node:
518
+ root.queue_free()
519
+ return {"ok": false, "error": "Node not found: " + node_path}
520
+
521
+ var parent: Node = node.get_parent()
522
+ if not parent:
523
+ root.queue_free()
524
+ return {"ok": false, "error": "Cannot delete root node"}
525
+
526
+ parent.remove_child(node)
527
+ node.queue_free()
528
+
529
+ var err: Dictionary = _save_scene(root, scene_path)
530
+ if not err.is_empty():
531
+ return err
532
+
533
+ return {"ok": true, "deletedNodePath": node_path}
534
+
535
+
536
+ func duplicate_node(args: Dictionary) -> Dictionary:
537
+ var project_path: String = str(args.get("projectPath", ""))
538
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
539
+ var node_path: String = str(args.get("nodePath", ""))
540
+ var new_name: String = str(args.get("newName", ""))
541
+ var parent_path: String = str(args.get("parentPath", ""))
542
+
543
+ if node_path.is_empty() or new_name.is_empty():
544
+ return {"ok": false, "error": "Missing nodePath or newName"}
545
+
546
+ var loaded: Array = _load_scene(scene_path)
547
+ var refused: Dictionary = loaded[1]
548
+ if not refused.is_empty():
549
+ return refused
550
+
551
+ var root: Node = loaded[0]
552
+ var source: Node = _find_node(root, node_path)
553
+ if not source:
554
+ root.queue_free()
555
+ return {"ok": false, "error": "Node not found: " + node_path}
556
+
557
+ var target_parent: Node = source.get_parent()
558
+ if not parent_path.is_empty():
559
+ target_parent = _find_node(root, parent_path)
560
+ if not target_parent:
561
+ root.queue_free()
562
+ return {"ok": false, "error": "Parent not found: " + parent_path}
563
+
564
+ var duplicated_node: Node = source.duplicate()
565
+ if not duplicated_node:
566
+ root.queue_free()
567
+ return {"ok": false, "error": "Failed to duplicate node: " + node_path}
568
+
569
+ duplicated_node.name = new_name
570
+ target_parent.add_child(duplicated_node)
571
+ _set_owner_recursive(duplicated_node, root)
572
+
573
+ var err: Dictionary = _save_scene(root, scene_path)
574
+ if not err.is_empty():
575
+ return err
576
+
577
+ return {"ok": true, "nodePath": node_path, "newName": new_name}
578
+
579
+
580
+ func reparent_node(args: Dictionary) -> Dictionary:
581
+ var project_path: String = str(args.get("projectPath", ""))
582
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
583
+ var node_path: String = str(args.get("nodePath", ""))
584
+ var new_parent_path: String = str(args.get("newParentPath", ""))
585
+
586
+ if node_path.is_empty() or node_path == ".":
587
+ return {"ok": false, "error": "Cannot reparent root node"}
588
+ if new_parent_path.is_empty():
589
+ return {"ok": false, "error": "Missing newParentPath"}
590
+
591
+ var loaded: Array = _load_scene(scene_path)
592
+ var refused: Dictionary = loaded[1]
593
+ if not refused.is_empty():
594
+ return refused
595
+
596
+ var root: Node = loaded[0]
597
+ var node: Node = _find_node(root, node_path)
598
+ var new_parent: Node = _find_node(root, new_parent_path)
599
+ if not node:
600
+ root.queue_free()
601
+ return {"ok": false, "error": "Node not found: " + node_path}
602
+ if not new_parent:
603
+ root.queue_free()
604
+ return {"ok": false, "error": "New parent not found: " + new_parent_path}
605
+
606
+ var old_parent: Node = node.get_parent()
607
+ if not old_parent:
608
+ root.queue_free()
609
+ return {"ok": false, "error": "Cannot reparent root node"}
610
+
611
+ old_parent.remove_child(node)
612
+ new_parent.add_child(node)
613
+ _set_owner_recursive(node, root)
614
+
615
+ var err: Dictionary = _save_scene(root, scene_path)
616
+ if not err.is_empty():
617
+ return err
618
+
619
+ return {"ok": true, "nodePath": node_path, "newParentPath": new_parent_path}
620
+
621
+
622
+ func set_node_properties(args: Dictionary) -> Dictionary:
623
+ var project_path: String = str(args.get("projectPath", ""))
624
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
625
+ var node_path: String = str(args.get("nodePath", "."))
626
+ var properties: Dictionary = _parse_properties_arg(args.get("properties", {}))
627
+
628
+ var loaded: Array = _load_scene(scene_path)
629
+ var refused: Dictionary = loaded[1]
630
+ if not refused.is_empty():
631
+ return refused
632
+
633
+ var root: Node = loaded[0]
634
+ var node: Node = _find_node(root, node_path)
635
+ if not node:
636
+ root.queue_free()
637
+ return {"ok": false, "error": "Node not found: " + node_path}
638
+
639
+ var refused_property: String = _set_node_properties(node, properties)
640
+ if not refused_property.is_empty():
641
+ root.queue_free()
642
+ return {"ok": false, "error": refused_property}
643
+
644
+ var err: Dictionary = _save_scene(root, scene_path)
645
+ if not err.is_empty():
646
+ return err
647
+
648
+ return {"ok": true, "nodePath": node_path}
649
+
650
+
651
+ func get_node_properties(args: Dictionary) -> Dictionary:
652
+ var project_path: String = str(args.get("projectPath", ""))
653
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
654
+ var node_path: String = str(args.get("nodePath", "."))
655
+ var include_defaults: bool = bool(args.get("includeDefaults", false))
656
+
657
+ var loaded: Array = _load_scene(scene_path)
658
+ var refused: Dictionary = loaded[1]
659
+ if not refused.is_empty():
660
+ return refused
661
+
662
+ var root: Node = loaded[0]
663
+ var node: Node = _find_node(root, node_path)
664
+ if not node:
665
+ root.queue_free()
666
+ return {"ok": false, "error": "Node not found: " + node_path}
667
+
668
+ var defaults: Node = null
669
+ if not include_defaults and ClassDB.class_exists(node.get_class()):
670
+ defaults = ClassDB.instantiate(node.get_class())
671
+
672
+ var props: Dictionary = {}
673
+ for p: Dictionary in node.get_property_list():
674
+ var usage: int = int(p.get("usage", 0))
675
+ if not (usage & PROPERTY_USAGE_STORAGE):
676
+ continue
677
+ var prop_name: String = str(p.get("name", ""))
678
+ if prop_name.is_empty():
679
+ continue
680
+ var current_val: Variant = node.get(prop_name)
681
+ if not include_defaults and defaults:
682
+ var default_val: Variant = defaults.get(prop_name)
683
+ if current_val == default_val:
684
+ continue
685
+ props[prop_name] = _serialize_value(current_val)
686
+
687
+ if defaults:
688
+ defaults.queue_free()
689
+ root.queue_free()
690
+ return {"ok": true, "nodePath": node_path, "properties": props}
691
+
692
+
693
+ func save_scene(args: Dictionary) -> Dictionary:
694
+ var project_path: String = str(args.get("projectPath", ""))
695
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
696
+ var new_path_raw: String = str(args.get("newPath", ""))
697
+ var target_path: String = scene_path
698
+ if not new_path_raw.is_empty():
699
+ target_path = _to_scene_res_path(project_path, new_path_raw)
700
+
701
+ var loaded: Array = _load_scene(scene_path)
702
+ var refused: Dictionary = loaded[1]
703
+ if not refused.is_empty():
704
+ return refused
705
+
706
+ _ensure_parent_dir_for_scene(target_path)
707
+ var root: Node = loaded[0]
708
+ var err: Dictionary = _save_scene(root, target_path)
709
+ if not err.is_empty():
710
+ return err
711
+
712
+ return {"ok": true, "scenePath": scene_path, "savedPath": target_path}
713
+
714
+
715
+ func connect_signal(args: Dictionary) -> Dictionary:
716
+ var project_path: String = str(args.get("projectPath", ""))
717
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
718
+ var source_node_path: String = str(args.get("sourceNodePath", ""))
719
+ var signal_name: String = str(args.get("signalName", ""))
720
+ var target_node_path: String = str(args.get("targetNodePath", ""))
721
+ var method_name: String = str(args.get("methodName", ""))
722
+ # A connection without CONNECT_PERSIST is a runtime one, and PackedScene.pack drops those on
723
+ # the way out: without this the scene saves unchanged and this answers success over nothing.
724
+ var flags: int = int(args.get("flags", 0)) | Object.CONNECT_PERSIST
725
+
726
+ if (
727
+ source_node_path.is_empty()
728
+ or signal_name.is_empty()
729
+ or target_node_path.is_empty()
730
+ or method_name.is_empty()
731
+ ):
732
+ return {"ok": false, "error": "Missing required signal connection arguments"}
733
+
734
+ var loaded: Array = _load_scene(scene_path)
735
+ var refused: Dictionary = loaded[1]
736
+ if not refused.is_empty():
737
+ return refused
738
+
739
+ var root: Node = loaded[0]
740
+ var source: Node = _find_node(root, source_node_path)
741
+ var target: Node = _find_node(root, target_node_path)
742
+ if not source:
743
+ root.queue_free()
744
+ return {"ok": false, "error": "Source node not found: " + source_node_path}
745
+ if not target:
746
+ root.queue_free()
747
+ return {"ok": false, "error": "Target node not found: " + target_node_path}
748
+ if not source.has_signal(signal_name):
749
+ root.queue_free()
750
+ return {"ok": false, "error": "Signal not found on source: " + signal_name}
751
+
752
+ var callable: Callable = Callable(target, method_name)
753
+ if not source.is_connected(signal_name, callable):
754
+ var connect_result: Error = source.connect(signal_name, callable, flags)
755
+ if connect_result != OK:
756
+ root.queue_free()
757
+ return {"ok": false, "error": "Failed to connect signal: " + error_string(connect_result)}
758
+
759
+ var err: Dictionary = _save_scene(root, scene_path)
760
+ if not err.is_empty():
761
+ return err
762
+
763
+ return {
764
+ "ok": true,
765
+ "sourceNodePath": source_node_path,
766
+ "signalName": signal_name,
767
+ "targetNodePath": target_node_path,
768
+ "methodName": method_name,
769
+ "flags": flags
770
+ }
771
+
772
+
773
+ func disconnect_signal(args: Dictionary) -> Dictionary:
774
+ var project_path: String = str(args.get("projectPath", ""))
775
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
776
+ var source_node_path: String = str(args.get("sourceNodePath", ""))
777
+ var signal_name: String = str(args.get("signalName", ""))
778
+ var target_node_path: String = str(args.get("targetNodePath", ""))
779
+ var method_name: String = str(args.get("methodName", ""))
780
+
781
+ if (
782
+ source_node_path.is_empty()
783
+ or signal_name.is_empty()
784
+ or target_node_path.is_empty()
785
+ or method_name.is_empty()
786
+ ):
787
+ return {"ok": false, "error": "Missing required signal disconnection arguments"}
788
+
789
+ var loaded: Array = _load_scene(scene_path)
790
+ var refused: Dictionary = loaded[1]
791
+ if not refused.is_empty():
792
+ return refused
793
+
794
+ var root: Node = loaded[0]
795
+ var source: Node = _find_node(root, source_node_path)
796
+ var target: Node = _find_node(root, target_node_path)
797
+ if not source:
798
+ root.queue_free()
799
+ return {"ok": false, "error": "Source node not found: " + source_node_path}
800
+ if not target:
801
+ root.queue_free()
802
+ return {"ok": false, "error": "Target node not found: " + target_node_path}
803
+
804
+ var callable: Callable = Callable(target, method_name)
805
+ if source.is_connected(signal_name, callable):
806
+ source.disconnect(signal_name, callable)
807
+
808
+ var err: Dictionary = _save_scene(root, scene_path)
809
+ if not err.is_empty():
810
+ return err
811
+
812
+ return {
813
+ "ok": true,
814
+ "sourceNodePath": source_node_path,
815
+ "signalName": signal_name,
816
+ "targetNodePath": target_node_path,
817
+ "methodName": method_name
818
+ }
819
+
820
+
821
+ func list_connections(args: Dictionary) -> Dictionary:
822
+ var project_path: String = str(args.get("projectPath", ""))
823
+ var scene_path: String = _to_scene_res_path(project_path, str(args.get("scenePath", "")))
824
+ var filter_path: String = str(args.get("nodePath", ""))
825
+
826
+ var loaded: Array = _load_scene(scene_path)
827
+ var refused: Dictionary = loaded[1]
828
+ if not refused.is_empty():
829
+ return refused
830
+
831
+ var root: Node = loaded[0]
832
+ var nodes: Array = []
833
+ _collect_nodes_recursive(root, ".", nodes)
834
+
835
+ var connections: Array = []
836
+ for entry: Dictionary in nodes:
837
+ var path: String = str(entry["path"])
838
+ if not filter_path.is_empty() and filter_path != path:
839
+ continue
840
+ var node: Node = entry["node"]
841
+ for signal_info: Dictionary in node.get_signal_list():
842
+ var signal_name: String = str(signal_info.get("name", ""))
843
+ if signal_name.is_empty():
844
+ continue
845
+ for conn: Dictionary in node.get_signal_connection_list(signal_name):
846
+ var callable: Callable = conn.get("callable", Callable())
847
+ var target_obj: Object = callable.get_object()
848
+ var target_path: String = ""
849
+ if target_obj is Node:
850
+ target_path = str(root.get_path_to(target_obj as Node))
851
+ connections.append(
852
+ {
853
+ "sourceNodePath": path,
854
+ "signalName": signal_name,
855
+ "targetNodePath": target_path,
856
+ "methodName": str(callable.get_method()),
857
+ "flags": int(conn.get("flags", 0))
858
+ }
859
+ )
860
+
861
+ root.queue_free()
862
+ return {"ok": true, "connections": connections}
863
+
864
+
865
+ ## Rescan the project filesystem, and report whether a scan is still running.
866
+ ##
867
+ ## The editor rescans when its window regains focus, so a script written by anything other
868
+ ## than the editor stays invisible until someone clicks on Godot. Until then its
869
+ ## `class_name` is missing from the global class list and the language server reports every
870
+ ## use of it as an unknown type, which is godotengine/godot#42786.
871
+ ##
872
+ ## Returns as soon as the scan is queued rather than awaiting it, because the tool executor
873
+ ## takes a Dictionary and not a coroutine. Pass `statusOnly` to poll without starting
874
+ ## another scan.
875
+ func rescan_filesystem(args: Dictionary) -> Dictionary:
876
+ if not _editor_plugin:
877
+ return {"ok": false, "error": "Editor plugin unavailable"}
878
+
879
+ var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
880
+ if not bool(args.get("statusOnly", false)):
881
+ filesystem.scan()
882
+
883
+ # Importing is reported separately from scanning, and a class is not registered until
884
+ # both are done, so a caller watching only one of them can look too early.
885
+ return {"ok": true, "scanning": filesystem.is_scanning(), "importing": filesystem.is_importing()}