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.
- package/build/cli.js +138 -123
- package/build/godot/operations/class_cache.gd +1 -4
- package/build/godot/operations/dependencies.gd +30 -9
- package/build/godot/operations/gdscript_analysis.gd +333 -48
- package/build/godot/operations/gdscript_authoring.gd +22 -11
- package/build/godot/operations/patterns.gd +64 -0
- package/build/index.js +20 -18
- 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():
|
|
@@ -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
|
-
|
|
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
|
-
|
|
148
|
-
kind =
|
|
149
|
-
|
|
150
|
-
|
|
151
|
-
references.append({"line": i + 1, "kind": kind, "text":
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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,68 @@ 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
|
+
# `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
|
-
|
|
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
|
|
109
|
-
signal_name =
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
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 =
|
|
167
|
-
var is_onready: bool =
|
|
302
|
+
var is_export: bool = false
|
|
303
|
+
var is_onready: bool = false
|
|
168
304
|
var export_hint: String = ""
|
|
169
305
|
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
if
|
|
174
|
-
|
|
175
|
-
|
|
176
|
-
|
|
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
|
|
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
|
-
|
|
237
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ")):
|