@triflare/fx 2.0.0 → 2.1.0-beta.1
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/README.md +27 -0
- package/package.json +4 -2
- package/product.yml +1 -1
- package/src/catalog.js +103 -0
- package/src/cli/commands/feature.js +114 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +6 -0
- package/src/cli/commands/lint.js +270 -0
- package/src/cli/context.js +13 -0
- package/src/cli/define-command.js +8 -1
- package/src/cli/reporter.js +9 -0
- package/src/compiler.js +133 -0
- package/src/config-edit.js +119 -0
- package/src/config.js +37 -1
- package/src/decompiler.js +45 -0
- package/src/features.js +339 -0
- package/src/formatter.js +499 -0
- package/src/index.js +10 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1414 -0
- package/src/lint/config-file.js +97 -0
- package/src/lint/config.js +174 -0
- package/src/lint/rules.js +461 -0
- package/src/linter.js +220 -0
- package/src/merge.js +109 -0
- package/src/parser.js +53 -13
- package/src/project.js +7 -87
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
|
@@ -0,0 +1,1414 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Browser-safe lint analyzer.
|
|
3
|
+
*
|
|
4
|
+
* This is the part of the linter that only needs source text: parsing, the AST
|
|
5
|
+
* rules, suppressions, severity selection and fixes. It deliberately imports
|
|
6
|
+
* no Node built-ins, so the same implementation powers `fx lint` and the VS
|
|
7
|
+
* Code extension (including its browser extension host). The Node-only project
|
|
8
|
+
* checks (assets on disk, `fx.lock` verification) live in `src/linter.js` and
|
|
9
|
+
* arrive here as `extraDiagnostics`.
|
|
10
|
+
*/
|
|
11
|
+
|
|
12
|
+
import { CATALOG, LIST_OPS } from "../catalog.js";
|
|
13
|
+
import { ParseError } from "../errors.js";
|
|
14
|
+
import { tokenizeWithComments } from "../lexer.js";
|
|
15
|
+
import { mergePrograms } from "../merge.js";
|
|
16
|
+
import { parse } from "../parser.js";
|
|
17
|
+
import { DEFAULT_LINT, resolveRuleSeverities } from "./config.js";
|
|
18
|
+
import { RULES, ruleById } from "./rules.js";
|
|
19
|
+
|
|
20
|
+
/** Rules that describe linter/project machinery and are never configurable. */
|
|
21
|
+
export const META_RULES = new Set(["FX000", "FX001", "FX002"]);
|
|
22
|
+
|
|
23
|
+
/** Control blocks nested deeper than this are reported by FX901. */
|
|
24
|
+
export const MAX_NESTING = 4;
|
|
25
|
+
|
|
26
|
+
/** A single hat or function with more statements is reported by FX902. */
|
|
27
|
+
export const MAX_SCRIPT_STATEMENTS = 60;
|
|
28
|
+
|
|
29
|
+
/** List method aliases, mapped to their canonical method name. */
|
|
30
|
+
const LIST_METHODS = new Map();
|
|
31
|
+
for (const [name, entry] of Object.entries(LIST_OPS)) {
|
|
32
|
+
LIST_METHODS.set(name, name);
|
|
33
|
+
for (const alias of entry.aliases ?? []) LIST_METHODS.set(alias, name);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
/** Catalog blocks that hand control back to the scheduler. */
|
|
37
|
+
const YIELDING_BLOCKS = new Set([
|
|
38
|
+
"control.wait",
|
|
39
|
+
"control.waitUntil",
|
|
40
|
+
"looks.sayForSecs",
|
|
41
|
+
"looks.thinkForSecs",
|
|
42
|
+
"looks.switchBackdropAndWait",
|
|
43
|
+
"sound.playUntilDone",
|
|
44
|
+
"sensing.askAndWait",
|
|
45
|
+
"event.broadcastAndWait",
|
|
46
|
+
"motion.glideTo",
|
|
47
|
+
"motion.glideToXY",
|
|
48
|
+
]);
|
|
49
|
+
|
|
50
|
+
// ---------------------------------------------------------------- utilities
|
|
51
|
+
|
|
52
|
+
/** Scratch-style truthiness of a static literal value. */
|
|
53
|
+
function truthy(value) {
|
|
54
|
+
if (typeof value === "boolean") return value;
|
|
55
|
+
if (typeof value === "number") return value !== 0 && !Number.isNaN(value);
|
|
56
|
+
const text = String(value).trim().toLowerCase();
|
|
57
|
+
return text !== "" && text !== "0" && text !== "false";
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/**
|
|
61
|
+
* Evaluate an expression whose value is known before the project runs.
|
|
62
|
+
* Returns `null` when the value depends on runtime state.
|
|
63
|
+
*/
|
|
64
|
+
function staticValue(expr) {
|
|
65
|
+
if (!expr) return null;
|
|
66
|
+
switch (expr.type) {
|
|
67
|
+
case "Number":
|
|
68
|
+
case "String":
|
|
69
|
+
case "Boolean":
|
|
70
|
+
return expr.value;
|
|
71
|
+
case "Unary": {
|
|
72
|
+
const value = staticValue(expr.operand);
|
|
73
|
+
if (value === null) return null;
|
|
74
|
+
if (expr.op === "not") return !truthy(value);
|
|
75
|
+
if (expr.op === "-") return typeof value === "number" ? -value : null;
|
|
76
|
+
return null;
|
|
77
|
+
}
|
|
78
|
+
case "Binary": {
|
|
79
|
+
const left = staticValue(expr.left);
|
|
80
|
+
const right = staticValue(expr.right);
|
|
81
|
+
if (expr.op === "and") {
|
|
82
|
+
if (left !== null && !truthy(left)) return false;
|
|
83
|
+
if (left !== null && right !== null) return truthy(right);
|
|
84
|
+
return null;
|
|
85
|
+
}
|
|
86
|
+
if (expr.op === "or") {
|
|
87
|
+
if (left !== null && truthy(left)) return true;
|
|
88
|
+
if (left !== null && right !== null) return truthy(right);
|
|
89
|
+
return null;
|
|
90
|
+
}
|
|
91
|
+
if (left === null || right === null) return null;
|
|
92
|
+
switch (expr.op) {
|
|
93
|
+
case "==":
|
|
94
|
+
return String(left) === String(right);
|
|
95
|
+
case "!=":
|
|
96
|
+
return String(left) !== String(right);
|
|
97
|
+
case "<":
|
|
98
|
+
return Number(left) < Number(right);
|
|
99
|
+
case "<=":
|
|
100
|
+
return Number(left) <= Number(right);
|
|
101
|
+
case ">":
|
|
102
|
+
return Number(left) > Number(right);
|
|
103
|
+
case ">=":
|
|
104
|
+
return Number(left) >= Number(right);
|
|
105
|
+
case "+":
|
|
106
|
+
if (typeof left === "number" && typeof right === "number")
|
|
107
|
+
return left + right;
|
|
108
|
+
if (typeof left === "string" || typeof right === "string")
|
|
109
|
+
return `${left}${right}`;
|
|
110
|
+
return null;
|
|
111
|
+
case "-":
|
|
112
|
+
case "*":
|
|
113
|
+
if (typeof left === "number" && typeof right === "number")
|
|
114
|
+
return expr.op === "-" ? left - right : left * right;
|
|
115
|
+
return null;
|
|
116
|
+
case "/":
|
|
117
|
+
case "%":
|
|
118
|
+
if (typeof left !== "number" || typeof right !== "number")
|
|
119
|
+
return null;
|
|
120
|
+
if (right === 0) return null; // FX404 owns this case
|
|
121
|
+
return expr.op === "/" ? left / right : left % right;
|
|
122
|
+
default:
|
|
123
|
+
return null;
|
|
124
|
+
}
|
|
125
|
+
}
|
|
126
|
+
default:
|
|
127
|
+
return null;
|
|
128
|
+
}
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
/** Static conversion matching the compiler's booleanInput and boolean operators. */
|
|
132
|
+
function staticBoolean(expr) {
|
|
133
|
+
if (expr.type === "Unary" && expr.op === "not") {
|
|
134
|
+
const value = staticBoolean(expr.operand);
|
|
135
|
+
return value === null ? null : !value;
|
|
136
|
+
}
|
|
137
|
+
if (expr.type === "Binary" && ["and", "or"].includes(expr.op)) {
|
|
138
|
+
const left = staticBoolean(expr.left);
|
|
139
|
+
const right = staticBoolean(expr.right);
|
|
140
|
+
if (expr.op === "and") {
|
|
141
|
+
if (left === false || right === false) return false;
|
|
142
|
+
return left === null || right === null ? null : left && right;
|
|
143
|
+
}
|
|
144
|
+
if (left === true || right === true) return true;
|
|
145
|
+
return left === null || right === null ? null : left || right;
|
|
146
|
+
}
|
|
147
|
+
const value = staticValue(expr);
|
|
148
|
+
if (value === null) return null;
|
|
149
|
+
// Primitive inputs are compared with "true"; reporter blocks pass through.
|
|
150
|
+
if (["Number", "String", "Boolean"].includes(expr.type))
|
|
151
|
+
return String(value).toLowerCase() === "true";
|
|
152
|
+
return truthy(value);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
/** Whether a statement ends the script it belongs to. */
|
|
156
|
+
function isTerminalCall(node) {
|
|
157
|
+
if (node.type === "Return") return true;
|
|
158
|
+
if (node.type !== "BlockCall") return false;
|
|
159
|
+
if (node.name === "control.deleteThisClone") return true;
|
|
160
|
+
if (node.name !== "control.stop") return false;
|
|
161
|
+
const option = node.args[0];
|
|
162
|
+
if (!option) return true;
|
|
163
|
+
return (
|
|
164
|
+
option.type === "String" && ["all", "this script"].includes(option.value)
|
|
165
|
+
);
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Descriptive text for the block that ends a script. */
|
|
169
|
+
function terminalDescription(node) {
|
|
170
|
+
if (node.type === "Return") return "`return`";
|
|
171
|
+
if (node.name === "control.stop") return "`control.stop(...)`";
|
|
172
|
+
return `\`${node.name}()\``;
|
|
173
|
+
}
|
|
174
|
+
|
|
175
|
+
/** Count statements in a hat or function body, including nested blocks. */
|
|
176
|
+
function countStatements(statements) {
|
|
177
|
+
let total = 0;
|
|
178
|
+
const visit = (list) => {
|
|
179
|
+
for (const statement of list ?? []) {
|
|
180
|
+
total += 1;
|
|
181
|
+
if (statement.type === "If") {
|
|
182
|
+
visit(statement.then);
|
|
183
|
+
visit(statement.else);
|
|
184
|
+
} else if (
|
|
185
|
+
statement.type === "Repeat" ||
|
|
186
|
+
statement.type === "While" ||
|
|
187
|
+
statement.type === "Forever"
|
|
188
|
+
) {
|
|
189
|
+
visit(statement.body);
|
|
190
|
+
} else if (statement.type === "BlockCall") {
|
|
191
|
+
visit(statement.body);
|
|
192
|
+
visit(statement.else);
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
visit(statements);
|
|
197
|
+
return total;
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
/** Whether every path through a `returning function` ends with a return. */
|
|
201
|
+
function alwaysReturns(statements) {
|
|
202
|
+
for (const statement of statements ?? []) {
|
|
203
|
+
if (statement.type === "Return") return true;
|
|
204
|
+
if (
|
|
205
|
+
statement.type === "If" &&
|
|
206
|
+
statement.else &&
|
|
207
|
+
alwaysReturns(statement.then) &&
|
|
208
|
+
alwaysReturns(statement.else)
|
|
209
|
+
) {
|
|
210
|
+
return true;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
return false;
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/** Declaration tables for one target's top-level items. */
|
|
217
|
+
function collectDeclarations(items) {
|
|
218
|
+
const vars = new Map();
|
|
219
|
+
const lists = new Map();
|
|
220
|
+
const funcs = new Map();
|
|
221
|
+
const order = [];
|
|
222
|
+
items.forEach((item, index) => {
|
|
223
|
+
if (item.type === "VarDecl") {
|
|
224
|
+
vars.set(item.name, item);
|
|
225
|
+
order.push({ kind: "value", node: item, index });
|
|
226
|
+
} else if (item.type === "ListDecl") {
|
|
227
|
+
lists.set(item.name, item);
|
|
228
|
+
order.push({ kind: "value", node: item, index });
|
|
229
|
+
} else if (item.type === "FunctionDecl") {
|
|
230
|
+
funcs.set(item.name, item);
|
|
231
|
+
order.push({ kind: "func", node: item, index });
|
|
232
|
+
}
|
|
233
|
+
});
|
|
234
|
+
return { vars, lists, funcs, order };
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/** Build stage and sprite targets, mirroring how the compiler hoists names. */
|
|
238
|
+
function buildTargets(program) {
|
|
239
|
+
const stageBody = program.stage ? program.stage.body : [];
|
|
240
|
+
const stageItems = [...stageBody, ...program.topItems];
|
|
241
|
+
const stageLoc = program.stage?.loc ??
|
|
242
|
+
stageItems[0]?.loc ?? { file: null, line: 1, column: 1 };
|
|
243
|
+
const stage = {
|
|
244
|
+
name: "Stage",
|
|
245
|
+
isStage: true,
|
|
246
|
+
loc: stageLoc,
|
|
247
|
+
// Only a real `stage {` block has a brace to insert declarations into.
|
|
248
|
+
open: program.stage?.open ?? null,
|
|
249
|
+
items: stageItems,
|
|
250
|
+
decls: collectDeclarations(stageItems),
|
|
251
|
+
usage: { vars: new Set(), lists: new Set(), funcs: new Set() },
|
|
252
|
+
};
|
|
253
|
+
const sprites = program.sprites.map((sprite) => ({
|
|
254
|
+
name: sprite.name,
|
|
255
|
+
isStage: false,
|
|
256
|
+
loc: sprite.loc,
|
|
257
|
+
open: sprite.open ?? null,
|
|
258
|
+
items: sprite.body,
|
|
259
|
+
decls: collectDeclarations(sprite.body),
|
|
260
|
+
usage: { vars: new Set(), lists: new Set(), funcs: new Set() },
|
|
261
|
+
}));
|
|
262
|
+
return { stage, sprites, globals: stage.decls };
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
/** Normalize a source location into the diagnostic range shape. */
|
|
266
|
+
function rangeOf(loc, length = 1) {
|
|
267
|
+
const line = loc?.line ?? 1;
|
|
268
|
+
const column = loc?.column ?? 1;
|
|
269
|
+
return {
|
|
270
|
+
file: loc?.file ?? null,
|
|
271
|
+
line,
|
|
272
|
+
column,
|
|
273
|
+
endLine: line,
|
|
274
|
+
endColumn: column + Math.max(0, length),
|
|
275
|
+
};
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
/** Last path segment, without importing `node:path`. */
|
|
279
|
+
function baseName(file) {
|
|
280
|
+
const parts = String(file ?? "").split(/[\\/]/);
|
|
281
|
+
return parts[parts.length - 1] || "";
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// ----------------------------------------------------------------- analyzer
|
|
285
|
+
|
|
286
|
+
const TEMP_SIGIL = /^\$\$?[A-Za-z_][A-Za-z0-9_]*$/;
|
|
287
|
+
|
|
288
|
+
class Analyzer {
|
|
289
|
+
/**
|
|
290
|
+
* @param {{
|
|
291
|
+
* severities: Map<string, string>,
|
|
292
|
+
* catalog: Record<string, object>,
|
|
293
|
+
* extensionIds: Set<string>,
|
|
294
|
+
* declaredExtensions: Set<string>,
|
|
295
|
+
* sourceText: (file: string) => string|null,
|
|
296
|
+
* }} options
|
|
297
|
+
*/
|
|
298
|
+
constructor({
|
|
299
|
+
severities,
|
|
300
|
+
catalog,
|
|
301
|
+
extensionIds,
|
|
302
|
+
declaredExtensions,
|
|
303
|
+
sourceText,
|
|
304
|
+
}) {
|
|
305
|
+
this.severities = severities;
|
|
306
|
+
this.catalog = catalog;
|
|
307
|
+
this.extensionIds = extensionIds;
|
|
308
|
+
this.declaredExtensions = declaredExtensions;
|
|
309
|
+
this.sourceText = sourceText;
|
|
310
|
+
this.diagnostics = [];
|
|
311
|
+
this.globalUsage = { vars: new Set(), lists: new Set() };
|
|
312
|
+
this.paramUsage = new Map();
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
/** Whether a rule is enabled for this run. */
|
|
316
|
+
enabled(id) {
|
|
317
|
+
return META_RULES.has(id) || this.severities.get(id) !== "off";
|
|
318
|
+
}
|
|
319
|
+
|
|
320
|
+
/** Append one diagnostic, tagged with its stable metadata. */
|
|
321
|
+
report(id, loc, message, { length = 1, fix = null } = {}) {
|
|
322
|
+
if (!this.enabled(id)) return;
|
|
323
|
+
const rule = RULES[id];
|
|
324
|
+
this.diagnostics.push({
|
|
325
|
+
ruleId: id,
|
|
326
|
+
severity: META_RULES.has(id) ? rule.severity : this.severities.get(id),
|
|
327
|
+
category: rule.category,
|
|
328
|
+
message,
|
|
329
|
+
...rangeOf(loc, length),
|
|
330
|
+
explanation: rule.explanation,
|
|
331
|
+
// Only advertise fixability when this diagnostic actually carries a
|
|
332
|
+
// fix, so FX204 without a block to insert into is not offered as fixable.
|
|
333
|
+
fixable: Boolean(rule.fixable) && Boolean(fix),
|
|
334
|
+
...(fix ? { fix } : {}),
|
|
335
|
+
});
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
// ------------------------------------------------------------ resolution
|
|
339
|
+
|
|
340
|
+
resolveName(env, params, name) {
|
|
341
|
+
if (params?.has(name)) return { kind: "param", name, scope: "param" };
|
|
342
|
+
const { target, globals } = env;
|
|
343
|
+
if (target.decls.vars.has(name))
|
|
344
|
+
return {
|
|
345
|
+
kind: "var",
|
|
346
|
+
name,
|
|
347
|
+
scope: target.isStage ? "global" : "local",
|
|
348
|
+
};
|
|
349
|
+
if (target.decls.lists.has(name))
|
|
350
|
+
return {
|
|
351
|
+
kind: "list",
|
|
352
|
+
name,
|
|
353
|
+
scope: target.isStage ? "global" : "local",
|
|
354
|
+
};
|
|
355
|
+
if (globals.vars.has(name)) return { kind: "var", name, scope: "global" };
|
|
356
|
+
if (globals.lists.has(name)) return { kind: "list", name, scope: "global" };
|
|
357
|
+
return null;
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
markUsage(state, resolved) {
|
|
361
|
+
if (resolved.kind === "param") {
|
|
362
|
+
const used = this.paramUsage.get(state.func);
|
|
363
|
+
if (used) used.add(resolved.name);
|
|
364
|
+
return;
|
|
365
|
+
}
|
|
366
|
+
const bucket = resolved.kind === "var" ? "vars" : "lists";
|
|
367
|
+
if (resolved.scope === "local")
|
|
368
|
+
state.env.target.usage[bucket].add(resolved.name);
|
|
369
|
+
else this.globalUsage[bucket].add(resolved.name);
|
|
370
|
+
}
|
|
371
|
+
|
|
372
|
+
checkIdentifier(expr, state) {
|
|
373
|
+
if (TEMP_SIGIL.test(expr.name)) return;
|
|
374
|
+
if (expr.segments.length > 1) {
|
|
375
|
+
this.report(
|
|
376
|
+
"FX201",
|
|
377
|
+
expr.loc,
|
|
378
|
+
`Dotted name ${JSON.stringify(expr.name)} cannot be read as a variable.`,
|
|
379
|
+
{ length: expr.name.length },
|
|
380
|
+
);
|
|
381
|
+
return;
|
|
382
|
+
}
|
|
383
|
+
const resolved = this.resolveName(state.env, state.params, expr.name);
|
|
384
|
+
if (!resolved) {
|
|
385
|
+
this.report(
|
|
386
|
+
"FX201",
|
|
387
|
+
expr.loc,
|
|
388
|
+
`Variable ${JSON.stringify(expr.name)} is not declared.`,
|
|
389
|
+
{ length: expr.name.length },
|
|
390
|
+
);
|
|
391
|
+
return;
|
|
392
|
+
}
|
|
393
|
+
this.markUsage(state, resolved);
|
|
394
|
+
}
|
|
395
|
+
|
|
396
|
+
/**
|
|
397
|
+
* Resolve a call, reporting the rule that matches its failure mode.
|
|
398
|
+
* @returns {{kind: 'list'|'catalog'|'function'|'extension'|'unknown', entry?: object}}
|
|
399
|
+
*/
|
|
400
|
+
resolveCall(node, state) {
|
|
401
|
+
const { segments, name } = node;
|
|
402
|
+
if (segments.length === 2) {
|
|
403
|
+
const method = LIST_METHODS.get(segments[1]);
|
|
404
|
+
if (method) {
|
|
405
|
+
const resolved = this.resolveName(state.env, state.params, segments[0]);
|
|
406
|
+
if (resolved?.kind === "list") {
|
|
407
|
+
this.markUsage(state, resolved);
|
|
408
|
+
return { kind: "list" };
|
|
409
|
+
}
|
|
410
|
+
// A catalog block may share a method name (`looks.show`, `pen.clear`),
|
|
411
|
+
// and the compiler prefers the catalog when the base is not a list.
|
|
412
|
+
const entry = this.catalog[name];
|
|
413
|
+
if (entry) return { kind: "catalog", entry };
|
|
414
|
+
this.report(
|
|
415
|
+
"FX202",
|
|
416
|
+
node.loc,
|
|
417
|
+
resolved
|
|
418
|
+
? `${JSON.stringify(segments[0])} is not a list, so ${name}() cannot be called on it.`
|
|
419
|
+
: `List ${JSON.stringify(segments[0])} is not declared.`,
|
|
420
|
+
{ length: name.length },
|
|
421
|
+
);
|
|
422
|
+
return { kind: "unknown" };
|
|
423
|
+
}
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
const entry = this.catalog[name];
|
|
427
|
+
if (entry) {
|
|
428
|
+
if (
|
|
429
|
+
entry.requiresExtension &&
|
|
430
|
+
entry.extension &&
|
|
431
|
+
this.extensionIds.size > 0 &&
|
|
432
|
+
!this.extensionIds.has(entry.extension)
|
|
433
|
+
)
|
|
434
|
+
this.report(
|
|
435
|
+
"FX709",
|
|
436
|
+
node.loc,
|
|
437
|
+
`${name} needs the ${entry.extension} extension, which is not loaded. Run "fx feature temporaryvariables enable".`,
|
|
438
|
+
{ length: name.length },
|
|
439
|
+
);
|
|
440
|
+
return { kind: "catalog", entry };
|
|
441
|
+
}
|
|
442
|
+
|
|
443
|
+
if (segments.length === 1 && state.env.target.decls.funcs.has(name)) {
|
|
444
|
+
state.env.target.usage.funcs.add(name);
|
|
445
|
+
return { kind: "function" };
|
|
446
|
+
}
|
|
447
|
+
|
|
448
|
+
const prefix = segments.length > 1 ? segments[0] : null;
|
|
449
|
+
const verified = Boolean(prefix && this.extensionIds.has(prefix));
|
|
450
|
+
const declared = Boolean(prefix && this.declaredExtensions.has(prefix));
|
|
451
|
+
// Editors know which extensions are declared but do not execute their
|
|
452
|
+
// metadata, so an unverifiable block from a declared extension is not a
|
|
453
|
+
// diagnostic there (the CLI, which loads the catalog, still reports it).
|
|
454
|
+
if (declared && !verified) return { kind: "extension" };
|
|
455
|
+
|
|
456
|
+
const hint = verified
|
|
457
|
+
? ` Extension ${JSON.stringify(prefix)} is loaded but has no such block.`
|
|
458
|
+
: "";
|
|
459
|
+
this.report(
|
|
460
|
+
"FX709",
|
|
461
|
+
node.loc,
|
|
462
|
+
`Unknown block or function ${JSON.stringify(name)}.${hint}`,
|
|
463
|
+
{ length: name.length },
|
|
464
|
+
);
|
|
465
|
+
return { kind: "unknown" };
|
|
466
|
+
}
|
|
467
|
+
|
|
468
|
+
checkYield(node, state) {
|
|
469
|
+
if (!state.inWarp || state.warpYieldReported) return;
|
|
470
|
+
if (!YIELDING_BLOCKS.has(node.name)) return;
|
|
471
|
+
state.warpYieldReported = true;
|
|
472
|
+
this.report(
|
|
473
|
+
"FX506",
|
|
474
|
+
node.loc,
|
|
475
|
+
`Warp function ${JSON.stringify(state.func.name)} calls ${node.name}(), which cannot yield inside warp.`,
|
|
476
|
+
{ length: node.name.length },
|
|
477
|
+
);
|
|
478
|
+
}
|
|
479
|
+
|
|
480
|
+
// ----------------------------------------------------------- expressions
|
|
481
|
+
|
|
482
|
+
walkExpression(expr, state) {
|
|
483
|
+
if (!expr) return;
|
|
484
|
+
switch (expr.type) {
|
|
485
|
+
case "Identifier":
|
|
486
|
+
this.checkIdentifier(expr, state);
|
|
487
|
+
break;
|
|
488
|
+
case "Call": {
|
|
489
|
+
this.resolveCall(expr, state);
|
|
490
|
+
this.checkYield(expr, state);
|
|
491
|
+
for (const arg of expr.args) this.walkExpression(arg, state);
|
|
492
|
+
break;
|
|
493
|
+
}
|
|
494
|
+
case "Binary":
|
|
495
|
+
this.checkDivideByZero(expr);
|
|
496
|
+
this.walkExpression(expr.left, state);
|
|
497
|
+
this.walkExpression(expr.right, state);
|
|
498
|
+
break;
|
|
499
|
+
case "Unary":
|
|
500
|
+
this.walkExpression(expr.operand, state);
|
|
501
|
+
break;
|
|
502
|
+
case "ArrayLiteral":
|
|
503
|
+
for (const item of expr.items) this.walkExpression(item, state);
|
|
504
|
+
break;
|
|
505
|
+
default:
|
|
506
|
+
break;
|
|
507
|
+
}
|
|
508
|
+
}
|
|
509
|
+
|
|
510
|
+
checkDivideByZero(expr) {
|
|
511
|
+
if (expr.op !== "/" && expr.op !== "%") return;
|
|
512
|
+
const divisor = staticValue(expr.right);
|
|
513
|
+
if (typeof divisor === "number" && divisor === 0) {
|
|
514
|
+
this.report(
|
|
515
|
+
"FX404",
|
|
516
|
+
expr.loc,
|
|
517
|
+
`Division by a constant zero${expr.op === "%" ? " (modulo)" : ""} yields Infinity or NaN.`,
|
|
518
|
+
{ length: 1 },
|
|
519
|
+
);
|
|
520
|
+
}
|
|
521
|
+
}
|
|
522
|
+
|
|
523
|
+
// ------------------------------------------------------------ statements
|
|
524
|
+
|
|
525
|
+
walkStatements(statements, state) {
|
|
526
|
+
let terminal = null;
|
|
527
|
+
for (const statement of statements ?? []) {
|
|
528
|
+
if (terminal) {
|
|
529
|
+
this.report(
|
|
530
|
+
"FX101",
|
|
531
|
+
statement.loc,
|
|
532
|
+
`Unreachable statement: nothing can follow ${terminal}.`,
|
|
533
|
+
{ length: 1 },
|
|
534
|
+
);
|
|
535
|
+
}
|
|
536
|
+
this.walkStatement(statement, state);
|
|
537
|
+
if (isTerminalCall(statement)) terminal = terminalDescription(statement);
|
|
538
|
+
}
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
enterBlock(node, state) {
|
|
542
|
+
const nesting = state.nesting + 1;
|
|
543
|
+
if (nesting > MAX_NESTING) {
|
|
544
|
+
this.report(
|
|
545
|
+
"FX901",
|
|
546
|
+
node.loc,
|
|
547
|
+
`Control blocks are nested ${nesting} levels deep; keep nesting at ${MAX_NESTING} or less.`,
|
|
548
|
+
{ length: 1 },
|
|
549
|
+
);
|
|
550
|
+
}
|
|
551
|
+
return { ...state, nesting };
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
walkStatement(statement, state) {
|
|
555
|
+
switch (statement.type) {
|
|
556
|
+
case "VarDecl":
|
|
557
|
+
case "ListDecl":
|
|
558
|
+
// Nested declarations are not hoisted by the compiler; their
|
|
559
|
+
// initializers still contribute references.
|
|
560
|
+
if (statement.type === "VarDecl")
|
|
561
|
+
this.walkExpression(statement.init, state);
|
|
562
|
+
else
|
|
563
|
+
for (const item of statement.items) this.walkExpression(item, state);
|
|
564
|
+
break;
|
|
565
|
+
case "PropertyDecl":
|
|
566
|
+
case "FunctionDecl":
|
|
567
|
+
case "HatDecl":
|
|
568
|
+
break;
|
|
569
|
+
case "If": {
|
|
570
|
+
const constant = staticBoolean(statement.cond);
|
|
571
|
+
if (constant !== null) {
|
|
572
|
+
this.report(
|
|
573
|
+
"FX403",
|
|
574
|
+
statement.cond.loc,
|
|
575
|
+
`Condition is always ${constant}, so one branch is dead.`,
|
|
576
|
+
{ length: 1 },
|
|
577
|
+
);
|
|
578
|
+
}
|
|
579
|
+
this.walkExpression(statement.cond, state);
|
|
580
|
+
const inner = this.enterBlock(statement, state);
|
|
581
|
+
this.walkStatements(statement.then, inner);
|
|
582
|
+
this.walkStatements(statement.else, inner);
|
|
583
|
+
break;
|
|
584
|
+
}
|
|
585
|
+
case "Repeat": {
|
|
586
|
+
const count = staticValue(statement.count);
|
|
587
|
+
if (typeof count === "number" && count <= 0) {
|
|
588
|
+
this.report(
|
|
589
|
+
"FX304",
|
|
590
|
+
statement.count.loc,
|
|
591
|
+
`repeat (${count}) never runs its body.`,
|
|
592
|
+
{ length: 1 },
|
|
593
|
+
);
|
|
594
|
+
}
|
|
595
|
+
this.walkExpression(statement.count, state);
|
|
596
|
+
this.walkStatements(statement.body, this.enterBlock(statement, state));
|
|
597
|
+
break;
|
|
598
|
+
}
|
|
599
|
+
case "While": {
|
|
600
|
+
const constant = staticBoolean(statement.cond);
|
|
601
|
+
if (constant !== null) {
|
|
602
|
+
this.report(
|
|
603
|
+
"FX302",
|
|
604
|
+
statement.cond.loc,
|
|
605
|
+
`Loop condition is always ${constant}.`,
|
|
606
|
+
{ length: 1 },
|
|
607
|
+
);
|
|
608
|
+
}
|
|
609
|
+
if (constant === true && !this.bodyYields(statement.body, state)) {
|
|
610
|
+
this.report(
|
|
611
|
+
"FX301",
|
|
612
|
+
statement.loc,
|
|
613
|
+
"Infinite loop body has nothing that yields, so other scripts cannot run.",
|
|
614
|
+
{ length: 5 },
|
|
615
|
+
);
|
|
616
|
+
}
|
|
617
|
+
this.walkExpression(statement.cond, state);
|
|
618
|
+
this.walkStatements(statement.body, this.enterBlock(statement, state));
|
|
619
|
+
break;
|
|
620
|
+
}
|
|
621
|
+
case "Forever": {
|
|
622
|
+
if (!this.bodyYields(statement.body, state)) {
|
|
623
|
+
this.report(
|
|
624
|
+
"FX301",
|
|
625
|
+
statement.loc,
|
|
626
|
+
"Infinite loop body has nothing that yields, so other scripts cannot run.",
|
|
627
|
+
{ length: 7 },
|
|
628
|
+
);
|
|
629
|
+
}
|
|
630
|
+
this.walkStatements(statement.body, this.enterBlock(statement, state));
|
|
631
|
+
break;
|
|
632
|
+
}
|
|
633
|
+
case "Return":
|
|
634
|
+
if (state.funcDepth === 0) {
|
|
635
|
+
this.report(
|
|
636
|
+
"FX307",
|
|
637
|
+
statement.loc,
|
|
638
|
+
"`return` is only valid inside a function.",
|
|
639
|
+
{ length: 6 },
|
|
640
|
+
);
|
|
641
|
+
}
|
|
642
|
+
if (statement.value && state.func && !state.func.returns) {
|
|
643
|
+
this.report(
|
|
644
|
+
"FX309",
|
|
645
|
+
statement.loc,
|
|
646
|
+
`Function ${JSON.stringify(state.func.name)} is not a returning function, so it cannot return a value.`,
|
|
647
|
+
{ length: 6 },
|
|
648
|
+
);
|
|
649
|
+
}
|
|
650
|
+
this.walkExpression(statement.value, state);
|
|
651
|
+
break;
|
|
652
|
+
case "TempVarDecl":
|
|
653
|
+
this.walkExpression(statement.init, state);
|
|
654
|
+
break;
|
|
655
|
+
case "Assign":
|
|
656
|
+
this.walkAssign(statement, state);
|
|
657
|
+
break;
|
|
658
|
+
case "BlockCall":
|
|
659
|
+
this.walkBlockCall(statement, state);
|
|
660
|
+
break;
|
|
661
|
+
default:
|
|
662
|
+
break;
|
|
663
|
+
}
|
|
664
|
+
}
|
|
665
|
+
|
|
666
|
+
walkAssign(statement, state) {
|
|
667
|
+
if (TEMP_SIGIL.test(statement.name)) {
|
|
668
|
+
this.walkExpression(statement.value, state);
|
|
669
|
+
return;
|
|
670
|
+
}
|
|
671
|
+
const resolved = this.resolveName(state.env, state.params, statement.name);
|
|
672
|
+
if (!resolved || resolved.kind !== "var") {
|
|
673
|
+
this.report(
|
|
674
|
+
"FX204",
|
|
675
|
+
statement.loc,
|
|
676
|
+
`Assignment to undeclared variable ${JSON.stringify(statement.name)}; declare it with \`let ${statement.name} = <value>;\`.`,
|
|
677
|
+
{
|
|
678
|
+
length: statement.name.length,
|
|
679
|
+
fix:
|
|
680
|
+
state.env.target.open == null
|
|
681
|
+
? null
|
|
682
|
+
: {
|
|
683
|
+
kind: "declare-variable",
|
|
684
|
+
name: statement.name,
|
|
685
|
+
file:
|
|
686
|
+
state.env.target.open.file ??
|
|
687
|
+
state.env.target.loc?.file ??
|
|
688
|
+
null,
|
|
689
|
+
// 1-based position of the opening brace; the fix inserts
|
|
690
|
+
// just after it so a brace on its own line still works.
|
|
691
|
+
line: state.env.target.open.line,
|
|
692
|
+
column: state.env.target.open.column,
|
|
693
|
+
indent: " ",
|
|
694
|
+
},
|
|
695
|
+
},
|
|
696
|
+
);
|
|
697
|
+
} else {
|
|
698
|
+
this.markUsage(state, resolved);
|
|
699
|
+
}
|
|
700
|
+
if (statement.op === "/=") {
|
|
701
|
+
const divisor = staticValue(statement.value);
|
|
702
|
+
if (typeof divisor === "number" && divisor === 0) {
|
|
703
|
+
this.report(
|
|
704
|
+
"FX404",
|
|
705
|
+
statement.loc,
|
|
706
|
+
"Dividing by a constant zero yields Infinity or NaN.",
|
|
707
|
+
{ length: statement.name.length },
|
|
708
|
+
);
|
|
709
|
+
}
|
|
710
|
+
}
|
|
711
|
+
this.walkExpression(statement.value, state);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
walkBlockCall(statement, state) {
|
|
715
|
+
this.resolveCall(statement, state);
|
|
716
|
+
this.checkYield(statement, state);
|
|
717
|
+
for (const arg of statement.args) this.walkExpression(arg, state);
|
|
718
|
+
if (statement.body) {
|
|
719
|
+
const inner = this.enterBlock(statement, state);
|
|
720
|
+
this.walkStatements(statement.body, inner);
|
|
721
|
+
this.walkStatements(statement.else, inner);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
|
|
725
|
+
/** Whether a loop body contains a block that lets the scheduler step. */
|
|
726
|
+
bodyYields(statements, state) {
|
|
727
|
+
for (const statement of statements ?? []) {
|
|
728
|
+
if (
|
|
729
|
+
statement.type === "Forever" ||
|
|
730
|
+
statement.type === "Repeat" ||
|
|
731
|
+
statement.type === "While"
|
|
732
|
+
) {
|
|
733
|
+
return true; // loops yield at the end of every iteration
|
|
734
|
+
}
|
|
735
|
+
if (statement.type === "BlockCall") {
|
|
736
|
+
if (
|
|
737
|
+
YIELDING_BLOCKS.has(statement.name) ||
|
|
738
|
+
state.env.target.decls.funcs.has(statement.name)
|
|
739
|
+
) {
|
|
740
|
+
return true;
|
|
741
|
+
}
|
|
742
|
+
if (
|
|
743
|
+
this.bodyYields(statement.body, state) ||
|
|
744
|
+
this.bodyYields(statement.else, state)
|
|
745
|
+
) {
|
|
746
|
+
return true;
|
|
747
|
+
}
|
|
748
|
+
} else if (statement.type === "If") {
|
|
749
|
+
if (
|
|
750
|
+
this.bodyYields(statement.then, state) ||
|
|
751
|
+
this.bodyYields(statement.else, state)
|
|
752
|
+
) {
|
|
753
|
+
return true;
|
|
754
|
+
}
|
|
755
|
+
}
|
|
756
|
+
}
|
|
757
|
+
return false;
|
|
758
|
+
}
|
|
759
|
+
|
|
760
|
+
// ---------------------------------------------------------- target rules
|
|
761
|
+
|
|
762
|
+
analyzeProgram(program) {
|
|
763
|
+
const { stage, sprites, globals } = buildTargets(program);
|
|
764
|
+
for (const target of [stage, ...sprites])
|
|
765
|
+
this.analyzeTarget(target, globals);
|
|
766
|
+
// Sprite reads feed `globalUsage`, so unused stage names are decided only
|
|
767
|
+
// after every target has been analyzed.
|
|
768
|
+
this.checkGlobalUsage(globals);
|
|
769
|
+
return this.diagnostics;
|
|
770
|
+
}
|
|
771
|
+
|
|
772
|
+
analyzeTarget(target, globals) {
|
|
773
|
+
const env = { target, globals };
|
|
774
|
+
for (const item of target.items) {
|
|
775
|
+
if (item.type === "VarDecl" || item.type === "ListDecl")
|
|
776
|
+
this.checkInitializer(item, target, globals);
|
|
777
|
+
}
|
|
778
|
+
|
|
779
|
+
for (const item of target.items) {
|
|
780
|
+
if (item.type === "FunctionDecl") {
|
|
781
|
+
this.checkDocumentation(item);
|
|
782
|
+
this.checkScriptLength(item.body, item.loc);
|
|
783
|
+
if (item.returns && !alwaysReturns(item.body)) {
|
|
784
|
+
this.report(
|
|
785
|
+
"FX308",
|
|
786
|
+
item.loc,
|
|
787
|
+
`Returning function ${JSON.stringify(item.name)} has a path that does not return a value.`,
|
|
788
|
+
{ length: item.name.length },
|
|
789
|
+
);
|
|
790
|
+
}
|
|
791
|
+
this.paramUsage.set(item, new Set());
|
|
792
|
+
const state = {
|
|
793
|
+
env,
|
|
794
|
+
params: new Map(item.params.map((param) => [param.name, param])),
|
|
795
|
+
func: item,
|
|
796
|
+
funcDepth: 1,
|
|
797
|
+
nesting: 0,
|
|
798
|
+
inWarp: Boolean(item.warp),
|
|
799
|
+
warpYieldReported: false,
|
|
800
|
+
};
|
|
801
|
+
this.walkStatements(item.body, state);
|
|
802
|
+
} else if (item.type === "HatDecl") {
|
|
803
|
+
this.checkScriptLength(item.body, item.loc);
|
|
804
|
+
const state = {
|
|
805
|
+
env,
|
|
806
|
+
params: null,
|
|
807
|
+
func: null,
|
|
808
|
+
funcDepth: 0,
|
|
809
|
+
nesting: 0,
|
|
810
|
+
inWarp: false,
|
|
811
|
+
warpYieldReported: false,
|
|
812
|
+
};
|
|
813
|
+
this.walkStatements(item.body, state);
|
|
814
|
+
}
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
this.checkDeclarations(target, globals);
|
|
818
|
+
}
|
|
819
|
+
|
|
820
|
+
/** FX203: initializers that read a declaration made later in the target. */
|
|
821
|
+
checkInitializer(decl, target, globals) {
|
|
822
|
+
const index = new Map();
|
|
823
|
+
target.items.forEach((item, position) => {
|
|
824
|
+
if (item.type !== "VarDecl" && item.type !== "ListDecl") return;
|
|
825
|
+
if (!index.has(item.name)) index.set(item.name, position);
|
|
826
|
+
});
|
|
827
|
+
const position = target.items.indexOf(decl);
|
|
828
|
+
const state = {
|
|
829
|
+
env: { target, globals },
|
|
830
|
+
params: null,
|
|
831
|
+
func: null,
|
|
832
|
+
funcDepth: 0,
|
|
833
|
+
nesting: 0,
|
|
834
|
+
inWarp: false,
|
|
835
|
+
warpYieldReported: false,
|
|
836
|
+
};
|
|
837
|
+
const visit = (expr) => {
|
|
838
|
+
if (!expr) return;
|
|
839
|
+
if (expr.type === "Identifier") {
|
|
840
|
+
const later = index.get(expr.name);
|
|
841
|
+
if (later !== undefined && later > position) {
|
|
842
|
+
this.report(
|
|
843
|
+
"FX203",
|
|
844
|
+
expr.loc,
|
|
845
|
+
`${JSON.stringify(expr.name)} is declared later in this target, so this initializer reads its default value.`,
|
|
846
|
+
{ length: expr.name.length },
|
|
847
|
+
);
|
|
848
|
+
}
|
|
849
|
+
this.walkExpression(expr, state);
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (expr.type === "Binary") {
|
|
853
|
+
visit(expr.left);
|
|
854
|
+
visit(expr.right);
|
|
855
|
+
} else if (expr.type === "Unary") {
|
|
856
|
+
visit(expr.operand);
|
|
857
|
+
} else if (expr.type === "ArrayLiteral") {
|
|
858
|
+
for (const item of expr.items) visit(item);
|
|
859
|
+
} else if (expr.type === "Call") {
|
|
860
|
+
this.walkExpression(expr, state);
|
|
861
|
+
}
|
|
862
|
+
};
|
|
863
|
+
if (decl.type === "VarDecl") visit(decl.init);
|
|
864
|
+
else for (const item of decl.items) visit(item);
|
|
865
|
+
}
|
|
866
|
+
|
|
867
|
+
checkScriptLength(statements, loc) {
|
|
868
|
+
const total = countStatements(statements);
|
|
869
|
+
if (total > MAX_SCRIPT_STATEMENTS) {
|
|
870
|
+
this.report(
|
|
871
|
+
"FX902",
|
|
872
|
+
loc,
|
|
873
|
+
`Script has ${total} statements; split it into smaller functions.`,
|
|
874
|
+
{ length: 1 },
|
|
875
|
+
);
|
|
876
|
+
}
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/** FX908: a public function should carry a `//` comment above it. */
|
|
880
|
+
checkDocumentation(decl) {
|
|
881
|
+
const text = decl.loc?.file ? this.sourceText(decl.loc.file) : null;
|
|
882
|
+
if (!text) return;
|
|
883
|
+
const lines = text.split(/\r?\n/);
|
|
884
|
+
let above = (decl.loc?.line ?? 1) - 2;
|
|
885
|
+
while (above >= 0 && lines[above].trim() === "") above -= 1;
|
|
886
|
+
if (above < 0) return;
|
|
887
|
+
const trimmed = lines[above].trim();
|
|
888
|
+
if (trimmed.startsWith("//") || trimmed.endsWith("*/")) return;
|
|
889
|
+
this.report(
|
|
890
|
+
"FX908",
|
|
891
|
+
decl.loc,
|
|
892
|
+
`Function ${JSON.stringify(decl.name)} has no documentation comment.`,
|
|
893
|
+
{ length: decl.name.length },
|
|
894
|
+
);
|
|
895
|
+
}
|
|
896
|
+
|
|
897
|
+
/** Declaration-level rules: shadowing, unused names, case collisions. */
|
|
898
|
+
checkDeclarations(target, globals) {
|
|
899
|
+
const { decls } = target;
|
|
900
|
+
|
|
901
|
+
for (const [, decl] of [...decls.vars, ...decls.lists]) {
|
|
902
|
+
if (decl.name.startsWith("$")) {
|
|
903
|
+
this.report(
|
|
904
|
+
"FX214",
|
|
905
|
+
decl.loc,
|
|
906
|
+
`Reserved temporary-variable name ${JSON.stringify(decl.name)}; names starting with "$" are read as temporary variables.`,
|
|
907
|
+
{ length: decl.name.length },
|
|
908
|
+
);
|
|
909
|
+
}
|
|
910
|
+
if (decl.type === "VarDecl" && decl.cloud && !target.isStage) {
|
|
911
|
+
this.report(
|
|
912
|
+
"FX212",
|
|
913
|
+
decl.loc,
|
|
914
|
+
`Cloud variable ${JSON.stringify(decl.name)} must be declared at the top level or inside \`stage\`.`,
|
|
915
|
+
{ length: decl.name.length },
|
|
916
|
+
);
|
|
917
|
+
}
|
|
918
|
+
}
|
|
919
|
+
|
|
920
|
+
// Shadowing: target-level names hide parameters and stage variables.
|
|
921
|
+
for (const [, fn] of decls.funcs) {
|
|
922
|
+
for (const param of fn.params) {
|
|
923
|
+
if (param.name.startsWith("$"))
|
|
924
|
+
this.report(
|
|
925
|
+
"FX214",
|
|
926
|
+
fn.decl?.loc ?? fn.loc,
|
|
927
|
+
`Reserved temporary-variable name ${JSON.stringify(param.name)}; rename the parameter.`,
|
|
928
|
+
{ length: param.name.length },
|
|
929
|
+
);
|
|
930
|
+
}
|
|
931
|
+
for (const param of fn.params) {
|
|
932
|
+
const local = decls.vars.get(param.name) ?? decls.lists.get(param.name);
|
|
933
|
+
if (local) {
|
|
934
|
+
this.report(
|
|
935
|
+
"FX205",
|
|
936
|
+
local.loc,
|
|
937
|
+
`Local ${local.type === "ListDecl" ? "list" : "variable"} ${JSON.stringify(param.name)} shadows a parameter of function ${JSON.stringify(fn.name)}.`,
|
|
938
|
+
{ length: param.name.length },
|
|
939
|
+
);
|
|
940
|
+
}
|
|
941
|
+
}
|
|
942
|
+
}
|
|
943
|
+
if (!target.isStage) {
|
|
944
|
+
for (const [, decl] of [...decls.vars, ...decls.lists]) {
|
|
945
|
+
if (globals.vars.has(decl.name) || globals.lists.has(decl.name)) {
|
|
946
|
+
this.report(
|
|
947
|
+
"FX206",
|
|
948
|
+
decl.loc,
|
|
949
|
+
`${JSON.stringify(decl.name)} shadows the project variable of the same name declared on the stage.`,
|
|
950
|
+
{ length: decl.name.length },
|
|
951
|
+
);
|
|
952
|
+
}
|
|
953
|
+
}
|
|
954
|
+
}
|
|
955
|
+
|
|
956
|
+
// Case-only collisions within each namespace.
|
|
957
|
+
const collisions = new Map();
|
|
958
|
+
for (const entry of decls.order) {
|
|
959
|
+
const key = `${entry.kind}:${entry.node.name.toLowerCase()}`;
|
|
960
|
+
const first = collisions.get(key);
|
|
961
|
+
if (!first) collisions.set(key, entry);
|
|
962
|
+
else if (first.node.name !== entry.node.name) {
|
|
963
|
+
this.report(
|
|
964
|
+
"FX211",
|
|
965
|
+
entry.node.loc,
|
|
966
|
+
`Declarations ${JSON.stringify(first.node.name)} and ${JSON.stringify(entry.node.name)} differ only by case.`,
|
|
967
|
+
{ length: entry.node.name.length },
|
|
968
|
+
);
|
|
969
|
+
}
|
|
970
|
+
}
|
|
971
|
+
|
|
972
|
+
// Unused declarations. Stage (global) names are checked separately, once
|
|
973
|
+
// every sprite has had a chance to read them.
|
|
974
|
+
if (!target.isStage) {
|
|
975
|
+
for (const [name, decl] of decls.vars) {
|
|
976
|
+
if (!target.usage.vars.has(name))
|
|
977
|
+
this.reportUnused(decl, "variable", name);
|
|
978
|
+
}
|
|
979
|
+
for (const [name, decl] of decls.lists) {
|
|
980
|
+
if (!target.usage.lists.has(name))
|
|
981
|
+
this.reportUnused(decl, "list", name);
|
|
982
|
+
}
|
|
983
|
+
}
|
|
984
|
+
for (const [name, fn] of decls.funcs) {
|
|
985
|
+
if (!target.usage.funcs.has(name)) {
|
|
986
|
+
this.report(
|
|
987
|
+
"FX209",
|
|
988
|
+
fn.loc,
|
|
989
|
+
`Function ${JSON.stringify(name)} is never called.`,
|
|
990
|
+
{ length: name.length },
|
|
991
|
+
);
|
|
992
|
+
}
|
|
993
|
+
const used = this.paramUsage.get(fn) ?? new Set();
|
|
994
|
+
for (const param of fn.params) {
|
|
995
|
+
if (!used.has(param.name)) {
|
|
996
|
+
this.report(
|
|
997
|
+
"FX207",
|
|
998
|
+
fn.loc,
|
|
999
|
+
`Parameter ${JSON.stringify(param.name)} of ${JSON.stringify(name)} is never used.`,
|
|
1000
|
+
{ length: name.length },
|
|
1001
|
+
);
|
|
1002
|
+
}
|
|
1003
|
+
}
|
|
1004
|
+
}
|
|
1005
|
+
}
|
|
1006
|
+
|
|
1007
|
+
/** FX208: stage names that no target reads, after all targets ran. */
|
|
1008
|
+
checkGlobalUsage(globals) {
|
|
1009
|
+
for (const [name, decl] of globals.vars) {
|
|
1010
|
+
if (!this.globalUsage.vars.has(name))
|
|
1011
|
+
this.reportUnused(decl, "variable", name);
|
|
1012
|
+
}
|
|
1013
|
+
for (const [name, decl] of globals.lists) {
|
|
1014
|
+
if (!this.globalUsage.lists.has(name))
|
|
1015
|
+
this.reportUnused(decl, "list", name);
|
|
1016
|
+
}
|
|
1017
|
+
}
|
|
1018
|
+
|
|
1019
|
+
reportUnused(decl, kind, name) {
|
|
1020
|
+
this.report("FX208", decl.loc, `Unused ${kind} ${JSON.stringify(name)}.`, {
|
|
1021
|
+
length: name.length,
|
|
1022
|
+
});
|
|
1023
|
+
}
|
|
1024
|
+
}
|
|
1025
|
+
|
|
1026
|
+
// ------------------------------------------------------------- suppressions
|
|
1027
|
+
|
|
1028
|
+
const DIRECTIVE =
|
|
1029
|
+
/^fx-lint-(disable-next-line|disable-file|disable|enable)\b(.*)$/;
|
|
1030
|
+
|
|
1031
|
+
/**
|
|
1032
|
+
* Read `// fx-lint-*` directives from one source file's comments.
|
|
1033
|
+
* @returns {{directives: object[], diagnostics: object[]}}
|
|
1034
|
+
*/
|
|
1035
|
+
function collectSuppressions(source, file) {
|
|
1036
|
+
const directives = [];
|
|
1037
|
+
const diagnostics = [];
|
|
1038
|
+
let comments;
|
|
1039
|
+
try {
|
|
1040
|
+
({ comments } = tokenizeWithComments(source, file));
|
|
1041
|
+
} catch {
|
|
1042
|
+
return { directives, diagnostics };
|
|
1043
|
+
}
|
|
1044
|
+
for (const comment of comments) {
|
|
1045
|
+
const body = comment.text
|
|
1046
|
+
.replace(/^\/\/+/, "")
|
|
1047
|
+
.replace(/^\/\*+/, "")
|
|
1048
|
+
.replace(/\*+\/$/, "")
|
|
1049
|
+
.trim();
|
|
1050
|
+
const match = DIRECTIVE.exec(body);
|
|
1051
|
+
if (!match) continue;
|
|
1052
|
+
const kind = match[1];
|
|
1053
|
+
const rest = match[2].trim();
|
|
1054
|
+
let rules = null;
|
|
1055
|
+
if (rest) {
|
|
1056
|
+
const valid = new Set();
|
|
1057
|
+
const invalid = [];
|
|
1058
|
+
for (const token of rest.split(/[\s,]+/).filter(Boolean)) {
|
|
1059
|
+
const rule = ruleById(token.replace(/[.,;]+$/, ""));
|
|
1060
|
+
if (rule) valid.add(rule.id);
|
|
1061
|
+
else invalid.push(token);
|
|
1062
|
+
}
|
|
1063
|
+
if (invalid.length) {
|
|
1064
|
+
for (const token of invalid) {
|
|
1065
|
+
diagnostics.push({
|
|
1066
|
+
ruleId: "FX001",
|
|
1067
|
+
severity: RULES.FX001.severity,
|
|
1068
|
+
category: RULES.FX001.category,
|
|
1069
|
+
message: `Unknown rule ${JSON.stringify(token)} in fx-lint directive.`,
|
|
1070
|
+
...rangeOf(
|
|
1071
|
+
{ file, line: comment.line, column: comment.column },
|
|
1072
|
+
body.length,
|
|
1073
|
+
),
|
|
1074
|
+
explanation: RULES.FX001.explanation,
|
|
1075
|
+
fixable: false,
|
|
1076
|
+
});
|
|
1077
|
+
}
|
|
1078
|
+
}
|
|
1079
|
+
rules = valid;
|
|
1080
|
+
}
|
|
1081
|
+
directives.push({
|
|
1082
|
+
kind,
|
|
1083
|
+
line: comment.line,
|
|
1084
|
+
column: comment.column,
|
|
1085
|
+
length: body.length,
|
|
1086
|
+
file,
|
|
1087
|
+
rules,
|
|
1088
|
+
used: false,
|
|
1089
|
+
});
|
|
1090
|
+
}
|
|
1091
|
+
return { directives, diagnostics };
|
|
1092
|
+
}
|
|
1093
|
+
|
|
1094
|
+
function suppressionFor(directive, diagnostic) {
|
|
1095
|
+
if (directive.rules && !directive.rules.has(diagnostic.ruleId)) return false;
|
|
1096
|
+
if (directive.kind === "disable-file") return true;
|
|
1097
|
+
if (directive.kind === "disable-next-line")
|
|
1098
|
+
return diagnostic.line === directive.line + 1;
|
|
1099
|
+
return false;
|
|
1100
|
+
}
|
|
1101
|
+
|
|
1102
|
+
/** Fold `disable`/`enable` ranges up to the diagnostic's line. */
|
|
1103
|
+
function rangeSuppression(directives, diagnostic) {
|
|
1104
|
+
let state = false;
|
|
1105
|
+
let matched = null;
|
|
1106
|
+
for (const directive of directives) {
|
|
1107
|
+
if (directive.kind !== "disable" && directive.kind !== "enable") continue;
|
|
1108
|
+
if (directive.line > diagnostic.line) continue;
|
|
1109
|
+
if (directive.rules && !directive.rules.has(diagnostic.ruleId)) continue;
|
|
1110
|
+
state = directive.kind === "disable";
|
|
1111
|
+
matched = directive;
|
|
1112
|
+
}
|
|
1113
|
+
return state ? matched : null;
|
|
1114
|
+
}
|
|
1115
|
+
|
|
1116
|
+
/**
|
|
1117
|
+
* Remove suppressed diagnostics and, in strict mode, report suppressions that
|
|
1118
|
+
* never matched anything.
|
|
1119
|
+
*/
|
|
1120
|
+
function applySuppressions(diagnostics, directivesByFile, { strict }) {
|
|
1121
|
+
const remaining = [];
|
|
1122
|
+
let suppressed = 0;
|
|
1123
|
+
for (const diagnostic of diagnostics) {
|
|
1124
|
+
if (META_RULES.has(diagnostic.ruleId) || !diagnostic.file) {
|
|
1125
|
+
remaining.push(diagnostic);
|
|
1126
|
+
continue;
|
|
1127
|
+
}
|
|
1128
|
+
const directives = directivesByFile.get(diagnostic.file) ?? [];
|
|
1129
|
+
const direct = directives.find((directive) =>
|
|
1130
|
+
suppressionFor(directive, diagnostic),
|
|
1131
|
+
);
|
|
1132
|
+
const ranged = direct ? null : rangeSuppression(directives, diagnostic);
|
|
1133
|
+
const match = direct ?? ranged;
|
|
1134
|
+
if (match) {
|
|
1135
|
+
match.used = true;
|
|
1136
|
+
suppressed += 1;
|
|
1137
|
+
} else {
|
|
1138
|
+
remaining.push(diagnostic);
|
|
1139
|
+
}
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
if (strict) {
|
|
1143
|
+
for (const directives of directivesByFile.values()) {
|
|
1144
|
+
for (const directive of directives) {
|
|
1145
|
+
if (directive.used) continue;
|
|
1146
|
+
if (directive.rules && directive.rules.size === 0) continue;
|
|
1147
|
+
remaining.push({
|
|
1148
|
+
ruleId: "FX002",
|
|
1149
|
+
severity: RULES.FX002.severity,
|
|
1150
|
+
category: RULES.FX002.category,
|
|
1151
|
+
message: `Unused lint suppression for ${directive.rules ? [...directive.rules].join(", ") : "all rules"}.`,
|
|
1152
|
+
...rangeOf(
|
|
1153
|
+
{
|
|
1154
|
+
file: directive.file,
|
|
1155
|
+
line: directive.line,
|
|
1156
|
+
column: directive.column,
|
|
1157
|
+
},
|
|
1158
|
+
directive.length,
|
|
1159
|
+
),
|
|
1160
|
+
explanation: RULES.FX002.explanation,
|
|
1161
|
+
fixable: false,
|
|
1162
|
+
});
|
|
1163
|
+
}
|
|
1164
|
+
}
|
|
1165
|
+
}
|
|
1166
|
+
return { diagnostics: remaining, suppressed };
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// ---------------------------------------------------------------- public API
|
|
1170
|
+
|
|
1171
|
+
/** Deterministic diagnostic order. */
|
|
1172
|
+
export function sortDiagnostics(diagnostics) {
|
|
1173
|
+
return [...diagnostics].sort(
|
|
1174
|
+
(a, b) =>
|
|
1175
|
+
String(a.file ?? "").localeCompare(String(b.file ?? "")) ||
|
|
1176
|
+
a.line - b.line ||
|
|
1177
|
+
a.column - b.column ||
|
|
1178
|
+
a.ruleId.localeCompare(b.ruleId) ||
|
|
1179
|
+
a.message.localeCompare(b.message),
|
|
1180
|
+
);
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
/**
|
|
1184
|
+
* Lint a set of sources with the browser-safe rules.
|
|
1185
|
+
*
|
|
1186
|
+
* `extraDiagnostics` lets the Node entry point (`src/linter.js`) add
|
|
1187
|
+
* filesystem-backed project checks while sharing severity handling,
|
|
1188
|
+
* suppressions and ordering.
|
|
1189
|
+
*
|
|
1190
|
+
* @param {{
|
|
1191
|
+
* sources: Array<{file: string, source: string}>,
|
|
1192
|
+
* config?: object|null,
|
|
1193
|
+
* configPath?: string|null,
|
|
1194
|
+
* configDir?: string|null,
|
|
1195
|
+
* rootDir?: string|null,
|
|
1196
|
+
* extensionCatalog?: Record<string, object>,
|
|
1197
|
+
* declaredExtensions?: string[],
|
|
1198
|
+
* lintConfig?: object,
|
|
1199
|
+
* strict?: boolean,
|
|
1200
|
+
* overrides?: Record<string, string>,
|
|
1201
|
+
* extraDiagnostics?: object[],
|
|
1202
|
+
* }} options
|
|
1203
|
+
* @returns {{
|
|
1204
|
+
* diagnostics: object[],
|
|
1205
|
+
* summary: {errors: number, warnings: number, suppressed: number, files: number},
|
|
1206
|
+
* severities: Map<string, string>,
|
|
1207
|
+
* sources: Map<string, string>,
|
|
1208
|
+
* }}
|
|
1209
|
+
*/
|
|
1210
|
+
export function lintSources({
|
|
1211
|
+
sources = [],
|
|
1212
|
+
config = null,
|
|
1213
|
+
configPath = null,
|
|
1214
|
+
extensionCatalog = {},
|
|
1215
|
+
declaredExtensions = [],
|
|
1216
|
+
lintConfig = DEFAULT_LINT,
|
|
1217
|
+
strict = false,
|
|
1218
|
+
overrides = {},
|
|
1219
|
+
extraDiagnostics = [],
|
|
1220
|
+
} = {}) {
|
|
1221
|
+
const severities = resolveRuleSeverities(lintConfig, { strict, overrides });
|
|
1222
|
+
const textByFile = new Map(sources.map(({ file, source }) => [file, source]));
|
|
1223
|
+
const verifiedExtensions = new Set(
|
|
1224
|
+
Object.keys(extensionCatalog)
|
|
1225
|
+
.filter((name) => name.includes("."))
|
|
1226
|
+
.map((name) => name.slice(0, name.indexOf("."))),
|
|
1227
|
+
);
|
|
1228
|
+
const analyzer = new Analyzer({
|
|
1229
|
+
severities,
|
|
1230
|
+
catalog: { ...CATALOG, ...extensionCatalog },
|
|
1231
|
+
extensionIds: verifiedExtensions,
|
|
1232
|
+
declaredExtensions: new Set(declaredExtensions.map(String)),
|
|
1233
|
+
sourceText: (file) => textByFile.get(file) ?? null,
|
|
1234
|
+
});
|
|
1235
|
+
|
|
1236
|
+
// Parse everything up front; syntax errors are reported as FX000 and the
|
|
1237
|
+
// file is excluded from the AST rules.
|
|
1238
|
+
const parsed = [];
|
|
1239
|
+
for (const { file, source } of sources) {
|
|
1240
|
+
try {
|
|
1241
|
+
parsed.push({ file, ast: parse(source, file) });
|
|
1242
|
+
} catch (error) {
|
|
1243
|
+
const loc =
|
|
1244
|
+
error instanceof ParseError
|
|
1245
|
+
? {
|
|
1246
|
+
file: error.file ?? file,
|
|
1247
|
+
line: error.line ?? 1,
|
|
1248
|
+
column: error.column ?? 1,
|
|
1249
|
+
}
|
|
1250
|
+
: { file, line: 1, column: 1 };
|
|
1251
|
+
analyzer.report("FX000", loc, error.message, { length: 1 });
|
|
1252
|
+
}
|
|
1253
|
+
}
|
|
1254
|
+
|
|
1255
|
+
// Partial sprites are resolved by the project merge, which throws when the
|
|
1256
|
+
// feature is off or a base declaration is missing. Report those first.
|
|
1257
|
+
const partialSprites = Boolean(config?.fx?.partialSprites);
|
|
1258
|
+
let partialBlocked = false;
|
|
1259
|
+
if (!partialSprites) {
|
|
1260
|
+
for (const { ast } of parsed) {
|
|
1261
|
+
for (const sprite of ast.sprites) {
|
|
1262
|
+
if (!sprite.partial) continue;
|
|
1263
|
+
partialBlocked = true;
|
|
1264
|
+
analyzer.report(
|
|
1265
|
+
"FX803",
|
|
1266
|
+
sprite.loc,
|
|
1267
|
+
`Partial sprite ${JSON.stringify(sprite.name)} needs "fx.partialSprites: true" in ${configPath ? baseName(configPath) : "fx.yml"}.`,
|
|
1268
|
+
{ length: sprite.name.length },
|
|
1269
|
+
);
|
|
1270
|
+
}
|
|
1271
|
+
}
|
|
1272
|
+
}
|
|
1273
|
+
const groups = new Map();
|
|
1274
|
+
for (const { ast } of parsed) {
|
|
1275
|
+
for (const sprite of ast.sprites) {
|
|
1276
|
+
const group = groups.get(sprite.name) ?? { base: false, fragment: null };
|
|
1277
|
+
if (sprite.partial) group.fragment ??= sprite;
|
|
1278
|
+
else group.base = true;
|
|
1279
|
+
groups.set(sprite.name, group);
|
|
1280
|
+
}
|
|
1281
|
+
}
|
|
1282
|
+
for (const [name, group] of groups) {
|
|
1283
|
+
if (group.base || !group.fragment) continue;
|
|
1284
|
+
analyzer.report(
|
|
1285
|
+
"FX804",
|
|
1286
|
+
group.fragment.loc,
|
|
1287
|
+
`Partial sprite ${JSON.stringify(name)} has no plain \`sprite ${JSON.stringify(name)} { }\` declaration.`,
|
|
1288
|
+
{ length: name.length },
|
|
1289
|
+
);
|
|
1290
|
+
}
|
|
1291
|
+
|
|
1292
|
+
let program = null;
|
|
1293
|
+
if (!partialBlocked) {
|
|
1294
|
+
try {
|
|
1295
|
+
program = mergePrograms(
|
|
1296
|
+
parsed.map(({ file, ast }) => ({ ast, file })),
|
|
1297
|
+
{ partialSprites },
|
|
1298
|
+
);
|
|
1299
|
+
} catch (error) {
|
|
1300
|
+
const loc =
|
|
1301
|
+
error instanceof ParseError
|
|
1302
|
+
? { file: error.file, line: error.line, column: error.column }
|
|
1303
|
+
: { file: null, line: 1, column: 1 };
|
|
1304
|
+
analyzer.report("FX000", loc, error.message, { length: 1 });
|
|
1305
|
+
}
|
|
1306
|
+
}
|
|
1307
|
+
if (program) analyzer.analyzeProgram(program);
|
|
1308
|
+
else for (const { ast } of parsed) analyzer.analyzeProgram(ast);
|
|
1309
|
+
|
|
1310
|
+
// Diagnostics from filesystem-backed project checks (assets, fx.lock).
|
|
1311
|
+
for (const diagnostic of extraDiagnostics) {
|
|
1312
|
+
const rule = RULES[diagnostic.ruleId];
|
|
1313
|
+
if (!rule || !analyzer.enabled(diagnostic.ruleId)) continue;
|
|
1314
|
+
analyzer.diagnostics.push({
|
|
1315
|
+
ruleId: rule.id,
|
|
1316
|
+
severity: severities.get(rule.id) ?? rule.severity,
|
|
1317
|
+
category: rule.category,
|
|
1318
|
+
message: diagnostic.message,
|
|
1319
|
+
...rangeOf(
|
|
1320
|
+
{
|
|
1321
|
+
file: diagnostic.file ?? null,
|
|
1322
|
+
line: diagnostic.line ?? 1,
|
|
1323
|
+
column: diagnostic.column ?? 1,
|
|
1324
|
+
},
|
|
1325
|
+
diagnostic.length ?? 1,
|
|
1326
|
+
),
|
|
1327
|
+
explanation: rule.explanation,
|
|
1328
|
+
fixable: Boolean(rule.fixable),
|
|
1329
|
+
});
|
|
1330
|
+
}
|
|
1331
|
+
|
|
1332
|
+
// Suppressions are per file, so parse each file's comments after analysis.
|
|
1333
|
+
const directivesByFile = new Map();
|
|
1334
|
+
for (const { file, source } of sources) {
|
|
1335
|
+
const { directives, diagnostics } = collectSuppressions(source, file);
|
|
1336
|
+
directivesByFile.set(file, directives);
|
|
1337
|
+
analyzer.diagnostics.push(...diagnostics);
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
const { diagnostics, suppressed } = applySuppressions(
|
|
1341
|
+
analyzer.diagnostics,
|
|
1342
|
+
directivesByFile,
|
|
1343
|
+
{ strict },
|
|
1344
|
+
);
|
|
1345
|
+
const sorted = sortDiagnostics(diagnostics);
|
|
1346
|
+
const summary = {
|
|
1347
|
+
errors: sorted.filter((entry) => entry.severity === "error").length,
|
|
1348
|
+
warnings: sorted.filter((entry) => entry.severity === "warning").length,
|
|
1349
|
+
suppressed,
|
|
1350
|
+
files: sources.length,
|
|
1351
|
+
};
|
|
1352
|
+
return { diagnostics: sorted, summary, severities, sources: textByFile };
|
|
1353
|
+
}
|
|
1354
|
+
|
|
1355
|
+
/**
|
|
1356
|
+
* Apply safe, explicit fixes. Today only FX204 (implicit variable) is fixable:
|
|
1357
|
+
* the declaration is inserted just after the target's opening brace, so the
|
|
1358
|
+
* fix also works when that brace is on its own line.
|
|
1359
|
+
*
|
|
1360
|
+
* @param {Array<{file: string, source: string}>} sources
|
|
1361
|
+
* @param {object[]} diagnostics
|
|
1362
|
+
* @returns {{sources: Map<string, string>, files: string[], fixed: number}}
|
|
1363
|
+
*/
|
|
1364
|
+
export function applyFixes(sources, diagnostics) {
|
|
1365
|
+
const textByFile = new Map(sources.map(({ file, source }) => [file, source]));
|
|
1366
|
+
const inserts = new Map(); // file -> Map<"line:column", {line, column, names}>
|
|
1367
|
+
let fixed = 0;
|
|
1368
|
+
for (const diagnostic of diagnostics) {
|
|
1369
|
+
if (!diagnostic.fix || diagnostic.fix.kind !== "declare-variable") continue;
|
|
1370
|
+
const { file, line, column } = diagnostic.fix;
|
|
1371
|
+
if (!diagnostic.file || !textByFile.has(diagnostic.file)) continue;
|
|
1372
|
+
// A target in another file has no brace to insert into; never apply a fix
|
|
1373
|
+
// to a file other than the one that reported it.
|
|
1374
|
+
if (file !== diagnostic.file) continue;
|
|
1375
|
+
if (!Number.isInteger(line) || !Number.isInteger(column)) continue;
|
|
1376
|
+
const byPosition = inserts.get(file) ?? new Map();
|
|
1377
|
+
const key = `${line}:${column}`;
|
|
1378
|
+
const insert = byPosition.get(key) ?? {
|
|
1379
|
+
line,
|
|
1380
|
+
column,
|
|
1381
|
+
names: new Set(),
|
|
1382
|
+
};
|
|
1383
|
+
const indent = diagnostic.fix.indent ?? " ";
|
|
1384
|
+
insert.names.add(`${indent}let ${diagnostic.fix.name} = 0;`);
|
|
1385
|
+
byPosition.set(key, insert);
|
|
1386
|
+
inserts.set(file, byPosition);
|
|
1387
|
+
fixed += 1;
|
|
1388
|
+
}
|
|
1389
|
+
|
|
1390
|
+
const files = [];
|
|
1391
|
+
for (const [file, byPosition] of inserts) {
|
|
1392
|
+
const text = textByFile.get(file);
|
|
1393
|
+
// Insertion must not rewrite the file's line endings.
|
|
1394
|
+
const newline = text.includes("\r\n") ? "\r\n" : "\n";
|
|
1395
|
+
const lines = text.split(/\r?\n/);
|
|
1396
|
+
const ordered = [...byPosition.values()].sort(
|
|
1397
|
+
(a, b) => b.line - a.line || b.column - a.column,
|
|
1398
|
+
);
|
|
1399
|
+
for (const { line, column, names } of ordered) {
|
|
1400
|
+
const index = Math.min(Math.max(0, line - 1), lines.length - 1);
|
|
1401
|
+
const current = lines[index];
|
|
1402
|
+
// `column` is the 1-based brace column, which is also the 0-based index
|
|
1403
|
+
// just past the brace character.
|
|
1404
|
+
const offset = Math.min(Math.max(0, column), current.length);
|
|
1405
|
+
const insert = [...names]
|
|
1406
|
+
.map((declaration) => `${newline}${declaration}`)
|
|
1407
|
+
.join("");
|
|
1408
|
+
lines[index] = current.slice(0, offset) + insert + current.slice(offset);
|
|
1409
|
+
}
|
|
1410
|
+
textByFile.set(file, lines.join(newline));
|
|
1411
|
+
files.push(file);
|
|
1412
|
+
}
|
|
1413
|
+
return { sources: textByFile, files, fixed };
|
|
1414
|
+
}
|