@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,61 @@
1
+ /**
2
+ * Assertion semantics: the two `expect` forms (value truthiness/equality and
3
+ * observable tools) and the `eventually` retry loop. The executor drives this
4
+ * module through the `ExpectationHost` seam, so expression evaluation and
5
+ * tool dispatch stay in the executor while the assertion rules live here.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Result } from "@sdxc/result";
11
+ import type { ArgumentNode, EventuallyNode, ExpectNode, ExpressionNode } from "./ast.js";
12
+ import type { Registry, ResolvedCallable } from "./registry.js";
13
+ import type { Span } from "./source.js";
14
+ import type { Value } from "./values.js";
15
+ import { SpecError } from "./errors.js";
16
+ /** Deadline of an `eventually` block with no `within` clause, in milliseconds. */
17
+ export declare const DEFAULT_EVENTUALLY_MS = 5000;
18
+ /** Pause between `eventually` attempts, in milliseconds. */
19
+ export declare const POLL_INTERVAL_MS = 100;
20
+ /**
21
+ * What `expect` and `eventually` need from their caller: the enclosing scope
22
+ * for binding lookups, the suite's resolution table, and the executor's
23
+ * dispatch seam, which owns argument evaluation and the central permission gate.
24
+ */
25
+ export interface ExpectationHost {
26
+ /** The enclosing scope, for binding lookups and ambiguity detection. */
27
+ scope: Map<string, Value>;
28
+ /** The suite's name-resolution table. */
29
+ registry: Registry;
30
+ /** Namespaces imported by the calling file, in `use` order. */
31
+ uses: readonly string[];
32
+ /** Evaluate one expression in the enclosing scope. */
33
+ evaluate(expression: ExpressionNode): Result<Value, SpecError>;
34
+ /**
35
+ * Invoke a resolved tool with raw argument nodes. The implementation owns
36
+ * argument evaluation and the runtime's central permission gate.
37
+ */
38
+ callTool(tool: Extract<ResolvedCallable, {
39
+ kind: "tool";
40
+ }>, args: ArgumentNode[], span: Span): Promise<Result<Value, SpecError>>;
41
+ }
42
+ /**
43
+ * Execute one `expect` statement. The first argument decides the form: bound
44
+ * and callable is `ambiguous-name`, a bound name (or literal) selects the
45
+ * value form, and a callable observable tool selects the observable form.
46
+ *
47
+ * @param node - The `expect` statement to execute.
48
+ * @param host - The executor-provided scope, registry, and dispatch seam.
49
+ * @returns Success when the assertion held, otherwise the structured failure.
50
+ */
51
+ export declare function executeExpect(node: ExpectNode, host: ExpectationHost): Promise<Result<undefined, SpecError>>;
52
+ /**
53
+ * Execute one `eventually` block: only `expect` statements and observable
54
+ * calls may appear, since a retried mutation is not a retried assertion. Every
55
+ * name resolves once before the first attempt, because `let` is banned inside the block.
56
+ *
57
+ * @param node - The `eventually` statement to execute.
58
+ * @param host - The executor-provided scope, registry, and dispatch seam.
59
+ * @returns Success once an attempt fully passed, otherwise the last failure.
60
+ */
61
+ export declare function executeEventually(node: EventuallyNode, host: ExpectationHost): Promise<Result<undefined, SpecError>>;
@@ -0,0 +1,222 @@
1
+ /**
2
+ * Assertion semantics: the two `expect` forms (value truthiness/equality and
3
+ * observable tools) and the `eventually` retry loop. The executor drives this
4
+ * module through the `ExpectationHost` seam, so expression evaluation and
5
+ * tool dispatch stay in the executor while the assertion rules live here.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import { failure, isFailure, isSuccess, success } from "@sdxc/result";
11
+ import { ExpectationError, ResolutionError, SpecError, ToolError } from "./errors.js";
12
+ import { formatValue, valueEquals } from "./values.js";
13
+ /** Deadline of an `eventually` block with no `within` clause, in milliseconds. */
14
+ export const DEFAULT_EVENTUALLY_MS = 5000;
15
+ /** Pause between `eventually` attempts, in milliseconds. */
16
+ export const POLL_INTERVAL_MS = 100;
17
+ /**
18
+ * Execute one `expect` statement. The first argument decides the form: bound
19
+ * and callable is `ambiguous-name`, a bound name (or literal) selects the
20
+ * value form, and a callable observable tool selects the observable form.
21
+ *
22
+ * @param node - The `expect` statement to execute.
23
+ * @param host - The executor-provided scope, registry, and dispatch seam.
24
+ * @returns Success when the assertion held, otherwise the structured failure.
25
+ */
26
+ export async function executeExpect(node, host) {
27
+ let head = node.args[0];
28
+ if (head === undefined) {
29
+ return failure(anchor(new SpecError("usage-error", "expect needs at least one argument"), node.span));
30
+ }
31
+ let resolved = resolveExpectMode(head, host);
32
+ if (isFailure(resolved))
33
+ return resolved;
34
+ if (resolved.data.mode === "observable") {
35
+ return executeObservableExpect(node, resolved.data.tool, host);
36
+ }
37
+ return executeValueExpect(node, head, host);
38
+ }
39
+ /**
40
+ * Execute one `eventually` block: only `expect` statements and observable
41
+ * calls may appear, since a retried mutation is not a retried assertion. Every
42
+ * name resolves once before the first attempt, because `let` is banned inside the block.
43
+ *
44
+ * @param node - The `eventually` statement to execute.
45
+ * @param host - The executor-provided scope, registry, and dispatch seam.
46
+ * @returns Success once an attempt fully passed, otherwise the last failure.
47
+ */
48
+ export async function executeEventually(node, host) {
49
+ let attempts = [];
50
+ for (let statement of node.block.statements) {
51
+ if (statement.kind === "expect") {
52
+ let expectNode = statement;
53
+ let head = expectNode.args[0];
54
+ if (head === undefined) {
55
+ return failure(anchor(new SpecError("usage-error", "expect needs at least one argument"), expectNode.span));
56
+ }
57
+ let headArgument = head;
58
+ let resolved = resolveExpectMode(headArgument, host);
59
+ if (isFailure(resolved))
60
+ return resolved;
61
+ let mode = resolved.data;
62
+ if (mode.mode === "observable") {
63
+ let tool = mode.tool;
64
+ attempts.push(() => executeObservableExpect(expectNode, tool, host));
65
+ }
66
+ else {
67
+ attempts.push(async () => executeValueExpect(expectNode, headArgument, host));
68
+ }
69
+ continue;
70
+ }
71
+ if (statement.kind === "call") {
72
+ let callNode = statement;
73
+ let resolved = host.registry.resolveCallable(callNode.target, host.uses);
74
+ if (isFailure(resolved))
75
+ return failure(anchor(resolved.error, callNode.span));
76
+ if (resolved.data.kind !== "tool" || resolved.data.descriptor.kind !== "observable") {
77
+ return failure(anchor(new SpecError("usage-error", `Only expect statements and observable calls may appear inside eventually; "${callNode.target}" is not an observable`), callNode.span));
78
+ }
79
+ let tool = resolved.data;
80
+ attempts.push(async () => {
81
+ let result = await host.callTool(tool, callNode.args, callNode.span);
82
+ if (isFailure(result))
83
+ return failure(anchor(result.error, callNode.span));
84
+ /**
85
+ * A bare observable is still an assertion: `false` fails the
86
+ * attempt exactly as it fails the expect form of the same call.
87
+ */
88
+ if (result.data === false) {
89
+ return failure(anchor(new ExpectationError(`Expected ${qualifiedName(tool)} to hold, observed false`, true, false), callNode.span));
90
+ }
91
+ return success(undefined);
92
+ });
93
+ continue;
94
+ }
95
+ return failure(anchor(new SpecError("usage-error", `Only expect statements and observable calls may appear inside eventually; found a ${statement.kind} statement`), statement.span));
96
+ }
97
+ let deadline = Date.now() + (node.withinMs ?? DEFAULT_EVENTUALLY_MS);
98
+ while (true) {
99
+ let error = await runAttempt(attempts);
100
+ if (error === undefined)
101
+ return success(undefined);
102
+ if (Date.now() >= deadline)
103
+ return failure(anchor(error, node.span));
104
+ await sleep(POLL_INTERVAL_MS);
105
+ }
106
+ }
107
+ /** Run one full attempt of an `eventually` block; the first failure ends it. */
108
+ async function runAttempt(attempts) {
109
+ for (let attempt of attempts) {
110
+ let result = await attempt();
111
+ if (isFailure(result))
112
+ return result.error;
113
+ }
114
+ return undefined;
115
+ }
116
+ /**
117
+ * Decide which `expect` form the first argument selects. Words and
118
+ * one-segment references are treated alike: bound and callable is ambiguous,
119
+ * bound alone is the value form, callable alone must be an observable tool.
120
+ */
121
+ function resolveExpectMode(head, host) {
122
+ let name;
123
+ let headBinding;
124
+ if (head.kind === "word") {
125
+ name = head.word;
126
+ headBinding = head.word;
127
+ }
128
+ else if (head.kind === "reference") {
129
+ let first = head.path[0];
130
+ if (first === undefined) {
131
+ return failure(anchor(new ResolutionError("unknown-name", "expect received an empty reference"), head.span));
132
+ }
133
+ name = head.path.join(".");
134
+ headBinding = first;
135
+ }
136
+ else {
137
+ return success({ mode: "value" });
138
+ }
139
+ let bound = host.scope.has(headBinding);
140
+ let resolved = host.registry.resolveCallable(name, host.uses);
141
+ if (bound && isSuccess(resolved)) {
142
+ return failure(anchor(new ResolutionError("ambiguous-name", `"${name}" is both a binding and a callable; the runtime never guesses — rename the binding or qualify the tool`, [qualifiedName(resolved.data)]), head.span));
143
+ }
144
+ if (bound)
145
+ return success({ mode: "value" });
146
+ if (isSuccess(resolved)) {
147
+ if (resolved.data.kind !== "tool" || resolved.data.descriptor.kind !== "observable") {
148
+ return failure(anchor(new ToolError(`"${qualifiedName(resolved.data)}" is not an observable; only observable tools can head an expect`), head.span));
149
+ }
150
+ return success({ mode: "observable", tool: resolved.data });
151
+ }
152
+ return failure(anchor(resolved.error, head.span));
153
+ }
154
+ /**
155
+ * The value form: `expect A` asserts truthiness, `expect A B` asserts deep
156
+ * structural equality, anything longer is a usage error.
157
+ */
158
+ function executeValueExpect(node, head, host) {
159
+ if (node.args.length > 2) {
160
+ return failure(anchor(new SpecError("usage-error", "value-form expect takes at most two arguments: a value and an optional expected value"), node.span));
161
+ }
162
+ let observed = evaluateValueArgument(head, host);
163
+ if (isFailure(observed))
164
+ return observed;
165
+ let expectedArgument = node.args[1];
166
+ if (expectedArgument === undefined) {
167
+ if (observed.data)
168
+ return success(undefined);
169
+ return failure(anchor(new ExpectationError(`Expected a truthy value, observed ${formatValue(observed.data)}`, undefined, observed.data), node.span));
170
+ }
171
+ let expected = evaluateValueArgument(expectedArgument, host);
172
+ if (isFailure(expected))
173
+ return expected;
174
+ if (valueEquals(observed.data, expected.data))
175
+ return success(undefined);
176
+ return failure(anchor(new ExpectationError(`Expected ${formatValue(expected.data)}, observed ${formatValue(observed.data)}`, expected.data, observed.data), node.span));
177
+ }
178
+ /**
179
+ * The observable form: call the tool with the remaining arguments; its own
180
+ * failure propagates, and a plain `false` return becomes an expectation
181
+ * failure.
182
+ */
183
+ async function executeObservableExpect(node, tool, host) {
184
+ let result = await host.callTool(tool, node.args.slice(1), node.span);
185
+ if (isFailure(result))
186
+ return failure(anchor(result.error, node.span));
187
+ if (result.data === false) {
188
+ return failure(anchor(new ExpectationError(`Expected ${qualifiedName(tool)} to hold, observed false`, true, false), node.span));
189
+ }
190
+ return success(undefined);
191
+ }
192
+ /**
193
+ * Evaluate one argument as a value: expressions evaluate in the scope, and a
194
+ * bare word reads the binding of the same spelling — words are only symbolic
195
+ * when a tool receives them.
196
+ */
197
+ function evaluateValueArgument(argument, host) {
198
+ if (argument.kind !== "word")
199
+ return host.evaluate(argument);
200
+ if (!host.scope.has(argument.word)) {
201
+ return failure(anchor(new ResolutionError("unknown-name", `Unknown name "${argument.word}" — nothing is bound under it`), argument.span));
202
+ }
203
+ return success(host.scope.get(argument.word) ?? null);
204
+ }
205
+ /** The fully qualified spelling of a resolved callable, for diagnostics. */
206
+ function qualifiedName(callable) {
207
+ if (callable.kind === "tool")
208
+ return `${callable.namespace}.${callable.descriptor.name}`;
209
+ return callable.command.name;
210
+ }
211
+ /** Stamp a span onto an error that does not carry one yet. */
212
+ function anchor(error, span) {
213
+ if (error.span === undefined)
214
+ error.span = span;
215
+ return error;
216
+ }
217
+ /** Resolve after the given pause, for the `eventually` poll loop. */
218
+ function sleep(milliseconds) {
219
+ return new Promise((resolve) => {
220
+ setTimeout(() => resolve(undefined), milliseconds);
221
+ });
222
+ }
@@ -0,0 +1,51 @@
1
+ /**
2
+ * Public surface of `@sdxc/spec`, the executable specification runtime
3
+ * consumed by the `spec` CLI and available to programmatic embedders. This
4
+ * entry point assumes a Bun or Node process because it reaches the
5
+ * filesystem and spawns processes; a runtime without those imports
6
+ * `@sdxc/spec/workers` for the same language core with a smaller capability set.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ export type * from "./ast.js";
12
+ export { BUILTIN_NAMESPACES, createBuiltinPlugins } from "./builtins.js";
13
+ export type { BuiltinNamespace } from "./builtins.js";
14
+ export type { Sink, SuiteResult, TestResult, TestStatus } from "./diagnostics.js";
15
+ export { ExpectationError, LoadError, ParseError, PermissionDeniedError, ResolutionError, SpecError, ToolError, WorkspaceEscapeError, } from "./errors.js";
16
+ export type { DiagnosticCode } from "./errors.js";
17
+ export { executeTest } from "./executor.js";
18
+ export type { ExecutionContext } from "./executor.js";
19
+ export { lex } from "./lexer.js";
20
+ export { loadSuite } from "./loader.js";
21
+ export { parse } from "./parser.js";
22
+ export { createPermissionSet, parseGrants } from "./permissions.js";
23
+ export type { Grant, Grants, PermissionKind, PermissionSet } from "./permissions.js";
24
+ export type { Plugin, ToolContext, ToolDescriptor, ToolParam } from "./plugin.js";
25
+ export { createBrowserPlugin } from "./plugins/browser.js";
26
+ export { createCliPlugin } from "./plugins/cli.js";
27
+ export { createDbPlugin } from "./plugins/db.js";
28
+ export { createEnvPlugin } from "./plugins/env.js";
29
+ export { createFsPlugin } from "./plugins/fs.js";
30
+ export { createHttpPlugin } from "./plugins/http.js";
31
+ export { createJwtPlugin } from "./plugins/jwt.js";
32
+ export { createUrlPlugin } from "./plugins/url.js";
33
+ export { createRegistry } from "./registry.js";
34
+ export type { Registry, ResolvedCallable } from "./registry.js";
35
+ export { reportFatal, reportSuite } from "./reporter.js";
36
+ export { runTests } from "./run.js";
37
+ export type { RunTestsOptions, WorkspaceFactory } from "./run.js";
38
+ export { runSuite } from "./runner.js";
39
+ export type { RunOptions } from "./runner.js";
40
+ export { positionAt } from "./source.js";
41
+ export type { Position, SourceFile, Span } from "./source.js";
42
+ export { loadSources } from "./sources.js";
43
+ export type { LoadedSuite, SpecSource } from "./sources.js";
44
+ export type { Token, TokenKind } from "./tokens.js";
45
+ export { KEYWORDS } from "./tokens.js";
46
+ export { connectStdioPlugin, servePlugin } from "./transport-stdio.js";
47
+ export { formatValue, valueEquals } from "./values.js";
48
+ export type { ToolArg, Value, ValueObject } from "./values.js";
49
+ export { createWorkspace } from "./workspace.js";
50
+ export type { Workspace } from "./workspace.js";
51
+ export { createNoFilesystemWorkspace } from "./workspace-none.js";
package/dist/index.js ADDED
@@ -0,0 +1,36 @@
1
+ /**
2
+ * Public surface of `@sdxc/spec`, the executable specification runtime
3
+ * consumed by the `spec` CLI and available to programmatic embedders. This
4
+ * entry point assumes a Bun or Node process because it reaches the
5
+ * filesystem and spawns processes; a runtime without those imports
6
+ * `@sdxc/spec/workers` for the same language core with a smaller capability set.
7
+ *
8
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
9
+ * @copyright Sergio Xalambrí 2026
10
+ */
11
+ export { BUILTIN_NAMESPACES, createBuiltinPlugins } from "./builtins.js";
12
+ export { ExpectationError, LoadError, ParseError, PermissionDeniedError, ResolutionError, SpecError, ToolError, WorkspaceEscapeError, } from "./errors.js";
13
+ export { executeTest } from "./executor.js";
14
+ export { lex } from "./lexer.js";
15
+ export { loadSuite } from "./loader.js";
16
+ export { parse } from "./parser.js";
17
+ export { createPermissionSet, parseGrants } from "./permissions.js";
18
+ export { createBrowserPlugin } from "./plugins/browser.js";
19
+ export { createCliPlugin } from "./plugins/cli.js";
20
+ export { createDbPlugin } from "./plugins/db.js";
21
+ export { createEnvPlugin } from "./plugins/env.js";
22
+ export { createFsPlugin } from "./plugins/fs.js";
23
+ export { createHttpPlugin } from "./plugins/http.js";
24
+ export { createJwtPlugin } from "./plugins/jwt.js";
25
+ export { createUrlPlugin } from "./plugins/url.js";
26
+ export { createRegistry } from "./registry.js";
27
+ export { reportFatal, reportSuite } from "./reporter.js";
28
+ export { runTests } from "./run.js";
29
+ export { runSuite } from "./runner.js";
30
+ export { positionAt } from "./source.js";
31
+ export { loadSources } from "./sources.js";
32
+ export { KEYWORDS } from "./tokens.js";
33
+ export { connectStdioPlugin, servePlugin } from "./transport-stdio.js";
34
+ export { formatValue, valueEquals } from "./values.js";
35
+ export { createWorkspace } from "./workspace.js";
36
+ export { createNoFilesystemWorkspace } from "./workspace-none.js";
@@ -0,0 +1,22 @@
1
+ /**
2
+ * The lexer for `.spec` source text: turns a file into the flat token stream
3
+ * GRAMMAR.md's token table defines — literals, dotted identifiers, keywords,
4
+ * punctuation, and significant newlines — with every failure returned as a
5
+ * `ParseError` value.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import type { Result } from "@sdxc/result";
11
+ import type { SourceFile } from "./source.js";
12
+ import type { Token } from "./tokens.js";
13
+ import { ParseError } from "./errors.js";
14
+ /**
15
+ * Tokenize a `.spec` file per GRAMMAR.md's lexical rules: comments are
16
+ * discarded, newlines collapse into single tokens, dotted identifiers merge,
17
+ * and durations are validated and converted to milliseconds at lex time.
18
+ *
19
+ * @param source - The file to tokenize.
20
+ * @returns The token stream, or a `ParseError` pointing at the offending text.
21
+ */
22
+ export declare function lex(source: SourceFile): Result<Token[], ParseError>;
package/dist/lexer.js ADDED
@@ -0,0 +1,284 @@
1
+ /**
2
+ * The lexer for `.spec` source text: turns a file into the flat token stream
3
+ * GRAMMAR.md's token table defines — literals, dotted identifiers, keywords,
4
+ * punctuation, and significant newlines — with every failure returned as a
5
+ * `ParseError` value.
6
+ *
7
+ * @author [Sergio Xalambrí](https://sergiodxa.com)
8
+ * @copyright Sergio Xalambrí 2026
9
+ */
10
+ import { parse as parseDuration } from "@sdxc/duration";
11
+ import { failure, isFailure, success } from "@sdxc/result";
12
+ import { ParseError } from "./errors.js";
13
+ import { KEYWORDS } from "./tokens.js";
14
+ /** Single-character punctuation, mapped to the token kind each one lexes as. */
15
+ const PUNCTUATION = {
16
+ "{": "lbrace",
17
+ "}": "rbrace",
18
+ "(": "lparen",
19
+ ")": "rparen",
20
+ ",": "comma",
21
+ ":": "colon",
22
+ "=": "equals",
23
+ };
24
+ /** The escape sequences a single-line string accepts, by escaped character. */
25
+ const STRING_ESCAPES = {
26
+ '"': '"',
27
+ "\\": "\\",
28
+ n: "\n",
29
+ t: "\t",
30
+ r: "\r",
31
+ };
32
+ /** Matches a line that is empty or contains only horizontal whitespace. */
33
+ const BLANK_LINE = /^[ \t\r]*$/;
34
+ /** Matches the leading horizontal whitespace of a line. */
35
+ const LEADING_WHITESPACE = /^[ \t]*/;
36
+ /**
37
+ * Tokenize a `.spec` file per GRAMMAR.md's lexical rules: comments are
38
+ * discarded, newlines collapse into single tokens, dotted identifiers merge,
39
+ * and durations are validated and converted to milliseconds at lex time.
40
+ *
41
+ * @param source - The file to tokenize.
42
+ * @returns The token stream, or a `ParseError` pointing at the offending text.
43
+ */
44
+ export function lex(source) {
45
+ let text = source.text;
46
+ let tokens = [];
47
+ let index = 0;
48
+ /** Abort lexing with a `ParseError` pointing at the offending range. */
49
+ function fail(message, start, end) {
50
+ throw new ParseError(message, source.path, { start, end: Math.max(end, start + 1) });
51
+ }
52
+ /** Emit a newline token unless the previous token already is one. */
53
+ function pushNewline() {
54
+ let last = tokens[tokens.length - 1];
55
+ if (last && last.kind !== "newline") {
56
+ tokens.push({ kind: "newline", text: "\n", span: { start: index, end: index + 1 } });
57
+ }
58
+ index += 1;
59
+ }
60
+ /** Lex a `"…"` string, decoding its escape sequences. */
61
+ function readString() {
62
+ let start = index;
63
+ index += 1;
64
+ let value = "";
65
+ while (true) {
66
+ if (index >= text.length || text[index] === "\n") {
67
+ fail('Unterminated string: expected a closing `"` before the end of the line.', start, index);
68
+ }
69
+ let char = text[index] ?? "";
70
+ if (char === '"') {
71
+ index += 1;
72
+ break;
73
+ }
74
+ if (char === "\\") {
75
+ let escape = text[index + 1] ?? "";
76
+ let decoded = STRING_ESCAPES[escape];
77
+ if (decoded === undefined) {
78
+ fail(`Unknown escape sequence "\\${escape}" in string; expected \\" \\\\ \\n \\t or \\r.`, index, index + 2);
79
+ }
80
+ value += decoded;
81
+ index += 2;
82
+ continue;
83
+ }
84
+ value += char;
85
+ index += 1;
86
+ }
87
+ tokens.push({
88
+ kind: "string",
89
+ text: text.slice(start, index),
90
+ span: { start, end: index },
91
+ value,
92
+ });
93
+ }
94
+ /** Lex a `"""…"""` multiline string; content is raw, then dedented. */
95
+ function readMultilineString() {
96
+ let start = index;
97
+ index += 3;
98
+ let close = text.indexOf('"""', index);
99
+ if (close === -1) {
100
+ fail('Unterminated multiline string: expected a closing `"""`.', start, text.length);
101
+ }
102
+ let raw = text.slice(index, close);
103
+ index = close + 3;
104
+ tokens.push({
105
+ kind: "multiline-string",
106
+ text: text.slice(start, index),
107
+ span: { start, end: index },
108
+ value: dedentMultiline(raw),
109
+ });
110
+ }
111
+ /** Lex a number, or a duration when a unit is glued to the integer. */
112
+ function readNumberOrDuration() {
113
+ let start = index;
114
+ if (text[index] === "-")
115
+ index += 1;
116
+ while (isDigit(text[index] ?? ""))
117
+ index += 1;
118
+ let isInteger = true;
119
+ if (text[index] === "." && isDigit(text[index + 1] ?? "")) {
120
+ isInteger = false;
121
+ index += 1;
122
+ while (isDigit(text[index] ?? ""))
123
+ index += 1;
124
+ }
125
+ if (isIdentifierStart(text[index] ?? "")) {
126
+ while (isIdentifierPart(text[index] ?? ""))
127
+ index += 1;
128
+ let raw = text.slice(start, index);
129
+ if (!isInteger) {
130
+ fail(`Invalid duration "${raw}": the amount must be a whole number.`, start, index);
131
+ }
132
+ let parsed = parseDuration(raw);
133
+ if (isFailure(parsed)) {
134
+ fail(`Invalid duration "${raw}": expected an integer followed by a unit like ms, s, m, h, d, or w.`, start, index);
135
+ }
136
+ tokens.push({
137
+ kind: "duration",
138
+ text: raw,
139
+ span: { start, end: index },
140
+ value: parsed.data,
141
+ });
142
+ return;
143
+ }
144
+ let raw = text.slice(start, index);
145
+ tokens.push({ kind: "number", text: raw, span: { start, end: index }, value: Number(raw) });
146
+ }
147
+ /** Lex an identifier, a keyword, or a dotted path joined by adjacent dots. */
148
+ function readIdentifierOrKeyword() {
149
+ let start = index;
150
+ let segments = [];
151
+ while (true) {
152
+ let segmentStart = index;
153
+ while (isIdentifierPart(text[index] ?? ""))
154
+ index += 1;
155
+ segments.push(text.slice(segmentStart, index));
156
+ if (text[index] === "." && isIdentifierStart(text[index + 1] ?? "")) {
157
+ index += 1;
158
+ continue;
159
+ }
160
+ break;
161
+ }
162
+ let raw = text.slice(start, index);
163
+ if (segments.length === 1) {
164
+ let keyword = asKeyword(raw);
165
+ if (keyword) {
166
+ tokens.push({ kind: "keyword", text: raw, span: { start, end: index }, keyword });
167
+ return;
168
+ }
169
+ tokens.push({ kind: "identifier", text: raw, span: { start, end: index } });
170
+ return;
171
+ }
172
+ for (let segment of segments) {
173
+ if (asKeyword(segment)) {
174
+ fail(`The keyword "${segment}" is reserved and cannot appear in the dotted name "${raw}".`, start, index);
175
+ }
176
+ }
177
+ tokens.push({ kind: "identifier", text: raw, span: { start, end: index } });
178
+ }
179
+ try {
180
+ while (index < text.length) {
181
+ let char = text[index] ?? "";
182
+ if (char === " " || char === "\t" || char === "\r") {
183
+ index += 1;
184
+ }
185
+ else if (char === "\n") {
186
+ pushNewline();
187
+ }
188
+ else if (char === "#") {
189
+ while (index < text.length && text[index] !== "\n")
190
+ index += 1;
191
+ }
192
+ else if (char === '"') {
193
+ if (text.startsWith('"""', index))
194
+ readMultilineString();
195
+ else
196
+ readString();
197
+ }
198
+ else if (isDigit(char) || (char === "-" && isDigit(text[index + 1] ?? ""))) {
199
+ readNumberOrDuration();
200
+ }
201
+ else if (isIdentifierStart(char)) {
202
+ readIdentifierOrKeyword();
203
+ }
204
+ else {
205
+ let kind = PUNCTUATION[char];
206
+ if (!kind)
207
+ fail(`Unexpected character ${JSON.stringify(char)}.`, index, index + 1);
208
+ tokens.push({ kind, text: char, span: { start: index, end: index + 1 } });
209
+ index += 1;
210
+ }
211
+ }
212
+ tokens.push({ kind: "eof", text: "", span: { start: text.length, end: text.length } });
213
+ return success(tokens);
214
+ }
215
+ catch (error) {
216
+ if (error instanceof ParseError)
217
+ return failure(error);
218
+ let message = error instanceof Error ? error.message : String(error);
219
+ return failure(new ParseError(message, source.path));
220
+ }
221
+ }
222
+ /** Whether the character can start an identifier segment. */
223
+ function isIdentifierStart(char) {
224
+ return (char >= "a" && char <= "z") || (char >= "A" && char <= "Z") || char === "_";
225
+ }
226
+ /** Whether the character can continue an identifier segment. */
227
+ function isIdentifierPart(char) {
228
+ return isIdentifierStart(char) || isDigit(char);
229
+ }
230
+ /** Whether the character is an ASCII digit. */
231
+ function isDigit(char) {
232
+ return char >= "0" && char <= "9";
233
+ }
234
+ /** The reserved word the text spells, or `undefined` when it is not one. */
235
+ function asKeyword(text) {
236
+ return KEYWORDS.find((keyword) => keyword === text);
237
+ }
238
+ /**
239
+ * Apply GRAMMAR.md's three multiline-string steps in order: drop a leading
240
+ * newline, drop whitespace indenting a closing delimiter on its own line,
241
+ * then strip the common indentation of the non-blank lines; content stays raw.
242
+ *
243
+ * @param raw - The text between the `"""` delimiters, untouched.
244
+ * @returns The processed string value.
245
+ */
246
+ function dedentMultiline(raw) {
247
+ let content = raw;
248
+ if (content.startsWith("\r\n"))
249
+ content = content.slice(2);
250
+ else if (content.startsWith("\n"))
251
+ content = content.slice(1);
252
+ let lastBreak = content.lastIndexOf("\n");
253
+ if (lastBreak !== -1) {
254
+ let tail = content.slice(lastBreak + 1);
255
+ if (tail.length > 0 && BLANK_LINE.test(tail))
256
+ content = content.slice(0, lastBreak + 1);
257
+ }
258
+ let lines = content.split("\n");
259
+ let indent;
260
+ for (let line of lines) {
261
+ if (BLANK_LINE.test(line))
262
+ continue;
263
+ let leading = LEADING_WHITESPACE.exec(line)?.[0] ?? "";
264
+ indent = indent === undefined ? leading : commonPrefix(indent, leading);
265
+ }
266
+ if (indent === undefined || indent === "")
267
+ return content;
268
+ let prefix = indent;
269
+ return lines
270
+ .map((line) => {
271
+ let leading = LEADING_WHITESPACE.exec(line)?.[0] ?? "";
272
+ let strip = commonPrefix(prefix, leading);
273
+ return line.slice(strip.length);
274
+ })
275
+ .join("\n");
276
+ }
277
+ /** The longest prefix two strings share, character by character. */
278
+ function commonPrefix(left, right) {
279
+ let length = 0;
280
+ while (length < left.length && length < right.length && left[length] === right[length]) {
281
+ length += 1;
282
+ }
283
+ return left.slice(0, length);
284
+ }