@triflare/fx 2.0.0 → 2.1.0-beta.2
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 +6 -4
- package/product.yml +1 -1
- package/src/build.js +23 -6
- package/src/catalog.js +103 -0
- package/src/cli/commands/check.js +3 -1
- package/src/cli/commands/feature.js +163 -0
- package/src/cli/commands/fmt.js +173 -0
- package/src/cli/commands/index.js +8 -0
- package/src/cli/commands/lint.js +274 -0
- package/src/cli/commands/test.js +186 -0
- package/src/cli/context.js +16 -1
- 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 +197 -0
- package/src/config.js +114 -1
- package/src/decompiler.js +45 -0
- package/src/features.js +425 -0
- package/src/formatter.js +519 -0
- package/src/index.js +31 -0
- package/src/lexer.js +45 -4
- package/src/lint/analyze.js +1434 -0
- package/src/lint/config-file.js +111 -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 +116 -0
- package/src/parser.js +131 -16
- package/src/project.js +21 -90
- package/src/test/discover.js +82 -0
- package/src/test/parse.js +72 -0
- package/src/test/pattern.js +84 -0
- package/src/test/reporters.js +332 -0
- package/src/test/runner.js +1457 -0
- package/src/vm.js +325 -0
- package/vendor/pm-packager-dist/.fx-build.json +1 -1
- package/vendor/tempvars/fx-tempvars.js +321 -0
package/src/formatter.js
ADDED
|
@@ -0,0 +1,519 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Triflare Fx source formatter.
|
|
3
|
+
*
|
|
4
|
+
* The formatter walks the parsed AST to decide structure and spacing, and
|
|
5
|
+
* replays the lexer's comment log to keep comments attached to the code around
|
|
6
|
+
* them. Formatting never compiles a project, so it cannot change generated
|
|
7
|
+
* project behavior, and running it twice produces no further changes.
|
|
8
|
+
*
|
|
9
|
+
* Canonical style:
|
|
10
|
+
* - four-space indentation, one statement per line;
|
|
11
|
+
* - `{` on the same line as its header, `} else {` for alternates;
|
|
12
|
+
* - single spaces around operators, after commas, and after colons;
|
|
13
|
+
* - double-quoted strings with minimal escapes;
|
|
14
|
+
* - unchanged declaration order and project data.
|
|
15
|
+
*/
|
|
16
|
+
import { tokenizeWithComments } from "./lexer.js";
|
|
17
|
+
import { parseTokens } from "./parser.js";
|
|
18
|
+
|
|
19
|
+
const INDENT = " ";
|
|
20
|
+
|
|
21
|
+
/** Binding strength of binary operators; larger binds tighter. */
|
|
22
|
+
const PRECEDENCE = {
|
|
23
|
+
or: 1,
|
|
24
|
+
and: 2,
|
|
25
|
+
"==": 3,
|
|
26
|
+
"!=": 3,
|
|
27
|
+
"<": 3,
|
|
28
|
+
"<=": 3,
|
|
29
|
+
">": 3,
|
|
30
|
+
">=": 3,
|
|
31
|
+
"+": 4,
|
|
32
|
+
"-": 4,
|
|
33
|
+
"*": 5,
|
|
34
|
+
"/": 5,
|
|
35
|
+
"%": 5,
|
|
36
|
+
};
|
|
37
|
+
|
|
38
|
+
const UNARY_PRECEDENCE = 6;
|
|
39
|
+
|
|
40
|
+
function escapeString(value) {
|
|
41
|
+
return String(value)
|
|
42
|
+
.replace(/\\/g, "\\\\")
|
|
43
|
+
.replace(/"/g, '\\"')
|
|
44
|
+
.replace(/\n/g, "\\n")
|
|
45
|
+
.replace(/\r/g, "\\r")
|
|
46
|
+
.replace(/\t/g, "\\t");
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function stringLiteral(value) {
|
|
50
|
+
return `"${escapeString(value)}"`;
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
function isIdentifier(name) {
|
|
54
|
+
return /^[A-Za-z_$][A-Za-z0-9_$]*$/.test(name);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function mapKey(key) {
|
|
58
|
+
return isIdentifier(key) ? key : stringLiteral(key);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/**
|
|
62
|
+
* Render an expression, adding parentheses only where the AST needs them to
|
|
63
|
+
* survive a round trip through the parser.
|
|
64
|
+
*
|
|
65
|
+
* @param {object} node
|
|
66
|
+
* @param {number} [minimum] lowest precedence allowed without parentheses
|
|
67
|
+
* @param {string} [parentOperator] operator receiving this expression
|
|
68
|
+
* @returns {string}
|
|
69
|
+
*/
|
|
70
|
+
function renderExpression(node, minimum = 0, parentOperator) {
|
|
71
|
+
switch (node.type) {
|
|
72
|
+
case "Number":
|
|
73
|
+
return String(node.value);
|
|
74
|
+
case "String":
|
|
75
|
+
return stringLiteral(node.value);
|
|
76
|
+
case "Boolean":
|
|
77
|
+
return node.value ? "true" : "false";
|
|
78
|
+
case "Identifier":
|
|
79
|
+
return node.name;
|
|
80
|
+
case "Call":
|
|
81
|
+
return `${node.name}(${node.args
|
|
82
|
+
.map((arg) => renderExpression(arg))
|
|
83
|
+
.join(", ")})`;
|
|
84
|
+
case "Member":
|
|
85
|
+
// Member access binds tighter than every operator, so its object is
|
|
86
|
+
// rendered without parentheses, as in `state.list("items").length`.
|
|
87
|
+
return `${renderExpression(node.object)}.${node.property}`;
|
|
88
|
+
case "ArrayLiteral":
|
|
89
|
+
return `[${node.items.map((item) => renderExpression(item)).join(", ")}]`;
|
|
90
|
+
case "Unary": {
|
|
91
|
+
const operand = renderExpression(node.operand, UNARY_PRECEDENCE, node.op);
|
|
92
|
+
if (node.op === "not") {
|
|
93
|
+
const text = `not ${operand}`;
|
|
94
|
+
return parentOperator && !["and", "or", "not"].includes(parentOperator)
|
|
95
|
+
? `(${text})`
|
|
96
|
+
: text;
|
|
97
|
+
}
|
|
98
|
+
// `--x` is legal but reads like a decrement waiting to happen.
|
|
99
|
+
const nested = node.operand.type === "Unary" && node.operand.op === "-";
|
|
100
|
+
return `-${nested ? `(${operand})` : operand}`;
|
|
101
|
+
}
|
|
102
|
+
case "Binary": {
|
|
103
|
+
const precedence = PRECEDENCE[node.op] ?? 0;
|
|
104
|
+
const left = renderExpression(node.left, precedence, node.op);
|
|
105
|
+
// Right operands need parentheses at equal precedence to keep left
|
|
106
|
+
// associativity from regrouping them, e.g. `a - (b - c)`.
|
|
107
|
+
const right = renderExpression(node.right, precedence + 1, node.op);
|
|
108
|
+
const text = `${left} ${node.op} ${right}`;
|
|
109
|
+
return precedence < minimum ? `(${text})` : text;
|
|
110
|
+
}
|
|
111
|
+
default:
|
|
112
|
+
throw new Error(`Cannot format expression node ${node.type}`);
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
/** Render a hat event: a bare name when it takes no arguments. */
|
|
117
|
+
function renderEvent(event) {
|
|
118
|
+
if (event.args.length === 0) return event.segments.join(".");
|
|
119
|
+
return `${event.segments.join(".")}(${event.args
|
|
120
|
+
.map((arg) => renderExpression(arg))
|
|
121
|
+
.join(", ")})`;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
/** Group statements so the formatter can insert stable blank lines. */
|
|
125
|
+
function category(node) {
|
|
126
|
+
switch (node.type) {
|
|
127
|
+
case "StageDecl":
|
|
128
|
+
case "SpriteDecl":
|
|
129
|
+
return "declaration";
|
|
130
|
+
case "PropertyDecl":
|
|
131
|
+
return "property";
|
|
132
|
+
case "VarDecl":
|
|
133
|
+
case "ListDecl":
|
|
134
|
+
return "data";
|
|
135
|
+
case "FunctionDecl":
|
|
136
|
+
return "function";
|
|
137
|
+
case "HatDecl":
|
|
138
|
+
case "TestDecl":
|
|
139
|
+
return "script";
|
|
140
|
+
default:
|
|
141
|
+
return "statement";
|
|
142
|
+
}
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
/** Whether two adjacent statements belong in separate visual groups. */
|
|
146
|
+
function needsBlankLine(previous, next) {
|
|
147
|
+
const before = category(previous);
|
|
148
|
+
const after = category(next);
|
|
149
|
+
if (before !== after) return true;
|
|
150
|
+
return (
|
|
151
|
+
before === "declaration" || before === "function" || before === "script"
|
|
152
|
+
);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
class Formatter {
|
|
156
|
+
/**
|
|
157
|
+
* @param {string} source
|
|
158
|
+
* @param {string} file
|
|
159
|
+
*/
|
|
160
|
+
constructor(source, file) {
|
|
161
|
+
const { tokens, comments } = tokenizeWithComments(source, file);
|
|
162
|
+
this.sourceLines = source.split(/\r?\n/);
|
|
163
|
+
this.tokens = tokens;
|
|
164
|
+
this.comments = comments;
|
|
165
|
+
this.commentIndex = 0;
|
|
166
|
+
this.tokenIndexes = new Map();
|
|
167
|
+
tokens.forEach((token, index) => this.tokenIndexes.set(token, index));
|
|
168
|
+
this.out = [];
|
|
169
|
+
this.program = parseTokens(tokens, source, file);
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
/** Token index for a loc that the parser took straight from the lexer. */
|
|
173
|
+
tokenIndexOf(token) {
|
|
174
|
+
return this.tokenIndexes.get(token) ?? this.tokens.length - 1;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
/** First token with `value` at or after `from`, or -1. */
|
|
178
|
+
findToken(from, value) {
|
|
179
|
+
for (let index = Math.max(0, from); index < this.tokens.length; index += 1)
|
|
180
|
+
if (this.tokens[index].value === value) return index;
|
|
181
|
+
return -1;
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
/** Index of the `}` matching the `{` at `open`. */
|
|
185
|
+
matchBrace(open) {
|
|
186
|
+
let depth = 0;
|
|
187
|
+
for (let index = open; index < this.tokens.length; index += 1) {
|
|
188
|
+
const token = this.tokens[index];
|
|
189
|
+
if (token.type !== "punct") continue;
|
|
190
|
+
if (token.value === "{") depth += 1;
|
|
191
|
+
else if (token.value === "}") {
|
|
192
|
+
depth -= 1;
|
|
193
|
+
if (depth === 0) return index;
|
|
194
|
+
}
|
|
195
|
+
}
|
|
196
|
+
return this.tokens.length - 1;
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
/** Token span of a node's block body. */
|
|
200
|
+
bodySpan(node) {
|
|
201
|
+
const open = this.findToken(this.tokenIndexOf(node.loc), "{");
|
|
202
|
+
return { open, close: this.matchBrace(open) };
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/** Whether a comment can share the previous output line. */
|
|
206
|
+
canInline(comment) {
|
|
207
|
+
if (/\r(?!$)|\n/.test(comment.text)) return false;
|
|
208
|
+
if (this.out.length === 0) return false;
|
|
209
|
+
if (this.out[this.out.length - 1].trim() === "") return false;
|
|
210
|
+
const previous = this.tokens[comment.after - 1];
|
|
211
|
+
return Boolean(previous && previous.line === comment.line);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
/** Attach only the trailing comments that belong on the previous line. */
|
|
215
|
+
flushInline(limit) {
|
|
216
|
+
while (this.commentIndex < this.comments.length) {
|
|
217
|
+
const comment = this.comments[this.commentIndex];
|
|
218
|
+
if (comment.after > limit || !this.canInline(comment)) return;
|
|
219
|
+
this.commentIndex += 1;
|
|
220
|
+
this.out[this.out.length - 1] += ` ${comment.text.trim()}`;
|
|
221
|
+
}
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
/**
|
|
225
|
+
* Emit every comment that appears before token `limit`.
|
|
226
|
+
*
|
|
227
|
+
* @param {number} limit
|
|
228
|
+
* @param {number} indent
|
|
229
|
+
*/
|
|
230
|
+
flush(limit, indent) {
|
|
231
|
+
const pad = INDENT.repeat(indent);
|
|
232
|
+
while (this.commentIndex < this.comments.length) {
|
|
233
|
+
const comment = this.comments[this.commentIndex];
|
|
234
|
+
if (comment.after > limit) break;
|
|
235
|
+
this.commentIndex += 1;
|
|
236
|
+
// The lexer includes the CR of a CRLF terminator in line comments.
|
|
237
|
+
const text = comment.text.startsWith("//")
|
|
238
|
+
? comment.text.replace(/\r$/, "")
|
|
239
|
+
: comment.text;
|
|
240
|
+
const lines = text
|
|
241
|
+
.replace(/\r\n?/g, "\n")
|
|
242
|
+
.replace(/[ \t]+$/gm, "")
|
|
243
|
+
.split("\n");
|
|
244
|
+
if (lines.length === 1 && this.canInline(comment)) {
|
|
245
|
+
this.out[this.out.length - 1] += ` ${lines[0].trim()}`;
|
|
246
|
+
continue;
|
|
247
|
+
}
|
|
248
|
+
this.out.push(`${pad}${lines[0]}`);
|
|
249
|
+
for (let index = 1; index < lines.length; index += 1)
|
|
250
|
+
this.out.push(lines[index]);
|
|
251
|
+
}
|
|
252
|
+
}
|
|
253
|
+
|
|
254
|
+
pushBlank() {
|
|
255
|
+
if (this.out.length === 0) return;
|
|
256
|
+
if (this.out[this.out.length - 1] === "") return;
|
|
257
|
+
this.out.push("");
|
|
258
|
+
}
|
|
259
|
+
|
|
260
|
+
/** Whether the source has a blank line strictly between two lines. */
|
|
261
|
+
hasBlankBetween(fromLine, toLine) {
|
|
262
|
+
for (let line = fromLine + 1; line < toLine; line += 1)
|
|
263
|
+
if ((this.sourceLines[line - 1] ?? "").trim() === "") return true;
|
|
264
|
+
return false;
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
/**
|
|
268
|
+
* Render a statement list, flushing comments between its statements.
|
|
269
|
+
*
|
|
270
|
+
* Blank lines are canonical: declarations, functions and scripts are always
|
|
271
|
+
* separated, an intentional single blank line in the source survives, and
|
|
272
|
+
* runs of blank lines collapse to one.
|
|
273
|
+
*/
|
|
274
|
+
renderBody(items, indent, closeIndex) {
|
|
275
|
+
items.forEach((item, index) => {
|
|
276
|
+
const start = this.tokenIndexOf(item.loc);
|
|
277
|
+
if (index > 0) {
|
|
278
|
+
this.flushInline(start);
|
|
279
|
+
const previousLine = this.tokens[start - 1]?.line ?? 0;
|
|
280
|
+
if (
|
|
281
|
+
needsBlankLine(items[index - 1], item) ||
|
|
282
|
+
this.hasBlankBetween(previousLine, item.loc.line)
|
|
283
|
+
)
|
|
284
|
+
this.pushBlank();
|
|
285
|
+
}
|
|
286
|
+
const pending = this.comments[this.commentIndex];
|
|
287
|
+
this.flush(start, indent);
|
|
288
|
+
// Keep a file header comment separated from the first declaration, or a
|
|
289
|
+
// leading comment inside a block separated from the code it introduces.
|
|
290
|
+
if (index === 0 && pending && pending.after <= start) {
|
|
291
|
+
const last = this.comments[this.commentIndex - 1];
|
|
292
|
+
const endLine = last.line + (last.text.match(/\n/g)?.length ?? 0);
|
|
293
|
+
if (this.hasBlankBetween(endLine, item.loc.line)) this.pushBlank();
|
|
294
|
+
}
|
|
295
|
+
this.renderItem(item, indent);
|
|
296
|
+
});
|
|
297
|
+
this.flush(closeIndex, indent);
|
|
298
|
+
}
|
|
299
|
+
|
|
300
|
+
/** Render the block body of a node that owns braces. */
|
|
301
|
+
renderNodeBody(node, indent) {
|
|
302
|
+
this.renderBody(node.body, indent + 1, this.bodySpan(node).close);
|
|
303
|
+
}
|
|
304
|
+
|
|
305
|
+
renderItem(node, indent) {
|
|
306
|
+
const pad = INDENT.repeat(indent);
|
|
307
|
+
switch (node.type) {
|
|
308
|
+
case "StageDecl":
|
|
309
|
+
this.out.push(`${pad}stage {`);
|
|
310
|
+
this.renderNodeBody(node, indent);
|
|
311
|
+
this.out.push(`${pad}}`);
|
|
312
|
+
return;
|
|
313
|
+
case "SpriteDecl": {
|
|
314
|
+
const modifier = node.partial ? "partial " : "";
|
|
315
|
+
this.out.push(`${pad}${modifier}sprite ${stringLiteral(node.name)} {`);
|
|
316
|
+
this.renderNodeBody(node, indent);
|
|
317
|
+
this.out.push(`${pad}}`);
|
|
318
|
+
return;
|
|
319
|
+
}
|
|
320
|
+
case "PropertyDecl":
|
|
321
|
+
this.renderProperty(node, indent);
|
|
322
|
+
return;
|
|
323
|
+
case "VarDecl":
|
|
324
|
+
this.out.push(
|
|
325
|
+
`${pad}${node.cloud ? "cloud " : ""}let ${node.name}` +
|
|
326
|
+
`${node.init ? ` = ${renderExpression(node.init)}` : ""};`,
|
|
327
|
+
);
|
|
328
|
+
return;
|
|
329
|
+
case "TempVarDecl":
|
|
330
|
+
this.out.push(
|
|
331
|
+
`${pad}${node.kind} let ${node.name}` +
|
|
332
|
+
`${node.init ? ` = ${renderExpression(node.init)}` : ""};`,
|
|
333
|
+
);
|
|
334
|
+
return;
|
|
335
|
+
case "ListDecl":
|
|
336
|
+
this.out.push(
|
|
337
|
+
`${pad}list ${node.name} = [${node.items
|
|
338
|
+
.map((item) => renderExpression(item))
|
|
339
|
+
.join(", ")}];`,
|
|
340
|
+
);
|
|
341
|
+
return;
|
|
342
|
+
case "FunctionDecl": {
|
|
343
|
+
const modifiers = `${node.returns ? "returning " : ""}${
|
|
344
|
+
node.warp ? "warp " : ""
|
|
345
|
+
}`;
|
|
346
|
+
const params = node.params
|
|
347
|
+
.map((param) =>
|
|
348
|
+
param.type ? `${param.name}: ${param.type}` : param.name,
|
|
349
|
+
)
|
|
350
|
+
.join(", ");
|
|
351
|
+
this.out.push(`${pad}${modifiers}function ${node.name}(${params}) {`);
|
|
352
|
+
this.renderNodeBody(node, indent);
|
|
353
|
+
this.out.push(`${pad}}`);
|
|
354
|
+
return;
|
|
355
|
+
}
|
|
356
|
+
case "HatDecl":
|
|
357
|
+
this.out.push(`${pad}when ${renderEvent(node.event)} {`);
|
|
358
|
+
this.renderNodeBody(node, indent);
|
|
359
|
+
this.out.push(`${pad}}`);
|
|
360
|
+
return;
|
|
361
|
+
case "TestDecl":
|
|
362
|
+
this.out.push(`${pad}test ${stringLiteral(node.name)} {`);
|
|
363
|
+
this.renderNodeBody(node, indent);
|
|
364
|
+
this.out.push(`${pad}}`);
|
|
365
|
+
return;
|
|
366
|
+
case "Expect":
|
|
367
|
+
this.out.push(
|
|
368
|
+
`${pad}expect ${renderExpression(node.value)}` +
|
|
369
|
+
`${node.message ? `, ${stringLiteral(node.message)}` : ""};`,
|
|
370
|
+
);
|
|
371
|
+
return;
|
|
372
|
+
case "If":
|
|
373
|
+
this.renderIf(node, indent);
|
|
374
|
+
return;
|
|
375
|
+
case "Repeat":
|
|
376
|
+
this.out.push(`${pad}repeat (${renderExpression(node.count)}) {`);
|
|
377
|
+
this.renderNodeBody(node, indent);
|
|
378
|
+
this.out.push(`${pad}}`);
|
|
379
|
+
return;
|
|
380
|
+
case "While":
|
|
381
|
+
this.out.push(`${pad}while (${renderExpression(node.cond)}) {`);
|
|
382
|
+
this.renderNodeBody(node, indent);
|
|
383
|
+
this.out.push(`${pad}}`);
|
|
384
|
+
return;
|
|
385
|
+
case "Forever":
|
|
386
|
+
this.out.push(`${pad}forever {`);
|
|
387
|
+
this.renderNodeBody(node, indent);
|
|
388
|
+
this.out.push(`${pad}}`);
|
|
389
|
+
return;
|
|
390
|
+
case "Return":
|
|
391
|
+
this.out.push(
|
|
392
|
+
`${pad}return${node.value ? ` ${renderExpression(node.value)}` : ""};`,
|
|
393
|
+
);
|
|
394
|
+
return;
|
|
395
|
+
case "Assign":
|
|
396
|
+
this.out.push(
|
|
397
|
+
`${pad}${node.name} ${node.op} ${renderExpression(node.value)};`,
|
|
398
|
+
);
|
|
399
|
+
return;
|
|
400
|
+
case "BlockCall":
|
|
401
|
+
this.renderBlockCall(node, indent);
|
|
402
|
+
return;
|
|
403
|
+
default:
|
|
404
|
+
throw new Error(`Cannot format statement node ${node.type}`);
|
|
405
|
+
}
|
|
406
|
+
}
|
|
407
|
+
|
|
408
|
+
renderProperty(node, indent) {
|
|
409
|
+
const pad = INDENT.repeat(indent);
|
|
410
|
+
if (!Array.isArray(node.value)) {
|
|
411
|
+
this.out.push(`${pad}${node.key}: ${renderExpression(node.value)};`);
|
|
412
|
+
return;
|
|
413
|
+
}
|
|
414
|
+
if (node.value.length === 0) {
|
|
415
|
+
this.out.push(`${pad}${node.key}: {};`);
|
|
416
|
+
return;
|
|
417
|
+
}
|
|
418
|
+
const span = this.bodySpan(node);
|
|
419
|
+
const inner = INDENT.repeat(indent + 1);
|
|
420
|
+
this.out.push(`${pad}${node.key}: {`);
|
|
421
|
+
node.value.forEach((entry, index) => {
|
|
422
|
+
this.flush(this.tokenIndexOf(entry.loc), indent + 1);
|
|
423
|
+
const comma = index === node.value.length - 1 ? "" : ",";
|
|
424
|
+
this.out.push(
|
|
425
|
+
`${inner}${mapKey(entry.key)}: ${stringLiteral(entry.value)}${comma}`,
|
|
426
|
+
);
|
|
427
|
+
});
|
|
428
|
+
this.flush(span.close, indent + 1);
|
|
429
|
+
this.out.push(`${pad}};`);
|
|
430
|
+
}
|
|
431
|
+
|
|
432
|
+
/** Find the `else` token that follows a block's closing brace. */
|
|
433
|
+
elseIndexAfter(close) {
|
|
434
|
+
const index = this.findToken(close + 1, "else");
|
|
435
|
+
return index === close + 1 ? index : -1;
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
renderIf(node, indent, prefix = "") {
|
|
439
|
+
const pad = INDENT.repeat(indent);
|
|
440
|
+
const span = this.bodySpan(node);
|
|
441
|
+
this.out.push(`${pad}${prefix}if (${renderExpression(node.cond)}) {`);
|
|
442
|
+
this.renderBody(node.then, indent + 1, span.close);
|
|
443
|
+
if (!node.else) {
|
|
444
|
+
this.out.push(`${pad}}`);
|
|
445
|
+
return;
|
|
446
|
+
}
|
|
447
|
+
const elseIndex = this.elseIndexAfter(span.close);
|
|
448
|
+
// Comments between `}` and `else` cannot sit on their own line without
|
|
449
|
+
// breaking the statement, so keep them inside the preceding block.
|
|
450
|
+
if (elseIndex !== -1) this.flush(elseIndex, indent + 1);
|
|
451
|
+
if (node.else.length === 1 && node.else[0].type === "If") {
|
|
452
|
+
this.renderIf(node.else[0], indent, "} else ");
|
|
453
|
+
return;
|
|
454
|
+
}
|
|
455
|
+
const elseOpen = elseIndex === -1 ? span.close + 1 : elseIndex + 1;
|
|
456
|
+
const elseClose = this.matchBrace(elseOpen);
|
|
457
|
+
this.out.push(`${pad}} else {`);
|
|
458
|
+
this.renderBody(node.else, indent + 1, elseClose);
|
|
459
|
+
this.out.push(`${pad}}`);
|
|
460
|
+
}
|
|
461
|
+
|
|
462
|
+
renderBlockCall(node, indent) {
|
|
463
|
+
const pad = INDENT.repeat(indent);
|
|
464
|
+
const args = node.args.map((arg) => renderExpression(arg)).join(", ");
|
|
465
|
+
// `control.forever { }` reads better than `control.forever() { }`, and the
|
|
466
|
+
// parser treats them identically.
|
|
467
|
+
const head =
|
|
468
|
+
node.body && node.args.length === 0
|
|
469
|
+
? `${pad}${node.name}`
|
|
470
|
+
: `${pad}${node.name}(${args})`;
|
|
471
|
+
if (!node.body) {
|
|
472
|
+
this.out.push(`${head};`);
|
|
473
|
+
return;
|
|
474
|
+
}
|
|
475
|
+
const span = this.bodySpan(node);
|
|
476
|
+
this.out.push(`${head} {`);
|
|
477
|
+
this.renderBody(node.body, indent + 1, span.close);
|
|
478
|
+
if (!node.else) {
|
|
479
|
+
this.out.push(`${pad}}`);
|
|
480
|
+
return;
|
|
481
|
+
}
|
|
482
|
+
const elseIndex = this.elseIndexAfter(span.close);
|
|
483
|
+
if (elseIndex !== -1) this.flush(elseIndex, indent + 1);
|
|
484
|
+
const elseOpen = elseIndex === -1 ? span.close + 1 : elseIndex + 1;
|
|
485
|
+
const elseClose = this.matchBrace(elseOpen);
|
|
486
|
+
this.out.push(`${pad}} else {`);
|
|
487
|
+
this.renderBody(node.else, indent + 1, elseClose);
|
|
488
|
+
this.out.push(`${pad}}`);
|
|
489
|
+
}
|
|
490
|
+
|
|
491
|
+
format() {
|
|
492
|
+
const units = [];
|
|
493
|
+
if (this.program.stage) units.push(this.program.stage);
|
|
494
|
+
units.push(
|
|
495
|
+
...this.program.sprites,
|
|
496
|
+
...this.program.topItems,
|
|
497
|
+
...this.program.tests,
|
|
498
|
+
);
|
|
499
|
+
// Preserve the order declarations had in this file; only separate them.
|
|
500
|
+
units.sort((a, b) => this.tokenIndexOf(a.loc) - this.tokenIndexOf(b.loc));
|
|
501
|
+
this.renderBody(units, 0, this.tokens.length - 1);
|
|
502
|
+
if (this.out.length === 0) return "";
|
|
503
|
+
return `${this.out.join("\n")}\n`;
|
|
504
|
+
}
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/**
|
|
508
|
+
* Format Triflare Fx source into the canonical style.
|
|
509
|
+
*
|
|
510
|
+
* Throws `LexError`/`ParseError` with source locations when the input cannot
|
|
511
|
+
* be parsed; callers must leave such files untouched.
|
|
512
|
+
*
|
|
513
|
+
* @param {string} source
|
|
514
|
+
* @param {string} [file]
|
|
515
|
+
* @returns {string}
|
|
516
|
+
*/
|
|
517
|
+
export function formatSource(source, file = "<input>") {
|
|
518
|
+
return new Formatter(source, file).format();
|
|
519
|
+
}
|
package/src/index.js
CHANGED
|
@@ -8,6 +8,16 @@
|
|
|
8
8
|
*/
|
|
9
9
|
export { tokenize } from "./lexer.js";
|
|
10
10
|
export { parse } from "./parser.js";
|
|
11
|
+
export { formatSource } from "./formatter.js";
|
|
12
|
+
export { applyFixes, lintSources, sortDiagnostics } from "./linter.js";
|
|
13
|
+
export {
|
|
14
|
+
DEFAULT_LINT,
|
|
15
|
+
effectivePreset,
|
|
16
|
+
normalizeLintConfig,
|
|
17
|
+
resolveRuleSeverities,
|
|
18
|
+
} from "./lint/config.js";
|
|
19
|
+
export { parseProjectConfigText } from "./lint/config-file.js";
|
|
20
|
+
export { CATEGORIES, PRESETS, RULES } from "./lint/rules.js";
|
|
11
21
|
export { Compiler, compile } from "./compiler.js";
|
|
12
22
|
export { build, writeSb3 } from "./build.js";
|
|
13
23
|
export { discoverSources, mergePrograms } from "./project.js";
|
|
@@ -51,6 +61,27 @@ export {
|
|
|
51
61
|
writeLock,
|
|
52
62
|
} from "./vendor.js";
|
|
53
63
|
export { formatDevUrls, injectDevClient, startDevServer } from "./dev.js";
|
|
64
|
+
export {
|
|
65
|
+
DEFAULT_TEST_PATTERN,
|
|
66
|
+
discoverTests,
|
|
67
|
+
globToRegExp,
|
|
68
|
+
isTestFile,
|
|
69
|
+
matchesTestPattern,
|
|
70
|
+
} from "./test/discover.js";
|
|
71
|
+
export { parseTestFile, parseTests } from "./test/parse.js";
|
|
72
|
+
export {
|
|
73
|
+
TEST_EXIT,
|
|
74
|
+
TestError,
|
|
75
|
+
formatFailure,
|
|
76
|
+
runTests,
|
|
77
|
+
} from "./test/runner.js";
|
|
78
|
+
export {
|
|
79
|
+
REPORTER_NAMES,
|
|
80
|
+
createTestReporter,
|
|
81
|
+
jsonReport,
|
|
82
|
+
junitReport,
|
|
83
|
+
} from "./test/reporters.js";
|
|
84
|
+
export { createVm, loadProject, loadVmModules } from "./vm.js";
|
|
54
85
|
export * from "./catalog.js";
|
|
55
86
|
export * from "./config.js";
|
|
56
87
|
export { FxError, LexError, ParseError, CompileError } from "./errors.js";
|
package/src/lexer.js
CHANGED
|
@@ -52,13 +52,28 @@ const isDigit = (ch) => ch >= "0" && ch <= "9";
|
|
|
52
52
|
*/
|
|
53
53
|
|
|
54
54
|
/**
|
|
55
|
-
*
|
|
55
|
+
* @typedef {object} Comment
|
|
56
|
+
* @property {string} text the raw comment, including its delimiters
|
|
57
|
+
* @property {number} line
|
|
58
|
+
* @property {number} column
|
|
59
|
+
* @property {number} after index of the token that follows the comment
|
|
60
|
+
*/
|
|
61
|
+
|
|
62
|
+
/**
|
|
63
|
+
* Tokenize a Triflare Fx source string and record its comments.
|
|
64
|
+
*
|
|
65
|
+
* Comments stay out of the token stream, but the formatter needs them to put
|
|
66
|
+
* source comments back into a canonical layout. `after` is the index of the
|
|
67
|
+
* next token, so a comment can be re-attached between tokens even after the
|
|
68
|
+
* whitespace around it changes.
|
|
69
|
+
*
|
|
56
70
|
* @param {string} source
|
|
57
71
|
* @param {string} [file]
|
|
58
|
-
* @returns {Token[]}
|
|
72
|
+
* @returns {{tokens: Token[], comments: Comment[]}}
|
|
59
73
|
*/
|
|
60
|
-
export function
|
|
74
|
+
export function tokenizeWithComments(source, file = "<input>") {
|
|
61
75
|
const tokens = [];
|
|
76
|
+
const comments = [];
|
|
62
77
|
let i = 0;
|
|
63
78
|
let line = 1;
|
|
64
79
|
let column = 1;
|
|
@@ -99,10 +114,20 @@ export function tokenize(source, file = "<input>") {
|
|
|
99
114
|
|
|
100
115
|
// --- comments -------------------------------------------------------
|
|
101
116
|
if (ch === "/" && source[i + 1] === "/") {
|
|
117
|
+
const startIndex = i;
|
|
118
|
+
const startLine = line;
|
|
119
|
+
const startColumn = column;
|
|
102
120
|
while (i < source.length && source[i] !== "\n") advance();
|
|
121
|
+
comments.push({
|
|
122
|
+
text: source.slice(startIndex, i),
|
|
123
|
+
line: startLine,
|
|
124
|
+
column: startColumn,
|
|
125
|
+
after: tokens.length,
|
|
126
|
+
});
|
|
103
127
|
continue;
|
|
104
128
|
}
|
|
105
129
|
if (ch === "/" && source[i + 1] === "*") {
|
|
130
|
+
const startIndex = i;
|
|
106
131
|
const startLine = line;
|
|
107
132
|
const startColumn = column;
|
|
108
133
|
advance(2);
|
|
@@ -111,6 +136,12 @@ export function tokenize(source, file = "<input>") {
|
|
|
111
136
|
if (i >= source.length)
|
|
112
137
|
fail("Unterminated block comment", startLine, startColumn);
|
|
113
138
|
advance(2);
|
|
139
|
+
comments.push({
|
|
140
|
+
text: source.slice(startIndex, i),
|
|
141
|
+
line: startLine,
|
|
142
|
+
column: startColumn,
|
|
143
|
+
after: tokens.length,
|
|
144
|
+
});
|
|
114
145
|
continue;
|
|
115
146
|
}
|
|
116
147
|
|
|
@@ -198,5 +229,15 @@ export function tokenize(source, file = "<input>") {
|
|
|
198
229
|
}
|
|
199
230
|
|
|
200
231
|
push("eof", "", line, column);
|
|
201
|
-
return tokens;
|
|
232
|
+
return { tokens, comments };
|
|
233
|
+
}
|
|
234
|
+
|
|
235
|
+
/**
|
|
236
|
+
* Tokenize a Triflare Fx source string.
|
|
237
|
+
* @param {string} source
|
|
238
|
+
* @param {string} [file]
|
|
239
|
+
* @returns {Token[]}
|
|
240
|
+
*/
|
|
241
|
+
export function tokenize(source, file = "<input>") {
|
|
242
|
+
return tokenizeWithComments(source, file).tokens;
|
|
202
243
|
}
|