gdharness 0.13.30 → 0.13.31

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():
@@ -145,7 +145,8 @@ func find_resource_usages(params: Dictionary) -> Dictionary:
145
145
  if by_path.search(line) != null:
146
146
  kind = _path_reference_kind(line)
147
147
  elif by_class != null and by_class.search(line) != null:
148
- kind = "extends" if line.strip_edges().begins_with("extends ") else "class_name"
148
+ var bare: String = Patterns.without_annotations(line)
149
+ kind = "extends" if bare.begins_with("extends ") else "class_name"
149
150
  if kind.is_empty():
150
151
  continue
151
152
  references.append({"line": i + 1, "kind": kind, "text": line.strip_edges()})
@@ -179,9 +180,9 @@ func _declared_class_name(path: String) -> String:
179
180
  var file: FileAccess = FileAccess.open(path, FileAccess.READ)
180
181
  if not file:
181
182
  return ""
182
- var declaration: RegEx = Patterns.compiled("^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)")
183
183
  while not file.eof_reached():
184
- var found: RegExMatch = declaration.search(file.get_line())
184
+ # Annotations first, since `@abstract class_name X` is one line and this read it as none.
185
+ var found: RegExMatch = Patterns.declared_class(Patterns.without_annotations(file.get_line()))
185
186
  if found != null:
186
187
  file.close()
187
188
  return found.get_string(1)
@@ -191,7 +192,7 @@ func _declared_class_name(path: String) -> String:
191
192
 
192
193
  # How a line that names the resource by path uses it.
193
194
  func _path_reference_kind(line: String) -> String:
194
- var trimmed: String = line.strip_edges()
195
+ var trimmed: String = Patterns.without_annotations(line)
195
196
  if trimmed.begins_with("extends "):
196
197
  return "extends"
197
198
  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,64 @@ 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
+ if _can_wrap(header):
84
+ while _bracket_depth(stripped) > 0 and i + 1 < lines.size():
85
+ i += 1
86
+ stripped = (stripped + " " + _without_comment(lines[i])).strip_edges()
87
+ header = Patterns.without_annotations(stripped)
88
+ var declaration: RegExMatch = Patterns.declared_class(header)
89
+ if declaration != null:
90
+ declared_class_name = declaration.get_string(1)
91
+ # `class_name X extends Y` is also one line, and taking the rest of it as the name
92
+ # reported `Blade extends Node2D` as the class while `extends` kept its default of
93
+ # `RefCounted`: the name unusable and the base flatly wrong.
94
+ if not declaration.get_string(2).is_empty():
95
+ extends_name = declaration.get_string(2)
96
+ elif header.begins_with("extends "):
97
+ extends_name = header.substr(8).strip_edges()
98
+ elif header.begins_with("signal "):
99
+ # `at`, not `i`: a wrapped declaration starts where it starts, and `i` is now sitting on
100
+ # the line that closed it.
101
+ signals.append(_parse_signal(header, at + 1))
102
+ elif header.begins_with("const "):
103
+ constants.append(_parse_constant(header, at + 1))
104
+ elif header.begins_with("enum "):
105
+ enums.append(_parse_enum(header, at + 1))
106
+ elif header.begins_with("var "):
107
+ # The whole line here, not the header: which annotations a variable carries is the
108
+ # answer rather than noise in front of it, and `@export_range(0, 1)` is the hint.
109
+ variables.append(_parse_variable(stripped, at + 1))
110
+ elif header.begins_with("func ") or header.begins_with("static func "):
111
+ functions.append(_parse_function(header, at + 1, stripped))
112
+ elif header.begins_with("class "):
113
+ inner_classes.append(header.substr(6).split(":")[0].split(" ")[0].strip_edges())
80
114
 
81
115
  if "preload(" in stripped or "load(" in stripped:
82
116
  for dep: String in _extract_dependencies(stripped):
83
117
  if dep not in dependencies:
84
118
  dependencies.append(dep)
85
119
 
86
- return {
120
+ # After the loop, because it needs the base the loop reads, and a file that declared one after
121
+ # its first function would otherwise be judged against the default.
122
+ _mark_virtuals(functions, extends_name)
123
+
124
+ var answer: Dictionary = {
87
125
  "path": script_path,
88
126
  "full_path": full_script_path,
89
127
  "class_name": declared_class_name,
@@ -97,6 +135,98 @@ func get_gdscript_info(params: Dictionary) -> Dictionary:
97
135
  "dependencies": dependencies,
98
136
  "line_count": lines.size()
99
137
  }
138
+ if Read.as_bool(params.get("include_inherited", false)):
139
+ _add_inherited(answer, [full_script_path])
140
+ return answer
141
+
142
+
143
+ # Which of these functions the engine will call, asked of the engine.
144
+ #
145
+ # `is_virtual` was `name.begins_with("_")`, which is the convention for a private method and not
146
+ # what the field says: it reported `_compute_damage` as something the engine calls, on every script
147
+ # with a private helper in it. The virtuals are the ones the native ancestor declares with
148
+ # `METHOD_FLAG_VIRTUAL`, which is a question `ClassDB` answers exactly, and `class_has_method` does
149
+ # not: it says false for `_ready` on `Node`, so the method list is what has to be walked.
150
+ func _mark_virtuals(functions: Array[Dictionary], extends_name: String) -> void:
151
+ var native: String = _native_ancestor(extends_name)
152
+ var virtuals: Dictionary = {}
153
+ if not native.is_empty():
154
+ for m: Dictionary in ClassDB.class_get_method_list(native, false):
155
+ var flags: int = Read.as_int(m.get("flags", 0))
156
+ if flags & METHOD_FLAG_VIRTUAL:
157
+ virtuals[str(m.get("name", ""))] = true
158
+ for one: Dictionary in functions:
159
+ one["is_virtual"] = virtuals.has(str(one.get("name", "")))
160
+
161
+
162
+ # The native class at the top of this script's chain, or empty when the chain does not reach one.
163
+ #
164
+ # A base named by a script is followed; a base that is neither a script nor a class the engine knows
165
+ # leaves nothing to ask, and then no function is called virtual rather than every underscore being
166
+ # guessed at.
167
+ func _native_ancestor(base: String) -> String:
168
+ var seen: Array[String] = []
169
+ var current: String = base
170
+ while true:
171
+ var path: String = _script_named(current)
172
+ if path.is_empty():
173
+ break
174
+ if path in seen:
175
+ return ""
176
+ seen.append(path)
177
+ var above: Dictionary = get_gdscript_info({"script_path": path})
178
+ if above.is_empty():
179
+ return ""
180
+ current = str(above.get("extends", ""))
181
+ return current if ClassDB.class_exists(current) else ""
182
+
183
+
184
+ # The members this script's ancestors declare, appended to the lists they belong in.
185
+ #
186
+ # Only the script ancestors: `extends` naming a native class is ClassDB's question and
187
+ # `editor_classes info` answers it with the whole hierarchy. A base is reachable either as a quoted
188
+ # `res://` path or as a `class_name` in the project's class list, and a script whose base is neither
189
+ # has no ancestor to read, which the answer says by leaving `inherits_from` short rather than by
190
+ # refusing. Each entry carries `inherited_from`, so a caller can tell a member the script declares
191
+ # from one it is given; a name a script overrides appears twice, at both lines, on purpose.
192
+ func _add_inherited(answer: Dictionary, seen: Array[String]) -> void:
193
+ var inherits_from: Array[String] = []
194
+ var base: String = str(answer.get("extends", ""))
195
+ while true:
196
+ var base_path: String = _script_named(base)
197
+ if base_path.is_empty() or base_path in seen:
198
+ break
199
+ seen.append(base_path)
200
+ var above: Dictionary = get_gdscript_info({"script_path": base_path})
201
+ # A read that failed answers with an empty dictionary rather than with a flag, so that is
202
+ # what is asked. `_script_named` has already checked the file is there, which leaves only
203
+ # an open that fails, and the chain stops without recording a file nothing was read from.
204
+ if above.is_empty():
205
+ break
206
+ inherits_from.append(base_path)
207
+ for list_name: String in ["signals", "variables", "functions", "constants", "enums"]:
208
+ var mine: Array[Dictionary] = answer[list_name]
209
+ var theirs: Array[Dictionary] = above[list_name]
210
+ for member: Dictionary in theirs:
211
+ var carried: Dictionary = member.duplicate()
212
+ carried["inherited_from"] = base_path
213
+ mine.append(carried)
214
+ base = str(above.get("extends", ""))
215
+ answer["inherits_from"] = inherits_from
216
+
217
+
218
+ # The file a base names, whether it named a path or a class, or empty for a native class.
219
+ func _script_named(base: String) -> String:
220
+ if base.is_empty():
221
+ return ""
222
+ if base.begins_with('"') and base.ends_with('"'):
223
+ var quoted: String = base.substr(1, base.length() - 2)
224
+ return quoted if FileAccess.file_exists(quoted) else ""
225
+ for entry: Dictionary in ProjectSettings.get_global_class_list():
226
+ if str(entry.get("class", "")) == base:
227
+ var path: String = str(entry.get("path", ""))
228
+ return path if FileAccess.file_exists(path) else ""
229
+ return ""
100
230
 
101
231
 
102
232
  func _parse_signal(line: String, line_num: int) -> Dictionary:
@@ -105,13 +235,15 @@ func _parse_signal(line: String, line_num: int) -> Dictionary:
105
235
  var params: Array[Dictionary] = []
106
236
 
107
237
  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()))
238
+ var paren_start: int = signal_text.find("(")
239
+ signal_name = signal_text.substr(0, paren_start).strip_edges()
240
+ # Through the same reader as a function's, because a signal's parameters are written the
241
+ # same way: `signal changed(d: Dictionary[String, int])` has one, and splitting on every
242
+ # comma answered with two.
243
+ var paren_end: int = _closing_paren(signal_text, paren_start)
244
+ if paren_end > paren_start:
245
+ for p: String in _arguments_in(signal_text.substr(paren_start + 1, paren_end - paren_start - 1)):
246
+ params.append(_parse_param(p))
115
247
  else:
116
248
  signal_name = signal_text
117
249
 
@@ -163,17 +295,20 @@ func _parse_enum(line: String, line_num: int) -> Dictionary:
163
295
 
164
296
 
165
297
  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
298
+ var is_export: bool = false
299
+ var is_onready: bool = false
168
300
  var export_hint: String = ""
169
301
 
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]
302
+ # From the annotations themselves rather than from where they sit. `@onready @export var x` put
303
+ # the export second and so read as not exported, and a hint with a space in it was cut at it.
304
+ for one: String in Patterns.annotations_on(line):
305
+ if one.begins_with("export"):
306
+ is_export = true
307
+ var after: String = one.substr(6)
308
+ if after.begins_with("_"):
309
+ export_hint = after.substr(1)
310
+ elif one == "onready":
311
+ is_onready = true
177
312
 
178
313
  var var_pos: int = line.find("var ")
179
314
  if var_pos == -1:
@@ -213,7 +348,7 @@ func _parse_variable(line: String, line_num: int) -> Dictionary:
213
348
  }
214
349
 
215
350
 
216
- func _parse_function(line: String, line_num: int) -> Dictionary:
351
+ func _parse_function(line: String, line_num: int, annotated: String = "") -> Dictionary:
217
352
  var is_static: bool = line.begins_with("static ")
218
353
  var func_text: String = line
219
354
 
@@ -230,27 +365,160 @@ func _parse_function(line: String, line_num: int) -> Dictionary:
230
365
  var paren_start: int = func_text.find("(")
231
366
  name = func_text.substr(0, paren_start).strip_edges()
232
367
 
233
- var paren_end: int = func_text.rfind(")")
368
+ var paren_end: int = _closing_paren(func_text, paren_start)
234
369
  if paren_end > paren_start:
235
370
  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()))
371
+ for p: String in _arguments_in(params_text):
372
+ params.append(_parse_param(p))
239
373
 
240
374
  var after_paren: String = func_text.substr(paren_end + 1).strip_edges()
241
375
  if after_paren.begins_with("->"):
242
- return_type = after_paren.substr(2).replace(":", "").strip_edges()
376
+ # Cut at the colon that ends the signature rather than deleting every colon in the
377
+ # rest of the line: `func noted(a: int) -> void: print(a)` is one line, and the old
378
+ # reading answered with `void print(a)` as the return type.
379
+ return_type = _up_to(after_paren.substr(2), ":").strip_edges()
243
380
 
244
381
  return {
245
382
  "name": name,
246
383
  "params": params,
247
384
  "return_type": return_type,
248
- "is_virtual": name.begins_with("_"),
385
+ # Filled in after the whole file is read, once the base is known; see `_mark_virtuals`.
386
+ "is_virtual": false,
249
387
  "is_static": is_static,
388
+ # Said rather than left to be inferred: an abstract method has no body, so a caller that
389
+ # saw only the declaration would otherwise take it for one whose body it failed to read.
390
+ "is_abstract": "abstract" in Patterns.annotations_on(annotated),
250
391
  "line": line_num
251
392
  }
252
393
 
253
394
 
395
+ # The commas that separate arguments are the ones outside every bracket and every string, and
396
+ # `split(",")` could not tell them from the rest.
397
+ #
398
+ # `func place(at: Vector2 = Vector2(1, 2), name: String = "a,b")` has two parameters and was
399
+ # answered with five, three of them invented and named after the pieces the split left behind:
400
+ # `2)`, `b"`. Each real one also lost its default to the truncation, `Vector2(1`. A `Vector2` or
401
+ # `Color` default is ordinary Godot, and a caller building a call from that signature writes
402
+ # arguments that are not identifiers.
403
+ func _arguments_in(text: String) -> Array[String]:
404
+ var pieces: Array[String] = []
405
+ var depth: int = 0
406
+ var quote: String = ""
407
+ var current: String = ""
408
+ for i: int in range(text.length()):
409
+ var ch: String = text[i]
410
+ if not quote.is_empty():
411
+ current += ch
412
+ if ch == quote and text[i - 1] != "\\":
413
+ quote = ""
414
+ continue
415
+ if ch == '"' or ch == "'":
416
+ quote = ch
417
+ current += ch
418
+ continue
419
+ if ch == "(" or ch == "[" or ch == "{":
420
+ depth += 1
421
+ elif ch == ")" or ch == "]" or ch == "}":
422
+ depth -= 1
423
+ elif ch == "," and depth == 0:
424
+ pieces.append(current.strip_edges())
425
+ current = ""
426
+ continue
427
+ current += ch
428
+ if not current.strip_edges().is_empty():
429
+ pieces.append(current.strip_edges())
430
+ return pieces
431
+
432
+
433
+ # Whether this is a declaration whose brackets may carry on to the next line.
434
+ func _can_wrap(header: String) -> bool:
435
+ for keyword: String in ["func ", "static func ", "enum ", "signal ", "var ", "const "]:
436
+ if header.begins_with(keyword):
437
+ return true
438
+ return false
439
+
440
+
441
+ # How much this line opens that it does not close, ignoring brackets inside strings.
442
+ func _bracket_depth(text: String) -> int:
443
+ var depth: int = 0
444
+ var quote: String = ""
445
+ for i: int in range(text.length()):
446
+ var ch: String = text[i]
447
+ if not quote.is_empty():
448
+ if ch == quote and text[i - 1] != "\\":
449
+ quote = ""
450
+ continue
451
+ if ch == '"' or ch == "'":
452
+ quote = ch
453
+ elif ch == "(" or ch == "[" or ch == "{":
454
+ depth += 1
455
+ elif ch == ")" or ch == "]" or ch == "}":
456
+ depth -= 1
457
+ return depth
458
+
459
+
460
+ # The text up to the first [needle] outside every bracket and every string, or all of it.
461
+ func _up_to(text: String, needle: String) -> String:
462
+ var depth: int = 0
463
+ var quote: String = ""
464
+ for i: int in range(text.length()):
465
+ var ch: String = text[i]
466
+ if not quote.is_empty():
467
+ if ch == quote and text[i - 1] != "\\":
468
+ quote = ""
469
+ continue
470
+ if ch == '"' or ch == "'":
471
+ quote = ch
472
+ elif ch == "(" or ch == "[" or ch == "{":
473
+ depth += 1
474
+ elif ch == ")" or ch == "]" or ch == "}":
475
+ depth -= 1
476
+ elif ch == needle and depth == 0:
477
+ return text.substr(0, i)
478
+ return text
479
+
480
+
481
+ # The line with any trailing comment taken off, quote-aware so a `#` inside a string stays.
482
+ func _without_comment(line: String) -> String:
483
+ var quote: String = ""
484
+ for i: int in range(line.length()):
485
+ var ch: String = line[i]
486
+ if not quote.is_empty():
487
+ if ch == quote and line[i - 1] != "\\":
488
+ quote = ""
489
+ continue
490
+ if ch == "#":
491
+ return line.substr(0, i).strip_edges()
492
+ if ch == '"' or ch == "'":
493
+ quote = ch
494
+ return line.strip_edges()
495
+
496
+
497
+ # Where the bracket opened at [from] closes, or -1 if it does not.
498
+ #
499
+ # `rfind(")")` took the last one on the line, which is the right answer until something after the
500
+ # signature has a bracket in it: a trailing comment, or a string in a default value.
501
+ func _closing_paren(text: String, from: int) -> int:
502
+ var depth: int = 0
503
+ var quote: String = ""
504
+ for i: int in range(from, text.length()):
505
+ var ch: String = text[i]
506
+ if not quote.is_empty():
507
+ if ch == quote and text[i - 1] != "\\":
508
+ quote = ""
509
+ continue
510
+ if ch == '"' or ch == "'":
511
+ quote = ch
512
+ continue
513
+ if ch == "(":
514
+ depth += 1
515
+ elif ch == ")":
516
+ depth -= 1
517
+ if depth == 0:
518
+ return i
519
+ return -1
520
+
521
+
254
522
  func _parse_param(param_text: String) -> Dictionary:
255
523
  var name: String = ""
256
524
  var type_hint: String = ""
@@ -274,7 +542,14 @@ func _parse_param(param_text: String) -> Dictionary:
274
542
  else:
275
543
  name = param_text
276
544
 
277
- return {"name": name, "type": type_hint, "default": default_value}
545
+ # `...rest: Array` is a rest parameter, and the dots are the syntax rather than part of what it
546
+ # is called: answering `...rest` as the name gives a caller building a call site a name it
547
+ # cannot use. gdUnit4 alone declares 56 of these.
548
+ var is_rest: bool = name.begins_with("...")
549
+ if is_rest:
550
+ name = name.substr(3).strip_edges()
551
+
552
+ return {"name": name, "type": type_hint, "default": default_value, "is_rest": is_rest}
278
553
 
279
554
 
280
555
  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 ")):
@@ -13,10 +13,69 @@ extends RefCounted
13
13
  # `engine_messages`. The empty RegEx it then returns matches nothing, which is the honest result of
14
14
  # a pattern that does not exist.
15
15
 
16
+ # Kept, because the readers below call these per line of every script in a project and a fresh
17
+ # compile each time is the whole cost of the scan. The patterns are literals in this repository, so
18
+ # the set is bounded by the source rather than by anything a caller passes.
19
+ static var _compiled: Dictionary = {}
20
+
16
21
 
17
22
  static func compiled(expression: String) -> RegEx:
23
+ if _compiled.has(expression):
24
+ var held: RegEx = _compiled[expression]
25
+ return held
18
26
  var regex: RegEx = RegEx.new()
19
27
  var built: Error = regex.compile(expression)
20
28
  if built != OK:
21
29
  push_error("gdharness: could not compile the pattern " + expression)
30
+ _compiled[expression] = regex
22
31
  return regex
32
+
33
+
34
+ # What a header line declares: the class name, and the base named on the same line if there is one.
35
+ #
36
+ # `class_name X extends Y` is one line, so a reader that takes the rest of the line as the name gets
37
+ # `Blade extends Node2D` for the class and never sees the base at all.
38
+ static func declared_class(line: String) -> RegExMatch:
39
+ return compiled("^class_name\\s+([A-Za-z_][A-Za-z0-9_]*)(?:\\s+extends\\s+(\\S+))?").search(line)
40
+
41
+
42
+ # The annotations at the front of a line, in order, written as they are without the leading `@`.
43
+ #
44
+ # For anything that has to answer about the annotations rather than see past them. Splitting one on
45
+ # whitespace loses half of it, since `@export_range(0, 10)` has a space inside the parentheses and
46
+ # was reported as the hint `range(0,`.
47
+ # An `Array[String]` rather than a `PackedStringArray`, because the packed one's `append` answers
48
+ # with a bool and a project holding `return_value_discarded` at error will not compile a script that
49
+ # throws it away, which is the whole reason `compiled` exists a few lines up.
50
+ static func annotations_on(line: String) -> Array[String]:
51
+ var annotation: RegEx = compiled('^@([a-z_]+)(\\(\\s*(?:"[^"]*")?[^)]*\\))?\\s*')
52
+ var found: Array[String] = []
53
+ var rest: String = line.strip_edges()
54
+ var matched: RegExMatch = annotation.search(rest)
55
+ while matched != null:
56
+ found.append(matched.get_string(1) + matched.get_string(2))
57
+ rest = rest.substr(matched.get_end()).strip_edges()
58
+ matched = annotation.search(rest)
59
+ return found
60
+
61
+
62
+ # A line with its leading annotations taken off, for anything reading a script's header.
63
+ #
64
+ # Annotations may share a line with what they annotate: `@abstract class_name X` is one line in
65
+ # Godot 4.5 and later, and `@tool` and `@icon("res://x.svg")` sit there too. Four readers here
66
+ # matched on `class_name` at the start of a line and so saw none of those as a declaration at all,
67
+ # which cost a project with gdUnit4 in it 23 classes reported as declared nowhere, and put a
68
+ # `script_edit` insertion above the `class_name` of a script whose whole header is that one line,
69
+ # where it does not parse. The scanner that writes the class cache had its own copy of this and was
70
+ # right, which is why only the readings were wrong.
71
+ #
72
+ # One home, because five spellings of the same rule is five things to remember when the language
73
+ # adds the next annotation.
74
+ static func without_annotations(line: String) -> String:
75
+ var annotation: RegEx = compiled('^@[a-z_]+(?:\\(\\s*(?:"[^"]*")?[^)]*\\))?\\s*')
76
+ var rest: String = line.strip_edges()
77
+ var found: RegExMatch = annotation.search(rest)
78
+ while found != null:
79
+ rest = rest.substr(found.get_end()).strip_edges()
80
+ found = annotation.search(rest)
81
+ return rest