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.
- package/build/cli.js +137 -122
- package/build/godot/operations/class_cache.gd +1 -4
- package/build/godot/operations/dependencies.gd +5 -4
- package/build/godot/operations/gdscript_analysis.gd +323 -48
- package/build/godot/operations/gdscript_authoring.gd +22 -11
- package/build/godot/operations/patterns.gd +59 -0
- package/build/index.js +19 -17
- package/package.json +1 -1
|
@@ -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 =
|
|
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
|
-
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
48
|
-
|
|
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()
|
|
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
|
-
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
66
|
-
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
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
|
-
|
|
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
|
|
109
|
-
signal_name =
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 =
|
|
167
|
-
var is_onready: bool =
|
|
298
|
+
var is_export: bool = false
|
|
299
|
+
var is_onready: bool = false
|
|
168
300
|
var export_hint: String = ""
|
|
169
301
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|