@sdxc/spec 0.0.0-pre.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.
Files changed (77) hide show
  1. package/LICENSE.md +21 -0
  2. package/README.md +924 -0
  3. package/dist/ast.d.ts +193 -0
  4. package/dist/ast.js +9 -0
  5. package/dist/builtins.d.ts +29 -0
  6. package/dist/builtins.js +66 -0
  7. package/dist/cli.d.ts +21 -0
  8. package/dist/cli.js +297 -0
  9. package/dist/diagnostics.d.ts +47 -0
  10. package/dist/diagnostics.js +8 -0
  11. package/dist/errors.d.ts +131 -0
  12. package/dist/errors.js +159 -0
  13. package/dist/executor.d.ts +66 -0
  14. package/dist/executor.js +320 -0
  15. package/dist/expectation.d.ts +61 -0
  16. package/dist/expectation.js +222 -0
  17. package/dist/index.d.ts +51 -0
  18. package/dist/index.js +36 -0
  19. package/dist/lexer.d.ts +22 -0
  20. package/dist/lexer.js +284 -0
  21. package/dist/loader.d.ts +21 -0
  22. package/dist/loader.js +81 -0
  23. package/dist/parser.d.ts +24 -0
  24. package/dist/parser.js +502 -0
  25. package/dist/permissions.d.ts +139 -0
  26. package/dist/permissions.js +325 -0
  27. package/dist/plugin.d.ts +90 -0
  28. package/dist/plugin.js +9 -0
  29. package/dist/plugins/browser.d.ts +24 -0
  30. package/dist/plugins/browser.js +896 -0
  31. package/dist/plugins/cli.d.ts +17 -0
  32. package/dist/plugins/cli.js +134 -0
  33. package/dist/plugins/db-e2e-probe.d.ts +14 -0
  34. package/dist/plugins/db-e2e-probe.js +112 -0
  35. package/dist/plugins/db.d.ts +19 -0
  36. package/dist/plugins/db.js +199 -0
  37. package/dist/plugins/demo.d.ts +17 -0
  38. package/dist/plugins/demo.js +70 -0
  39. package/dist/plugins/env.d.ts +18 -0
  40. package/dist/plugins/env.js +87 -0
  41. package/dist/plugins/fs.d.ts +16 -0
  42. package/dist/plugins/fs.js +415 -0
  43. package/dist/plugins/http.d.ts +19 -0
  44. package/dist/plugins/http.js +505 -0
  45. package/dist/plugins/jwt.d.ts +17 -0
  46. package/dist/plugins/jwt.js +342 -0
  47. package/dist/plugins/sample.d.ts +27 -0
  48. package/dist/plugins/sample.js +400 -0
  49. package/dist/plugins/url.d.ts +18 -0
  50. package/dist/plugins/url.js +126 -0
  51. package/dist/project-config.d.ts +163 -0
  52. package/dist/project-config.js +497 -0
  53. package/dist/registry.d.ts +56 -0
  54. package/dist/registry.js +110 -0
  55. package/dist/reporter.d.ts +30 -0
  56. package/dist/reporter.js +237 -0
  57. package/dist/run.d.ts +74 -0
  58. package/dist/run.js +179 -0
  59. package/dist/runner.d.ts +52 -0
  60. package/dist/runner.js +38 -0
  61. package/dist/source.d.ts +37 -0
  62. package/dist/source.js +31 -0
  63. package/dist/sources.d.ts +45 -0
  64. package/dist/sources.js +54 -0
  65. package/dist/tokens.d.ts +34 -0
  66. package/dist/tokens.js +25 -0
  67. package/dist/transport-stdio.d.ts +34 -0
  68. package/dist/transport-stdio.js +400 -0
  69. package/dist/values.d.ts +48 -0
  70. package/dist/values.js +52 -0
  71. package/dist/workers.d.ts +40 -0
  72. package/dist/workers.js +26 -0
  73. package/dist/workspace-none.d.ts +23 -0
  74. package/dist/workspace-none.js +33 -0
  75. package/dist/workspace.d.ts +47 -0
  76. package/dist/workspace.js +116 -0
  77. package/package.json +28 -0
@@ -0,0 +1,21 @@
1
+ /**
2
+ * Suite loading from a directory: discovers every `.spec` file under a root,
3
+ * reads each one, and hands the texts to `loadSources` for parsing and
4
+ * registration. Everything here is the filesystem half — the walk, the reads,
5
+ * and the lexicographic order they impose; the language half lives in
6
+ * `sources.ts` and is reachable without a disk.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import type { Result } from "@sdxc/result";
12
+ import type { SpecError } from "./errors.js";
13
+ import type { LoadedSuite } from "./sources.js";
14
+ /**
15
+ * Load a suite from a directory: find `*.spec` files recursively, read them in
16
+ * lexicographic relative-path order, then parse and register them.
17
+ *
18
+ * @param root - The suite directory, conventionally `spec/`.
19
+ * @returns The loaded suite, or the load/parse error that prevented it.
20
+ */
21
+ export declare function loadSuite(root: string): Promise<Result<LoadedSuite, SpecError>>;
package/dist/loader.js ADDED
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Suite loading from a directory: discovers every `.spec` file under a root,
3
+ * reads each one, and hands the texts to `loadSources` for parsing and
4
+ * registration. Everything here is the filesystem half — the walk, the reads,
5
+ * and the lexicographic order they impose; the language half lives in
6
+ * `sources.ts` and is reachable without a disk.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ import { readdir, readFile } from "node:fs/promises";
12
+ import { join } from "node:path";
13
+ import { failure } from "@sdxc/result";
14
+ import { LoadError } from "./errors.js";
15
+ import { loadSources } from "./sources.js";
16
+ /**
17
+ * Load a suite from a directory: find `*.spec` files recursively, read them in
18
+ * lexicographic relative-path order, then parse and register them.
19
+ *
20
+ * @param root - The suite directory, conventionally `spec/`.
21
+ * @returns The loaded suite, or the load/parse error that prevented it.
22
+ */
23
+ export async function loadSuite(root) {
24
+ let relativePaths;
25
+ try {
26
+ relativePaths = await collectSpecFiles(root, "");
27
+ }
28
+ catch (cause) {
29
+ return failure(new LoadError("load-error", `Could not read the suite directory ${root}: ${describeCause(cause)}`));
30
+ }
31
+ if (relativePaths.length === 0) {
32
+ return failure(new LoadError("load-error", `No .spec files found under ${root}.`));
33
+ }
34
+ relativePaths.sort();
35
+ let sources = [];
36
+ for (let relativePath of relativePaths) {
37
+ let path = join(root, relativePath);
38
+ try {
39
+ sources.push({ path, text: await readFile(path, "utf8") });
40
+ }
41
+ catch (cause) {
42
+ return failure(new LoadError("load-error", `Could not read ${path}: ${describeCause(cause)}`));
43
+ }
44
+ }
45
+ return loadSources(sources);
46
+ }
47
+ /**
48
+ * Walk a directory tree collecting `.spec` file paths relative to the root.
49
+ * Directories reached through symlinks are skipped, which keeps the walk
50
+ * cycle-free. Filesystem failures propagate for the caller to wrap.
51
+ *
52
+ * @param root - The suite root the walk started from.
53
+ * @param prefix - The directory currently being read, relative to the root.
54
+ * @returns Relative paths of every `.spec` file found under the prefix.
55
+ */
56
+ async function collectSpecFiles(root, prefix) {
57
+ let entries = await readdir(join(root, prefix), { withFileTypes: true });
58
+ let found = [];
59
+ for (let entry of entries) {
60
+ let relativePath = prefix === "" ? entry.name : join(prefix, entry.name);
61
+ if (entry.isDirectory()) {
62
+ found.push(...(await collectSpecFiles(root, relativePath)));
63
+ }
64
+ else if (entry.isFile() && entry.name.endsWith(".spec")) {
65
+ found.push(relativePath);
66
+ }
67
+ }
68
+ return found;
69
+ }
70
+ /**
71
+ * Render an unknown thrown value (from `node:fs`) as a one-line reason for a
72
+ * load error message.
73
+ *
74
+ * @param cause - Whatever the filesystem call threw.
75
+ * @returns The cause's message, or its string form for non-Error throws.
76
+ */
77
+ function describeCause(cause) {
78
+ if (cause instanceof Error)
79
+ return cause.message;
80
+ return String(cause);
81
+ }
@@ -0,0 +1,24 @@
1
+ /**
2
+ * The recursive-descent parser for `.spec` files: consumes the lexer's token
3
+ * stream and builds the AST one node per GRAMMAR.md production, enforcing the
4
+ * structural rules the grammar states in prose — phase order, `eventually`
5
+ * placement, call expressions only as a full right-hand side, unique object
6
+ * keys. Every failure is a `ParseError` value naming what was expected and
7
+ * what was found.
8
+ *
9
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
10
+ * @copyright Sergio Xalambrí 2026
11
+ */
12
+ import type { Result } from "@sdxc/result";
13
+ import type { SpecFileNode } from "./ast.js";
14
+ import type { SourceFile } from "./source.js";
15
+ import { ParseError } from "./errors.js";
16
+ /**
17
+ * Parse a `.spec` file into its AST, lexing it first. The result is either
18
+ * the complete `SpecFileNode` or the first `ParseError` encountered, which
19
+ * carries the file path and the span of the offending text.
20
+ *
21
+ * @param source - The file to parse.
22
+ * @returns The parsed file, or a `ParseError` describing the failure.
23
+ */
24
+ export declare function parse(source: SourceFile): Result<SpecFileNode, ParseError>;
package/dist/parser.js ADDED
@@ -0,0 +1,502 @@
1
+ /**
2
+ * The recursive-descent parser for `.spec` files: consumes the lexer's token
3
+ * stream and builds the AST one node per GRAMMAR.md production, enforcing the
4
+ * structural rules the grammar states in prose — phase order, `eventually`
5
+ * placement, call expressions only as a full right-hand side, unique object
6
+ * keys. Every failure is a `ParseError` value naming what was expected and
7
+ * what was found.
8
+ *
9
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
10
+ * @copyright Sergio Xalambrí 2026
11
+ */
12
+ import { failure, isFailure, success } from "@sdxc/result";
13
+ import { ParseError } from "./errors.js";
14
+ import { lex } from "./lexer.js";
15
+ /** The three test phases, in the only order the grammar admits. */
16
+ const PHASES = ["given", "when", "then"];
17
+ /** Token kinds that can begin an argument (plus the `true`/`false` keywords). */
18
+ const ARGUMENT_START_KINDS = new Set([
19
+ "string",
20
+ "multiline-string",
21
+ "number",
22
+ "duration",
23
+ "identifier",
24
+ "lbrace",
25
+ ]);
26
+ /**
27
+ * Parse a `.spec` file into its AST, lexing it first. The result is either
28
+ * the complete `SpecFileNode` or the first `ParseError` encountered, which
29
+ * carries the file path and the span of the offending text.
30
+ *
31
+ * @param source - The file to parse.
32
+ * @returns The parsed file, or a `ParseError` describing the failure.
33
+ */
34
+ export function parse(source) {
35
+ let lexed = lex(source);
36
+ if (isFailure(lexed))
37
+ return lexed;
38
+ let tokens = lexed.data;
39
+ let position = 0;
40
+ /** The token under the cursor; the stream always ends with `eof`. */
41
+ function current() {
42
+ let token = tokens[position];
43
+ if (token)
44
+ return token;
45
+ let last = tokens[tokens.length - 1];
46
+ if (last)
47
+ return last;
48
+ throw new ParseError("Unexpected end of input.", source.path);
49
+ }
50
+ /** Consume and return the current token; `eof` is never consumed. */
51
+ function advance() {
52
+ let token = current();
53
+ if (token.kind !== "eof")
54
+ position += 1;
55
+ return token;
56
+ }
57
+ function check(kind) {
58
+ return current().kind === kind;
59
+ }
60
+ function checkKeyword(word) {
61
+ let token = current();
62
+ return token.kind === "keyword" && token.keyword === word;
63
+ }
64
+ function fail(message, span) {
65
+ throw new ParseError(message, source.path, span);
66
+ }
67
+ /** Abort naming what was expected and what the cursor actually found. */
68
+ function expected(what) {
69
+ let token = current();
70
+ fail(`Expected ${what}, found ${describeToken(token)}.`, token.span);
71
+ }
72
+ function expectKind(kind, what) {
73
+ if (!check(kind))
74
+ expected(what);
75
+ return advance();
76
+ }
77
+ function expectKeyword(word) {
78
+ if (!checkKeyword(word))
79
+ expected(`the keyword "${word}"`);
80
+ return advance();
81
+ }
82
+ /** Consume a plain (dot-free, non-reserved) identifier used as a name. */
83
+ function expectName(what) {
84
+ let token = current();
85
+ if (token.kind === "keyword") {
86
+ fail(`Expected ${what}, found the reserved keyword "${token.text}"; keywords cannot be used as names.`, token.span);
87
+ }
88
+ if (token.kind !== "identifier")
89
+ expected(what);
90
+ if (token.text.includes(".")) {
91
+ fail(`Expected ${what}, found the dotted name "${token.text}"; ${what} cannot contain dots.`, token.span);
92
+ }
93
+ return advance();
94
+ }
95
+ /** Skip a run of newline tokens (blank lines are insignificant). */
96
+ function skipNewlines() {
97
+ while (check("newline"))
98
+ advance();
99
+ }
100
+ /** A statement ends at a newline or at the block's closing brace. */
101
+ function expectStatementEnd() {
102
+ if (check("newline")) {
103
+ skipNewlines();
104
+ return;
105
+ }
106
+ if (check("rbrace") || check("eof"))
107
+ return;
108
+ expected("a newline to end the statement");
109
+ }
110
+ /** A top-level item ends at a newline or at the end of the file. */
111
+ function expectTopLevelEnd() {
112
+ if (check("newline")) {
113
+ skipNewlines();
114
+ return;
115
+ }
116
+ if (check("eof"))
117
+ return;
118
+ expected("a newline after the declaration");
119
+ }
120
+ /** file = { use | definition | test } */
121
+ function parseFile() {
122
+ let uses = [];
123
+ let definitions = [];
124
+ let tests = [];
125
+ skipNewlines();
126
+ while (!check("eof")) {
127
+ if (checkKeyword("use"))
128
+ uses.push(parseUse());
129
+ else if (checkKeyword("command"))
130
+ definitions.push(parseCommand());
131
+ else if (checkKeyword("fixture"))
132
+ definitions.push(parseFixture());
133
+ else if (checkKeyword("test"))
134
+ tests.push(parseTest());
135
+ else
136
+ expected('"use", "command", "fixture", or "test" at the top level');
137
+ expectTopLevelEnd();
138
+ }
139
+ return { path: source.path, uses, definitions, tests };
140
+ }
141
+ /** use = "use" IDENT */
142
+ function parseUse() {
143
+ let keyword = expectKeyword("use");
144
+ let name = expectName("a namespace name");
145
+ return { namespace: name.text, span: { start: keyword.span.start, end: name.span.end } };
146
+ }
147
+ /** command = "command" IDENT [ "(" [ params ] ")" ] block */
148
+ function parseCommand() {
149
+ let keyword = expectKeyword("command");
150
+ let name = expectName("a command name");
151
+ let params = [];
152
+ if (check("lparen")) {
153
+ advance();
154
+ skipNewlines();
155
+ if (!check("rparen")) {
156
+ while (true) {
157
+ params.push(expectName("a parameter name").text);
158
+ skipNewlines();
159
+ if (check("comma")) {
160
+ advance();
161
+ skipNewlines();
162
+ continue;
163
+ }
164
+ break;
165
+ }
166
+ }
167
+ expectKind("rparen", '")" to close the parameter list');
168
+ }
169
+ let body = parseBlock(false);
170
+ return {
171
+ kind: "command",
172
+ name: name.text,
173
+ params,
174
+ body,
175
+ span: { start: keyword.span.start, end: body.span.end },
176
+ };
177
+ }
178
+ /** fixture = "fixture" IDENT block */
179
+ function parseFixture() {
180
+ let keyword = expectKeyword("fixture");
181
+ let name = expectName("a fixture name");
182
+ let body = parseBlock(false);
183
+ return {
184
+ kind: "fixture",
185
+ name: name.text,
186
+ body,
187
+ span: { start: keyword.span.start, end: body.span.end },
188
+ };
189
+ }
190
+ /** test = "test" STRING "{" [ given ] [ when ] [ then ] "}" — ≥1 phase. */
191
+ function parseTest() {
192
+ let keyword = expectKeyword("test");
193
+ let title = expectKind("string", "a test title string");
194
+ expectKind("lbrace", '"{" to open the test');
195
+ skipNewlines();
196
+ let blocks = new Map();
197
+ let lastPhase;
198
+ while (!check("rbrace")) {
199
+ let token = current();
200
+ let phase = PHASES.find((name) => checkKeyword(name));
201
+ if (!phase)
202
+ expected('"given", "when", "then", or "}"');
203
+ if (blocks.has(phase)) {
204
+ fail(`The "${phase}" phase appears more than once; each phase may appear at most once per test.`, token.span);
205
+ }
206
+ if (lastPhase && PHASES.indexOf(phase) < PHASES.indexOf(lastPhase)) {
207
+ fail(`The "${phase}" phase cannot follow "${lastPhase}"; phases run in given, when, then order.`, token.span);
208
+ }
209
+ advance();
210
+ blocks.set(phase, parseBlock(phase === "then"));
211
+ lastPhase = phase;
212
+ skipNewlines();
213
+ }
214
+ let close = advance();
215
+ let span = { start: keyword.span.start, end: close.span.end };
216
+ if (blocks.size === 0) {
217
+ fail("A test must contain at least one phase (given, when, or then).", span);
218
+ }
219
+ let node = { title: stringValue(title), span };
220
+ let given = blocks.get("given");
221
+ let when = blocks.get("when");
222
+ let then = blocks.get("then");
223
+ if (given)
224
+ node.given = given;
225
+ if (when)
226
+ node.when = when;
227
+ // oxlint-disable-next-line unicorn/no-thenable -- the grammar names the phase "then"; a TestNode is never awaited.
228
+ if (then)
229
+ node.then = then;
230
+ return node;
231
+ }
232
+ /** block = "{" { statement } "}" — newlines ignored after `{`/before `}`. */
233
+ function parseBlock(allowEventually) {
234
+ let open = expectKind("lbrace", '"{" to open a block');
235
+ skipNewlines();
236
+ let statements = [];
237
+ while (!check("rbrace")) {
238
+ if (check("eof"))
239
+ expected('"}" to close the block');
240
+ statements.push(parseStatement(allowEventually));
241
+ expectStatementEnd();
242
+ }
243
+ let close = advance();
244
+ return { statements, span: { start: open.span.start, end: close.span.end } };
245
+ }
246
+ /** statement = let | return | expect | eventually | call */
247
+ function parseStatement(allowEventually) {
248
+ if (checkKeyword("let"))
249
+ return parseLet();
250
+ if (checkKeyword("return"))
251
+ return parseReturn();
252
+ if (checkKeyword("expect"))
253
+ return parseExpect();
254
+ if (checkKeyword("eventually")) {
255
+ if (!allowEventually) {
256
+ fail('"eventually" is only valid directly inside a "then" block.', current().span);
257
+ }
258
+ return parseEventually();
259
+ }
260
+ if (check("identifier"))
261
+ return parseCall();
262
+ return expected('a statement ("let", "return", "expect", "eventually", or a call)');
263
+ }
264
+ /** let = "let" IDENT "=" rhs */
265
+ function parseLet() {
266
+ let keyword = expectKeyword("let");
267
+ let name = expectName("a binding name");
268
+ expectKind("equals", '"=" after the binding name');
269
+ let value = parseRhs();
270
+ return {
271
+ kind: "let",
272
+ name: name.text,
273
+ value,
274
+ span: { start: keyword.span.start, end: value.span.end },
275
+ };
276
+ }
277
+ /** return = "return" rhs */
278
+ function parseReturn() {
279
+ let keyword = expectKeyword("return");
280
+ let value = parseRhs();
281
+ return { kind: "return", value, span: { start: keyword.span.start, end: value.span.end } };
282
+ }
283
+ /**
284
+ * rhs = call-expr | expression. A `PATH` here with arguments is a call
285
+ * expression, without arguments a reference — the only place the grammar
286
+ * allows a value-producing invocation.
287
+ */
288
+ function parseRhs() {
289
+ if (checkKeyword("fixture")) {
290
+ let keyword = advance();
291
+ let name = expectName("a fixture name");
292
+ return {
293
+ kind: "fixture-call",
294
+ name: name.text,
295
+ span: { start: keyword.span.start, end: name.span.end },
296
+ };
297
+ }
298
+ if (check("identifier")) {
299
+ let target = advance();
300
+ if (!atArgumentStart())
301
+ return referenceFrom(target);
302
+ let args = parseArguments();
303
+ let last = args[args.length - 1];
304
+ return {
305
+ kind: "call-expr",
306
+ target: target.text,
307
+ args,
308
+ span: { start: target.span.start, end: last ? last.span.end : target.span.end },
309
+ };
310
+ }
311
+ return parseExpression();
312
+ }
313
+ function atArgumentStart() {
314
+ let token = current();
315
+ if (token.kind === "keyword")
316
+ return token.keyword === "true" || token.keyword === "false";
317
+ return ARGUMENT_START_KINDS.has(token.kind);
318
+ }
319
+ /** argument list: as many arguments as the line offers, possibly none. */
320
+ function parseArguments() {
321
+ let args = [];
322
+ while (atArgumentStart())
323
+ args.push(parseArgument());
324
+ return args;
325
+ }
326
+ /** argument = expression | word — a bare identifier here is a word. */
327
+ function parseArgument() {
328
+ let token = current();
329
+ if (token.kind === "identifier" && !token.text.includes(".")) {
330
+ advance();
331
+ return { kind: "word", word: token.text, span: token.span };
332
+ }
333
+ return parseExpression();
334
+ }
335
+ /** expression = literal | object | PATH-as-reference */
336
+ function parseExpression() {
337
+ let token = current();
338
+ if (token.kind === "string" || token.kind === "multiline-string") {
339
+ advance();
340
+ return { kind: "string", value: stringValue(token), span: token.span };
341
+ }
342
+ if (token.kind === "number") {
343
+ advance();
344
+ return { kind: "number", value: numberValue(token), span: token.span };
345
+ }
346
+ if (token.kind === "duration") {
347
+ advance();
348
+ return { kind: "duration", milliseconds: numberValue(token), span: token.span };
349
+ }
350
+ if (token.kind === "keyword" && (token.keyword === "true" || token.keyword === "false")) {
351
+ advance();
352
+ return { kind: "boolean", value: token.keyword === "true", span: token.span };
353
+ }
354
+ if (token.kind === "lbrace")
355
+ return parseObject();
356
+ if (token.kind === "identifier") {
357
+ advance();
358
+ return referenceFrom(token);
359
+ }
360
+ return expected("an expression (a literal, an object, or a reference)");
361
+ }
362
+ /** A dotted identifier token as a reference into bindings. */
363
+ function referenceFrom(token) {
364
+ return { kind: "reference", path: token.text.split("."), span: token.span };
365
+ }
366
+ /** object = "{" [ entry { entry-sep entry } ] "}" — keys must be unique. */
367
+ function parseObject() {
368
+ let open = expectKind("lbrace", '"{" to open an object literal');
369
+ skipNewlines();
370
+ let entries = [];
371
+ let seen = new Set();
372
+ while (!check("rbrace")) {
373
+ let entry = parseObjectEntry();
374
+ if (seen.has(entry.key))
375
+ fail(`Duplicate key "${entry.key}" in object literal.`, entry.span);
376
+ seen.add(entry.key);
377
+ entries.push(entry);
378
+ let separated = false;
379
+ if (check("newline")) {
380
+ skipNewlines();
381
+ separated = true;
382
+ }
383
+ if (check("comma")) {
384
+ advance();
385
+ skipNewlines();
386
+ if (check("rbrace"))
387
+ expected('an object key after ","');
388
+ separated = true;
389
+ }
390
+ if (check("rbrace"))
391
+ break;
392
+ if (!separated) {
393
+ expected('"," or a newline between object entries, or "}" to close the object');
394
+ }
395
+ }
396
+ let close = expectKind("rbrace", '"}" to close the object literal');
397
+ return { kind: "object", entries, span: { start: open.span.start, end: close.span.end } };
398
+ }
399
+ /** entry = ( IDENT | STRING ) ":" expression */
400
+ function parseObjectEntry() {
401
+ let token = current();
402
+ let key;
403
+ if (token.kind === "identifier" && !token.text.includes(".")) {
404
+ advance();
405
+ key = token.text;
406
+ }
407
+ else if (token.kind === "string") {
408
+ advance();
409
+ key = stringValue(token);
410
+ }
411
+ else if (token.kind === "keyword") {
412
+ fail(`Expected an object key, found the reserved keyword "${token.text}"; quote it ("${token.text}") to use it as a key.`, token.span);
413
+ }
414
+ else {
415
+ return expected("an object key (an identifier or a string)");
416
+ }
417
+ expectKind("colon", '":" after the object key');
418
+ let value = parseExpression();
419
+ return { key, value, span: { start: token.span.start, end: value.span.end } };
420
+ }
421
+ /** expect = "expect" argument { argument } — at least one argument. */
422
+ function parseExpect() {
423
+ let keyword = expectKeyword("expect");
424
+ if (!atArgumentStart())
425
+ expected('at least one argument to "expect"');
426
+ let args = parseArguments();
427
+ let last = args[args.length - 1];
428
+ return {
429
+ kind: "expect",
430
+ args,
431
+ span: { start: keyword.span.start, end: last ? last.span.end : keyword.span.end },
432
+ };
433
+ }
434
+ /** eventually = "eventually" [ "within" DURATION ] block */
435
+ function parseEventually() {
436
+ let keyword = expectKeyword("eventually");
437
+ let withinMs;
438
+ if (checkKeyword("within")) {
439
+ advance();
440
+ let duration = expectKind("duration", 'a duration (like 10s) after "within"');
441
+ withinMs = numberValue(duration);
442
+ }
443
+ let block = parseBlock(false);
444
+ let node = {
445
+ kind: "eventually",
446
+ block,
447
+ span: { start: keyword.span.start, end: block.span.end },
448
+ };
449
+ if (withinMs !== undefined)
450
+ node.withinMs = withinMs;
451
+ return node;
452
+ }
453
+ /** call = PATH { argument } — a statement-position invocation. */
454
+ function parseCall() {
455
+ let target = expectKind("identifier", "a call target");
456
+ let args = parseArguments();
457
+ let last = args[args.length - 1];
458
+ return {
459
+ kind: "call",
460
+ target: target.text,
461
+ args,
462
+ span: { start: target.span.start, end: last ? last.span.end : target.span.end },
463
+ };
464
+ }
465
+ try {
466
+ return success(parseFile());
467
+ }
468
+ catch (error) {
469
+ if (error instanceof ParseError)
470
+ return failure(error);
471
+ let message = error instanceof Error ? error.message : String(error);
472
+ return failure(new ParseError(message, source.path));
473
+ }
474
+ }
475
+ /** Render a token for an error message, the way a reader would name it. */
476
+ function describeToken(token) {
477
+ if (token.kind === "eof")
478
+ return "the end of the file";
479
+ if (token.kind === "newline")
480
+ return "a line break";
481
+ if (token.kind === "keyword")
482
+ return `the keyword "${token.text}"`;
483
+ if (token.kind === "identifier")
484
+ return `"${token.text}"`;
485
+ if (token.kind === "string")
486
+ return "a string";
487
+ if (token.kind === "multiline-string")
488
+ return "a multiline string";
489
+ if (token.kind === "number")
490
+ return `the number ${token.text}`;
491
+ if (token.kind === "duration")
492
+ return `the duration ${token.text}`;
493
+ return `"${token.text}"`;
494
+ }
495
+ /** The decoded payload of a string-like token; the lexer always sets it. */
496
+ function stringValue(token) {
497
+ return typeof token.value === "string" ? token.value : token.text;
498
+ }
499
+ /** The numeric payload of a number or duration token; the lexer always sets it. */
500
+ function numberValue(token) {
501
+ return typeof token.value === "number" ? token.value : Number(token.text);
502
+ }