gdharness 1.2.31 → 1.2.32

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 (27) hide show
  1. package/build/cli.js +7212 -6230
  2. package/build/godot/addons/gdharness_editor/bridge_client.gd +14 -0
  3. package/build/godot/addons/gdharness_editor/file_reads.gd +237 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd.uid +1 -0
  5. package/build/godot/addons/gdharness_editor/plugin.gd +27 -0
  6. package/build/godot/addons/gdharness_editor/scene_file.gd +38 -1
  7. package/build/godot/addons/gdharness_editor/tool_executor.gd +1 -0
  8. package/build/godot/addons/gdharness_editor/tools/class_tools.gd +22 -0
  9. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +64 -1
  10. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +4 -41
  11. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd +178 -0
  12. package/build/godot/addons/gdharness_editor/tools/scene_placing.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +54 -120
  14. package/build/godot/addons/gdharness_runtime/runtime_autoload.gd +39 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_hover.gd +285 -0
  16. package/build/godot/addons/gdharness_runtime/runtime_hover.gd.uid +1 -0
  17. package/build/godot/addons/gdharness_runtime/runtime_input.gd +151 -106
  18. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +112 -0
  19. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd.uid +1 -0
  20. package/build/godot/operations/file_reads.gd +237 -0
  21. package/build/godot/operations/gdscript_authoring.gd +136 -19
  22. package/build/godot/operations/godot_operations.gd +24 -0
  23. package/build/godot/operations/property_values.gd +767 -0
  24. package/build/godot/operations/rename_checks.gd +5 -2
  25. package/build/index.js +5437 -4469
  26. package/build/keeper.js +184 -71
  27. package/package.json +1 -1
@@ -0,0 +1,237 @@
1
+ extends RefCounted
2
+
3
+ ## Scenes and resources read from their files, by the editor's tools and by a headless engine with
4
+ ## none connected, so a read answers the same either way.
5
+ ##
6
+ ## A scene is read off its SceneState rather than instantiated. Instantiating outside the editor
7
+ ## runs every script's member initialisers and _init (measured on 4.7.2, with or without -e), so a
8
+ ## read with no editor would have run the game's code on every node; the editor gets placeholders
9
+ ## and runs none. The state holds everything the file says, and the scenes it instances theirs,
10
+ ## without making a node. Loading the file still does what loading does in the game: static
11
+ ## initialisers run, and a resource with a script is made with it.
12
+
13
+ const PropertyValues = preload("property_values.gd")
14
+ const Read = preload("reading.gd")
15
+ const Serialisation = preload("serialisation.gd")
16
+
17
+
18
+ ## The nodes of [param scene_path] as {name, type, path, children}, with instanceOf on a node that
19
+ ## instances another scene and every stored property when [param include_properties] is set, the
20
+ ## file's value or the default where it sets none. [param depth] -1 reads every level.
21
+ static func scene_tree(scene_path: String, depth: int, include_properties: bool) -> Dictionary:
22
+ if not FileAccess.file_exists(scene_path):
23
+ return {"ok": false, "error": "Scene not found: " + scene_path}
24
+ # Loaded without naming the type, so a resource handed in by mistake loads as what it is and the
25
+ # refusal can say so, where asking for a PackedScene printed the loader's error and a backtrace.
26
+ var loaded: Resource = ResourceLoader.load(scene_path, "", ResourceLoader.CACHE_MODE_IGNORE)
27
+ if loaded == null:
28
+ return {"ok": false, "error": "Failed to load %s as a scene" % scene_path}
29
+ if not loaded is PackedScene:
30
+ return {
31
+ "ok": false,
32
+ "error":
33
+ (
34
+ "%s is a %s, not a scene: resource_edit read reads its properties."
35
+ % [scene_path, loaded.get_class()]
36
+ )
37
+ }
38
+ var packed: PackedScene = loaded
39
+ var nodes: Dictionary = {}
40
+ var refused: String = _lay(packed.get_state(), ".", nodes, [scene_path])
41
+ if not refused.is_empty():
42
+ return {"ok": false, "error": refused}
43
+ if not nodes.has("."):
44
+ return {"ok": false, "error": "%s holds no nodes" % scene_path}
45
+ return {"ok": true, "tree": _entry(nodes, ".", include_properties, depth, 0)}
46
+
47
+
48
+ ## Lays the nodes of [param state] into [param nodes] under [param prefix], keyed by their path
49
+ ## from the outermost root, each instanced scene laid where it is instanced and the instancing
50
+ ## file's own entries for it applied over it. Answers a refusal, or "".
51
+ ##
52
+ ## [param within] is the files being laid, outermost first, so a scene that instances one of them
53
+ ## is refused rather than laid forever; the engine refuses to load such a scene, so this is only
54
+ ## reached through a state built some other way.
55
+ static func _lay(state: SceneState, prefix: String, nodes: Dictionary, within: Array) -> String:
56
+ var base: SceneState = state.get_base_scene_state()
57
+ if base != null and not nodes.has(prefix):
58
+ var refused: String = _lay(base, prefix, nodes, within)
59
+ if not refused.is_empty():
60
+ return refused
61
+ for index: int in state.get_node_count():
62
+ var path: String = _joined(prefix, str(state.get_node_path(index)))
63
+ var fresh: bool = not nodes.has(path)
64
+ if fresh:
65
+ var instance: PackedScene = state.get_node_instance(index)
66
+ if instance != null:
67
+ if within.has(instance.resource_path):
68
+ return "%s instances %s, which holds it" % [within.back(), instance.resource_path]
69
+ var refused: String = _lay(
70
+ instance.get_state(), path, nodes, within + [instance.resource_path]
71
+ )
72
+ if not refused.is_empty():
73
+ return refused
74
+ if not nodes.has(path):
75
+ return "%s holds no nodes" % instance.resource_path
76
+ nodes[path]["instanceOf"] = instance.resource_path
77
+ elif state.is_node_instance_placeholder(index):
78
+ nodes[path] = _made("InstancePlaceholder")
79
+ nodes[path]["instanceOf"] = state.get_node_instance_placeholder(index)
80
+ elif str(state.get_node_type(index)).is_empty():
81
+ # An entry for a node of an instance that no longer has it, which the engine skips too.
82
+ continue
83
+ else:
84
+ nodes[path] = _made(str(state.get_node_type(index)))
85
+ # The name is the state's even for an entry laid already, since an inherited scene names its
86
+ # root itself; a root instanced inside another scene is renamed by that scene once laid.
87
+ nodes[path]["name"] = str(state.get_node_name(index))
88
+ if fresh:
89
+ # The root of what is laid here is placed by whoever instanced it.
90
+ if path != prefix:
91
+ _place(
92
+ nodes,
93
+ _joined(prefix, str(state.get_node_path(index, true))),
94
+ path,
95
+ state.get_node_index(index)
96
+ )
97
+ var held: Dictionary = nodes[path]["properties"]
98
+ for property: int in state.get_node_property_count(index):
99
+ held[str(state.get_node_property_name(index, property))] = state.get_node_property_value(
100
+ index, property
101
+ )
102
+ return ""
103
+
104
+
105
+ static func _made(type: String) -> Dictionary:
106
+ var children: Array[String] = []
107
+ return {"name": "", "type": type, "properties": {}, "children": children}
108
+
109
+
110
+ ## Puts [param path] among [param parent]'s children, at [param at] when the file says where (a node
111
+ ## added inside an instance) and last when it does not.
112
+ static func _place(nodes: Dictionary, parent: String, path: String, at: int) -> void:
113
+ if not nodes.has(parent):
114
+ return
115
+ var children: Array[String] = nodes[parent]["children"]
116
+ if at >= 0 and at < children.size():
117
+ var _placed: int = children.insert(at, path)
118
+ else:
119
+ children.append(path)
120
+
121
+
122
+ ## [param given], a path as a SceneState gives it ("." or "./Body/Tail"), under [param prefix].
123
+ static func _joined(prefix: String, given: String) -> String:
124
+ var relative: String = given.trim_prefix("./")
125
+ if relative == "." or relative.is_empty():
126
+ return prefix
127
+ if prefix == ".":
128
+ return relative
129
+ return prefix + "/" + relative
130
+
131
+
132
+ static func _entry(
133
+ nodes: Dictionary, path: String, include_properties: bool, depth: int, current_depth: int
134
+ ) -> Dictionary:
135
+ var node: Dictionary = nodes[path]
136
+ var children: Array[Dictionary] = []
137
+ var data: Dictionary = {"name": node["name"], "type": node["type"], "path": path, "children": children}
138
+ # The root's file is the scene being read, so only another node's names a second scene.
139
+ if path != "." and node.has("instanceOf"):
140
+ data["instanceOf"] = node["instanceOf"]
141
+ if include_properties:
142
+ var given: Dictionary = node["properties"]
143
+ data["properties"] = _stored(str(node["type"]), given)
144
+ if depth >= 0 and current_depth >= depth:
145
+ return data
146
+ for child: String in node["children"]:
147
+ children.append(_entry(nodes, child, include_properties, depth, current_depth + 1))
148
+ return data
149
+
150
+
151
+ ## Every property a node of [param type] stores, with the value [param given] sets or the default,
152
+ ## read as a node holding those values would answer them.
153
+ ##
154
+ ## The engine's half is read off a bare node of the class with the file's values set on it, which
155
+ ## runs no script: what a node lists depends on its values (a thread group's settings appear only
156
+ ## once it has one) and some setters set others (a Control's anchors preset sets how it grows), so
157
+ ## the class's defaults and the file's entries alone answered neither. The script's half is its
158
+ ## declared defaults under the file's values.
159
+ static func _stored(type: String, given: Dictionary) -> Dictionary:
160
+ var attached: Variant = given.get("script")
161
+ var script: Script = attached if attached is Script else null
162
+ var scripted: Dictionary = {}
163
+ if script != null:
164
+ for declared: Dictionary in script.get_script_property_list():
165
+ var property: String = str(declared.get("name", ""))
166
+ if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
167
+ scripted[property] = script.get_property_default_value(property)
168
+ var values: Dictionary = {}
169
+ if ClassDB.class_exists(type) and ClassDB.can_instantiate(type):
170
+ var bare: Object = ClassDB.instantiate(type)
171
+ for property: Variant in given:
172
+ if property != "script" and not scripted.has(property):
173
+ bare.set(str(property), given[property])
174
+ for declared: Dictionary in bare.get_property_list():
175
+ var property: String = str(declared.get("name", ""))
176
+ if not property.is_empty() and Read.as_int(declared.get("usage", 0)) & PROPERTY_USAGE_STORAGE:
177
+ values[property] = bare.get(property)
178
+ if bare is Node:
179
+ bare.free()
180
+ for property: String in scripted:
181
+ values[property] = scripted[property]
182
+ # Given last, which keeps what a bare node cannot hold: the script itself, and a path to a node
183
+ # for a property that holds the node once it is in a tree.
184
+ values.merge(given, true)
185
+ var serialiser: Serialisation = Serialisation.new()
186
+ var answered: Dictionary = {}
187
+ for property: Variant in values:
188
+ answered[property] = serialiser.serialize_value(values[property])
189
+ return answered
190
+
191
+
192
+ ## What a resource file holds, in the form modify takes, so a value read here can be written back.
193
+ ## Read from the file rather than a cached copy, since the file is what the writes change and what
194
+ ## the project loads.
195
+ static func resource(res_path: String, include_defaults: bool) -> Dictionary:
196
+ if not FileAccess.file_exists(res_path):
197
+ return {"ok": false, "error": "No resource at " + res_path}
198
+ var loaded: Resource = ResourceLoader.load(res_path, "", ResourceLoader.CACHE_MODE_IGNORE)
199
+ if loaded == null:
200
+ return {"ok": false, "error": "%s did not load as a resource." % res_path}
201
+ # A scene's own properties are its packed bundle, which says nothing a caller can use.
202
+ if loaded is PackedScene:
203
+ return {
204
+ "ok": false,
205
+ "error":
206
+ "%s is a scene: scene_tree reads its nodes, and scene_node get one node's properties." % res_path
207
+ }
208
+
209
+ var script: Script = loaded.get_script()
210
+ var scripted: Dictionary = {}
211
+ if script != null:
212
+ for declared: Dictionary in script.get_script_property_list():
213
+ scripted[str(declared.get("name", ""))] = true
214
+ var serialiser: Serialisation = Serialisation.new()
215
+ var properties: Dictionary = {}
216
+ for p: Dictionary in loaded.get_property_list():
217
+ var property: String = str(p.get("name", ""))
218
+ if property.is_empty() or property == "script":
219
+ continue
220
+ if not (Read.as_int(p.get("usage", 0)) & PROPERTY_USAGE_STORAGE):
221
+ continue
222
+ var value: Variant = loaded.get(property)
223
+ if not include_defaults:
224
+ var default: Variant = (
225
+ script.get_property_default_value(property)
226
+ if scripted.has(property)
227
+ else ClassDB.class_get_property_default_value(loaded.get_class(), property)
228
+ )
229
+ if PropertyValues.same(value, default):
230
+ continue
231
+ properties[property] = serialiser.serialize_value(value)
232
+ var answer: Dictionary = {
233
+ "ok": true, "resourcePath": res_path, "resourceType": loaded.get_class(), "properties": properties
234
+ }
235
+ if script != null:
236
+ answer["script"] = script.resource_path
237
+ return answer
@@ -4,6 +4,9 @@ const Read = preload("reading.gd")
4
4
  const Log = preload("logger.gd")
5
5
  const Patterns = preload("patterns.gd")
6
6
 
7
+ const MODIFICATION_TYPES: Array[String] = ["add_constant", "add_variable", "add_signal", "add_function"]
8
+ const BLANKS_AROUND_FUNCTIONS: int = 2
9
+
7
10
  var _log: Log
8
11
 
9
12
 
@@ -49,7 +52,9 @@ func create_gdscript(params: Dictionary) -> Dictionary:
49
52
  return _log.failure("Script path must end with .gd extension")
50
53
 
51
54
  if FileAccess.file_exists(full_script_path):
52
- return _log.failure("Script file already exists: " + full_script_path)
55
+ return _log.failure(
56
+ "Script file already exists: " + full_script_path + ". script_edit modify adds to it."
57
+ )
53
58
 
54
59
  var dir: DirAccess = DirAccess.open("res://")
55
60
  var script_dir: String = full_script_path.get_base_dir()
@@ -115,14 +120,14 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
115
120
  var original_content: String = file.get_as_text()
116
121
  file.close()
117
122
 
123
+ var lines: Array[String] = []
124
+ lines.assign(original_content.split("\n"))
125
+
118
126
  # All of them checked before any is made, so a call is made whole or not at all: one skipped for a
119
127
  # missing name used to leave the rest written and the answer saying success over an empty list.
120
- var problems: Array[String] = _modification_problems(modifications)
128
+ var problems: Array[String] = _modification_problems(modifications, _declared(lines))
121
129
  if not problems.is_empty():
122
130
  return _log.failure("Nothing was changed: " + "; ".join(problems))
123
-
124
- var lines: Array[String] = []
125
- lines.assign(original_content.split("\n"))
126
131
  # Each placed line as a zero-based index that moves down whenever a later insertion lands above
127
132
  # it, so the numbers answered are where the lines are in the file written, not where each went
128
133
  # at the moment it was placed.
@@ -133,6 +138,8 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
133
138
  var mod_type: String = str(fields.get("type", ""))
134
139
  var at: int = -1
135
140
  match mod_type:
141
+ "add_constant":
142
+ at = _add_constant(lines, fields, placed)
136
143
  "add_variable":
137
144
  at = _add_variable(lines, fields, placed)
138
145
  "add_signal":
@@ -170,10 +177,15 @@ func modify_gdscript(params: Dictionary) -> Dictionary:
170
177
 
171
178
 
172
179
  # Every reason [param modifications] cannot be made as asked, each naming the entry by position.
173
- static func _modification_problems(modifications: Array) -> Array[String]:
180
+ #
181
+ # A name the script already declares is one of them, as is one the call adds twice. GDScript gives a
182
+ # class one namespace for its members, so a second `maximum` does not parse; and the engine reports
183
+ # only the first error it meets, so a second `func damage` behind it was written with nothing said.
184
+ static func _modification_problems(modifications: Array, declared: Dictionary) -> Array[String]:
174
185
  var problems: Array[String] = []
175
186
  if modifications.is_empty():
176
187
  problems.append("modifications is empty")
188
+ var added: Dictionary = {}
177
189
  for index: int in range(modifications.size()):
178
190
  var mod: Variant = modifications[index]
179
191
  var which: String = "modification " + str(index + 1)
@@ -183,17 +195,55 @@ static func _modification_problems(modifications: Array) -> Array[String]:
183
195
  var fields: Dictionary = mod
184
196
  var mod_type: String = str(fields.get("type", ""))
185
197
  var mod_name: String = str(fields.get("name", ""))
186
- if mod_type not in ["add_variable", "add_signal", "add_function"]:
198
+ if mod_type not in MODIFICATION_TYPES:
187
199
  problems.append(
188
- which + " has type '" + mod_type + "', not add_variable, add_signal or add_function"
200
+ (
201
+ which
202
+ + " has type '"
203
+ + mod_type
204
+ + "', not add_constant, add_variable, add_signal or add_function"
205
+ )
189
206
  )
207
+ if mod_type == "add_constant" and str(fields.get("defaultValue", "")).is_empty():
208
+ problems.append(which + " is a constant with no defaultValue, and a constant needs a value")
190
209
  if mod_name.is_empty():
191
210
  problems.append(which + " has no name")
192
- elif not mod_name.is_valid_unicode_identifier():
211
+ continue
212
+ if not mod_name.is_valid_unicode_identifier():
193
213
  problems.append(which + " is named '" + mod_name + "', which GDScript does not accept as a name")
214
+ continue
215
+ if declared.has(mod_name):
216
+ var existing: Dictionary = declared[mod_name]
217
+ problems.append(
218
+ (
219
+ "%s adds %s, and the script already declares %s %s on line %d"
220
+ % [which, mod_name, existing["kind"], mod_name, existing["line"]]
221
+ )
222
+ )
223
+ elif added.has(mod_name):
224
+ problems.append("%s adds %s, which modification %d adds too" % [which, mod_name, added[mod_name]])
225
+ else:
226
+ added[mod_name] = index + 1
194
227
  return problems
195
228
 
196
229
 
230
+ # The members [param lines] declares at the top level, by name, as {"kind", "line"} counted from
231
+ # one: variables, constants, signals, functions, enums and inner classes, which share one namespace.
232
+ static func _declared(lines: Array[String]) -> Dictionary:
233
+ var found: Dictionary = {}
234
+ var declaration: RegEx = Patterns.compiled(
235
+ "^(?:static\\s+)?(var|const|signal|func|enum|class)\\s+([A-Za-z_][A-Za-z0-9_]*)"
236
+ )
237
+ for i: int in range(lines.size()):
238
+ var raw: String = lines[i]
239
+ if raw.is_empty() or raw[0] == "\t" or raw[0] == " ":
240
+ continue
241
+ var matched: RegExMatch = declaration.search(Patterns.without_annotations(raw))
242
+ if matched != null and not found.has(matched.get_string(2)):
243
+ found[matched.get_string(2)] = {"kind": matched.get_string(1), "line": i + 1}
244
+ return found
245
+
246
+
197
247
  # Inserts [param new_lines] at [param at] and moves every placed line at or below it down by as many.
198
248
  static func _insert(
199
249
  lines: Array[String], at: int, new_lines: Array[String], placed: Array[Dictionary]
@@ -226,6 +276,35 @@ static func _base_named(script: Script) -> String:
226
276
  return named if not named.is_empty() else base.resource_path
227
277
 
228
278
 
279
+ # A constant, typed as a variable is and placed after the header, the signals and the constants
280
+ # already there, which is where the style guide puts them. Its zero-based index, or -1.
281
+ func _add_constant(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
282
+ var value: String = str(mod.get("defaultValue", ""))
283
+ var declared_type: String = str(mod.get("varType", ""))
284
+ if declared_type.is_empty():
285
+ declared_type = _type_of_literal(value)
286
+ var const_line: String = "const %s: %s = %s" % [str(mod.get("name", "")), declared_type, value]
287
+ var after: int = 0
288
+ for i: int in range(lines.size()):
289
+ var line: String = Patterns.without_annotations(lines[i])
290
+ if (
291
+ line.begins_with("extends ")
292
+ or line.begins_with("class_name ")
293
+ or line.begins_with("signal ")
294
+ or line.begins_with("const ")
295
+ ):
296
+ after = i + 1
297
+ elif (
298
+ line.begins_with("var ")
299
+ or line.begins_with("func ")
300
+ or line.begins_with("static ")
301
+ or line.begins_with("enum ")
302
+ or line.begins_with("class ")
303
+ ):
304
+ break
305
+ return after if _insert(lines, after, [const_line], placed) else -1
306
+
307
+
229
308
  # Inserts the declaration and answers with its zero-based index, or -1 for one that could not be placed.
230
309
  func _add_variable(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
231
310
  var var_name: String = str(mod.get("name", ""))
@@ -293,7 +372,12 @@ func _add_signal(lines: Array[String], mod: Dictionary, placed: Array[Dictionary
293
372
  return insert_line if _insert(lines, insert_line, [signal_line], placed) else -1
294
373
 
295
374
 
296
- # Answers with the zero-based index of the `func` line, below the blank line that separates it.
375
+ # Answers with the zero-based index of the `func` line, below the blank lines that separate it.
376
+ #
377
+ # Two blank lines on each side, counting those already there, because that is the spacing GDScript's
378
+ # style guide gives functions and the one gdformat writes and gdlint checks: one blank line passed
379
+ # the engine and failed every project gating on either. A function last in the file ends it with a
380
+ # newline, which the file is written with only when its last line is empty.
297
381
  func _add_function(lines: Array[String], mod: Dictionary, placed: Array[Dictionary]) -> int:
298
382
  var func_name: String = str(mod.get("name", ""))
299
383
  var func_params: String = str(mod.get("params", ""))
@@ -305,21 +389,34 @@ func _add_function(lines: Array[String], mod: Dictionary, placed: Array[Dictiona
305
389
  var body: String = str(mod.get("body", "pass"))
306
390
  var position: String = str(mod.get("position", "end"))
307
391
 
392
+ var insert_line: int = _function_insertion_point(lines, position)
308
393
  var func_lines: Array[String] = []
309
- var func_decl: String = "func " + func_name + "(" + func_params + ") -> " + return_type + ":"
310
- func_lines.append("")
311
- func_lines.append(func_decl)
394
+ for i: int in range(_blank_run(lines, insert_line - 1, -1), BLANKS_AROUND_FUNCTIONS):
395
+ func_lines.append("")
396
+ var declared_at: int = insert_line + func_lines.size()
397
+ func_lines.append("func " + func_name + "(" + func_params + ") -> " + return_type + ":")
312
398
 
313
399
  for bl: String in body.split("\n"):
314
400
  func_lines.append("\t" + bl)
315
401
 
316
- var insert_line: int = _function_insertion_point(lines, position)
317
- # A separator on the far side too when something follows, since `after_ready` and `after_init`
318
- # put the new function immediately above an existing one and left the two declarations touching.
319
- if insert_line < lines.size():
402
+ var below: int = _blank_run(lines, insert_line, 1)
403
+ if insert_line + below < lines.size():
404
+ for i: int in range(below, BLANKS_AROUND_FUNCTIONS):
405
+ func_lines.append("")
406
+ elif below == 0:
320
407
  func_lines.append("")
321
408
 
322
- return insert_line + 1 if _insert(lines, insert_line, func_lines, placed) else -1
409
+ return declared_at if _insert(lines, insert_line, func_lines, placed) else -1
410
+
411
+
412
+ # How many blank lines run from [param from] on in the direction of [param step], 1 or -1.
413
+ static func _blank_run(lines: Array[String], from: int, step: int) -> int:
414
+ var count: int = 0
415
+ var at: int = from
416
+ while at >= 0 and at < lines.size() and lines[at].strip_edges().is_empty():
417
+ count += 1
418
+ at += step
419
+ return count
323
420
 
324
421
 
325
422
  func _variable_insertion_point(lines: Array[String]) -> int:
@@ -389,10 +486,30 @@ func _line_after_function(lines: Array[String], prefix: String) -> int:
389
486
  if line.begins_with(prefix):
390
487
  inside = true
391
488
  elif inside and (line.begins_with("func ") or line.begins_with("static func ")):
392
- return i
489
+ return _start_of_declaration(lines, i)
393
490
  return lines.size()
394
491
 
395
492
 
493
+ # The first line belonging to the declaration on [param at]: the comments and the lines holding only
494
+ # annotations directly above it, at its indentation. A function placed on [param at] itself takes the
495
+ # next function's doc comment, which then documents the wrong one, and its `@rpc` line, which then
496
+ # annotates it. A comment indented further is the end of the body above, and stays with it.
497
+ static func _start_of_declaration(lines: Array[String], at: int) -> int:
498
+ var indent: String = _indentation(lines[at])
499
+ var start: int = at
500
+ while start > 0 and _indentation(lines[start - 1]) == indent:
501
+ var above: String = lines[start - 1].strip_edges()
502
+ var annotation_only: bool = above.begins_with("@") and Patterns.without_annotations(above).is_empty()
503
+ if not above.begins_with("#") and not annotation_only:
504
+ break
505
+ start -= 1
506
+ return start
507
+
508
+
509
+ static func _indentation(line: String) -> String:
510
+ return line.substr(0, line.length() - line.strip_edges(true, false).length())
511
+
512
+
396
513
  func _script_template(template_name: String) -> String:
397
514
  match template_name:
398
515
  "singleton":
@@ -18,6 +18,7 @@ const AudioBuses = preload("audio_buses.gd")
18
18
  const ClassCache = preload("class_cache.gd")
19
19
  const ClassDbQueries = preload("classdb_queries.gd")
20
20
  const Dependencies = preload("dependencies.gd")
21
+ const FileReads = preload("file_reads.gd")
21
22
  const GdscriptAnalysis = preload("gdscript_analysis.gd")
22
23
  const GdscriptAuthoring = preload("gdscript_authoring.gd")
23
24
  const ImportPipeline = preload("import_pipeline.gd")
@@ -26,6 +27,7 @@ const Log = preload("logger.gd")
26
27
  const Plugins = preload("plugins.gd")
27
28
  const ProjectConfig = preload("project_config.gd")
28
29
  const ProjectDiagnostics = preload("project_diagnostics.gd")
30
+ const Read = preload("reading.gd")
29
31
  const RenameChecks = preload("rename_checks.gd")
30
32
  const ResourceFiles = preload("resource_files.gd")
31
33
 
@@ -137,6 +139,20 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
137
139
  payload = ResourceFiles.new(_log).get_uid(params)
138
140
  "refresh_class_cache":
139
141
  payload = ClassCache.new(_log).refresh_class_cache(params)
142
+ "read_scene_tree":
143
+ payload = _read(
144
+ FileReads.scene_tree(
145
+ str(params.get("scene_path", "")),
146
+ Read.as_int(params.get("depth", -1), -1),
147
+ Read.as_bool(params.get("include_properties", false))
148
+ )
149
+ )
150
+ "read_resource":
151
+ payload = _read(
152
+ FileReads.resource(
153
+ str(params.get("resource_path", "")), Read.as_bool(params.get("include_defaults", false))
154
+ )
155
+ )
140
156
 
141
157
  # Import and export pipeline
142
158
  "get_import_status":
@@ -220,3 +236,11 @@ func _run(operation: String, params: Dictionary) -> Dictionary:
220
236
  _log.error("Unknown operation: " + operation)
221
237
 
222
238
  return payload
239
+
240
+
241
+ ## A read the editor answers too, in the shape an operation answers: its refusal as the run's error.
242
+ func _read(read: Dictionary) -> Dictionary:
243
+ if not read.get("ok", false):
244
+ return _log.failure(str(read.get("error", "the read failed")))
245
+ var _answered: bool = read.erase("ok")
246
+ return read