gdharness 0.13.30 → 0.13.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.
@@ -130,9 +130,6 @@ func _header_of(path: String) -> Dictionary:
130
130
  if not file:
131
131
  return header
132
132
  var annotation: RegEx = Patterns.compiled('^@([a-z_]+)(?:\\(\\s*(?:"([^"]*)")?[^)]*\\))?\\s*')
133
- var class_line: RegEx = Patterns.compiled(
134
- "^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s+extends\\s+(\\S+))?"
135
- )
136
133
  var extends_line: RegEx = Patterns.compiled("^extends\\s+(\\S+)")
137
134
  while not file.eof_reached():
138
135
  var rest: String = file.get_line().strip_edges()
@@ -151,7 +148,7 @@ func _header_of(path: String) -> Dictionary:
151
148
  if rest.is_empty() or rest.begins_with("#"):
152
149
  continue
153
150
  if rest.begins_with("class_name"):
154
- var declared: RegExMatch = class_line.search(rest)
151
+ var declared: RegExMatch = Patterns.declared_class(rest)
155
152
  if declared != null:
156
153
  header["class_name"] = declared.get_string(1)
157
154
  if not declared.get_string(2).is_empty():
@@ -128,6 +128,7 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
128
128
  var usages: Array[Dictionary] = []
129
129
  var by_kind: Dictionary = {}
130
130
  var total: int = 0
131
+ var in_code: int = 0
131
132
 
132
133
  for file_path: String in all_files:
133
134
  if file_path == resource_path:
@@ -141,15 +142,32 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
141
142
  var references: Array[Dictionary] = []
142
143
  for i: int in range(lines.size()):
143
144
  var line: String = lines[i]
145
+ var by_its_path: bool = by_path.search(line) != null
146
+ var by_its_name: bool = not by_its_path and by_class != null and by_class.search(line) != null
147
+ if not by_its_path and not by_its_name:
148
+ continue
149
+ var trimmed: String = line.strip_edges()
144
150
  var kind: String = ""
145
- if by_path.search(line) != null:
151
+ # A mention in a comment is not a use, and both were answered as one. The question a
152
+ # reverse walk is asked is whether anything still uses a class, and a project that
153
+ # documents itself names its collaborators in `##` links: four references to one class,
154
+ # three of them prose, all counted as code. The more carefully a project is documented
155
+ # the worse that ratio gets. `##` and `#` are kept apart because renaming the class
156
+ # breaks Godot's generated documentation and leaves the prose merely stale.
157
+ if trimmed.begins_with("##"):
158
+ kind = "doc"
159
+ elif trimmed.begins_with("#"):
160
+ kind = "comment"
161
+ elif by_its_path:
146
162
  kind = _path_reference_kind(line)
147
- elif by_class != null and by_class.search(line) != null:
148
- kind = "extends" if line.strip_edges().begins_with("extends ") else "class_name"
149
- if kind.is_empty():
150
- continue
151
- references.append({"line": i + 1, "kind": kind, "text": line.strip_edges()})
163
+ else:
164
+ kind = (
165
+ "extends" if Patterns.without_annotations(line).begins_with("extends ") else "class_name"
166
+ )
167
+ references.append({"line": i + 1, "kind": kind, "text": trimmed})
152
168
  by_kind[kind] = Read.as_int(by_kind.get(kind, 0)) + 1
169
+ if kind != "doc" and kind != "comment":
170
+ in_code += 1
153
171
 
154
172
  if not references.is_empty():
155
173
  usages.append({"file": file_path, "references": references})
@@ -167,6 +185,9 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
167
185
  "files_searched": all_files.size(),
168
186
  "files_with_usages": usages.size(),
169
187
  "total": total,
188
+ # The arithmetic done here rather than left to the caller, because the caller who most
189
+ # needs it is the one asking whether a class is dead, and that caller reads one number.
190
+ "in_code": in_code,
170
191
  "by_kind": by_kind,
171
192
  },
172
193
  }
@@ -179,9 +200,9 @@ func _declared_class_name(path: String) -> String:
179
200
  var file: FileAccess = FileAccess.open(path, FileAccess.READ)
180
201
  if not file:
181
202
  return ""
182
- var declaration: RegEx = Patterns.compiled("^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)")
183
203
  while not file.eof_reached():
184
- var found: RegExMatch = declaration.search(file.get_line())
204
+ # Annotations first, since `@abstract class_name X` is one line and this read it as none.
205
+ var found: RegExMatch = Patterns.declared_class(Patterns.without_annotations(file.get_line()))
185
206
  if found != null:
186
207
  file.close()
187
208
  return found.get_string(1)
@@ -191,7 +212,7 @@ func _declared_class_name(path: String) -> String:
191
212
 
192
213
  # How a line that names the resource by path uses it.
193
214
  func _path_reference_kind(line: String) -> String:
194
- var trimmed: String = line.strip_edges()
215
+ var trimmed: String = Patterns.without_annotations(line)
195
216
  if trimmed.begins_with("extends "):
196
217
  return "extends"
197
218
  if trimmed.begins_with("[ext_resource"):
@@ -1,6 +1,7 @@
1
1
  extends RefCounted
2
2
 
3
3
  const Patterns = preload("patterns.gd")
4
+ const Read = preload("reading.gd")
4
5
  const Log = preload("logger.gd")
5
6
 
6
7
  var _log: Log
@@ -44,10 +45,16 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
44
45
 
45
46
  var in_multiline_string: bool = false
46
47
 
47
- for i: int in range(lines.size()):
48
- var stripped: String = lines[i].strip_edges()
48
+ var i: int = -1
49
+ while i + 1 < lines.size():
50
+ i += 1
51
+ # The comment off first, so nothing downstream has to know about them. It was reaching the
52
+ # answer: a `var x: int = 5 # note` carried `5 # note` as its default, and correcting the
53
+ # end of a parameter list put the comment into the return type, where `rfind(")")` had been
54
+ # hiding it by stopping inside the comment instead.
55
+ var stripped: String = _without_comment(lines[i])
49
56
 
50
- if stripped.is_empty() or stripped.begins_with("#"):
57
+ if stripped.is_empty():
51
58
  continue
52
59
 
53
60
  if '"""' in stripped or "'''" in stripped:
@@ -57,33 +64,68 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
57
64
  if in_multiline_string:
58
65
  continue
59
66
 
60
- if stripped.begins_with("class_name "):
61
- declared_class_name = stripped.substr(11).strip_edges()
62
- elif stripped.begins_with("extends "):
63
- extends_name = stripped.substr(8).strip_edges()
64
- elif stripped.begins_with("signal "):
65
- signals.append(_parse_signal(stripped, i + 1))
66
- elif stripped.begins_with("const "):
67
- constants.append(_parse_constant(stripped, i + 1))
68
- elif stripped.begins_with("enum "):
69
- enums.append(_parse_enum(stripped, i + 1))
70
- elif (
71
- stripped.begins_with("var ")
72
- or stripped.begins_with("@export")
73
- or stripped.begins_with("@onready")
74
- ):
75
- variables.append(_parse_variable(stripped, i + 1))
76
- elif stripped.begins_with("func ") or stripped.begins_with("static func "):
77
- functions.append(_parse_function(stripped, i + 1))
78
- elif stripped.begins_with("class "):
79
- inner_classes.append(stripped.substr(6).split(":")[0].split(" ")[0].strip_edges())
67
+ # Annotations may share the line with anything they annotate, so every branch below decides
68
+ # on the line with them off. `@abstract class_name X` was reported as no declared class,
69
+ # and `@abstract func x()` and `@warning_ignore("...") func x()` as no function at all:
70
+ # gdUnit4 alone declares 248 methods that way and this answered with none of them.
71
+ var header: String = Patterns.without_annotations(stripped)
72
+
73
+ var at: int = i
74
+ # A declaration wrapped across lines is one declaration, and reading the first line alone
75
+ # answered with the half that was not wrapped away: `values: []` for every `enum X {` on its
76
+ # own line and `params: []` with no return type for every `func x(`. `gdformat` wraps
77
+ # anything past the line length, so that is what a formatted project looks like: 23 and 19
78
+ # of them in gdUnit4 v6.2.1, 55 in one game project, and four in this addon.
79
+ #
80
+ # Only a declaration, because an unbalanced line anywhere else is a file mid-edit and
81
+ # joining from one swallows the rest of it: a body holding `print(` with nothing closing it
82
+ # took every declaration below it, which is the state an agent is most likely to ask about.
83
+ # `header` rather than `stripped`: an annotation in front pushes the keyword off the start
84
+ # of the line, so asking the raw line whether it can wrap says no to every annotated
85
+ # declaration that does. Held by the combined case, which nothing else in the fixture
86
+ # reaches: the single-feature cases each carry one of these and never a second.
87
+ if _can_wrap(header):
88
+ while _bracket_depth(stripped) > 0 and i + 1 < lines.size():
89
+ i += 1
90
+ stripped = (stripped + " " + _without_comment(lines[i])).strip_edges()
91
+ header = Patterns.without_annotations(stripped)
92
+ var declaration: RegExMatch = Patterns.declared_class(header)
93
+ if declaration != null:
94
+ declared_class_name = declaration.get_string(1)
95
+ # `class_name X extends Y` is also one line, and taking the rest of it as the name
96
+ # reported `Blade extends Node2D` as the class while `extends` kept its default of
97
+ # `RefCounted`: the name unusable and the base flatly wrong.
98
+ if not declaration.get_string(2).is_empty():
99
+ extends_name = declaration.get_string(2)
100
+ elif header.begins_with("extends "):
101
+ extends_name = header.substr(8).strip_edges()
102
+ elif header.begins_with("signal "):
103
+ # `at`, not `i`: a wrapped declaration starts where it starts, and `i` is now sitting on
104
+ # the line that closed it.
105
+ signals.append(_parse_signal(header, at + 1))
106
+ elif header.begins_with("const "):
107
+ constants.append(_parse_constant(header, at + 1))
108
+ elif header.begins_with("enum "):
109
+ enums.append(_parse_enum(header, at + 1))
110
+ elif header.begins_with("var "):
111
+ # The whole line here, not the header: which annotations a variable carries is the
112
+ # answer rather than noise in front of it, and `@export_range(0, 1)` is the hint.
113
+ variables.append(_parse_variable(stripped, at + 1))
114
+ elif header.begins_with("func ") or header.begins_with("static func "):
115
+ functions.append(_parse_function(header, at + 1, stripped))
116
+ elif header.begins_with("class "):
117
+ inner_classes.append(header.substr(6).split(":")[0].split(" ")[0].strip_edges())
80
118
 
81
119
  if "preload(" in stripped or "load(" in stripped:
82
120
  for dep: String in _extract_dependencies(stripped):
83
121
  if dep not in dependencies:
84
122
  dependencies.append(dep)
85
123
 
86
- return {
124
+ # After the loop, because it needs the base the loop reads, and a file that declared one after
125
+ # its first function would otherwise be judged against the default.
126
+ _mark_virtuals(functions, extends_name)
127
+
128
+ var answer: Dictionary = {
87
129
  "path": script_path,
88
130
  "full_path": full_script_path,
89
131
  "class_name": declared_class_name,
@@ -97,6 +139,98 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
97
139
  "dependencies": dependencies,
98
140
  "line_count": lines.size()
99
141
  }
142
+ if Read.as_bool(params.get("include_inherited", false)):
143
+ _add_inherited(answer, [full_script_path])
144
+ return answer
145
+
146
+
147
+ # Which of these functions the engine will call, asked of the engine.
148
+ #
149
+ # `is_virtual` was `name.begins_with("_")`, which is the convention for a private method and not
150
+ # what the field says: it reported `_compute_damage` as something the engine calls, on every script
151
+ # with a private helper in it. The virtuals are the ones the native ancestor declares with
152
+ # `METHOD_FLAG_VIRTUAL`, which is a question `ClassDB` answers exactly, and `class_has_method` does
153
+ # not: it says false for `_ready` on `Node`, so the method list is what has to be walked.
154
+ func _mark_virtuals(functions: Array[Dictionary], extends_name: String) -> void:
155
+ var native: String = _native_ancestor(extends_name)
156
+ var virtuals: Dictionary = {}
157
+ if not native.is_empty():
158
+ for m: Dictionary in ClassDB.class_get_method_list(native, false):
159
+ var flags: int = Read.as_int(m.get("flags", 0))
160
+ if flags & METHOD_FLAG_VIRTUAL:
161
+ virtuals[str(m.get("name", ""))] = true
162
+ for one: Dictionary in functions:
163
+ one["is_virtual"] = virtuals.has(str(one.get("name", "")))
164
+
165
+
166
+ # The native class at the top of this script's chain, or empty when the chain does not reach one.
167
+ #
168
+ # A base named by a script is followed; a base that is neither a script nor a class the engine knows
169
+ # leaves nothing to ask, and then no function is called virtual rather than every underscore being
170
+ # guessed at.
171
+ func _native_ancestor(base: String) -> String:
172
+ var seen: Array[String] = []
173
+ var current: String = base
174
+ while true:
175
+ var path: String = _script_named(current)
176
+ if path.is_empty():
177
+ break
178
+ if path in seen:
179
+ return ""
180
+ seen.append(path)
181
+ var above: Dictionary = get_gdscript_info({"script_path": path})
182
+ if above.is_empty():
183
+ return ""
184
+ current = str(above.get("extends", ""))
185
+ return current if ClassDB.class_exists(current) else ""
186
+
187
+
188
+ # The members this script's ancestors declare, appended to the lists they belong in.
189
+ #
190
+ # Only the script ancestors: `extends` naming a native class is ClassDB's question and
191
+ # `editor_classes info` answers it with the whole hierarchy. A base is reachable either as a quoted
192
+ # `res://` path or as a `class_name` in the project's class list, and a script whose base is neither
193
+ # has no ancestor to read, which the answer says by leaving `inherits_from` short rather than by
194
+ # refusing. Each entry carries `inherited_from`, so a caller can tell a member the script declares
195
+ # from one it is given; a name a script overrides appears twice, at both lines, on purpose.
196
+ func _add_inherited(answer: Dictionary, seen: Array[String]) -> void:
197
+ var inherits_from: Array[String] = []
198
+ var base: String = str(answer.get("extends", ""))
199
+ while true:
200
+ var base_path: String = _script_named(base)
201
+ if base_path.is_empty() or base_path in seen:
202
+ break
203
+ seen.append(base_path)
204
+ var above: Dictionary = get_gdscript_info({"script_path": base_path})
205
+ # A read that failed answers with an empty dictionary rather than with a flag, so that is
206
+ # what is asked. `_script_named` has already checked the file is there, which leaves only
207
+ # an open that fails, and the chain stops without recording a file nothing was read from.
208
+ if above.is_empty():
209
+ break
210
+ inherits_from.append(base_path)
211
+ for list_name: String in ["signals", "variables", "functions", "constants", "enums"]:
212
+ var mine: Array[Dictionary] = answer[list_name]
213
+ var theirs: Array[Dictionary] = above[list_name]
214
+ for member: Dictionary in theirs:
215
+ var carried: Dictionary = member.duplicate()
216
+ carried["inherited_from"] = base_path
217
+ mine.append(carried)
218
+ base = str(above.get("extends", ""))
219
+ answer["inherits_from"] = inherits_from
220
+
221
+
222
+ # The file a base names, whether it named a path or a class, or empty for a native class.
223
+ func _script_named(base: String) -> String:
224
+ if base.is_empty():
225
+ return ""
226
+ if base.begins_with('"') and base.ends_with('"'):
227
+ var quoted: String = base.substr(1, base.length() - 2)
228
+ return quoted if FileAccess.file_exists(quoted) else ""
229
+ for entry: Dictionary in ProjectSettings.get_global_class_list():
230
+ if str(entry.get("class", "")) == base:
231
+ var path: String = str(entry.get("path", ""))
232
+ return path if FileAccess.file_exists(path) else ""
233
+ return ""
100
234
 
101
235
 
102
236
  func _parse_signal(line: String, line_num: int) -> Dictionary:
@@ -105,13 +239,15 @@ func _parse_signal(line: String, line_num: int) -> Dictionary:
105
239
  var params: Array[Dictionary] = []
106
240
 
107
241
  if "(" in signal_text:
108
- var parts: PackedStringArray = signal_text.split("(")
109
- signal_name = parts[0].strip_edges()
110
- if parts.size() > 1:
111
- var params_text: String = parts[1].replace(")", "").strip_edges()
112
- if not params_text.is_empty():
113
- for p: String in params_text.split(","):
114
- params.append(_parse_param(p.strip_edges()))
242
+ var paren_start: int = signal_text.find("(")
243
+ signal_name = signal_text.substr(0, paren_start).strip_edges()
244
+ # Through the same reader as a function's, because a signal's parameters are written the
245
+ # same way: `signal changed(d: Dictionary[String, int])` has one, and splitting on every
246
+ # comma answered with two.
247
+ var paren_end: int = _closing_paren(signal_text, paren_start)
248
+ if paren_end > paren_start:
249
+ for p: String in _arguments_in(signal_text.substr(paren_start + 1, paren_end - paren_start - 1)):
250
+ params.append(_parse_param(p))
115
251
  else:
116
252
  signal_name = signal_text
117
253
 
@@ -163,17 +299,20 @@ func _parse_enum(line: String, line_num: int) -> Dictionary:
163
299
 
164
300
 
165
301
  func _parse_variable(line: String, line_num: int) -> Dictionary:
166
- var is_export: bool = line.begins_with("@export")
167
- var is_onready: bool = "@onready" in line
302
+ var is_export: bool = false
303
+ var is_onready: bool = false
168
304
  var export_hint: String = ""
169
305
 
170
- if is_export:
171
- var export_match: int = line.find("@export")
172
- var hint_end: int = line.find("var ")
173
- if hint_end > export_match:
174
- var hint_part: String = line.substr(export_match + 7, hint_end - export_match - 7).strip_edges()
175
- if hint_part.begins_with("_"):
176
- export_hint = hint_part.substr(1).split(" ")[0]
306
+ # From the annotations themselves rather than from where they sit. `@onready @export var x` put
307
+ # the export second and so read as not exported, and a hint with a space in it was cut at it.
308
+ for one: String in Patterns.annotations_on(line):
309
+ if one.begins_with("export"):
310
+ is_export = true
311
+ var after: String = one.substr(6)
312
+ if after.begins_with("_"):
313
+ export_hint = after.substr(1)
314
+ elif one == "onready":
315
+ is_onready = true
177
316
 
178
317
  var var_pos: int = line.find("var ")
179
318
  if var_pos == -1:
@@ -213,7 +352,7 @@ func _parse_variable(line: String, line_num: int) -> Dictionary:
213
352
  }
214
353
 
215
354
 
216
- func _parse_function(line: String, line_num: int) -> Dictionary:
355
+ func _parse_function(line: String, line_num: int, annotated: String = "") -> Dictionary:
217
356
  var is_static: bool = line.begins_with("static ")
218
357
  var func_text: String = line
219
358
 
@@ -230,27 +369,166 @@ func _parse_function(line: String, line_num: int) -> Dictionary:
230
369
  var paren_start: int = func_text.find("(")
231
370
  name = func_text.substr(0, paren_start).strip_edges()
232
371
 
233
- var paren_end: int = func_text.rfind(")")
372
+ var paren_end: int = _closing_paren(func_text, paren_start)
234
373
  if paren_end > paren_start:
235
374
  var params_text: String = func_text.substr(paren_start + 1, paren_end - paren_start - 1)
236
- if not params_text.is_empty():
237
- for p: String in params_text.split(","):
238
- params.append(_parse_param(p.strip_edges()))
375
+ for p: String in _arguments_in(params_text):
376
+ params.append(_parse_param(p))
239
377
 
240
378
  var after_paren: String = func_text.substr(paren_end + 1).strip_edges()
241
379
  if after_paren.begins_with("->"):
242
- return_type = after_paren.substr(2).replace(":", "").strip_edges()
380
+ # Cut at the colon that ends the signature rather than deleting every colon in the
381
+ # rest of the line: `func noted(a: int) -> void: print(a)` is one line, and the old
382
+ # reading answered with `void print(a)` as the return type.
383
+ return_type = _up_to(after_paren.substr(2), ":").strip_edges()
243
384
 
244
385
  return {
245
386
  "name": name,
246
387
  "params": params,
247
388
  "return_type": return_type,
248
- "is_virtual": name.begins_with("_"),
389
+ # Filled in after the whole file is read, once the base is known; see `_mark_virtuals`.
390
+ "is_virtual": false,
249
391
  "is_static": is_static,
392
+ # Said rather than left to be inferred: an abstract method has no body, so a caller that
393
+ # saw only the declaration would otherwise take it for one whose body it failed to read.
394
+ "is_abstract": "abstract" in Patterns.annotations_on(annotated),
250
395
  "line": line_num
251
396
  }
252
397
 
253
398
 
399
+ # The commas that separate arguments are the ones outside every bracket and every string, and
400
+ # `split(",")` could not tell them from the rest.
401
+ #
402
+ # `func place(at: Vector2 = Vector2(1, 2), name: String = "a,b")` has two parameters and was
403
+ # answered with five, three of them invented and named after the pieces the split left behind:
404
+ # `2)`, `b"`. Each real one also lost its default to the truncation, `Vector2(1`. A `Vector2` or
405
+ # `Color` default is ordinary Godot, and a caller building a call from that signature writes
406
+ # arguments that are not identifiers.
407
+ func _arguments_in(text: String) -> Array[String]:
408
+ var pieces: Array[String] = []
409
+ var depth: int = 0
410
+ var quote: String = ""
411
+ var current: String = ""
412
+ for i: int in range(text.length()):
413
+ var ch: String = text[i]
414
+ if not quote.is_empty():
415
+ current += ch
416
+ if ch == quote and text[i - 1] != "\\":
417
+ quote = ""
418
+ continue
419
+ if ch == '"' or ch == "'":
420
+ quote = ch
421
+ current += ch
422
+ continue
423
+ if ch == "(" or ch == "[" or ch == "{":
424
+ depth += 1
425
+ elif ch == ")" or ch == "]" or ch == "}":
426
+ depth -= 1
427
+ elif ch == "," and depth == 0:
428
+ pieces.append(current.strip_edges())
429
+ current = ""
430
+ continue
431
+ current += ch
432
+ if not current.strip_edges().is_empty():
433
+ pieces.append(current.strip_edges())
434
+ return pieces
435
+
436
+
437
+ # Whether this is a declaration whose brackets may carry on to the next line.
438
+ func _can_wrap(header: String) -> bool:
439
+ for keyword: String in ["func ", "static func ", "enum ", "signal ", "var ", "const "]:
440
+ if header.begins_with(keyword):
441
+ return true
442
+ return false
443
+
444
+
445
+ # How much this line opens that it does not close, ignoring brackets inside strings.
446
+ func _bracket_depth(text: String) -> int:
447
+ var depth: int = 0
448
+ var quote: String = ""
449
+ for i: int in range(text.length()):
450
+ var ch: String = text[i]
451
+ if not quote.is_empty():
452
+ if ch == quote and text[i - 1] != "\\":
453
+ quote = ""
454
+ continue
455
+ if ch == '"' or ch == "'":
456
+ quote = ch
457
+ elif ch == "(" or ch == "[" or ch == "{":
458
+ depth += 1
459
+ elif ch == ")" or ch == "]" or ch == "}":
460
+ depth -= 1
461
+ return depth
462
+
463
+
464
+ # The text up to the first [needle] outside every bracket and every string, or all of it.
465
+ func _up_to(text: String, needle: String) -> String:
466
+ var depth: int = 0
467
+ var quote: String = ""
468
+ for i: int in range(text.length()):
469
+ var ch: String = text[i]
470
+ if not quote.is_empty():
471
+ if ch == quote and text[i - 1] != "\\":
472
+ quote = ""
473
+ continue
474
+ if ch == '"' or ch == "'":
475
+ quote = ch
476
+ elif ch == "(" or ch == "[" or ch == "{":
477
+ depth += 1
478
+ elif ch == ")" or ch == "]" or ch == "}":
479
+ depth -= 1
480
+ elif ch == needle and depth == 0:
481
+ return text.substr(0, i)
482
+ return text
483
+
484
+
485
+ # The line with any trailing comment taken off, quote-aware so a `#` inside a string stays.
486
+ #
487
+ # The only one of these scanners that does not count brackets, on purpose: a `#` starts a comment
488
+ # wherever it is, and there is no depth at which it does not. Giving this one the depth its
489
+ # neighbours have puts the comment into the value of `var table := { # keyed by name`, because the
490
+ # brace opens before the `#` and closes on a later line. Held by a case, since the argument for the
491
+ # difference is not visible from the code and reads like an oversight beside its neighbours.
492
+ func _without_comment(line: String) -> String:
493
+ var quote: String = ""
494
+ for i: int in range(line.length()):
495
+ var ch: String = line[i]
496
+ if not quote.is_empty():
497
+ if ch == quote and line[i - 1] != "\\":
498
+ quote = ""
499
+ continue
500
+ if ch == "#":
501
+ return line.substr(0, i).strip_edges()
502
+ if ch == '"' or ch == "'":
503
+ quote = ch
504
+ return line.strip_edges()
505
+
506
+
507
+ # Where the bracket opened at [from] closes, or -1 if it does not.
508
+ #
509
+ # `rfind(")")` took the last one on the line, which is the right answer until something after the
510
+ # signature has a bracket in it: a trailing comment, or a string in a default value.
511
+ func _closing_paren(text: String, from: int) -> int:
512
+ var depth: int = 0
513
+ var quote: String = ""
514
+ for i: int in range(from, text.length()):
515
+ var ch: String = text[i]
516
+ if not quote.is_empty():
517
+ if ch == quote and text[i - 1] != "\\":
518
+ quote = ""
519
+ continue
520
+ if ch == '"' or ch == "'":
521
+ quote = ch
522
+ continue
523
+ if ch == "(" or ch == "[" or ch == "{":
524
+ depth += 1
525
+ elif ch == ")" or ch == "]" or ch == "}":
526
+ depth -= 1
527
+ if depth == 0:
528
+ return i
529
+ return -1
530
+
531
+
254
532
  func _parse_param(param_text: String) -> Dictionary:
255
533
  var name: String = ""
256
534
  var type_hint: String = ""
@@ -274,7 +552,14 @@ func _parse_param(param_text: String) -> Dictionary:
274
552
  else:
275
553
  name = param_text
276
554
 
277
- return {"name": name, "type": type_hint, "default": default_value}
555
+ # `...rest: Array` is a rest parameter, and the dots are the syntax rather than part of what it
556
+ # is called: answering `...rest` as the name gives a caller building a call site a name it
557
+ # cannot use. gdUnit4 alone declares 56 of these.
558
+ var is_rest: bool = name.begins_with("...")
559
+ if is_rest:
560
+ name = name.substr(3).strip_edges()
561
+
562
+ return {"name": name, "type": type_hint, "default": default_value, "is_rest": is_rest}
278
563
 
279
564
 
280
565
  func _extract_dependencies(line: String) -> Array[String]:
@@ -2,6 +2,7 @@ extends RefCounted
2
2
 
3
3
  const Read = preload("reading.gd")
4
4
  const Log = preload("logger.gd")
5
+ const Patterns = preload("patterns.gd")
5
6
 
6
7
  var _log: Log
7
8
 
@@ -242,6 +243,10 @@ func _add_function(lines: Array[String], mod: Dictionary) -> int:
242
243
  func_lines.append("\t" + bl)
243
244
 
244
245
  var insert_line: int = _function_insertion_point(lines, position)
246
+ # A separator on the far side too when something follows, since `after_ready` and `after_init`
247
+ # put the new function immediately above an existing one and left the two declarations touching.
248
+ if insert_line < lines.size():
249
+ func_lines.append("")
245
250
 
246
251
  for i: int in range(func_lines.size() - 1, -1, -1):
247
252
  if lines.insert(insert_line, func_lines[i]) != OK:
@@ -256,7 +261,13 @@ func _variable_insertion_point(lines: Array[String]) -> int:
256
261
  var before_func: int = lines.size()
257
262
 
258
263
  for i: int in range(lines.size()):
259
- var line: String = lines[i].strip_edges()
264
+ # Annotations off first. `@abstract class_name X` is one line in Godot 4.5 and later, and
265
+ # unstripped it matches nothing here, so the header is only found when some other line
266
+ # carries it. A script whose whole header is that one line leaves `after_header` at 0 and
267
+ # takes the new line above the `class_name`, where it does not parse; one that declares
268
+ # `extends` first takes it between the two, which does not parse either. Stripping also
269
+ # turns `@export var x` into `var x`, which is why the annotation names are gone below.
270
+ var line: String = Patterns.without_annotations(lines[i])
260
271
  if line.begins_with("extends ") or line.begins_with("class_name "):
261
272
  after_header = i + 1
262
273
  elif line.begins_with("signal "):
@@ -266,8 +277,7 @@ func _variable_insertion_point(lines: Array[String]) -> int:
266
277
  break
267
278
 
268
279
  for i: int in range(after_header, before_func):
269
- var line: String = lines[i].strip_edges()
270
- if line.begins_with("var ") or line.begins_with("@export") or line.begins_with("@onready"):
280
+ if Patterns.without_annotations(lines[i]).begins_with("var "):
271
281
  after_header = i + 1
272
282
 
273
283
  return after_header
@@ -277,17 +287,14 @@ func _signal_insertion_point(lines: Array[String]) -> int:
277
287
  var after_header: int = 0
278
288
 
279
289
  for i: int in range(lines.size()):
280
- var line: String = lines[i].strip_edges()
290
+ # As above: the annotations come off before the line is read, so an abstract class has a
291
+ # header to find and `@export var x` reads as the var it is.
292
+ var line: String = Patterns.without_annotations(lines[i])
281
293
  if line.begins_with("extends ") or line.begins_with("class_name "):
282
294
  after_header = i + 1
283
295
  elif line.begins_with("signal "):
284
296
  after_header = i + 1
285
- elif (
286
- line.begins_with("var ")
287
- or line.begins_with("@export")
288
- or line.begins_with("@onready")
289
- or line.begins_with("func ")
290
- ):
297
+ elif line.begins_with("var ") or line.begins_with("func "):
291
298
  break
292
299
 
293
300
  return after_header
@@ -308,7 +315,11 @@ func _function_insertion_point(lines: Array[String], position: String) -> int:
308
315
  func _line_after_function(lines: Array[String], prefix: String) -> int:
309
316
  var inside: bool = false
310
317
  for i: int in range(lines.size()):
311
- var line: String = lines[i].strip_edges()
318
+ # As above: an annotated `func` is still a `func`, and reading the raw line missed both
319
+ # ends of this. An annotated `_ready` was never found, so `after_ready` placed the new
320
+ # function at the end of the file, and an annotated function after `_ready` was not the
321
+ # boundary it is, so the new one went in after whichever later function was plain.
322
+ var line: String = Patterns.without_annotations(lines[i])
312
323
  if line.begins_with(prefix):
313
324
  inside = true
314
325
  elif inside and (line.begins_with("func ") or line.begins_with("static func ")):