gdharness 1.3.35 → 1.3.37

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (31) hide show
  1. package/build/cli.js +288 -78
  2. package/build/godot/addons/gdharness_editor/animation_reads.gd +17 -1
  3. package/build/godot/addons/gdharness_editor/reading.gd +10 -2
  4. package/build/godot/addons/gdharness_editor/scene_file.gd +12 -6
  5. package/build/godot/addons/gdharness_editor/tools/animation_tracks.gd +57 -0
  6. package/build/godot/addons/gdharness_editor/tools/filesystem_tools.gd +16 -0
  7. package/build/godot/addons/gdharness_editor/tools/navigation_bake.gd +81 -4
  8. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +3 -3
  9. package/build/godot/addons/gdharness_editor/tools/scene_kin.gd +4 -2
  10. package/build/godot/addons/gdharness_editor/tools/scene_paths.gd +59 -0
  11. package/build/godot/addons/gdharness_editor/tools/script_paths.gd +148 -0
  12. package/build/godot/addons/gdharness_editor/tools/script_paths.gd.uid +1 -0
  13. package/build/godot/addons/gdharness_runtime/reading.gd +10 -2
  14. package/build/godot/addons/gdharness_runtime/runtime_hover.gd +70 -0
  15. package/build/godot/addons/gdharness_runtime/runtime_input.gd +223 -248
  16. package/build/godot/addons/gdharness_runtime/runtime_mouse.gd +128 -5
  17. package/build/godot/addons/gdharness_runtime/runtime_screen.gd +107 -0
  18. package/build/godot/addons/gdharness_runtime/runtime_targets.gd +15 -1
  19. package/build/godot/addons/gdharness_runtime/runtime_waits.gd +8 -0
  20. package/build/godot/addons/gdharness_runtime/runtime_words.gd +22 -0
  21. package/build/godot/operations/animation_reads.gd +17 -1
  22. package/build/godot/operations/classdb_queries.gd +16 -0
  23. package/build/godot/operations/dependencies.gd +46 -4
  24. package/build/godot/operations/editor_tools.gd +5 -5
  25. package/build/godot/operations/gdscript_authoring.gd +7 -3
  26. package/build/godot/operations/input_actions.gd +105 -0
  27. package/build/godot/operations/project_diagnostics.gd +4 -0
  28. package/build/godot/operations/project_validation.gd +15 -0
  29. package/build/godot/operations/reading.gd +10 -2
  30. package/build/keeper.js +5 -5
  31. package/package.json +1 -1
@@ -131,6 +131,22 @@ static func _summary(anim: Animation) -> Dictionary:
131
131
  }
132
132
 
133
133
 
134
+ ## [param seconds], a key's time, as the shortest decimal holding the same 32-bit float. A scene keeps
135
+ ## key times as 32-bit floats, so a key put at 0.7 read back as 0.699999988079071.
136
+ static func key_time(seconds: float) -> float:
137
+ var held: float = PackedFloat32Array([seconds])[0]
138
+ for places: int in range(0, 10):
139
+ var shorter: float = String.num(held, places).to_float()
140
+ if PackedFloat32Array([shorter])[0] == held:
141
+ return shorter
142
+ return held
143
+
144
+
145
+ ## Whether two times are one key's: the same 32-bit float, as a scene stores them.
146
+ static func same_time(first: float, second: float) -> bool:
147
+ return PackedFloat32Array([first])[0] == PackedFloat32Array([second])[0]
148
+
149
+
134
150
  static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dictionary:
135
151
  var kind: int = anim.track_get_type(track)
136
152
  var path: NodePath = anim.track_get_path(track)
@@ -143,7 +159,7 @@ static func _track(anim: Animation, track: int, values: GdhSerialisation) -> Dic
143
159
  var keyframes: Array[Dictionary] = []
144
160
  var methods: Dictionary = {}
145
161
  for key: int in anim.track_get_key_count(track):
146
- var keyframe: Dictionary = {"time": anim.track_get_key_time(track, key)}
162
+ var keyframe: Dictionary = {"time": key_time(anim.track_get_key_time(track, key))}
147
163
  if kind == Animation.TYPE_METHOD:
148
164
  var method: String = str(anim.method_track_get_name(track, key))
149
165
  methods[method] = true
@@ -215,7 +215,8 @@ static func offered(said: String, word: String, names: Array) -> String:
215
215
  ## [param said], the refusal for [param property], which [param target] does not declare, with the
216
216
  ## names it declares nearest it when any are, as a misspelt one is usually one of them, or else the
217
217
  ## variables its script declares. The engine's own are not listed when none is near, since an object
218
- ## has a hundred, while a script's are the few a caller most likely meant.
218
+ ## has a hundred, while a script's are the few a caller most likely meant; an object with no script
219
+ ## is told where they are listed instead, since the refusal alone left nothing to try.
219
220
  static func near_property(said: String, target: Object, property: String) -> String:
220
221
  var headings: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
221
222
  var names: Array[String] = []
@@ -233,8 +234,15 @@ static func near_property(said: String, target: Object, property: String) -> Str
233
234
  var close: PackedStringArray = nearest(property, PackedStringArray(names))
234
235
  if not close.is_empty():
235
236
  return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
236
- if scripted.is_empty():
237
+ # With a script that declares nothing readable, as one that does not compile, the name is likelier
238
+ # the script's than the class's, so the class's list would point the wrong way.
239
+ if scripted.is_empty() and target.get_script() != null:
237
240
  return said
241
+ if scripted.is_empty():
242
+ return (
243
+ "%s; editor_classes info with className %s and includeInherited true lists the ones it has"
244
+ % [said, target.get_class()]
245
+ )
238
246
  scripted.sort()
239
247
  return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
240
248
 
@@ -630,11 +630,12 @@ func write(check: Callable = Callable()) -> Dictionary:
630
630
  }
631
631
 
632
632
 
633
- ## Each property [param state] stores differently from the file, on a node the file held and the call
634
- ## did not ask to change, as {node, property, was, saved}: was left out where the file did not set it,
635
- ## saved where the write does not. The engine writes a node as it holds it once the file has loaded,
636
- ## and one property can set others as it loads, as anchors_preset gives a centred Control both grow
637
- ## directions, so a file not written by the editor gains lines on nodes nobody named.
633
+ ## Each property [param state] stores differently from the file, on a node the file held, that the
634
+ ## call neither asked for nor changed, as {node, property, was, saved}: was left out where the file did
635
+ ## not set it, saved where the write does not. The engine writes a node as it holds it once the file
636
+ ## has loaded, and one property can set others as it loads, as anchors_preset gives a centred Control
637
+ ## both grow directions, so a file not written by the editor gains lines on any node, the one a set
638
+ ## names among them. What the call's own change moved on that node is its answer's alsoChanged.
638
639
  func _also_saved(state: SceneState) -> Array[Dictionary]:
639
640
  var noted: Array[Dictionary] = []
640
641
  var values: GdhSerialisation = GdhSerialisation.new()
@@ -643,14 +644,19 @@ func _also_saved(state: SceneState) -> Array[Dictionary]:
643
644
  if node == null:
644
645
  continue
645
646
  var id: int = node.get_instance_id()
646
- if _asked.has(id) or not _filed.has(id):
647
+ if not _filed.has(id):
647
648
  continue
649
+ # What the call set on it, and what the engine moved as it did, which the answer gives as
650
+ # alsoChanged.
651
+ var on: Dictionary = _asked.get(id, {})
648
652
  var filed: Dictionary = _filed[id]
649
653
  var stored: Dictionary = _stored_at(state, index)
650
654
  var names: Dictionary = filed.duplicate()
651
655
  names.merge(stored)
652
656
  for key: Variant in names:
653
657
  var name: String = str(key)
658
+ if on.has(name):
659
+ continue
654
660
  var held: bool = filed.has(name)
655
661
  if held == stored.has(name) and (not held or GdhSame.same(stored[name], filed[name])):
656
662
  continue
@@ -306,6 +306,10 @@ func remove_animation_track(args: Dictionary) -> Dictionary:
306
306
  var property: String = str(track.get("property", ""))
307
307
  var shape: Array = [track_type, node_path, property]
308
308
  var method: String = str(track.get("method", "")) if track_type == "method" else ""
309
+ var read_times: Variant = _times_of(args.get("times"))
310
+ if read_times is String:
311
+ return {"ok": false, "error": read_times}
312
+ var times: Array = read_times
309
313
 
310
314
  var found: Array = _animation_at(scene_path, player_node_path, animation_name)
311
315
  if found[1] == null:
@@ -345,6 +349,12 @@ func remove_animation_track(args: Dictionary) -> Dictionary:
345
349
  % [animation_name, node_path, method, ", ".join(_methods_called(anim, matching))]
346
350
  )
347
351
  )
352
+ if not times.is_empty():
353
+ var timed: Array = _keys_at(anim, calling, times)
354
+ if timed[1] != "":
355
+ var on: String = node_path if property.is_empty() else "%s:%s" % [node_path, property]
356
+ return scene.refuse("%s's %s track on %s %s" % [animation_name, track_type, on, timed[1]])
357
+ calling = timed[0]
348
358
  var keys: int = 0
349
359
  var tracks_removed: int = 0
350
360
  calling.reverse()
@@ -404,6 +414,53 @@ static func _keys_calling(anim: Animation, matching: Array[int], method: String)
404
414
  return calling
405
415
 
406
416
 
417
+ ## The key times [param given] names, as floats, empty when it is null, or the String refusing it.
418
+ static func _times_of(given: Variant) -> Variant:
419
+ var times: Array = []
420
+ if given == null:
421
+ return times
422
+ if not given is Array:
423
+ return "times is a list of the key times to remove, in seconds"
424
+ var listed: Array = given
425
+ if listed.is_empty():
426
+ return "times is a list of the key times to remove, in seconds"
427
+ for time: Variant in listed:
428
+ if not (time is float or time is int):
429
+ return "times holds %s, which is not a number of seconds" % str(time)
430
+ times.append(GdhRead.as_float(time))
431
+ return times
432
+
433
+
434
+ ## Of the keys [param calling] names, as [method _keys_calling] answers them, the ones at
435
+ ## [param times], as [[[track, key]...], ""], or with the second the words refusing a time none is at.
436
+ ## Removing a key took removing its whole track, so moving one key in time rewrote every other.
437
+ static func _keys_at(anim: Animation, calling: Array, times: Array) -> Array:
438
+ var at: Array = []
439
+ var held: PackedStringArray = []
440
+ var missed: Array = times.duplicate()
441
+ for pair: Array in calling:
442
+ var index: int = pair[0]
443
+ for key: int in anim.track_get_key_count(index):
444
+ if pair[1] >= 0 and key != pair[1]:
445
+ continue
446
+ var time: float = anim.track_get_key_time(index, key)
447
+ var _listed: bool = held.append(str(GdhAnimationReads.key_time(time)))
448
+ for given: float in times:
449
+ if GdhAnimationReads.same_time(time, given):
450
+ at.append([index, key])
451
+ missed.erase(given)
452
+ break
453
+ if missed.is_empty():
454
+ return [at, ""]
455
+ var named: PackedStringArray = []
456
+ for given: float in missed:
457
+ var _named: bool = named.append(str(given))
458
+ return [
459
+ at,
460
+ "has no key at %s s; its keys are at %s s. Nothing was removed." % [", ".join(named), ", ".join(held)]
461
+ ]
462
+
463
+
407
464
  ## The methods the keys of the tracks [param matching] call, each once.
408
465
  static func _methods_called(anim: Animation, matching: Array[int]) -> PackedStringArray:
409
466
  var called: PackedStringArray = []
@@ -168,8 +168,20 @@ func scan_status(_args: Dictionary) -> Dictionary:
168
168
  if not _editor_plugin:
169
169
  return {"ok": false, "error": "Editor plugin unavailable"}
170
170
  var filesystem: EditorFileSystem = EditorInterface.get_resource_filesystem()
171
+ # The scenes open in tabs, the one shown and the ones holding unsaved edits: a scene edit on disk
172
+ # meets what the editor holds of it, and nothing said which scenes those were. A tab of a scene
173
+ # never saved has no path, and a new project's editor opens with an empty one, so it is left out
174
+ # of the open; one with edits is kept among the unsaved, as the edits a close would lose.
175
+ var shown: Node = EditorInterface.get_edited_scene_root()
176
+ var open: Array[String] = []
177
+ open.assign(Array(EditorInterface.get_open_scenes()).filter(_has_path))
178
+ var unsaved: Array[String] = []
179
+ unsaved.assign(EditorInterface.get_unsaved_scenes())
171
180
  return {
172
181
  "ok": true,
182
+ "openScenes": open,
183
+ "editedScene": "" if shown == null else shown.scene_file_path,
184
+ "unsavedScenes": unsaved,
173
185
  "scanning": filesystem.is_scanning(),
174
186
  "importing": filesystem.is_importing(),
175
187
  "pending": GdhSceneFile.scan_due,
@@ -179,6 +191,10 @@ func scan_status(_args: Dictionary) -> Dictionary:
179
191
  }
180
192
 
181
193
 
194
+ func _has_path(path: String) -> bool:
195
+ return path != ""
196
+
197
+
182
198
  ## Reimport the files named under `paths`, whatever their state, the way the editor's Reimport
183
199
  ## button does, and report whether the reimport was started.
184
200
  ##
@@ -24,8 +24,26 @@ static func bake(scene: GdhSceneFile, region: Node) -> Dictionary:
24
24
  var at: String = scene.path_of(region)
25
25
  var flat: bool = region is NavigationRegion2D
26
26
  if not flat and not region is NavigationRegion3D:
27
- return scene.refuse(
28
- "%s is a %s, not a NavigationRegion2D or a NavigationRegion3D." % [at, region.get_class()]
27
+ var regions: PackedStringArray = []
28
+ for node: Node in _all_under(scene.root):
29
+ if node is NavigationRegion2D or node is NavigationRegion3D:
30
+ var _named: bool = regions.append(scene.path_of(node))
31
+ return (
32
+ scene
33
+ . refuse(
34
+ (
35
+ "%s is a %s, not a NavigationRegion2D or a NavigationRegion3D; %s."
36
+ % [
37
+ at,
38
+ region.get_class(),
39
+ (
40
+ "the scene has none, and scene_node add puts one in"
41
+ if regions.is_empty()
42
+ else "the scene's are at %s" % ", ".join(regions)
43
+ ),
44
+ ]
45
+ )
46
+ )
29
47
  )
30
48
  var property: String = "navigation_polygon" if flat else "navigation_mesh"
31
49
  var held: Resource = region.get(property)
@@ -64,6 +82,8 @@ static func bake(scene: GdhSceneFile, region: Node) -> Dictionary:
64
82
  )
65
83
  if polygons == 0:
66
84
  answer["note"] = _why_empty(held, flat, parsed)
85
+ elif flat and not parsed:
86
+ answer["note"] = _nothing_read(scene, held)
67
87
  if not flat and not Engine.is_editor_hint() and _reads_meshes(region, held):
68
88
  answer["meshesNote"] = (
69
89
  "The engine's warning that it had to parse RenderingServer meshes at runtime is about a "
@@ -97,7 +117,7 @@ static func bake(scene: GdhSceneFile, region: Node) -> Dictionary:
97
117
 
98
118
 
99
119
  ## Reads the source geometry for [param held] off a copy of the region at [param at] and bakes it
100
- ## into [param held], answering whether the copy gave any geometry.
120
+ ## into [param held], answering whether the copy gave any geometry: in 2D, any obstacle.
101
121
  static func _parse_and_bake(scene: GdhSceneFile, at: String, held: Resource, flat: bool) -> bool:
102
122
  var copy: Node = scene.root.duplicate(Node.DUPLICATE_GROUPS)
103
123
  var reading: Resource = held.duplicate()
@@ -116,7 +136,12 @@ static func _parse_and_bake(scene: GdhSceneFile, at: String, held: Resource, fla
116
136
  var outline: NavigationPolygon = reading
117
137
  var geometry: NavigationMeshSourceGeometryData2D = NavigationMeshSourceGeometryData2D.new()
118
138
  NavigationServer2D.parse_source_geometry_data(outline, geometry, region)
119
- found = geometry.has_data()
139
+ # has_data() counts only traversable outlines, so a bake carving every obstacle it read would
140
+ # still say it read nothing.
141
+ found = (
142
+ not geometry.get_obstruction_outlines().is_empty()
143
+ or not geometry.get_projected_obstructions().is_empty()
144
+ )
120
145
  var polygon: NavigationPolygon = held
121
146
  NavigationServer2D.bake_from_source_geometry_data(polygon, geometry)
122
147
  else:
@@ -215,6 +240,58 @@ static func _why_empty(held: Resource, flat: bool, parsed: bool) -> String:
215
240
  )
216
241
 
217
242
 
243
+ ## Why a 2D bake of [param held] left the whole outline walkable: no obstacle was read, and which
244
+ ## nodes of [param scene] that give obstacles are under no region. A tile layer beside the region
245
+ ## rather than under it is the usual cause, and the bake alone does not show it.
246
+ static func _nothing_read(scene: GdhSceneFile, held: Resource) -> String:
247
+ var polygon: NavigationPolygon = held
248
+ var said: String = (
249
+ (
250
+ "No obstacle was read from %s, so all the outlines enclose is walkable, less the "
251
+ + "agent_radius %s kept from their edges."
252
+ )
253
+ % [
254
+ _source_said(polygon.source_geometry_mode, polygon.source_geometry_group_name),
255
+ polygon.agent_radius
256
+ ]
257
+ )
258
+ var outside: PackedStringArray = []
259
+ for node: Node in _all_under(scene.root):
260
+ var gives: bool = (
261
+ node is TileMapLayer
262
+ or node is StaticBody2D
263
+ or node is MeshInstance2D
264
+ or node is Polygon2D
265
+ or node is NavigationObstacle2D
266
+ )
267
+ if gives and not _in_a_region(node):
268
+ var _named: bool = outside.append("%s (a %s)" % [scene.path_of(node), node.get_class()])
269
+ if outside.is_empty():
270
+ return said
271
+ return (
272
+ (
273
+ "%s %s %s outside what was read; the bake reads a node elsewhere once it is in the group %s and "
274
+ + "source_geometry_mode is groups_with_children, or once it is moved under the region."
275
+ )
276
+ % [
277
+ said,
278
+ ", ".join(outside),
279
+ "is" if outside.size() == 1 else "are",
280
+ polygon.source_geometry_group_name,
281
+ ]
282
+ )
283
+
284
+
285
+ ## Whether [param node] is under a 2D region, this one or another, whose obstacle it then is.
286
+ static func _in_a_region(node: Node) -> bool:
287
+ var above: Node = node.get_parent()
288
+ while above != null:
289
+ if above is NavigationRegion2D:
290
+ return true
291
+ above = above.get_parent()
292
+ return false
293
+
294
+
218
295
  static func _source_said(mode: int, group: String) -> String:
219
296
  if mode == 0:
220
297
  return "the region's children (source mode root_node_children)"
@@ -470,7 +470,7 @@ func set_theme_color(args: Dictionary) -> Dictionary:
470
470
  return _noting_variation(
471
471
  {
472
472
  "ok": true,
473
- "themePath": theme_path,
473
+ "resourcePath": theme_path,
474
474
  "controlType": control_type,
475
475
  "colorName": color_name,
476
476
  "color": {"r": stored.r, "g": stored.g, "b": stored.b, "a": stored.a}
@@ -524,7 +524,7 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
524
524
  return _noting_variation(
525
525
  {
526
526
  "ok": true,
527
- "themePath": theme_path,
527
+ "resourcePath": theme_path,
528
528
  "controlType": control_type,
529
529
  "fontSizeName": font_size_name,
530
530
  "size": saved.get_font_size(font_size_name, control_type)
@@ -572,7 +572,7 @@ func remove_theme_item(args: Dictionary) -> Dictionary:
572
572
  var unsaved: String = _save_theme(theme, theme_path, removed)
573
573
  if not unsaved.is_empty():
574
574
  return {"ok": false, "error": unsaved}
575
- return {"ok": true, "themePath": theme_path, "controlType": control_type, "removed": removed}
575
+ return {"ok": true, "resourcePath": theme_path, "controlType": control_type, "removed": removed}
576
576
 
577
577
 
578
578
  ## [param answer] with a note when its controlType names no class a theme styles. Any name is a
@@ -204,9 +204,11 @@ static func follow(changed: String, old_path: String, new_path: String, carried:
204
204
  file.close()
205
205
  GdhSceneFile.refresh(scene_path)
206
206
  followed.append(scene_path)
207
- if moves.is_empty() or not text.contains("NodePath("):
207
+ var holds_paths: bool = text.contains("NodePath(")
208
+ # A script is only read, for the lines naming the node, so it does not need the file written.
209
+ if moves.is_empty() or not (holds_paths or text.contains('Script"')):
208
210
  continue
209
- if not unsaved.is_empty() and not new_path.is_empty():
211
+ if holds_paths and not unsaved.is_empty() and not new_path.is_empty():
210
212
  not_followed.append({"scenePath": scene_path, "reason": unsaved})
211
213
  continue
212
214
  var before: int = carried.retargeted.size()
@@ -15,6 +15,7 @@ const GdhHeadlessCheck = preload("../headless_check.gd")
15
15
  const GdhRead = preload("../reading.gd")
16
16
  const GdhSceneFile = preload("../scene_file.gd")
17
17
  const GdhSceneUids = preload("../scene_uids.gd")
18
+ const GdhScriptPaths = preload("script_paths.gd")
18
19
  ## PROPERTY_HINT_NO_NODEPATH, which marks a property the editor's rename skips. It is not bound for
19
20
  ## scripts, and follows PROPERTY_HINT_ONESHOT in the engine's list.
20
21
  const HINT_NO_NODEPATH: int = PROPERTY_HINT_ONESHOT + 1
@@ -25,6 +26,14 @@ var retargeted: Array[Dictionary] = []
25
26
  var not_retargeted: Array[Dictionary] = []
26
27
  ## Each path naming a deleted node, or a node below one, which now names nothing.
27
28
  var still_naming: Array[Dictionary] = []
29
+ ## Each line of a script on a node naming a node that moved or went by the path it had, as
30
+ ## {scenePath, nodePath, script, line, from, to}, to absent for a deleted node. Code is left as it
31
+ ## was, as the editor's own rename leaves it.
32
+ var scripts_naming: Array[Dictionary] = []
33
+ ## The script lines already named, so one shared by an instance and its scene is named once.
34
+ var _lines: Dictionary = {}
35
+ ## The paths each script names, by the script, read once however many nodes it is on.
36
+ var _named_in: Dictionary = {}
28
37
  ## The files beside a scene holding a rewritten track, each to the resource saved there.
29
38
  var _homes: Dictionary = {}
30
39
  ## Each track rewritten in an animation one of [member _homes] keeps, as [animation, track, the path
@@ -87,6 +96,7 @@ func carry(scene: GdhSceneFile, moves: Dictionary) -> int:
87
96
  var mixer: AnimationMixer = node as AnimationMixer
88
97
  if mixer != null:
89
98
  _tracks(mixer, now)
99
+ _scripts(node, was, now)
90
100
  return retargeted.size() - before
91
101
 
92
102
 
@@ -146,9 +156,45 @@ func answer() -> Dictionary:
146
156
  fields["notRetargeted"] = not_retargeted
147
157
  if not still_naming.is_empty():
148
158
  fields["stillNaming"] = still_naming
159
+ if not scripts_naming.is_empty():
160
+ fields["scriptsNaming"] = scripts_naming
149
161
  return fields
150
162
 
151
163
 
164
+ ## Names each line of the scripts on [param node], its own and those it extends, that names from
165
+ ## where the node was a node the move took, by the path that no longer reaches it.
166
+ func _scripts(node: Node, was: String, now: String) -> void:
167
+ var script: Script = node.get_script()
168
+ while script is GDScript:
169
+ var file: String = script.resource_path
170
+ if not _named_in.has(script):
171
+ var source: String = script.source_code
172
+ if source.is_empty() and FileAccess.file_exists(file):
173
+ source = FileAccess.get_file_as_string(file)
174
+ _named_in[script] = GdhScriptPaths.named(source)
175
+ var listed: Array[Dictionary] = _named_in[script]
176
+ for named: Dictionary in listed:
177
+ var path: String = named["path"]
178
+ var key: String = "%s:%d:%s" % [file, named["line"], path]
179
+ if _lines.has(key):
180
+ continue
181
+ var after: Array = _after(NodePath(path), was, now)
182
+ if after[0] == UNCHANGED:
183
+ continue
184
+ _lines[key] = true
185
+ var record: Dictionary = {
186
+ "scenePath": _scene.path,
187
+ "nodePath": now,
188
+ "script": file,
189
+ "line": named["line"],
190
+ "from": path,
191
+ }
192
+ if after[0] == REWRITTEN:
193
+ record["to"] = after[1]
194
+ scripts_naming.append(record)
195
+ script = script.get_base_script()
196
+
197
+
152
198
  ## Puts back each track rewritten outside the scene whose record [param matching] takes, or that is
153
199
  ## kept in [param file], and answers their records. A file left with none is not saved.
154
200
  func _put_back(matching: Callable, file: String = "") -> Array[Dictionary]:
@@ -294,6 +340,8 @@ func _after(path: NodePath, was: String, now: String) -> Array:
294
340
  if holder.get_node_or_null(NodePath(path.get_concatenated_names())) == target:
295
341
  return [UNCHANGED, ""]
296
342
  var to: String = str(holder.get_path_to(target, unique))
343
+ if unique:
344
+ to = _unique_to(start, target, to)
297
345
  if path.get_subname_count() > 0:
298
346
  to += ":" + path.get_concatenated_subnames()
299
347
  return [REWRITTEN, to]
@@ -305,6 +353,17 @@ func _names_nothing(path: NodePath, now: String) -> bool:
305
353
  return holder != null and holder.get_node_or_null(NodePath(path.get_concatenated_names())) == null
306
354
 
307
355
 
356
+ ## The path to [param target] through the unique node that was at [param start], written through its
357
+ ## unique name as the path being carried was, or [param otherwise] when it is no longer unique. The
358
+ ## engine's own unique path is given only from a node with the same owner, so never from the root.
359
+ func _unique_to(start: String, target: Node, otherwise: String) -> String:
360
+ var through: Node = _scene.root.get_node_or_null(moved(start, _moves))
361
+ if through == null or not through.unique_name_in_owner:
362
+ return otherwise
363
+ var rest: String = str(through.get_path_to(target))
364
+ return "%" + through.name + ("" if rest == "." else "/" + rest)
365
+
366
+
308
367
  ## Where the node a path held by the node at [param now] names as %[param name] was: one marked
309
368
  ## unique in the same owner that had that name before the move, or a deleted one that had it.
310
369
  func _unique_was(name: String, now: String) -> String:
@@ -0,0 +1,148 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## The node paths a GDScript names in its own source, from the node it is on: `$Path`, `%Unique`,
5
+ ## and a string handed to get_node, get_node_or_null or has_node called on itself. Read from the
6
+ ## tokens rather than the lines, so a `$` or a `%` inside a string or a comment names nothing, and
7
+ ## a `%` after an operand is the remainder of a division rather than a unique name.
8
+
9
+ enum { NAME, STRING, MARK }
10
+
11
+ ## The calls that take a path from the node they are called on.
12
+ const CALLS: Array[String] = ["get_node", "get_node_or_null", "has_node"]
13
+ ## The words after which a `%` starts an operand.
14
+ const OPERAND_AFTER: Array[String] = [
15
+ "return", "and", "or", "not", "in", "if", "elif", "while", "await", "match", "when", "else"
16
+ ]
17
+ ## The marks after which a `%` starts an operand: an opening bracket, a separator or an operator.
18
+ const OPERAND_AFTER_MARKS: String = "([{,=:;+-*/<>!&|^~%"
19
+ const MARKS: String = "+-*/%<>=!&|^~()[]{},.:;$@?\\"
20
+ const QUOTES: String = "\"'"
21
+
22
+
23
+ ## Each path [param source] names, as {line, path}: the line it is on, counted from 1, and the path
24
+ ## as a NodePath writes it, a unique name with its `%`.
25
+ static func named(source: String) -> Array[Dictionary]:
26
+ var tokens: Array[Array] = _tokens(source)
27
+ var found: Array[Dictionary] = []
28
+ for at: int in tokens.size():
29
+ var token: Array = tokens[at]
30
+ var path: String = ""
31
+ if _is_mark(token, "$"):
32
+ path = _path_from(tokens, at + 1)
33
+ elif _is_mark(token, "%") and _starts_operand(tokens, at):
34
+ path = _path_from(tokens, at)
35
+ elif token[0] == NAME and CALLS.has(str(token[1])) and _on_itself(tokens, at):
36
+ if at + 2 < tokens.size() and _is_mark(tokens[at + 1], "(") and tokens[at + 2][0] == STRING:
37
+ path = str(tokens[at + 2][1])
38
+ if not path.is_empty():
39
+ found.append({"line": token[2], "path": path})
40
+ return found
41
+
42
+
43
+ ## The path written from [param at]: a string, or names joined by `/`, each a word, `.` or `..`,
44
+ ## and any of them a unique name after a `%`. "" when nothing there is a path.
45
+ static func _path_from(tokens: Array[Array], at: int) -> String:
46
+ if at < tokens.size() and tokens[at][0] == STRING:
47
+ return str(tokens[at][1])
48
+ var parts: PackedStringArray = []
49
+ var more: bool = true
50
+ while more:
51
+ var part: Array = _part_at(tokens, at)
52
+ if part.is_empty():
53
+ break
54
+ var _added: bool = parts.append(str(part[0]))
55
+ at = part[1]
56
+ more = at + 1 < tokens.size() and _is_mark(tokens[at], "/")
57
+ at += 1
58
+ return "/".join(parts)
59
+
60
+
61
+ ## The name of a path at [param at], as [its text, where the next token is], or [] when there is none.
62
+ static func _part_at(tokens: Array[Array], at: int) -> Array:
63
+ var unique: String = ""
64
+ if at < tokens.size() and _is_mark(tokens[at], "%"):
65
+ unique = "%"
66
+ at += 1
67
+ if at >= tokens.size():
68
+ return []
69
+ var token: Array = tokens[at]
70
+ if token[0] == NAME or (unique.is_empty() and _is_mark(token, ".", "..")):
71
+ return [unique + str(token[1]), at + 1]
72
+ if token[0] == STRING and not unique.is_empty():
73
+ return [unique + str(token[1]), at + 1]
74
+ return []
75
+
76
+
77
+ ## Whether the `%` at [param at] begins an operand, where after one it is a division's remainder.
78
+ static func _starts_operand(tokens: Array[Array], at: int) -> bool:
79
+ if at == 0:
80
+ return true
81
+ var before: Array = tokens[at - 1]
82
+ if before[0] == MARK:
83
+ return OPERAND_AFTER_MARKS.contains(str(before[1]))
84
+ return before[0] == NAME and OPERAND_AFTER.has(str(before[1]))
85
+
86
+
87
+ ## Whether the call named at [param at] is made on the script's own node: bare, or on `self`.
88
+ static func _on_itself(tokens: Array[Array], at: int) -> bool:
89
+ if at == 0 or not _is_mark(tokens[at - 1], "."):
90
+ return true
91
+ return at >= 2 and tokens[at - 2][0] == NAME and tokens[at - 2][1] == "self"
92
+
93
+
94
+ static func _is_mark(token: Array, mark: String, other: String = "") -> bool:
95
+ return token[0] == MARK and (token[1] == mark or (not other.is_empty() and token[1] == other))
96
+
97
+
98
+ ## [param source] as [kind, text, line] tokens, without comments or blank space. A string's text is
99
+ ## what is between its quotes, as written.
100
+ static func _tokens(source: String) -> Array[Array]:
101
+ var tokens: Array[Array] = []
102
+ var line: int = 1
103
+ var at: int = 0
104
+ while at < source.length():
105
+ var c: String = source[at]
106
+ var next: String = source[at + 1] if at + 1 < source.length() else ""
107
+ if c == "\n":
108
+ line += 1
109
+ at += 1
110
+ elif c == "#":
111
+ while at < source.length() and source[at] != "\n":
112
+ at += 1
113
+ elif c in " \t\r":
114
+ at += 1
115
+ elif QUOTES.contains(c) or ("r^&".contains(c) and QUOTES.contains(next) and not next.is_empty()):
116
+ var quoted: Array = _string(source, at if QUOTES.contains(c) else at + 1)
117
+ tokens.append([STRING, quoted[0], line])
118
+ line += str(quoted[0]).count("\n")
119
+ at = quoted[1]
120
+ elif c == "." and next == ".":
121
+ tokens.append([MARK, "..", line])
122
+ at += 2
123
+ elif MARKS.contains(c):
124
+ tokens.append([MARK, c, line])
125
+ at += 1
126
+ else:
127
+ var start: int = at
128
+ at += 1
129
+ while at < source.length() and not _ends_a_word(source[at]):
130
+ at += 1
131
+ tokens.append([NAME, source.substr(start, at - start), line])
132
+ return tokens
133
+
134
+
135
+ ## The string whose opening quote is at [param at], as [what is between its quotes, where it ends].
136
+ ## A backslash keeps the character after it inside the string, raw or not, as the tokenizer reads it.
137
+ static func _string(source: String, at: int) -> Array:
138
+ var quote: String = source[at]
139
+ var closing: String = quote.repeat(3) if source.substr(at, 3) == quote.repeat(3) else quote
140
+ var start: int = at + closing.length()
141
+ var end: int = start
142
+ while end < source.length() and source.substr(end, closing.length()) != closing:
143
+ end += 2 if source[end] == "\\" else 1
144
+ return [source.substr(start, end - start), mini(end + closing.length(), source.length())]
145
+
146
+
147
+ static func _ends_a_word(c: String) -> bool:
148
+ return c in " \t\r\n#" or MARKS.contains(c) or QUOTES.contains(c)
@@ -0,0 +1 @@
1
+ uid://s4xm1m4xqtxf
@@ -215,7 +215,8 @@ static func offered(said: String, word: String, names: Array) -> String:
215
215
  ## [param said], the refusal for [param property], which [param target] does not declare, with the
216
216
  ## names it declares nearest it when any are, as a misspelt one is usually one of them, or else the
217
217
  ## variables its script declares. The engine's own are not listed when none is near, since an object
218
- ## has a hundred, while a script's are the few a caller most likely meant.
218
+ ## has a hundred, while a script's are the few a caller most likely meant; an object with no script
219
+ ## is told where they are listed instead, since the refusal alone left nothing to try.
219
220
  static func near_property(said: String, target: Object, property: String) -> String:
220
221
  var headings: int = PROPERTY_USAGE_CATEGORY | PROPERTY_USAGE_GROUP | PROPERTY_USAGE_SUBGROUP
221
222
  var names: Array[String] = []
@@ -233,8 +234,15 @@ static func near_property(said: String, target: Object, property: String) -> Str
233
234
  var close: PackedStringArray = nearest(property, PackedStringArray(names))
234
235
  if not close.is_empty():
235
236
  return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
236
- if scripted.is_empty():
237
+ # With a script that declares nothing readable, as one that does not compile, the name is likelier
238
+ # the script's than the class's, so the class's list would point the wrong way.
239
+ if scripted.is_empty() and target.get_script() != null:
237
240
  return said
241
+ if scripted.is_empty():
242
+ return (
243
+ "%s; editor_classes info with className %s and includeInherited true lists the ones it has"
244
+ % [said, target.get_class()]
245
+ )
238
246
  scripted.sort()
239
247
  return "%s; its script declares %s" % [said, ", ".join(PackedStringArray(scripted))]
240
248