@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/merge.js
ADDED
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Multi-file project merge.
|
|
3
|
+
*
|
|
4
|
+
* Kept separate from `project.js` because merging is pure: it needs no
|
|
5
|
+
* filesystem access, so editor tooling (including the browser VS Code
|
|
6
|
+
* extension host) can reuse it without pulling in `node:fs`.
|
|
7
|
+
*/
|
|
8
|
+
import { ParseError } from "./errors.js";
|
|
9
|
+
|
|
10
|
+
/** @param {object} loc */
|
|
11
|
+
function errorInfo(loc) {
|
|
12
|
+
return {
|
|
13
|
+
file: loc && loc.file,
|
|
14
|
+
line: loc && loc.line,
|
|
15
|
+
column: loc && loc.column,
|
|
16
|
+
};
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Merge parsed programs into a single project AST.
|
|
21
|
+
*
|
|
22
|
+
* - At most one `stage { }` declaration is allowed across all files.
|
|
23
|
+
* - Top-level statements from every file become stage content.
|
|
24
|
+
* - At most one plain `sprite "Name" { }` declaration is allowed per name.
|
|
25
|
+
* Additional `partial sprite "Name" { }` fragments are appended to it.
|
|
26
|
+
* - Partial sprites only merge when `partialSprites` is enabled.
|
|
27
|
+
*
|
|
28
|
+
* Bodies are composed base-first, then fragments in build order, so costume
|
|
29
|
+
* order and local variable initializers stay predictable regardless of which
|
|
30
|
+
* file happens to sort first.
|
|
31
|
+
*
|
|
32
|
+
* @param {Array<{ast: object, file: string}>} entries
|
|
33
|
+
* @param {{partialSprites?: boolean, configName?: string}} [options]
|
|
34
|
+
* @returns {object} a `Program` node
|
|
35
|
+
*/
|
|
36
|
+
export function mergePrograms(
|
|
37
|
+
entries,
|
|
38
|
+
{ partialSprites = false, configName = "fx.yml" } = {},
|
|
39
|
+
) {
|
|
40
|
+
let stage = null;
|
|
41
|
+
const topItems = [];
|
|
42
|
+
/** @type {Map<string, {name: string, loc: object, base: object|null, fragments: object[], files: string[]}>} */
|
|
43
|
+
const groups = new Map();
|
|
44
|
+
|
|
45
|
+
for (const { ast, file } of entries) {
|
|
46
|
+
if (ast.tests && ast.tests.length > 0)
|
|
47
|
+
throw new ParseError(
|
|
48
|
+
`Test ${JSON.stringify(ast.tests[0].name)} is declared in a project source. ` +
|
|
49
|
+
`Move test declarations to a test file (for example ` +
|
|
50
|
+
`test/score.test.tfx); ordinary builds never compile test files.`,
|
|
51
|
+
errorInfo(ast.tests[0].loc),
|
|
52
|
+
);
|
|
53
|
+
if (ast.stage) {
|
|
54
|
+
if (stage) {
|
|
55
|
+
throw new ParseError(
|
|
56
|
+
"A project may only declare one stage",
|
|
57
|
+
errorInfo(ast.stage.loc),
|
|
58
|
+
);
|
|
59
|
+
}
|
|
60
|
+
stage = ast.stage;
|
|
61
|
+
}
|
|
62
|
+
topItems.push(...ast.topItems);
|
|
63
|
+
for (const sprite of ast.sprites) {
|
|
64
|
+
if (sprite.partial && !partialSprites) {
|
|
65
|
+
throw new ParseError(
|
|
66
|
+
`partial sprite ${JSON.stringify(sprite.name)} is disabled; ` +
|
|
67
|
+
`set "fx.partialSprites: true" in ${configName} to enable it`,
|
|
68
|
+
errorInfo(sprite.loc),
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
let group = groups.get(sprite.name);
|
|
73
|
+
if (!group) {
|
|
74
|
+
group = {
|
|
75
|
+
name: sprite.name,
|
|
76
|
+
loc: sprite.loc,
|
|
77
|
+
base: null,
|
|
78
|
+
fragments: [],
|
|
79
|
+
files: [],
|
|
80
|
+
};
|
|
81
|
+
groups.set(sprite.name, group);
|
|
82
|
+
}
|
|
83
|
+
group.files.push(file);
|
|
84
|
+
|
|
85
|
+
if (sprite.partial) {
|
|
86
|
+
group.fragments.push({ decl: sprite, file });
|
|
87
|
+
} else if (group.base) {
|
|
88
|
+
throw new ParseError(
|
|
89
|
+
`Duplicate sprite ${JSON.stringify(sprite.name)} ` +
|
|
90
|
+
`(declared in ${group.base.file} and ${file}); ` +
|
|
91
|
+
`use \`partial sprite ${JSON.stringify(sprite.name)}\` to split it across files`,
|
|
92
|
+
errorInfo(sprite.loc),
|
|
93
|
+
);
|
|
94
|
+
} else {
|
|
95
|
+
group.base = { decl: sprite, file };
|
|
96
|
+
}
|
|
97
|
+
}
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
const sprites = [...groups.values()].map((group) => {
|
|
101
|
+
const base = group.base ? [group.base.decl] : [];
|
|
102
|
+
const fragments = group.fragments.map(({ decl }) => decl);
|
|
103
|
+
const [first] = base.length ? base : fragments;
|
|
104
|
+
return {
|
|
105
|
+
type: "SpriteDecl",
|
|
106
|
+
name: group.name,
|
|
107
|
+
body: [...base, ...fragments].flatMap((decl) => decl.body),
|
|
108
|
+
loc: first.loc,
|
|
109
|
+
open: first.open ?? null,
|
|
110
|
+
partial: false,
|
|
111
|
+
files: group.files,
|
|
112
|
+
};
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
return { type: "Program", stage, sprites, topItems };
|
|
116
|
+
}
|
package/src/parser.js
CHANGED
|
@@ -97,6 +97,7 @@ class Parser {
|
|
|
97
97
|
const sprites = [];
|
|
98
98
|
let stage = null;
|
|
99
99
|
const topItems = [];
|
|
100
|
+
const tests = [];
|
|
100
101
|
|
|
101
102
|
while (this.peek().type !== "eof") {
|
|
102
103
|
if (
|
|
@@ -116,29 +117,68 @@ class Parser {
|
|
|
116
117
|
) {
|
|
117
118
|
if (stage) this.fail("A project may only declare one stage");
|
|
118
119
|
stage = this.parseStage();
|
|
120
|
+
} else if (
|
|
121
|
+
this.atIdent("test") &&
|
|
122
|
+
this.peek(1).type === "string" &&
|
|
123
|
+
this.peek(2).type === "punct" &&
|
|
124
|
+
this.peek(2).value === "{"
|
|
125
|
+
) {
|
|
126
|
+
tests.push(this.parseTestDecl());
|
|
119
127
|
} else {
|
|
120
128
|
topItems.push(this.parseBodyItem());
|
|
121
129
|
}
|
|
122
130
|
}
|
|
123
131
|
|
|
124
|
-
return { type: "Program", stage, sprites, topItems };
|
|
132
|
+
return { type: "Program", stage, sprites, topItems, tests };
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
/** `test "name" { ... }` — only valid in test files. */
|
|
136
|
+
parseTestDecl() {
|
|
137
|
+
const tok = this.expect("test");
|
|
138
|
+
const name = this.expectType("string", "a test name string").value;
|
|
139
|
+
const body = this.parseBlockBody();
|
|
140
|
+
return { type: "TestDecl", name, body, loc: tok };
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/** `expect <expression>[, "message"];` */
|
|
144
|
+
parseExpect() {
|
|
145
|
+
const tok = this.expect("expect");
|
|
146
|
+
const value = this.parseExpression();
|
|
147
|
+
let message = null;
|
|
148
|
+
if (this.eat(","))
|
|
149
|
+
message = this.expectType("string", "an assertion message string").value;
|
|
150
|
+
this.expect(";");
|
|
151
|
+
return { type: "Expect", value, message, loc: tok };
|
|
125
152
|
}
|
|
126
153
|
|
|
127
154
|
parseSprite(partial = false, startTok = null) {
|
|
128
155
|
const tok = this.expect("sprite");
|
|
129
156
|
const name = this.expectType("string", "a sprite name string").value;
|
|
130
|
-
const body = this.parseBlockBody();
|
|
131
|
-
return {
|
|
157
|
+
const { body, open } = this.parseBlockBody(true);
|
|
158
|
+
return {
|
|
159
|
+
type: "SpriteDecl",
|
|
160
|
+
name,
|
|
161
|
+
body,
|
|
162
|
+
loc: startTok ?? tok,
|
|
163
|
+
// Tokens are the brace's position, needed to insert declarations into
|
|
164
|
+
// the body when the brace is not on the declaration line.
|
|
165
|
+
open,
|
|
166
|
+
partial,
|
|
167
|
+
};
|
|
132
168
|
}
|
|
133
169
|
|
|
134
170
|
parseStage() {
|
|
135
171
|
const tok = this.expect("stage");
|
|
136
|
-
const body = this.parseBlockBody();
|
|
137
|
-
return { type: "StageDecl", name: "Stage", body, loc: tok };
|
|
172
|
+
const { body, open } = this.parseBlockBody(true);
|
|
173
|
+
return { type: "StageDecl", name: "Stage", body, loc: tok, open };
|
|
138
174
|
}
|
|
139
175
|
|
|
140
|
-
|
|
141
|
-
|
|
176
|
+
/**
|
|
177
|
+
* Parse `{ ... }`.
|
|
178
|
+
* @param {boolean} [withOpen] return `{body, open}` instead of the items only
|
|
179
|
+
*/
|
|
180
|
+
parseBlockBody(withOpen = false) {
|
|
181
|
+
const open = this.expect("{");
|
|
142
182
|
const items = [];
|
|
143
183
|
while (!this.atPunct("}")) {
|
|
144
184
|
if (this.peek().type === "eof")
|
|
@@ -146,7 +186,7 @@ class Parser {
|
|
|
146
186
|
items.push(this.parseBodyItem());
|
|
147
187
|
}
|
|
148
188
|
this.expect("}");
|
|
149
|
-
return items;
|
|
189
|
+
return withOpen ? { body: items, open } : items;
|
|
150
190
|
}
|
|
151
191
|
|
|
152
192
|
parseBodyItem() {
|
|
@@ -161,6 +201,12 @@ class Parser {
|
|
|
161
201
|
return this.parseVarDecl(true);
|
|
162
202
|
}
|
|
163
203
|
break;
|
|
204
|
+
case "thread":
|
|
205
|
+
case "runtime":
|
|
206
|
+
if (this.peek(1).type === "ident" && this.peek(1).value === "let") {
|
|
207
|
+
return this.parseTempVarDecl(tok.value);
|
|
208
|
+
}
|
|
209
|
+
break;
|
|
164
210
|
case "list":
|
|
165
211
|
if (this.peek(1).type === "ident") {
|
|
166
212
|
return this.parseListDecl();
|
|
@@ -188,6 +234,18 @@ class Parser {
|
|
|
188
234
|
}
|
|
189
235
|
case "when":
|
|
190
236
|
return this.parseHat();
|
|
237
|
+
case "expect":
|
|
238
|
+
// A complete call or assignment stays a normal statement. An
|
|
239
|
+
// expression continuing after the closing parenthesis is an assertion.
|
|
240
|
+
if (
|
|
241
|
+
!(
|
|
242
|
+
this.peek(1).type === "punct" &&
|
|
243
|
+
ASSIGN_OPS.has(this.peek(1).value)
|
|
244
|
+
) &&
|
|
245
|
+
!this.isExpectCall()
|
|
246
|
+
)
|
|
247
|
+
return this.parseExpect();
|
|
248
|
+
break;
|
|
191
249
|
case "if":
|
|
192
250
|
return this.parseIf();
|
|
193
251
|
case "repeat":
|
|
@@ -214,6 +272,22 @@ class Parser {
|
|
|
214
272
|
return this.parseSimpleStatement();
|
|
215
273
|
}
|
|
216
274
|
|
|
275
|
+
isExpectCall() {
|
|
276
|
+
if (this.peek(1).type !== "punct" || this.peek(1).value !== "(")
|
|
277
|
+
return false;
|
|
278
|
+
let depth = 0;
|
|
279
|
+
for (let offset = 1; this.peek(offset).type !== "eof"; offset++) {
|
|
280
|
+
const token = this.peek(offset);
|
|
281
|
+
if (token.type !== "punct") continue;
|
|
282
|
+
if (token.value === "(") depth++;
|
|
283
|
+
if (token.value === ")" && --depth === 0) {
|
|
284
|
+
const next = this.peek(offset + 1);
|
|
285
|
+
return next.type === "punct" && [";", "{"].includes(next.value);
|
|
286
|
+
}
|
|
287
|
+
}
|
|
288
|
+
return false;
|
|
289
|
+
}
|
|
290
|
+
|
|
217
291
|
parseVarDecl(cloud) {
|
|
218
292
|
const tok = cloud ? this.expect("cloud") : this.expect("let");
|
|
219
293
|
if (cloud) this.expect("let");
|
|
@@ -224,6 +298,16 @@ class Parser {
|
|
|
224
298
|
return { type: "VarDecl", name, init, cloud, loc: tok };
|
|
225
299
|
}
|
|
226
300
|
|
|
301
|
+
parseTempVarDecl(kind) {
|
|
302
|
+
const tok = this.next();
|
|
303
|
+
this.expect("let");
|
|
304
|
+
const name = this.expectIdent();
|
|
305
|
+
let init = null;
|
|
306
|
+
if (this.eat("=")) init = this.parseExpression();
|
|
307
|
+
this.expect(";");
|
|
308
|
+
return { type: "TempVarDecl", kind, name, init, loc: tok };
|
|
309
|
+
}
|
|
310
|
+
|
|
227
311
|
parseListDecl() {
|
|
228
312
|
const tok = this.expect("list");
|
|
229
313
|
const name = this.expectIdent();
|
|
@@ -248,15 +332,16 @@ class Parser {
|
|
|
248
332
|
const ptok = this.peek();
|
|
249
333
|
const paramName = this.expectIdent();
|
|
250
334
|
let boolean = false;
|
|
335
|
+
let type = null;
|
|
251
336
|
// Optional type annotation: `flag: boolean`
|
|
252
337
|
if (this.eat(":")) {
|
|
253
|
-
|
|
254
|
-
if (
|
|
255
|
-
else if (
|
|
256
|
-
this.fail(`Unknown parameter type ${JSON.stringify(
|
|
338
|
+
type = this.expectIdent();
|
|
339
|
+
if (type === "boolean") boolean = true;
|
|
340
|
+
else if (type !== "number" && type !== "string") {
|
|
341
|
+
this.fail(`Unknown parameter type ${JSON.stringify(type)}`, ptok);
|
|
257
342
|
}
|
|
258
343
|
}
|
|
259
|
-
params.push({ name: paramName, boolean });
|
|
344
|
+
params.push({ name: paramName, boolean, type });
|
|
260
345
|
if (!this.eat(",")) break;
|
|
261
346
|
}
|
|
262
347
|
this.expect(")");
|
|
@@ -444,6 +529,26 @@ class Parser {
|
|
|
444
529
|
return { name: segments.join("."), segments, loc: tok };
|
|
445
530
|
}
|
|
446
531
|
|
|
532
|
+
/**
|
|
533
|
+
* Postfix member access on a call result, as in
|
|
534
|
+
* `state.list("items").length`. Qualified identifiers already absorb their
|
|
535
|
+
* segments while they are parsed.
|
|
536
|
+
*/
|
|
537
|
+
parseMemberAccess(expression) {
|
|
538
|
+
let node = expression;
|
|
539
|
+
while (this.atPunct(".") && this.peek(1).type === "ident") {
|
|
540
|
+
this.next();
|
|
541
|
+
const property = this.next();
|
|
542
|
+
node = {
|
|
543
|
+
type: "Member",
|
|
544
|
+
object: node,
|
|
545
|
+
property: property.value,
|
|
546
|
+
loc: property,
|
|
547
|
+
};
|
|
548
|
+
}
|
|
549
|
+
return node;
|
|
550
|
+
}
|
|
551
|
+
|
|
447
552
|
parseExpressionList(closing) {
|
|
448
553
|
const list = [];
|
|
449
554
|
if (this.atPunct(closing)) return list;
|
|
@@ -577,13 +682,13 @@ class Parser {
|
|
|
577
682
|
if (this.eat("(")) {
|
|
578
683
|
const args = this.parseExpressionList(")");
|
|
579
684
|
this.expect(")");
|
|
580
|
-
return {
|
|
685
|
+
return this.parseMemberAccess({
|
|
581
686
|
type: "Call",
|
|
582
687
|
name: name.name,
|
|
583
688
|
segments: name.segments,
|
|
584
689
|
args,
|
|
585
690
|
loc: tok,
|
|
586
|
-
};
|
|
691
|
+
});
|
|
587
692
|
}
|
|
588
693
|
return {
|
|
589
694
|
type: "Identifier",
|
|
@@ -603,6 +708,16 @@ class Parser {
|
|
|
603
708
|
* @param {string} [file]
|
|
604
709
|
*/
|
|
605
710
|
export function parse(source, file = "<input>") {
|
|
606
|
-
|
|
711
|
+
return parseTokens(tokenize(source, file), source, file);
|
|
712
|
+
}
|
|
713
|
+
|
|
714
|
+
/**
|
|
715
|
+
* Parse an existing token stream. Keeping the caller's tokens lets tools such
|
|
716
|
+
* as the formatter match AST locations back to tokens without re-lexing.
|
|
717
|
+
* @param {import('./lexer.js').Token[]} tokens
|
|
718
|
+
* @param {string} source
|
|
719
|
+
* @param {string} [file]
|
|
720
|
+
*/
|
|
721
|
+
export function parseTokens(tokens, source, file = "<input>") {
|
|
607
722
|
return new Parser(tokens, source, file).parse();
|
|
608
723
|
}
|
package/src/project.js
CHANGED
|
@@ -7,8 +7,9 @@
|
|
|
7
7
|
*/
|
|
8
8
|
import fs from "node:fs";
|
|
9
9
|
import path from "node:path";
|
|
10
|
-
import { ParseError } from "./errors.js";
|
|
11
10
|
import { configFilename, sourceExtension } from "./brand.js";
|
|
11
|
+
import { mergePrograms as mergeParsed } from "./merge.js";
|
|
12
|
+
import { DEFAULT_TEST_PATTERN, matchesTestPattern } from "./test/discover.js";
|
|
12
13
|
|
|
13
14
|
/** Directories never scanned for `.tfx` sources. */
|
|
14
15
|
const IGNORED_DIRS = new Set([
|
|
@@ -21,12 +22,18 @@ const IGNORED_DIRS = new Set([
|
|
|
21
22
|
|
|
22
23
|
/**
|
|
23
24
|
* Recursively find every `.tfx` file under `dir`, skipping hidden entries and
|
|
24
|
-
* well-known build/dependency directories.
|
|
25
|
-
*
|
|
25
|
+
* well-known build/dependency directories. Test files are skipped so they
|
|
26
|
+
* never become part of the project target.
|
|
27
|
+
*
|
|
28
|
+
* Results are deterministic (sorted by path) so builds are reproducible.
|
|
26
29
|
* @param {string} dir
|
|
30
|
+
* @param {{testPattern?: string|null}} [options] `null` keeps test files
|
|
27
31
|
* @returns {string[]} absolute file paths
|
|
28
32
|
*/
|
|
29
|
-
export function discoverSources(
|
|
33
|
+
export function discoverSources(
|
|
34
|
+
dir,
|
|
35
|
+
{ testPattern = DEFAULT_TEST_PATTERN } = {},
|
|
36
|
+
) {
|
|
30
37
|
const root = path.resolve(dir);
|
|
31
38
|
const found = [];
|
|
32
39
|
const extension = sourceExtension().toLowerCase();
|
|
@@ -49,6 +56,10 @@ export function discoverSources(dir) {
|
|
|
49
56
|
entry.isFile() &&
|
|
50
57
|
entry.name.toLowerCase().endsWith(extension)
|
|
51
58
|
) {
|
|
59
|
+
if (testPattern) {
|
|
60
|
+
const relative = path.relative(root, full).split(path.sep).join("/");
|
|
61
|
+
if (matchesTestPattern(relative, testPattern)) continue;
|
|
62
|
+
}
|
|
52
63
|
found.push(full);
|
|
53
64
|
}
|
|
54
65
|
}
|
|
@@ -58,99 +69,19 @@ export function discoverSources(dir) {
|
|
|
58
69
|
return found.sort();
|
|
59
70
|
}
|
|
60
71
|
|
|
61
|
-
/** @param {object} loc */
|
|
62
|
-
function errorInfo(loc) {
|
|
63
|
-
return {
|
|
64
|
-
file: loc && loc.file,
|
|
65
|
-
line: loc && loc.line,
|
|
66
|
-
column: loc && loc.column,
|
|
67
|
-
};
|
|
68
|
-
}
|
|
69
|
-
|
|
70
72
|
/**
|
|
71
73
|
* Merge parsed programs into a single project AST.
|
|
72
74
|
*
|
|
73
|
-
*
|
|
74
|
-
*
|
|
75
|
-
* - At most one plain `sprite "Name" { }` declaration is allowed per name.
|
|
76
|
-
* Additional `partial sprite "Name" { }` fragments are appended to it.
|
|
77
|
-
* - Partial sprites only merge when `partialSprites` is enabled.
|
|
78
|
-
*
|
|
79
|
-
* Bodies are composed base-first, then fragments in build order, so costume
|
|
80
|
-
* order and local variable initializers stay predictable regardless of which
|
|
81
|
-
* file happens to sort first.
|
|
75
|
+
* The merge itself is pure (see `merge.js`); this wrapper only injects the
|
|
76
|
+
* configured config filename so project-facing errors keep the right name.
|
|
82
77
|
*
|
|
83
78
|
* @param {Array<{ast: object, file: string}>} entries
|
|
84
79
|
* @param {{partialSprites?: boolean}} [options]
|
|
85
80
|
* @returns {object} a `Program` node
|
|
86
81
|
*/
|
|
87
|
-
export function mergePrograms(entries,
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
const groups = new Map();
|
|
92
|
-
|
|
93
|
-
for (const { ast, file } of entries) {
|
|
94
|
-
if (ast.stage) {
|
|
95
|
-
if (stage) {
|
|
96
|
-
throw new ParseError(
|
|
97
|
-
"A project may only declare one stage",
|
|
98
|
-
errorInfo(ast.stage.loc),
|
|
99
|
-
);
|
|
100
|
-
}
|
|
101
|
-
stage = ast.stage;
|
|
102
|
-
}
|
|
103
|
-
topItems.push(...ast.topItems);
|
|
104
|
-
for (const sprite of ast.sprites) {
|
|
105
|
-
if (sprite.partial && !partialSprites) {
|
|
106
|
-
throw new ParseError(
|
|
107
|
-
`partial sprite ${JSON.stringify(sprite.name)} is disabled; ` +
|
|
108
|
-
`set "fx.partialSprites: true" in ${configFilename()} to enable it`,
|
|
109
|
-
errorInfo(sprite.loc),
|
|
110
|
-
);
|
|
111
|
-
}
|
|
112
|
-
|
|
113
|
-
let group = groups.get(sprite.name);
|
|
114
|
-
if (!group) {
|
|
115
|
-
group = {
|
|
116
|
-
name: sprite.name,
|
|
117
|
-
loc: sprite.loc,
|
|
118
|
-
base: null,
|
|
119
|
-
fragments: [],
|
|
120
|
-
files: [],
|
|
121
|
-
};
|
|
122
|
-
groups.set(sprite.name, group);
|
|
123
|
-
}
|
|
124
|
-
group.files.push(file);
|
|
125
|
-
|
|
126
|
-
if (sprite.partial) {
|
|
127
|
-
group.fragments.push({ decl: sprite, file });
|
|
128
|
-
} else if (group.base) {
|
|
129
|
-
throw new ParseError(
|
|
130
|
-
`Duplicate sprite ${JSON.stringify(sprite.name)} ` +
|
|
131
|
-
`(declared in ${group.base.file} and ${file}); ` +
|
|
132
|
-
`use \`partial sprite ${JSON.stringify(sprite.name)}\` to split it across files`,
|
|
133
|
-
errorInfo(sprite.loc),
|
|
134
|
-
);
|
|
135
|
-
} else {
|
|
136
|
-
group.base = { decl: sprite, file };
|
|
137
|
-
}
|
|
138
|
-
}
|
|
139
|
-
}
|
|
140
|
-
|
|
141
|
-
const sprites = [...groups.values()].map((group) => {
|
|
142
|
-
const base = group.base ? [group.base.decl] : [];
|
|
143
|
-
const fragments = group.fragments.map(({ decl }) => decl);
|
|
144
|
-
const [first] = base.length ? base : fragments;
|
|
145
|
-
return {
|
|
146
|
-
type: "SpriteDecl",
|
|
147
|
-
name: group.name,
|
|
148
|
-
body: [...base, ...fragments].flatMap((decl) => decl.body),
|
|
149
|
-
loc: first.loc,
|
|
150
|
-
partial: false,
|
|
151
|
-
files: group.files,
|
|
152
|
-
};
|
|
82
|
+
export function mergePrograms(entries, options = {}) {
|
|
83
|
+
return mergeParsed(entries, {
|
|
84
|
+
...options,
|
|
85
|
+
configName: configFilename(),
|
|
153
86
|
});
|
|
154
|
-
|
|
155
|
-
return { type: "Program", stage, sprites, topItems };
|
|
156
87
|
}
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-file discovery.
|
|
3
|
+
*
|
|
4
|
+
* Test files are ordinary `.tfx` sources that match a project glob. They are
|
|
5
|
+
* never part of the project target, so both `fx test` and the normal build
|
|
6
|
+
* path use the same matcher.
|
|
7
|
+
*/
|
|
8
|
+
import fs from "node:fs";
|
|
9
|
+
import path from "node:path";
|
|
10
|
+
import { DEFAULT_TEST_PATTERN, matchesTestPattern } from "./pattern.js";
|
|
11
|
+
|
|
12
|
+
export {
|
|
13
|
+
DEFAULT_TEST_PATTERN,
|
|
14
|
+
globToRegExp,
|
|
15
|
+
matchesTestPattern,
|
|
16
|
+
} from "./pattern.js";
|
|
17
|
+
|
|
18
|
+
const IGNORED_DIRS = new Set([
|
|
19
|
+
"node_modules",
|
|
20
|
+
"build",
|
|
21
|
+
"dist",
|
|
22
|
+
"coverage",
|
|
23
|
+
".cache",
|
|
24
|
+
]);
|
|
25
|
+
|
|
26
|
+
/** Recursively collect test files below `current`, skipping ignored dirs. */
|
|
27
|
+
function walk(root, current, found, pattern, extension) {
|
|
28
|
+
let entries;
|
|
29
|
+
try {
|
|
30
|
+
entries = fs.readdirSync(current, { withFileTypes: true });
|
|
31
|
+
} catch {
|
|
32
|
+
return;
|
|
33
|
+
}
|
|
34
|
+
entries.sort((a, b) => a.name.localeCompare(b.name));
|
|
35
|
+
for (const entry of entries) {
|
|
36
|
+
if (entry.name.startsWith(".")) continue;
|
|
37
|
+
const full = path.join(current, entry.name);
|
|
38
|
+
if (entry.isDirectory()) {
|
|
39
|
+
if (IGNORED_DIRS.has(entry.name)) continue;
|
|
40
|
+
walk(root, full, found, pattern, extension);
|
|
41
|
+
} else if (entry.isFile() && entry.name.toLowerCase().endsWith(extension)) {
|
|
42
|
+
const relative = path.relative(root, full).split(path.sep).join("/");
|
|
43
|
+
if (matchesTestPattern(relative, pattern)) found.push(full);
|
|
44
|
+
}
|
|
45
|
+
}
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/**
|
|
49
|
+
* Find every test file under a directory.
|
|
50
|
+
*
|
|
51
|
+
* @param {{dir: string, pattern?: string, extension?: string}} options
|
|
52
|
+
* @returns {Array<{file: string, relative: string}>} sorted by path
|
|
53
|
+
*/
|
|
54
|
+
export function discoverTests({
|
|
55
|
+
dir,
|
|
56
|
+
pattern = DEFAULT_TEST_PATTERN,
|
|
57
|
+
extension = ".tfx",
|
|
58
|
+
} = {}) {
|
|
59
|
+
if (!dir) throw new Error("discoverTests needs a directory");
|
|
60
|
+
const root = path.resolve(dir);
|
|
61
|
+
const found = [];
|
|
62
|
+
walk(root, root, found, pattern, String(extension).toLowerCase());
|
|
63
|
+
found.sort();
|
|
64
|
+
return found.map((file) => ({
|
|
65
|
+
file,
|
|
66
|
+
relative: path.relative(root, file).split(path.sep).join("/"),
|
|
67
|
+
}));
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Whether one explicit path is a test file for a project directory.
|
|
72
|
+
* @param {string} file
|
|
73
|
+
* @param {string} dir
|
|
74
|
+
* @param {string} [pattern]
|
|
75
|
+
*/
|
|
76
|
+
export function isTestFile(file, dir, pattern = DEFAULT_TEST_PATTERN) {
|
|
77
|
+
const relative = path
|
|
78
|
+
.relative(path.resolve(dir), path.resolve(file))
|
|
79
|
+
.split(path.sep)
|
|
80
|
+
.join("/");
|
|
81
|
+
return matchesTestPattern(relative, pattern);
|
|
82
|
+
}
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Test-file parsing.
|
|
3
|
+
*
|
|
4
|
+
* A test file is a normal `.tfx` source with `test` declarations. Project
|
|
5
|
+
* declarations (sprites, the stage, hats, project variables) are rejected:
|
|
6
|
+
* tests reuse the language, not the project structure.
|
|
7
|
+
*/
|
|
8
|
+
import { parse } from "../parser.js";
|
|
9
|
+
import { ParseError } from "../errors.js";
|
|
10
|
+
|
|
11
|
+
const ALLOWED_TOP_LEVEL = new Set(["VarDecl", "FunctionDecl"]);
|
|
12
|
+
|
|
13
|
+
/**
|
|
14
|
+
* @param {string} source
|
|
15
|
+
* @param {string} file
|
|
16
|
+
* @returns {{file: string, tests: object[], helpers: object[]}}
|
|
17
|
+
*/
|
|
18
|
+
export function parseTestFile(source, file = "<test>") {
|
|
19
|
+
const ast = parse(source, file);
|
|
20
|
+
|
|
21
|
+
if (ast.stage)
|
|
22
|
+
throw new ParseError(
|
|
23
|
+
"Test files cannot declare a stage; tests run against the project build",
|
|
24
|
+
locationOf(ast.stage),
|
|
25
|
+
);
|
|
26
|
+
if (ast.sprites.length > 0)
|
|
27
|
+
throw new ParseError(
|
|
28
|
+
`Test files cannot declare sprites (found ${JSON.stringify(ast.sprites[0].name)})`,
|
|
29
|
+
locationOf(ast.sprites[0]),
|
|
30
|
+
);
|
|
31
|
+
|
|
32
|
+
for (const item of ast.topItems) {
|
|
33
|
+
if (!ALLOWED_TOP_LEVEL.has(item.type))
|
|
34
|
+
throw new ParseError(
|
|
35
|
+
"Test files may only declare tests, shared variables and shared functions",
|
|
36
|
+
locationOf(item),
|
|
37
|
+
);
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return { file, tests: ast.tests, helpers: ast.topItems };
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Source location of an AST node, defaulting unknown fields to null. */
|
|
44
|
+
function locationOf(node) {
|
|
45
|
+
const loc = node?.loc ?? {};
|
|
46
|
+
return { file: loc.file, line: loc.line, column: loc.column };
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Parse several test files. Errors are collected so a run can report every
|
|
51
|
+
* broken file before failing with the language-error exit code.
|
|
52
|
+
*
|
|
53
|
+
* @param {Array<{file: string, source: string}>} entries
|
|
54
|
+
* @returns {{files: Array<{file: string, tests: object[], helpers: object[]}>, diagnostics: object[]}}
|
|
55
|
+
*/
|
|
56
|
+
export function parseTests(entries) {
|
|
57
|
+
const files = [];
|
|
58
|
+
const diagnostics = [];
|
|
59
|
+
for (const entry of entries) {
|
|
60
|
+
try {
|
|
61
|
+
files.push(parseTestFile(entry.source, entry.file));
|
|
62
|
+
} catch (error) {
|
|
63
|
+
diagnostics.push({
|
|
64
|
+
file: entry.file,
|
|
65
|
+
message: error.message,
|
|
66
|
+
line: error.line ?? null,
|
|
67
|
+
column: error.column ?? null,
|
|
68
|
+
});
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
return { files, diagnostics };
|
|
72
|
+
}
|