gdharness 1.3.1 → 1.3.2

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 (34) hide show
  1. package/build/cli.js +311 -86
  2. package/build/godot/addons/gdharness_editor/declared_values.gd +116 -0
  3. package/build/godot/addons/gdharness_editor/declared_values.gd.uid +1 -0
  4. package/build/godot/addons/gdharness_editor/file_reads.gd +5 -0
  5. package/build/godot/addons/gdharness_editor/property_values.gd +106 -10
  6. package/build/godot/addons/gdharness_editor/reading.gd +37 -0
  7. package/build/godot/addons/gdharness_editor/serialisation.gd +4 -0
  8. package/build/godot/addons/gdharness_editor/tools/resource_tools.gd +133 -39
  9. package/build/godot/addons/gdharness_editor/tools/scene_tools.gd +9 -9
  10. package/build/godot/addons/gdharness_editor/value_shapes.gd +7 -0
  11. package/build/godot/addons/gdharness_runtime/reading.gd +37 -0
  12. package/build/godot/addons/gdharness_runtime/runtime_values.gd +27 -1
  13. package/build/godot/addons/gdharness_runtime/serialisation.gd +4 -0
  14. package/build/godot/addons/gdharness_runtime/value_shapes.gd +7 -0
  15. package/build/godot/operations/classdb_queries.gd +13 -3
  16. package/build/godot/operations/declared_values.gd +116 -0
  17. package/build/godot/operations/dependencies.gd +109 -5
  18. package/build/godot/operations/export_presets.gd +4 -6
  19. package/build/godot/operations/file_reads.gd +5 -0
  20. package/build/godot/operations/file_walk.gd +34 -0
  21. package/build/godot/operations/gdscript_analysis.gd +7 -2
  22. package/build/godot/operations/gdscript_authoring.gd +105 -2
  23. package/build/godot/operations/godot_operations.gd +2 -0
  24. package/build/godot/operations/import_pipeline.gd +39 -0
  25. package/build/godot/operations/input_actions.gd +73 -1
  26. package/build/godot/operations/project_config.gd +59 -3
  27. package/build/godot/operations/project_diagnostics.gd +27 -2
  28. package/build/godot/operations/property_values.gd +106 -10
  29. package/build/godot/operations/reading.gd +37 -0
  30. package/build/godot/operations/resource_files.gd +4 -1
  31. package/build/godot/operations/script_check.gd +16 -0
  32. package/build/godot/operations/serialisation.gd +4 -0
  33. package/build/godot/operations/value_shapes.gd +7 -0
  34. package/package.json +1 -1
@@ -252,7 +252,7 @@ func create_resource(args: Dictionary) -> Dictionary:
252
252
  if not folder.is_empty():
253
253
  return {"ok": false, "error": folder}
254
254
  var answer: Dictionary = {"ok": true, "resourcePath": res_path, "resourceType": asked_type}
255
- _properties.note_also_changed(answer)
255
+ _properties.note_write(answer)
256
256
  var failed: String = _save_checked(resource, res_path, properties.keys(), null)
257
257
  if not failed.is_empty():
258
258
  return {"ok": false, "error": failed}
@@ -290,7 +290,7 @@ func modify_resource(args: Dictionary) -> Dictionary:
290
290
  if not refused.is_empty():
291
291
  return {"ok": false, "error": refused}
292
292
  var answer: Dictionary = {"ok": true, "resourcePath": res_path}
293
- _properties.note_also_changed(answer)
293
+ _properties.note_write(answer)
294
294
  var previous: PackedByteArray = FileAccess.get_file_as_bytes(res_path)
295
295
  var failed: String = _save_checked(resource, res_path, properties.keys(), previous)
296
296
  if not failed.is_empty():
@@ -346,7 +346,19 @@ func create_tileset(args: Dictionary) -> Dictionary:
346
346
  var tileset_path: String = _ensure_res_path(str(args.get("resourcePath", "")))
347
347
  if tileset_path == "res://":
348
348
  return {"ok": false, "error": "resourcePath is required"}
349
- var taken: String = _taken(tileset_path, "resource_edit op=create_tileset")
349
+ # modify sets properties and builds no atlas, so the other path is the only way on offered.
350
+ var taken: String = (
351
+ ""
352
+ if not FileAccess.file_exists(tileset_path)
353
+ else (
354
+ (
355
+ "%s already exists, and create_tileset builds a whole new TileSet, which would replace "
356
+ + "everything in it, the tiles a scene paints with among them. Give resourcePath a path "
357
+ + "nothing is at."
358
+ )
359
+ % tileset_path
360
+ )
361
+ )
350
362
  if not taken.is_empty():
351
363
  return {"ok": false, "error": taken}
352
364
 
@@ -374,9 +386,16 @@ func create_tileset(args: Dictionary) -> Dictionary:
374
386
  "ok": false,
375
387
  "error": "The TileSet did not take the atlas for %s" % atlas.texture.resource_path
376
388
  }
377
- made.append(
378
- {"sourceId": added, "texture": atlas.texture.resource_path, "tiles": atlas.get_tiles_count()}
379
- )
389
+ var entry: Dictionary = {
390
+ "sourceId": added, "texture": atlas.texture.resource_path, "tiles": atlas.get_tiles_count()
391
+ }
392
+ var blank: Array = built[2]
393
+ if not blank.is_empty():
394
+ entry["leftBlank"] = blank
395
+ var spare: String = built[3]
396
+ if not spare.is_empty():
397
+ entry["notInAnyTile"] = spare
398
+ made.append(entry)
380
399
 
381
400
  var folder: String = GdhSceneFile.make_folder_for(tileset_path)
382
401
  if not folder.is_empty():
@@ -389,11 +408,14 @@ func create_tileset(args: Dictionary) -> Dictionary:
389
408
  return {"ok": true, "tilesetPath": tileset_path, "sources": made}
390
409
 
391
410
 
392
- ## One atlas source from its description, with its tiles made, as [true, source] or [false, why].
411
+ ## One atlas source from its description, with its tiles made, as [true, source, the grid cells left
412
+ ## without a tile for being wholly transparent, a sentence on the pixels no tile covers or ""] or
413
+ ## [false, why].
393
414
  ##
394
415
  ## Tiles are made in every cell of the grid that has a pixel that is not transparent, which is
395
416
  ## what the editor offers to do when an atlas gets a texture. Without them the atlas held no tiles,
396
- ## and every cell painted from it drew nothing.
417
+ ## and every cell painted from it drew nothing. The cells passed over and the strip a tile size that
418
+ ## does not divide the picture leaves are said, since a count alone left a caller to work out which.
397
419
  func _atlas(entry: Variant, index: int) -> Array:
398
420
  if not entry is Dictionary:
399
421
  return [false, "sources[%d] must be an object with texture and tileSize" % index]
@@ -455,6 +477,7 @@ func _atlas(entry: Variant, index: int) -> Array:
455
477
  )
456
478
  ]
457
479
  var image: Image = texture.get_image()
480
+ var blank: Array = []
458
481
  for y: int in grid.y:
459
482
  for x: int in grid.x:
460
483
  var cell: Vector2i = Vector2i(x, y)
@@ -464,9 +487,33 @@ func _atlas(entry: Variant, index: int) -> Array:
464
487
  atlas.texture_region_size
465
488
  )
466
489
  if image != null and _blank(image, region):
490
+ blank.append({"x": x, "y": y})
467
491
  continue
468
492
  atlas.create_tile(cell)
469
- return [true, atlas]
493
+ var covered: Vector2i = atlas.margins + grid * tile_size + (grid - Vector2i.ONE) * atlas.separation
494
+ var left: Vector2i = Vector2i(texture.get_size()) - covered
495
+ var sides: PackedStringArray = []
496
+ if left.x > 0:
497
+ var _right: bool = sides.append("%d px on the right" % left.x)
498
+ if left.y > 0:
499
+ var _bottom: bool = sides.append("%d px at the bottom" % left.y)
500
+ var spare: String = (
501
+ ""
502
+ if sides.is_empty()
503
+ else (
504
+ "%s is %d by %d, so %s %s in no %d by %d tile."
505
+ % [
506
+ texture_path,
507
+ texture.get_width(),
508
+ texture.get_height(),
509
+ " and ".join(sides),
510
+ "is" if sides.size() == 1 else "are",
511
+ tile_size.x,
512
+ tile_size.y
513
+ ]
514
+ )
515
+ )
516
+ return [true, atlas, blank, spare]
470
517
 
471
518
 
472
519
  func _blank(image: Image, region: Rect2i) -> bool:
@@ -475,12 +522,15 @@ func _blank(image: Image, region: Rect2i) -> bool:
475
522
  return image.get_region(region).is_invisible()
476
523
 
477
524
 
478
- ## The source ids a tile set holds, for saying what a cell could have named instead.
479
- func _source_ids(tile_set: TileSet) -> Array[int]:
480
- var ids: Array[int] = []
525
+ ## The sources a tile set holds, as "source 0", "sources 0, 2" or "no sources", for saying what a
526
+ ## cell could have named instead.
527
+ func _sources_of(tile_set: TileSet) -> String:
528
+ var ids: PackedStringArray = []
481
529
  for index: int in tile_set.get_source_count():
482
- ids.append(tile_set.get_source_id(index))
483
- return ids
530
+ var _listed: bool = ids.append(str(tile_set.get_source_id(index)))
531
+ if ids.is_empty():
532
+ return "no sources"
533
+ return ("source " if ids.size() == 1 else "sources ") + ", ".join(ids)
484
534
 
485
535
 
486
536
  func set_tilemap_cells(args: Dictionary) -> Dictionary:
@@ -517,11 +567,20 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
517
567
  var tile_set: TileSet = layered.tile_set if layered != null else single.tile_set
518
568
 
519
569
  scene.asked(node, ["layer_%d/tile_data" % layer if layered != null else "tile_map_data"])
520
- var wanted: Array = []
570
+ # Every cell is checked before any is placed, and every one refused is named: stopping at the first
571
+ # left a caller fixing one cell a call.
572
+ var checked: Array = []
573
+ var refusals: PackedStringArray = []
521
574
  for index: int in placed.size():
522
575
  var cell: Array = _cell(placed[index], index, tile_set)
523
576
  if not cell[0]:
524
- return scene.refuse(str(cell[1]))
577
+ var _named: bool = refusals.append(str(cell[1]))
578
+ checked.append(cell)
579
+ if not refusals.is_empty():
580
+ return scene.refuse("%s. Nothing was placed." % "; ".join(refusals))
581
+ var wanted: Array = []
582
+ var erased: int = 0
583
+ for cell: Array in checked:
525
584
  var at: Vector2i = cell[1]
526
585
  var source_id: int = cell[2]
527
586
  var atlas_coords: Vector2i = cell[3]
@@ -531,6 +590,8 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
531
590
  else:
532
591
  single.set_cell(at, source_id, atlas_coords, alternative)
533
592
  wanted.append([at, source_id, atlas_coords, alternative])
593
+ if source_id == -1:
594
+ erased += 1
534
595
 
535
596
  var at_path: String = scene.path_of(node)
536
597
  var written: Dictionary = scene.write(
@@ -546,7 +607,13 @@ func set_tilemap_cells(args: Dictionary) -> Dictionary:
546
607
  if not written.is_empty():
547
608
  return written
548
609
 
549
- return {"ok": true, "layer": layer, "placed": wanted.size(), "requested": placed.size()}
610
+ return {
611
+ "ok": true,
612
+ "layer": layer,
613
+ "placed": wanted.size() - erased,
614
+ "erased": erased,
615
+ "requested": placed.size()
616
+ }
550
617
 
551
618
 
552
619
  ## The node at [param node_path] cells are painted on, with a TileSet, as [node, why not].
@@ -575,40 +642,55 @@ func _tile_node(scene: GdhSceneFile, node_path: String, layer: int) -> Array:
575
642
 
576
643
 
577
644
  ## One cell from its description, checked against [param tile_set], as
578
- ## [true, coords, source id, atlas coords, alternative] or [false, why].
645
+ ## [true, coords, source id, atlas coords, alternative] or [false, why]. A source of -1 erases the
646
+ ## cell, as set_cell takes it, and needs no atlasCoords.
579
647
  func _cell(entry: Variant, index: int, tile_set: TileSet) -> Array:
580
648
  if not entry is Dictionary:
581
649
  return [false, "cells[%d] must be an object with coords, sourceId and atlasCoords" % index]
582
650
  var cell: Dictionary = entry
583
- for field: String in ["coords", "sourceId", "atlasCoords"]:
651
+ for field: String in ["coords", "sourceId"]:
584
652
  if not cell.has(field):
585
653
  return [false, "cells[%d] has no %s" % [index, field]]
586
654
  var at: Array = _xy(cell["coords"], "cells[%d].coords" % index)
587
655
  if not at[0]:
588
656
  return at
657
+ var coords: Vector2i = at[1]
658
+ var named: String = "cells[%d] at (%d, %d)" % [index, coords.x, coords.y]
659
+ var source_id: int = GdhRead.as_int(cell["sourceId"], -2)
660
+ if source_id == -1:
661
+ return [true, coords, -1, Vector2i(-1, -1), -1]
662
+ if not cell.has("atlasCoords"):
663
+ return [
664
+ false, "%s has no atlasCoords, which every cell but an erased one (sourceId -1) needs" % named
665
+ ]
589
666
  var atlas_at: Array = _xy(cell["atlasCoords"], "cells[%d].atlasCoords" % index)
590
667
  if not atlas_at[0]:
591
668
  return atlas_at
592
- var source_id: int = GdhRead.as_int(cell["sourceId"], -1)
593
669
  if not tile_set.has_source(source_id):
594
- return [false, "The TileSet has no source %d; it has %s" % [source_id, _source_ids(tile_set)]]
670
+ return [
671
+ false,
672
+ (
673
+ "%s names source %d, and the TileSet has %s; sourceId -1 erases a cell"
674
+ % [named, source_id, _sources_of(tile_set)]
675
+ )
676
+ ]
595
677
  var source: TileSetSource = tile_set.get_source(source_id)
596
678
  var atlas_coords: Vector2i = atlas_at[1]
597
679
  var alternative: int = GdhRead.as_int(cell.get("alternativeTile", 0))
598
680
  if not source.has_tile(atlas_coords):
599
681
  return [
600
682
  false,
601
- (
602
- "cells[%d]: source %d has no tile at %s; it has %s. Nothing was placed."
603
- % [index, source_id, atlas_coords, _tiles_of(source)]
604
- )
683
+ "%s: source %d has no tile at %s; it has %s" % [named, source_id, atlas_coords, _tiles_of(source)]
605
684
  ]
606
685
  if not source.has_alternative_tile(atlas_coords, alternative):
607
686
  return [
608
687
  false,
609
- "The tile at %s in source %d has no alternative %d" % [atlas_coords, source_id, alternative]
688
+ (
689
+ "%s: the tile at %s in source %d has no alternative %d"
690
+ % [named, atlas_coords, source_id, alternative]
691
+ )
610
692
  ]
611
- return [true, at[1], source_id, atlas_coords, alternative]
693
+ return [true, coords, source_id, atlas_coords, alternative]
612
694
 
613
695
 
614
696
  ## The atlas coordinates [param source] has tiles at, the first twelve and how many more, or that it
@@ -722,13 +804,12 @@ func set_theme_color(args: Dictionary) -> Dictionary:
722
804
  if theme == null:
723
805
  return {"ok": false, "error": "No Theme at %s. Create one there first." % theme_path}
724
806
 
725
- var c: Dictionary = args.get("color", {})
726
- var color: Color = Color(
727
- GdhRead.as_float(c.get("r", 1.0), 1.0),
728
- GdhRead.as_float(c.get("g", 1.0), 1.0),
729
- GdhRead.as_float(c.get("b", 1.0), 1.0),
730
- GdhRead.as_float(c.get("a", 1.0), 1.0)
731
- )
807
+ # Read as a property write reads a colour, html code included, so a value scene_node takes for a
808
+ # Button's modulate is taken for its theme colour too; "#ff8000" was refused here alone.
809
+ var parsed: Array = _properties.colour_from(args.get("color", {}), "color")
810
+ if not parsed[0]:
811
+ return {"ok": false, "error": parsed[1]}
812
+ var color: Color = parsed[1]
732
813
  theme.set_color(color_name, control_type, color)
733
814
 
734
815
  var unsaved: String = _save_theme(theme, theme_path, "%s/colors/%s" % [control_type, color_name])
@@ -768,17 +849,18 @@ func set_theme_font_size(args: Dictionary) -> Dictionary:
768
849
  # Godot reads a size of 0 or less as unset and answers the theme's default, so -3 would be read
769
850
  # back as 16.
770
851
  var pixels: float = GdhRead.as_float(args.get("size"), 0.0)
771
- if not is_finite(pixels) or pixels < 1 or pixels != floorf(pixels):
772
- var shown: String = str(int(pixels)) if pixels == floorf(pixels) else str(pixels)
852
+ if not is_finite(pixels) or pixels != floorf(pixels):
853
+ return {"ok": false, "error": "size is a whole number of pixels, and %s is not one." % str(pixels)}
854
+ if pixels < 1:
773
855
  return {
774
856
  "ok": false,
775
857
  "error":
776
858
  (
777
859
  (
778
- "size must be a whole number of pixels, 1 or more, not %s: Godot reads 0 or less as no size "
779
- % shown
860
+ "size must be 1 or more, not %d: the theme would hold it, and Godot reads a size of 0 or "
861
+ + "less as unset and draws the default size instead."
780
862
  )
781
- + "and uses the theme's default instead."
863
+ % int(pixels)
782
864
  )
783
865
  }
784
866
  theme.set_font_size(font_size_name, control_type, int(pixels))
@@ -819,4 +901,16 @@ func _noting_variation(answer: Dictionary) -> Dictionary:
819
901
  )
820
902
  % [type, type]
821
903
  )
904
+ # A variation is any name, so a misspelt class is written all the same, and the classes nearest
905
+ # it are what tells a caller which one was meant.
906
+ var themed: PackedStringArray = []
907
+ for known: String in ClassDB.get_class_list():
908
+ if ClassDB.is_parent_class(known, "Control") or ClassDB.is_parent_class(known, "Window"):
909
+ var _added: bool = themed.append(known)
910
+ var close: PackedStringArray = GdhRead.nearest(type, themed)
911
+ if not close.is_empty():
912
+ answer["note"] += (
913
+ " If a class was meant, the nearest %s %s."
914
+ % ["is" if close.size() == 1 else "are", ", ".join(close)]
915
+ )
822
916
  return answer
@@ -60,17 +60,15 @@ func _find_node(root: Node, path: String) -> Node:
60
60
  ## Why the property or signal [param missing] could not be found on [param node], adding the one
61
61
  ## cause the name alone does not show: a script that does not compile leaves the editor holding a
62
62
  ## placeholder with no properties or signals at all, and the refusal named the property instead.
63
+ ## Such a script has no engine class beneath it. Compiling a copy of the source to find out failed on
64
+ ## every script with a class_name, since the copy hides the global class the original declares.
63
65
  func _missing_on(node: Node, missing: String) -> String:
64
66
  var script: Script = node.get_script()
65
- if script == null or not script.has_source_code():
66
- return missing
67
- var probe: GDScript = GDScript.new()
68
- probe.source_code = script.source_code
69
- if probe.reload() == OK:
67
+ if script == null or not str(script.get_instance_base_type()).is_empty():
70
68
  return missing
71
69
  return (
72
70
  "%s. Its script, %s, does not compile, so none of what it declares is known here."
73
- % [missing, script.resource_path]
71
+ % [missing.trim_suffix("."), script.resource_path]
74
72
  )
75
73
 
76
74
 
@@ -343,7 +341,7 @@ func add_node(args: Dictionary) -> Dictionary:
343
341
  "nodePath": new_path,
344
342
  "index": new_node.get_index(),
345
343
  }
346
- _properties.note_also_changed(answer, new_node)
344
+ _properties.note_write(answer, new_node)
347
345
  var written: Dictionary = scene.write(
348
346
  func(saved: Node) -> String:
349
347
  return _kept(
@@ -608,7 +606,7 @@ func set_node_properties(args: Dictionary) -> Dictionary:
608
606
  held[str(key)] = _readable(node, _properties.read_at(node, str(key)))
609
607
  var new_path: String = scene.path_of(node)
610
608
  var answer: Dictionary = {"ok": true, "nodePath": new_path, "properties": held}
611
- _properties.note_also_changed(answer, node)
609
+ _properties.note_write(answer, node)
612
610
  var carried: GdhPaths = GdhPaths.new()
613
611
  var _rewritten: int = carried.carry(scene, {old_path: new_path} if new_path != old_path else {})
614
612
  var written: Dictionary = scene.write(
@@ -848,7 +846,8 @@ func _connection_ends(
848
846
  if not foreign.is_empty():
849
847
  return [null, foreign]
850
848
  if not source.has_signal(signal_name):
851
- return [null, _missing_on(source, "%s has no signal %s" % [source_node_path, signal_name])]
849
+ var said: String = "%s has no signal %s" % [source_node_path, signal_name]
850
+ return [null, _missing_on(source, GdhRead.offered(said, signal_name, GdhRead.signals_of(source)))]
852
851
  return [source, target]
853
852
 
854
853
 
@@ -920,6 +919,7 @@ func disconnect_signal(args: Dictionary) -> Dictionary:
920
919
 
921
920
  return {
922
921
  "ok": true,
922
+ "removed": true,
923
923
  "sourceNodePath": from,
924
924
  "signalName": signal_name,
925
925
  "targetNodePath": to,
@@ -82,6 +82,13 @@ static func spare(fields: Dictionary, tag: String) -> Array:
82
82
  ##
83
83
  ## The refusal names only the two types, and the key is what the caller has to take out.
84
84
  static func remedy(value: Variant, type: int) -> String:
85
+ if type == TYPE_STRING and typeof(value) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]:
86
+ var number: Variant = value
87
+ if value is float:
88
+ var whole: float = value
89
+ if is_finite(whole) and whole == floorf(whole):
90
+ number = int(whole)
91
+ return ' Text is sent in quotes, as "%s".' % str(number)
85
92
  var shape: String = written_as(type)
86
93
  if shape.is_empty():
87
94
  return ""
@@ -164,6 +164,43 @@ static func not_of_kind(
164
164
  return told + " The scene's %ss are at %s." % [kind, shown]
165
165
 
166
166
 
167
+ ## Up to three of [param names] closest to [param word], closest first, ignoring case: the names a
168
+ ## refusal offers for one that is not there, as a typo usually is one of them.
169
+ static func nearest(word: String, names: PackedStringArray) -> PackedStringArray:
170
+ var wanted: String = word.to_lower()
171
+ var close: Array[String] = []
172
+ for name: String in names:
173
+ if name.to_lower().similarity(wanted) >= 0.5:
174
+ close.append(name)
175
+ close.sort_custom(
176
+ func(a: String, b: String) -> bool:
177
+ return a.to_lower().similarity(wanted) > b.to_lower().similarity(wanted)
178
+ )
179
+ return PackedStringArray(close.slice(0, 3))
180
+
181
+
182
+ ## [param said], the refusal for [param word], which is not among [param names], with those nearest
183
+ ## it when any are, or else every one of them, so the caller has a name to send instead.
184
+ static func offered(said: String, word: String, names: Array) -> String:
185
+ var every: PackedStringArray = PackedStringArray(names)
186
+ var close: PackedStringArray = nearest(word, every)
187
+ if close.is_empty():
188
+ return "%s; it has %s" % [said, ", ".join(every)]
189
+ return "%s; the nearest it has %s %s" % [said, "is" if close.size() == 1 else "are", ", ".join(close)]
190
+
191
+
192
+ ## The signals [param object] has that a caller can connect, its own class's first: the engine
193
+ ## lists them from Object down, which puts a Button's pressed after thirty a Control has, and names
194
+ ## starting with an underscore are the engine's own.
195
+ static func signals_of(object: Object) -> Array:
196
+ var names: Array = []
197
+ for entry: Dictionary in object.get_signal_list():
198
+ var name: String = str(entry["name"])
199
+ if not name.begins_with("_"):
200
+ names.push_front(name)
201
+ return names
202
+
203
+
167
204
  ## [param word] after the article it takes, "an AnimationTree" or "a Node2D".
168
205
  static func with_article(word: String) -> String:
169
206
  return ("an " if word.left(1).to_upper() in ["A", "E", "I", "O", "U"] else "a ") + word
@@ -71,10 +71,36 @@ static func node_at(root: Node, path: String) -> Dictionary:
71
71
 
72
72
  var node: Node = root.get_node_or_null(path)
73
73
  if node == null:
74
- return {"type": "error", "message": "Node not found: " + path}
74
+ return {"type": "error", "message": _not_found(root, path)}
75
75
  return {"node": node}
76
76
 
77
77
 
78
+ ## "Node not found" for [param path], with the deepest node of it the game has and the children that
79
+ ## node has instead, as a scene's refusal names them: a mistyped step was otherwise found by reading
80
+ ## the whole tree.
81
+ static func _not_found(root: Node, path: String) -> String:
82
+ var said: String = "Node not found: " + path
83
+ var parts: PackedStringArray = path.split("/", false)
84
+ var at: Node = root
85
+ var step: int = 0
86
+ if path.begins_with("/"):
87
+ if parts.is_empty() or parts[0] != str(root.name):
88
+ return said
89
+ step = 1
90
+ while step < parts.size():
91
+ var below: Node = at.get_node_or_null(NodePath(parts[step]))
92
+ if below == null:
93
+ break
94
+ at = below
95
+ step += 1
96
+ if step >= parts.size():
97
+ return said
98
+ var names: PackedStringArray = []
99
+ for child: Node in at.get_children():
100
+ var _named: bool = names.append(str(child.name))
101
+ return GdhRead.missing_below(said, "", PackedStringArray([str(at.get_path())]), parts[step], names)
102
+
103
+
78
104
  ## How many process frames a whole press holds before its release, from `holdFrames`: one unless
79
105
  ## asked, as the halves of a press have always been a frame apart. Held inside the game because the
80
106
  ## time between a press, a wait and a release sent as three calls counts as held too: a 60-frame
@@ -410,6 +410,10 @@ func fitted(value: Variant, type: int) -> Variant:
410
410
  var simple: Array[int] = [TYPE_BOOL, TYPE_INT, TYPE_FLOAT, TYPE_STRING]
411
411
  if not simple.has(type) or not simple.has(typeof(rebuilt)):
412
412
  return rebuilt
413
+ # A number or a truth value stays as sent where text is wanted, for the refusal to name. Converted,
414
+ # 1 arrived as the double it is in JSON and was saved as "1.0", which is not the text anyone sends.
415
+ if type == TYPE_STRING and not rebuilt is String:
416
+ return rebuilt
413
417
 
414
418
  # Digits are read as digits, since read as JSON they become a double and one past 2^53 a
415
419
  # neighbour of the number written.
@@ -82,6 +82,13 @@ static func spare(fields: Dictionary, tag: String) -> Array:
82
82
  ##
83
83
  ## The refusal names only the two types, and the key is what the caller has to take out.
84
84
  static func remedy(value: Variant, type: int) -> String:
85
+ if type == TYPE_STRING and typeof(value) in [TYPE_BOOL, TYPE_INT, TYPE_FLOAT]:
86
+ var number: Variant = value
87
+ if value is float:
88
+ var whole: float = value
89
+ if is_finite(whole) and whole == floorf(whole):
90
+ number = int(whole)
91
+ return ' Text is sent in quotes, as "%s".' % str(number)
85
92
  var shape: String = written_as(type)
86
93
  if shape.is_empty():
87
94
  return ""
@@ -183,13 +183,23 @@ func _not_found(wanted: String) -> Dictionary:
183
183
  )
184
184
  )
185
185
  )
186
+ # The usual miss is a spelling, one letter off a class the engine has.
187
+ var close: PackedStringArray = GdhRead.nearest(wanted, ClassDB.get_class_list())
188
+ var offered: String = (
189
+ ""
190
+ if close.is_empty()
191
+ else (
192
+ " The nearest engine %s %s."
193
+ % ["class is" if close.size() == 1 else "classes are", ", ".join(close)]
194
+ )
195
+ )
186
196
  return _log.failure(
187
197
  (
188
198
  (
189
- "Class not found: %s. editor_classes reads the engine's ClassDB, so a class_name a project "
190
- + "script declares is not in it either; script_info structure reads one."
199
+ "Class not found: %s.%s editor_classes reads the engine's ClassDB, so a class_name a "
200
+ + "project script declares is not in it either; script_info structure reads one."
191
201
  )
192
- % wanted
202
+ % [wanted, offered]
193
203
  )
194
204
  )
195
205
 
@@ -0,0 +1,116 @@
1
+ @tool
2
+ extends RefCounted
3
+
4
+ ## What a property declares beyond its type: the values of its enum and the range it is exported
5
+ ## with, for taking a value by name and for saying when a value written lies outside them.
6
+
7
+ const GdhRead = preload("reading.gd")
8
+
9
+
10
+ ## The values an enum hint names, by name, from its hint string: "SWORD:0,SHIELD:1" as a script
11
+ ## declares one, or "Add,Subtract" as an engine property does, each name then taking the number
12
+ ## after the one before it.
13
+ static func enum_values(hint_string: String) -> Dictionary:
14
+ var values: Dictionary = {}
15
+ var next: int = 0
16
+ for entry: String in hint_string.split(",", false):
17
+ var name: String = entry.get_slice(":", 0).strip_edges()
18
+ var number: String = entry.get_slice(":", 1).strip_edges() if ":" in entry else ""
19
+ var value: int = number.to_int() if number.is_valid_int() else next
20
+ values[name] = value
21
+ next = value + 1
22
+ return values
23
+
24
+
25
+ ## The values of the enum [param declared] declares, by name, spelt as the script of [param target]
26
+ ## spells them when it declares that enum. The engine's hint capitalises each name, "SWORD" as
27
+ ## "Sword", and a refusal listing those named values the script never spells.
28
+ static func enum_of(target: Object, declared: Dictionary) -> Dictionary:
29
+ var hinted: Dictionary = enum_values(str(declared.get("hint_string", "")))
30
+ var attached: Variant = target.get_script()
31
+ if not attached is Script:
32
+ return hinted
33
+ var script: Script = attached
34
+ for constant: Variant in script.get_script_constant_map().values():
35
+ if not constant is Dictionary:
36
+ continue
37
+ var values: Dictionary = constant
38
+ if values.size() != hinted.size():
39
+ continue
40
+ var spelt: Dictionary = {}
41
+ for name: Variant in values:
42
+ if hinted.get(str(name).capitalize()) == values[name]:
43
+ spelt[str(name)] = values[name]
44
+ if spelt.size() == hinted.size():
45
+ return spelt
46
+ return hinted
47
+
48
+
49
+ ## The number [param word] names among [param choices], matched as written and then ignoring case,
50
+ ## or null when it names none.
51
+ static func enum_named(choices: Dictionary, word: String) -> Variant:
52
+ var wanted: String = word.strip_edges()
53
+ if choices.has(wanted):
54
+ return choices[wanted]
55
+ for name: String in choices:
56
+ if name.to_lower() == wanted.to_lower():
57
+ return choices[name]
58
+ return null
59
+
60
+
61
+ static func enum_refusal(holder: String, property: String, word: String, choices: Dictionary) -> String:
62
+ var said: String = (
63
+ "%s.%s is an enum, and %s is none of its values: send %s by name or by number"
64
+ % [holder, property, word, _listed(choices)]
65
+ )
66
+ var close: PackedStringArray = GdhRead.nearest(word, PackedStringArray(choices.keys()))
67
+ if close.is_empty():
68
+ return said + "."
69
+ return said + ". The nearest %s %s." % ["is" if close.size() == 1 else "are", ", ".join(close)]
70
+
71
+
72
+ ## Why [param value], shown as [param shown], lies outside what [param declared] allows, an export
73
+ ## range or an enum's values, or "" when it does not. A range marked or_greater or or_less is open
74
+ ## on that side.
75
+ static func outside(target: Object, declared: Dictionary, value: Variant, shown: String) -> String:
76
+ var hint: int = GdhRead.as_int(declared.get("hint", PROPERTY_HINT_NONE))
77
+ var hint_string: String = str(declared.get("hint_string", ""))
78
+ var property: String = str(declared.get("name", ""))
79
+ if typeof(value) != TYPE_INT and typeof(value) != TYPE_FLOAT:
80
+ return ""
81
+ var number: float = GdhRead.as_float(value)
82
+ if hint == PROPERTY_HINT_ENUM and typeof(value) == TYPE_INT:
83
+ var choices: Dictionary = enum_of(target, declared)
84
+ if choices.is_empty() or choices.values().has(value):
85
+ return ""
86
+ return "%d is none of the values %s declares: %s." % [value, property, _listed(choices)]
87
+ if hint != PROPERTY_HINT_RANGE:
88
+ return ""
89
+ var parts: PackedStringArray = []
90
+ for part: String in hint_string.split(","):
91
+ var _added: bool = parts.append(part.strip_edges())
92
+ if parts.size() < 2 or not parts[0].is_valid_float() or not parts[1].is_valid_float():
93
+ return ""
94
+ var low: float = parts[0].to_float()
95
+ var high: float = parts[1].to_float()
96
+ if (number <= high or parts.has("or_greater")) and (number >= low or parts.has("or_less")):
97
+ return ""
98
+ return (
99
+ (
100
+ "%s is outside the range %s declares, %s to %s, which the inspector holds it to; the "
101
+ + "engine stores it as given."
102
+ )
103
+ % [shown, property, _bound(low), _bound(high)]
104
+ )
105
+
106
+
107
+ static func _listed(choices: Dictionary) -> String:
108
+ var listed: PackedStringArray = []
109
+ for name: String in choices:
110
+ var _added: bool = listed.append("%s (%d)" % [name, choices[name]])
111
+ return ", ".join(listed)
112
+
113
+
114
+ ## A range's end as the script wrote it: the engine's hint gives @export_range(0, 10) as 0.0 and 10.0.
115
+ static func _bound(end: float) -> String:
116
+ return str(int(end)) if end == floorf(end) and absf(end) < 1e15 else str(end)