@scinorandex/sparse 0.0.8 → 0.1.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.
Files changed (65) hide show
  1. package/.github/workflows/ci.yml +41 -0
  2. package/AGENTS.md +60 -0
  3. package/README.md +286 -30
  4. package/dist/cli.js +113 -24
  5. package/dist/cli.js.map +1 -1
  6. package/dist/generator.d.ts +9 -2
  7. package/dist/generator.js +179 -50
  8. package/dist/generator.js.map +1 -1
  9. package/dist/index.d.ts +17 -4
  10. package/dist/index.js +34 -1
  11. package/dist/index.js.map +1 -1
  12. package/dist/meta/common.d.ts +17 -0
  13. package/dist/meta/common.js +66 -1
  14. package/dist/meta/common.js.map +1 -1
  15. package/dist/meta/selfhosted.d.ts +4 -5
  16. package/dist/meta/selfhosted.js +51 -38
  17. package/dist/meta/selfhosted.js.map +1 -1
  18. package/dist/parser.d.ts +56 -33
  19. package/dist/parser.js +146 -18
  20. package/dist/parser.js.map +1 -1
  21. package/dist/table/selfhosted.d.ts +10 -1
  22. package/dist/table/selfhosted.js +32 -12
  23. package/dist/table/selfhosted.js.map +1 -1
  24. package/dist/table/validate.d.ts +17 -0
  25. package/dist/table/validate.js +84 -0
  26. package/dist/table/validate.js.map +1 -0
  27. package/dist/utils/Stack.d.ts +4 -0
  28. package/dist/utils/Stack.js +16 -1
  29. package/dist/utils/Stack.js.map +1 -1
  30. package/dist/utils/errorWindowBuilder.js +11 -11
  31. package/dist/utils/errorWindowBuilder.js.map +1 -1
  32. package/dist/utils/loadFiles.d.ts +8 -0
  33. package/dist/utils/loadFiles.js +47 -0
  34. package/dist/utils/loadFiles.js.map +1 -0
  35. package/dist/utils/reducers.d.ts +9 -0
  36. package/dist/utils/reducers.js +56 -0
  37. package/dist/utils/reducers.js.map +1 -0
  38. package/example/LoLang/example.ts +42 -18
  39. package/example/LoLang/tablelalr.txt +381 -0
  40. package/example/kleene-test/example.ts +35 -17
  41. package/example/math/example.ts +12 -7
  42. package/example/selfhosted/example.ts +30 -24
  43. package/opencode.json +18 -0
  44. package/package.json +9 -3
  45. package/src/cli.ts +132 -23
  46. package/src/generator.ts +237 -67
  47. package/src/index.ts +56 -3
  48. package/src/meta/common.ts +116 -0
  49. package/src/meta/selfhosted.ts +42 -11
  50. package/src/parser.ts +259 -47
  51. package/src/table/selfhosted.ts +48 -12
  52. package/src/table/validate.ts +143 -0
  53. package/src/utils/Stack.ts +22 -2
  54. package/src/utils/errorWindowBuilder.ts +15 -13
  55. package/src/utils/loadFiles.ts +54 -0
  56. package/src/utils/reducers.ts +93 -0
  57. package/test/cli.test.ts +144 -0
  58. package/test/codegen.test.ts +75 -0
  59. package/test/grammar.test.ts +243 -0
  60. package/test/helpers.ts +59 -0
  61. package/test/lalr.test.ts +114 -0
  62. package/test/parser.test.ts +392 -0
  63. package/test/table.test.ts +144 -0
  64. package/tsconfig.json +1 -1
  65. package/example/complicated/grammar.txt +0 -37
@@ -0,0 +1,392 @@
1
+ import path from "path";
2
+ import { ColumnAndRow, Token } from "@scinorandex/slex";
3
+ import { describe, expect, it } from "vitest";
4
+ import {
5
+ LR1ParserGraveError,
6
+ LR1StackSymbol,
7
+ Sparse,
8
+ SparseGrammarError,
9
+ assertReducersCoverGrammar,
10
+ buildProductions,
11
+ buildStates,
12
+ defineReducers,
13
+ enumToString,
14
+ loadGrammar,
15
+ missingReducerNames,
16
+ namedProductions,
17
+ } from "../src/index";
18
+ import {
19
+ MathTokenType,
20
+ SemiTokenType,
21
+ mathLexer,
22
+ readFile,
23
+ root,
24
+ semiLexer,
25
+ } from "./helpers";
26
+
27
+ type Node = { nodes: LR1StackSymbol<MathTokenType, {}, Node>[] };
28
+
29
+ class AstNode implements Node {
30
+ constructor(public readonly nodes: LR1StackSymbol<MathTokenType, {}, Node>[]) {}
31
+ toObject(): unknown[] {
32
+ return this.nodes.map((node) => (node.type === "token" ? node.token.lexeme : (node.node as AstNode).toObject()));
33
+ }
34
+ }
35
+
36
+ const reducer = (_newInput: any, oldInput: { input: LR1StackSymbol<MathTokenType, {}, Node>[] }) =>
37
+ new AstNode(oldInput.input);
38
+
39
+ const toStringifiedTokenType = enumToString<MathTokenType>(MathTokenType);
40
+ const toSemiStringifiedTokenType = enumToString<SemiTokenType>(SemiTokenType);
41
+
42
+ const mathGrammarPath = path.join(root, "example/math/grammar.txt");
43
+ const mathTablePath = path.join(root, "example/math/table.txt");
44
+
45
+ describe("parsing", () => {
46
+ it("parses a math expression from a prebuilt table", async () => {
47
+ const generator = await Sparse.fromGrammarFile<MathTokenType, {}, Node>({
48
+ grammarPath: mathGrammarPath,
49
+ tablePath: mathTablePath,
50
+ toStringifiedTokenType,
51
+ });
52
+
53
+ const result = generator.generate(mathLexer("2.4 + 3.5 * 1 / 456.789"), { reducer }).parse();
54
+
55
+ expect(result.errors).toEqual([]);
56
+ expect(result.result).not.toBe(null);
57
+ });
58
+
59
+ it("exposes the named symbols of the right hand side through the bag", () => {
60
+ const productions = buildProductions(`<S>: <PROGRAM>;
61
+ <PROGRAM: program>: [NUMBER: first] [EOF];
62
+ `);
63
+ const generator = Sparse.fromProductions<MathTokenType, {}, unknown>({
64
+ productions,
65
+ toStringifiedTokenType,
66
+ quiet: true,
67
+ });
68
+
69
+ const seen: unknown[] = [];
70
+ generator
71
+ .generate(mathLexer("1"), {
72
+ reducer: (newInput) => {
73
+ seen.push(newInput.name);
74
+ if (newInput.name === "program")
75
+ seen.push({ first: (newInput.bag.first as Token<MathTokenType, {}>).lexeme });
76
+ return new AstNode([]);
77
+ },
78
+ })
79
+ .parse();
80
+
81
+ expect(seen).toEqual(["program", { first: "1" }]);
82
+ });
83
+
84
+ it("reports a syntax error with the position and the terminals it wanted", () => {
85
+ const productions = buildProductions(`<S>: <PROGRAM>;
86
+ <PROGRAM: program>: <EXPRESSION> [EOF];
87
+ <EXPRESSION: expression>: [NUMBER] ([PLUS] [NUMBER])*;
88
+ `);
89
+ const generator = Sparse.fromProductions<MathTokenType, {}, Node>({
90
+ productions,
91
+ toStringifiedTokenType,
92
+ quiet: true,
93
+ });
94
+
95
+ try {
96
+ generator.generate(mathLexer("1 + "), { reducer }).parse();
97
+ throw new Error("expected the parse to fail");
98
+ } catch (err) {
99
+ expect(err).toBeInstanceOf(LR1ParserGraveError);
100
+ const message = (err as LR1ParserGraveError<MathTokenType, {}>).reason;
101
+ expect(message).toContain("Invalid syntax at 1:4");
102
+ expect(message).toContain("got EOF");
103
+ expect(message).toContain("[NUMBER]");
104
+ // gotos are not terminals, so they must not show up in the message
105
+ expect(message).not.toContain("<");
106
+ }
107
+ });
108
+
109
+ it("keeps the token that could not be parsed", () => {
110
+ const productions = buildProductions(`<S>: <PROGRAM>;
111
+ <PROGRAM: program>: [NUMBER] [EOF];
112
+ `);
113
+ const generator = Sparse.fromProductions<MathTokenType, {}, Node>({
114
+ productions,
115
+ toStringifiedTokenType,
116
+ quiet: true,
117
+ });
118
+
119
+ try {
120
+ generator.generate(mathLexer("1 1"), { reducer }).parse();
121
+ throw new Error("expected the parse to fail");
122
+ } catch (err) {
123
+ expect(err).toBeInstanceOf(LR1ParserGraveError);
124
+ const token = (err as LR1ParserGraveError<MathTokenType, {}>).currentToken;
125
+ expect(token?.type).toBe(MathTokenType.NUMBER);
126
+ expect(token?.lexeme).toBe("1");
127
+ expect(token?.line).toBe(1);
128
+ }
129
+ });
130
+
131
+ it("explains that the table does not match the grammar instead of crashing", () => {
132
+ const productions = buildProductions(`<S>: <PROGRAM>;
133
+ <PROGRAM: program>: [NUMBER] [EOF];
134
+ `);
135
+ // Reduce by production 0 (accept) for everything: syntactically valid, semantically nonsense.
136
+ const generator = new Sparse<MathTokenType, {}, Node>({
137
+ productions,
138
+ states: buildStates(`[NUMBER]=r0\n`),
139
+ toStringifiedTokenType,
140
+ validate: true,
141
+ });
142
+
143
+ // Nothing gets shifted, so the parser has no tree to return rather than crashing on an empty stack.
144
+ expect(generator.generate(mathLexer("1"), { reducer }).parse().result).toBe(null);
145
+ });
146
+
147
+ it("rejects a table that does not belong to the grammar when validate is on", () => {
148
+ const productions = buildProductions(`<S>: <PROGRAM>;
149
+ <PROGRAM: program>: [NUMBER] [EOF];
150
+ `);
151
+
152
+ expect(
153
+ () =>
154
+ new Sparse<MathTokenType, {}, Node>({
155
+ productions,
156
+ states: buildStates(`[EOF]=r9\n`),
157
+ toStringifiedTokenType,
158
+ validate: true,
159
+ }),
160
+ ).toThrow(/does not match the grammar/);
161
+ });
162
+
163
+ it("keeps the collected errors between reductions and clears them on reset", () => {
164
+ const productions = buildProductions(`<S>: <PROGRAM>;
165
+ <PROGRAM: program>: [NUMBER] [SEMICOLON] [EOF];
166
+ `);
167
+ const generator = Sparse.fromProductions<SemiTokenType, {}, unknown>({
168
+ productions: productions as any,
169
+ toStringifiedTokenType: toSemiStringifiedTokenType,
170
+ quiet: true,
171
+ });
172
+
173
+ const parser = generator.generate(semiLexer("1"), {
174
+ reducer: () => ({ tag: "reduced" }),
175
+ recover({ lexer, insertToken, addError, finish }) {
176
+ const next = lexer.peekNextToken();
177
+ const inserted = insertToken(
178
+ new Token<SemiTokenType, {}>(SemiTokenType.SEMICOLON, ";", new ColumnAndRow(next.line, next.column), {}),
179
+ );
180
+ if (inserted != null) return inserted;
181
+ addError("inserted a semicolon");
182
+ return finish();
183
+ },
184
+ });
185
+
186
+ expect(parser.parse().errors).toHaveLength(1);
187
+ parser.reset();
188
+
189
+ // The lexer is exhausted, so the second run fails on the input rather than on leftover stacks.
190
+ expect(() => parser.parse()).toThrow(/Cannot insert|Invalid syntax/);
191
+ });
192
+ });
193
+
194
+ describe("error recovery", () => {
195
+ const productionsWithSemicolon = () =>
196
+ Sparse.fromProductions<SemiTokenType, {}, unknown>({
197
+ productions: buildProductions(`<S>: <PROGRAM>;
198
+ <PROGRAM: program>: [NUMBER] [SEMICOLON] [EOF];
199
+ `),
200
+ toStringifiedTokenType: toSemiStringifiedTokenType,
201
+ quiet: true,
202
+ });
203
+
204
+ it("inserts a token that was missing from the input", () => {
205
+ const generator = productionsWithSemicolon();
206
+ const parser = generator.generate(semiLexer("1"), {
207
+ reducer: () => ({ tag: "reduced" }),
208
+ recover({ lexer, insertToken, addError, finish }) {
209
+ const next = lexer.peekNextToken();
210
+ const inserted = insertToken(
211
+ new Token<SemiTokenType, {}>(SemiTokenType.SEMICOLON, ";", new ColumnAndRow(next.line, next.column), {}),
212
+ );
213
+ if (inserted != null) return inserted;
214
+ addError("inserted a semicolon");
215
+ return finish();
216
+ },
217
+ });
218
+
219
+ const result = parser.parse();
220
+ expect(result.errors.map((error) => error.message)).toEqual(["inserted a semicolon"]);
221
+ expect(result.result).not.toBe(null);
222
+ });
223
+
224
+ it("records the position of the error it adds", () => {
225
+ const generator = productionsWithSemicolon();
226
+ const parser = generator.generate(semiLexer("1"), {
227
+ reducer: () => null,
228
+ recover({ insertToken, addError, finish, crash }) {
229
+ const inserted = insertToken(
230
+ new Token<SemiTokenType, {}>(SemiTokenType.SEMICOLON, ";", new ColumnAndRow(0, 0), {}),
231
+ );
232
+ if (inserted != null) return crash(inserted.reason);
233
+ addError("inserted a semicolon");
234
+ return finish();
235
+ },
236
+ });
237
+
238
+ expect(parser.parse().errors[0].token.line).toBe(1);
239
+ });
240
+
241
+ it("refuses to insert a token the current state does not accept", () => {
242
+ const generator = productionsWithSemicolon();
243
+ let reason: string | null = null;
244
+
245
+ const parser = generator.generate(semiLexer("1"), {
246
+ reducer: () => null,
247
+ recover({ insertToken, crash }) {
248
+ // NUMBER is not shiftable at this point, so inserting it has to be refused.
249
+ const inserted = insertToken(new Token<SemiTokenType, {}>(SemiTokenType.NUMBER, "1", new ColumnAndRow(0, 0), {}));
250
+ if (inserted != null) {
251
+ reason = inserted.reason;
252
+ return inserted;
253
+ }
254
+ return crash("unreachable");
255
+ },
256
+ });
257
+
258
+ expect(() => parser.parse()).toThrow(LR1ParserGraveError);
259
+ expect(reason).toContain("Cannot insert");
260
+ });
261
+ });
262
+
263
+ describe("reducer helpers", () => {
264
+ const grammar = `<S>: <PROGRAM>;
265
+ <PROGRAM: program>: [NUMBER] ([PLUS] [NUMBER])* [EOF];
266
+ `;
267
+
268
+ it("lists the productions a grammar names", () => {
269
+ expect(namedProductions(buildProductions(grammar))).toEqual(["autogenerated-kleene", "program"]);
270
+ });
271
+
272
+ it("names the reducers that are missing", () => {
273
+ expect(missingReducerNames(buildProductions(grammar), { program: () => null })).toEqual([
274
+ "autogenerated-kleene",
275
+ ]);
276
+ });
277
+
278
+ it("explains that * and + need the autogenerated-kleene reducer", () => {
279
+ expect(() => assertReducersCoverGrammar(buildProductions(grammar), { program: () => null })).toThrow(
280
+ /autogenerated-kleene/,
281
+ );
282
+ });
283
+
284
+ it("says which reducers it does have", () => {
285
+ expect(() => assertReducersCoverGrammar(buildProductions(grammar), {})).toThrow(/Your reducer map is empty/);
286
+ });
287
+
288
+ it("dispatches to the reducer named in the grammar", () => {
289
+ const generator = Sparse.fromProductions<MathTokenType, {}, string>({
290
+ productions: buildProductions(grammar),
291
+ toStringifiedTokenType,
292
+ quiet: true,
293
+ });
294
+
295
+ const parser = generator.generate(mathLexer("1 + 2"), {
296
+ reducer: defineReducers({ program: () => "program", "autogenerated-kleene": () => "list" }),
297
+ });
298
+
299
+ expect(parser.parse().result).toBe("program");
300
+ });
301
+
302
+ it("throws a named error when a reducer is missing at parse time", () => {
303
+ const generator = Sparse.fromProductions<MathTokenType, {}, string>({
304
+ productions: buildProductions(`<S>: <PROGRAM>;
305
+ <PROGRAM: program>: [NUMBER] [EOF];
306
+ `),
307
+ toStringifiedTokenType,
308
+ quiet: true,
309
+ });
310
+
311
+ expect(() => generator.generate(mathLexer("1"), { reducer: defineReducers({}) }).parse()).toThrow(
312
+ /No reducer for production "program"/,
313
+ );
314
+ });
315
+
316
+ it("surfaces the reducer's own message when a reducer throws", () => {
317
+ const generator = Sparse.fromProductions<MathTokenType, {}, string>({
318
+ productions: buildProductions(`<S>: <PROGRAM>;
319
+ <PROGRAM: program>: [NUMBER] [EOF];
320
+ `),
321
+ toStringifiedTokenType,
322
+ quiet: true,
323
+ });
324
+
325
+ try {
326
+ generator
327
+ .generate(mathLexer("1"), {
328
+ reducer: () => {
329
+ throw new Error("I refuse");
330
+ },
331
+ })
332
+ .parse();
333
+ throw new Error("expected the parse to fail");
334
+ } catch (err) {
335
+ expect((err as LR1ParserGraveError<MathTokenType, {}>).reason).toContain("I refuse");
336
+ expect((err as LR1ParserGraveError<MathTokenType, {}>).reason).toContain('("program")');
337
+ expect((err as Error).cause).toBeInstanceOf(Error);
338
+ }
339
+ });
340
+ });
341
+
342
+ describe("loading grammars and tables from disk", () => {
343
+ it("reads a grammar from disk", async () => {
344
+ const result = await loadGrammar(mathGrammarPath);
345
+ expect(result.success).toBe(true);
346
+ if (result.success) expect(result.value.length).toBeGreaterThan(0);
347
+ });
348
+
349
+ it("turns a missing file into a failure", async () => {
350
+ const result = await loadGrammar(path.join(root, "example/nope.txt"));
351
+ expect(result.success).toBe(false);
352
+ });
353
+
354
+ it("returns a Result instead of throwing when the grammar is missing", async () => {
355
+ const result = await Sparse.tryFromGrammarFile<MathTokenType, {}, Node>({
356
+ grammarPath: path.join(root, "example/math/does-not-exist.txt"),
357
+ tablePath: mathTablePath,
358
+ toStringifiedTokenType,
359
+ });
360
+
361
+ expect(result.success).toBe(false);
362
+ if (result.success === false) expect(result.reason).toContain("Could not read the grammar file");
363
+ });
364
+
365
+ it("reports a table that does not match the grammar", async () => {
366
+ const wrongTable = path.join(root, "example/kleene-test/grammar.txt");
367
+ const result = await Sparse.tryFromGrammarFile<MathTokenType, {}, Node>({
368
+ grammarPath: mathGrammarPath,
369
+ tablePath: wrongTable,
370
+ toStringifiedTokenType,
371
+ });
372
+
373
+ expect(result.success).toBe(false);
374
+ if (result.success === false) expect(result.reason).toContain(wrongTable);
375
+ });
376
+
377
+ it("throws SparseGrammarError with a position", async () => {
378
+ await expect(
379
+ Sparse.fromGrammarFile<MathTokenType, {}, Node>({
380
+ grammarPath: mathGrammarPath,
381
+ tablePath: mathGrammarPath,
382
+ toStringifiedTokenType,
383
+ }),
384
+ ).rejects.toThrow(SparseGrammarError);
385
+ });
386
+
387
+ it("keeps the math grammar's structure intact", async () => {
388
+ const productions = buildProductions(await readFile("math/grammar.txt"));
389
+ expect(productions.map((production) => production.identifier)).toContain("<FACTOR_EXPRESSION>");
390
+ expect(productions.every((production) => production.rhs.length > 0)).toBe(true);
391
+ });
392
+ });
@@ -0,0 +1,144 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import {
3
+ buildProductions,
4
+ buildStates,
5
+ generateStates,
6
+ tryBuildStates,
7
+ validateTable,
8
+ validateTableStates,
9
+ TableState,
10
+ type TableState as TableStateType,
11
+ } from "../src/index";
12
+ import { EXAMPLE_GRAMMARS, readFile } from "./helpers";
13
+
14
+ const dump = (states: TableStateType[]) =>
15
+ states.map((state) =>
16
+ [...state.actions.entries()].map(([key, action]) => `${key}=${action.type === "goto" ? "" : action.type === "shift" ? "s" : "r"}${action.value}`),
17
+ );
18
+
19
+ describe("table file parsing", () => {
20
+ it("reads a hand written table", () => {
21
+ const states = buildStates(`[EOF]=s1, <PROGRAM>=1\n[EOF]=r0\n`);
22
+ expect(states.length).toBe(2);
23
+ expect(states[0].getTerminalAction("EOF")).toEqual({ type: "shift", value: 1 });
24
+ expect(states[0].getVariableAction("<PROGRAM>")).toEqual({ type: "goto", value: 1 });
25
+ expect(states[1].getTerminalAction("EOF")).toEqual({ type: "reduce", value: 0 });
26
+ });
27
+
28
+ it("reports malformed actions as failures instead of silently accepting them", () => {
29
+ const cases: [string, string, string][] = [
30
+ ["wrong action prefix", `[A]=q3\n`, `"[A]=q3"`],
31
+ ["non numeric shift", `[A]=sX\n`, `"[A]=sX"`],
32
+ ["state out of range", `[A]=s99999\n`, "only has 1 states"],
33
+ ];
34
+
35
+ for (const [name, table, expected] of cases) {
36
+ const result = tryBuildStates(table);
37
+ expect(result.success, `${name} should fail`).toBe(false);
38
+ if (result.success === false) expect(result.reason).toContain(expected);
39
+ }
40
+ });
41
+
42
+ it("reports tables that are not tables at all", () => {
43
+ for (const table of ["", "hello world\n", "A=s1\n"]) {
44
+ const result = tryBuildStates(table);
45
+ expect(result.success).toBe(false);
46
+ if (result.success === false) expect(result.reason).toContain("Invalid syntax");
47
+ }
48
+ });
49
+
50
+ it("throws from buildStates, mentioning the position", () => {
51
+ expect(() => buildStates("hello world\n")).toThrow(/while parsing the parsing table at 1:\d+/);
52
+ });
53
+
54
+ it("rejects an empty table", () => {
55
+ const result = validateTableStates([]);
56
+ expect(result.success).toBe(false);
57
+ if (result.success === false) expect(result.reason).toContain("does not contain any states");
58
+ });
59
+
60
+ it("rejects state 0 without actions", () => {
61
+ const result = validateTableStates([new TableState()]);
62
+ expect(result.success).toBe(false);
63
+ if (result.success === false) expect(result.reason).toContain("State 0 has no actions");
64
+ });
65
+
66
+ it("rejects a terminal mapped to a goto", () => {
67
+ const states = [new TableState(new Map([["[A]", { type: "goto" as const, value: 0 }]]))];
68
+ const result = validateTableStates(states);
69
+ expect(result.success).toBe(false);
70
+ if (result.success === false) expect(result.reason).toContain("variables can only be reached by a \"goto\"");
71
+ });
72
+
73
+ it("rejects a variable mapped to a shift", () => {
74
+ const states = [new TableState(new Map([["<A>", { type: "shift" as const, value: 0 }]]))];
75
+ const result = validateTableStates(states);
76
+ expect(result.success).toBe(false);
77
+ if (result.success === false) expect(result.reason).toContain("variables can only be reached by a \"goto\"");
78
+ });
79
+ });
80
+
81
+ describe("table generation and round trip", () => {
82
+ it.each(EXAMPLE_GRAMMARS)("round trips the %s table through the table file format", async (_name, grammarPath) => {
83
+ const grammar = await readFile(grammarPath);
84
+ const productions = buildProductions(grammar);
85
+
86
+ const result = generateStates(productions);
87
+ if (result.success === false) throw new Error(result.reason);
88
+
89
+ const fromMemory = result.value.toStates();
90
+ const fromText = tryBuildStates(result.value.toTable(), { productions });
91
+ if (fromText.success === false) throw new Error(fromText.reason);
92
+
93
+ expect(dump(fromText.value)).toEqual(dump(fromMemory));
94
+ });
95
+
96
+ it("rebuilds the committed math table byte for byte", async () => {
97
+ const committed = await readFile("math/table.txt");
98
+ const productions = buildProductions(await readFile("math/grammar.txt"));
99
+ const result = generateStates(productions);
100
+ if (result.success === false) throw new Error(result.reason);
101
+ expect(result.value.toTable().trim()).toBe(committed.trim());
102
+ });
103
+
104
+ it("rebuilds the committed LoLang table byte for byte", async () => {
105
+ const committed = await readFile("LoLang/table.txt");
106
+ const productions = buildProductions(await readFile("LoLang/grammar.txt"));
107
+ const result = generateStates(productions);
108
+ if (result.success === false) throw new Error(result.reason);
109
+ expect(result.value.toTable().trim()).toBe(committed.trim());
110
+ });
111
+
112
+ it("keeps the JSON representation used for codegen stable", async () => {
113
+ const productions = buildProductions(await readFile("math/grammar.txt"));
114
+ const result = generateStates(productions);
115
+ if (result.success === false) throw new Error(result.reason);
116
+
117
+ const json = result.value.toJSObject();
118
+ const restored = json.map((state) => TableState.fromJSObject(state));
119
+
120
+ expect(dump(restored)).toEqual(dump(result.value.toStates()));
121
+ expect(validateTableStates(restored, { productions }).success).toBe(true);
122
+ });
123
+ });
124
+
125
+ describe("validateTable", () => {
126
+ it("accepts a table that belongs to the grammar", () => {
127
+ const productions = buildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: [EOF];\n`);
128
+ const states = buildStates(`[EOF]=s1, <PROGRAM>=1\n[EOF]=r0\n`);
129
+ expect(validateTable(productions, states).success).toBe(true);
130
+ });
131
+
132
+ it("rejects a stale table whose reduce actions point past the end of the grammar", () => {
133
+ const productions = buildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: [EOF];\n`);
134
+ const states = buildStates(`[EOF]=r7\n`);
135
+ const result = validateTable(productions, states);
136
+ expect(result.success).toBe(false);
137
+ if (result.success === false) expect(result.reason).toContain("Is the table stale?");
138
+ });
139
+
140
+ it("rejects an empty grammar", () => {
141
+ const result = validateTable([], [new TableState(new Map([["[A]", { type: "reduce" as const, value: 0 }]]))]);
142
+ expect(result.success).toBe(false);
143
+ });
144
+ });
package/tsconfig.json CHANGED
@@ -26,5 +26,5 @@
26
26
  "types": ["node"]
27
27
  },
28
28
  "exclude": ["node_modules"],
29
- "include": ["./src/**/*.ts", "./example/**/*.ts"]
29
+ "include": ["./src/**/*.ts", "./example/**/*.ts", "./test/**/*.ts"]
30
30
  }
@@ -1,37 +0,0 @@
1
- <S>: <PROGRAM>;
2
- <PROGRAM>: (<FUNCTION_DEFINITION_LIST: functions>)? [EOF];
3
-
4
- <FUNCTION_DEFINITION_LIST>: <FUNCTION_DEFINITION: function> (<FUNCTION_DEFINITION_LIST: rest>)?;
5
- <FUNCTION_DEFINITION>: [FUNCTION] [IDENTIFIER] [LPAREN] (<PARAMETER_LIST: parameters>)? [RPAREN] [LBRACE] (<STATEMENT_LIST: statements>)? [RBRACE];
6
- <PARAMETER_LIST>: [IDENTIFIER: parameter_name] ([COMMA] <PARAMETER_LIST: rest>)?;
7
-
8
- <STATEMENT_LIST>: <STATEMENT: stmt> (<STATEMENT_LIST: rest>)?;
9
- <BLOCK_STATEMENT>: [LBRACE] (<STATEMENT_LIST: stmt_list>) [RBRACE];
10
-
11
- <STATEMENT>: <IF_STATEMENT | WHILE_LOOP | EXPRESSION_STATEMENT | BLOCK_STATEMENT | VARIABLE_DEFINITION: stmt>;
12
-
13
- <VARIABLE_DEFINITION>: [VAR] [IDENTIFIER: identifier] [EQUALS] <EXPRESSION: expr> [SEMICOLON];
14
- <IF_STATEMENT>: [IF] [LPAREN] (<EXPRESSION: condition>)? [RPAREN] <STATEMENT: stmt> ([ELSE] <STATEMENT: else_stmt>)?;
15
- <WHILE_LOOP>: [WHILE] [IDENTIFIER: loop_name] [LPAREN] <EXPRESSION: condition> [RPAREN] <STATEMENT: stmt>;
16
- <EXPRESSION_STATEMENT>: <EXPRESSION: expr> [SEMICOLON];
17
-
18
- <EXPRESSION>: <ASSIGNMENT_EXPRESSION: expr>;
19
- <ASSIGNMENT_EXPRESSION>: ([IDENTIFIER: identifier] [EQUALS])? <EQUALITY_EXPRESSION: expr>;
20
- <EQUALITY_EXPRESSION>: <COMPARISON_EXPRESSION: left> ([DOUBLE_EQUALS | NOT_EQUALS: op] <COMPARISON_EXPRESSION: right>)?;
21
-
22
- <COMPARISON_EXPRESSION>: <BITWISE_OR_EXPRESSION: left> ([LESS_THAN | GREATER_THAN | LESS_THAN_EQUALS | GREATER_THAN_EQUALS: op] <BITWISE_OR_EXPRESSION: right>)?;
23
- <BITWISE_OR_EXPRESSION>: <BITWISE_AND_EXPRESSION: left> ([PIPE | TILDE_PIPE: op] <BITWISE_OR_EXPRESSION: right>)?;
24
- <BITWISE_AND_EXPRESSION>: <BITWISE_XOR_EXPRESSION: left> ([AMPERSAND | TILDE_AMPERSAND: op] <BITWISE_AND_EXPRESSION: right>)?;
25
- <BITWISE_XOR_EXPRESSION>: <TERM_EXPRESSION: left> ([CARAT | TILDE_CARAT: op] <BITWISE_XOR_EXPRESSION: right>)?;
26
-
27
- <TERM_EXPRESSION>: <UNARY_EXPR: left> ([PLUS | MINUS: op] <TERM_EXPRESSION: right>)?;
28
- <UNARY_EXPR>: ([LEFTSHIFT | RIGHTSHIFT | TILDE: op])? <ENDPOINT: expr>;
29
-
30
- <GROUPING_EXPRESSION>: [LPAREN] <EXPRESSION: expr> [RPAREN];
31
- <FUNCTION_CALL_EXPRESSION>: [IDENTIFIER: function_name] [LPAREN] (<ARGUMENT_LIST: arguments>)? [RPAREN];
32
- <ARGUMENT_LIST>: <EXPRESSION: arg> ([COMMA] <ARGUMENT_LIST: rest>)?;
33
-
34
- <ENDPOINT>: [IDENTIFIER: identifier];
35
- <ENDPOINT>: [NUMBER: number];
36
- <ENDPOINT>: <GROUPING_EXPRESSION: expr>;
37
- <ENDPOINT>: <FUNCTION_CALL_EXPRESSION: expr>;