@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/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 { type: "SpriteDecl", name, body, loc: startTok ?? tok, partial };
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
- parseBlockBody() {
141
- this.expect("{");
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
- const typeName = this.expectIdent();
254
- if (typeName === "boolean") boolean = true;
255
- else if (typeName !== "number" && typeName !== "string") {
256
- this.fail(`Unknown parameter type ${JSON.stringify(typeName)}`, ptok);
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
- const tokens = tokenize(source, file);
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. Results are deterministic (sorted by
25
- * path) so builds are reproducible.
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(dir) {
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
- * - At most one `stage { }` declaration is allowed across all files.
74
- * - Top-level statements from every file become stage content.
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, { partialSprites = false } = {}) {
88
- let stage = null;
89
- const topItems = [];
90
- /** @type {Map<string, {name: string, loc: object, base: object|null, fragments: object[], files: string[]}>} */
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
+ }