@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,243 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { dehydateProduction, hydrateProduction } from "../src/meta/common";
3
+ import {
4
+ buildProductions,
5
+ generateStates,
6
+ namedProductions,
7
+ tryBuildProductions,
8
+ validateProductions,
9
+ type Production,
10
+ type ProductionWarning,
11
+ } from "../src/index";
12
+ import { EXAMPLE_GRAMMARS, readFile } from "./helpers";
13
+
14
+ const MATH_GRAMMAR = `<S>: <PROGRAM>;
15
+ <PROGRAM: program>: [EOF];
16
+ `;
17
+
18
+ const kleeneGrammar = (extra: string) => `<S>: <PROGRAM>;
19
+ <PROGRAM: program>: [NUMBER] ([PLUS] [NUMBER])* [EOF];${extra}
20
+ `;
21
+
22
+ describe("grammar parsing", () => {
23
+ it.each(EXAMPLE_GRAMMARS)("builds productions for the %s grammar", async (_name, grammarPath) => {
24
+ const grammar = await readFile(grammarPath);
25
+ const result = tryBuildProductions(grammar);
26
+ if (result.success === false) throw new Error(`${result.reason} at ${result.token.line}:${result.token.column}`);
27
+ expect(result.value.length).toBeGreaterThan(0);
28
+ });
29
+
30
+ it("keeps production names and identifiers", () => {
31
+ const productions = buildProductions(MATH_GRAMMAR);
32
+ expect(productions[0].identifier).toBe("<S>");
33
+ expect(productions[0].name).toBe(null);
34
+ expect(productions[1].identifier).toBe("<PROGRAM>");
35
+ expect(productions[1].name).toBe("program");
36
+ expect(namedProductions(productions)).toEqual(["program"]);
37
+ });
38
+
39
+ it("unrolls * into an autogenerated list production that the user has to reduce", () => {
40
+ const productions = buildProductions(kleeneGrammar(""));
41
+ expect(productions[0].identifier).toBe("<S>");
42
+
43
+ const kleene = productions.filter((production) => production.name === "autogenerated-kleene");
44
+ expect(kleene.length).toBeGreaterThan(0);
45
+ expect(kleene.every((production) => production.identifier.startsWith("<autogen-"))).toBe(true);
46
+ expect(namedProductions(productions)).toContain("autogenerated-kleene");
47
+ });
48
+
49
+ it("unrolls + into a list production that contains at least one item", () => {
50
+ const productions = buildProductions(`<S>: <PROGRAM>;
51
+ <PROGRAM: program>: ([PLUS] [NUMBER])+ [EOF];
52
+ `);
53
+ const kleene = productions.filter((production) => production.name === "autogenerated-kleene");
54
+ expect(kleene.length).toBeGreaterThan(0);
55
+ expect(kleene.some((production) => production.rhs.length > 1)).toBe(true);
56
+ });
57
+
58
+ it("unrolls ? into one production with the group and one without", () => {
59
+ const productions = buildProductions(`<S>: <PROGRAM>;
60
+ <PROGRAM: program>: [NUMBER] ([PLUS] [NUMBER])? [EOF];
61
+ `);
62
+ const program = productions.filter((production) => production.identifier === "<PROGRAM>");
63
+ expect(program.length).toBe(2);
64
+ expect(program[0].rhs.map((item) => item.identifier)).toEqual(["[NUMBER]", "[EOF]"]);
65
+ expect(program[1].rhs.map((item) => item.identifier)).toEqual(["[NUMBER]", "[PLUS]", "[NUMBER]", "[EOF]"]);
66
+ });
67
+
68
+ it("unrolls terminal alternatives into one production per alternative", () => {
69
+ const productions = buildProductions(`<S>: <PROGRAM>;
70
+ <PROGRAM: program>: [PLUS | MINUS] [EOF];
71
+ `);
72
+ const program = productions.filter((production) => production.identifier === "<PROGRAM>");
73
+ expect(program.length).toBe(2);
74
+ expect(program[0].rhs.map((item) => item.identifier)).toEqual(["[PLUS]", "[EOF]"]);
75
+ expect(program[1].rhs.map((item) => item.identifier)).toEqual(["[MINUS]", "[EOF]"]);
76
+ });
77
+
78
+ it("unrolls variable alternatives into one production per alternative", () => {
79
+ const productions = buildProductions(`<S>: <PROGRAM>;
80
+ <PROGRAM: program>: <A | B> [EOF];
81
+ <A>: [EOF];
82
+ <B>: [EOF];
83
+ `);
84
+ const program = productions.filter((production) => production.identifier === "<PROGRAM>");
85
+ expect(program.length).toBe(2);
86
+ expect(program.map((production) => production.rhs[0].identifier)).toEqual(["<A>", "<B>"]);
87
+ });
88
+
89
+ it("ignores comments", () => {
90
+ const withComments = `// a comment
91
+ <S>: <PROGRAM>; /* another
92
+ comment */
93
+ <PROGRAM: program>: [EOF];
94
+ `;
95
+ expect(buildProductions(withComments).length).toBe(2);
96
+ });
97
+
98
+ describe("reports problems as Result failures instead of throwing", () => {
99
+ const cases: [string, string, string][] = [
100
+ ["empty grammar", ``, "does not contain any productions"],
101
+ [
102
+ "missing semicolon",
103
+ `<S>: <PROGRAM>;\n<PROGRAM: program>: [NUMBER]\n`,
104
+ "expected one of",
105
+ ],
106
+ [
107
+ "undefined variable in the first position",
108
+ `<S>: <PROGRAM>;\n<PROGRAM: program>: <NOPE> [EOF];\n`,
109
+ `Variable "<NOPE>" is used on the right hand side`,
110
+ ],
111
+ [
112
+ "undefined variable in a later position",
113
+ `<S>: <PROGRAM>;\n<PROGRAM: program>: [NUMBER] <NOPE> [EOF];\n`,
114
+ `Variable "<NOPE>" is used on the right hand side`,
115
+ ],
116
+ [
117
+ "group that expands to nothing",
118
+ `<S>: <PROGRAM>;\n<PROGRAM: program>: ([NUMBER])?;\n`,
119
+ "empty right hand side",
120
+ ],
121
+ [
122
+ "start symbol used on the right hand side",
123
+ `<S>: <PROGRAM>;\n<PROGRAM: program>: <S> [EOF];\n`,
124
+ "cannot be used on the right hand side",
125
+ ],
126
+ ];
127
+
128
+ it.each(cases)("rejects a %s", (_name, grammar, expectedReason) => {
129
+ const result = tryBuildProductions(grammar);
130
+ expect(result.success).toBe(false);
131
+ if (result.success === false) {
132
+ expect(result.reason).toContain(expectedReason);
133
+ expect(result.token.line).toBeGreaterThan(0);
134
+ expect(result.token.column).toBeGreaterThanOrEqual(0);
135
+ }
136
+ });
137
+
138
+ it("points at the offending token", () => {
139
+ const result = tryBuildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: [NUMBER] <NOPE> [EOF];\n`);
140
+ if (result.success === false) {
141
+ expect(result.token.line).toBe(2);
142
+ expect(result.token.lexeme).toBe("NOPE");
143
+ } else throw new Error("expected a failure");
144
+ });
145
+
146
+ it("throws from buildProductions with the position in the message", () => {
147
+ expect(() => buildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: <NOPE> [EOF];\n`)).toThrow(
148
+ /Encountered error .* at 2:\d+/,
149
+ );
150
+ });
151
+ });
152
+ });
153
+
154
+ describe("validateProductions", () => {
155
+ it("accepts every grammar checked into the repository", async () => {
156
+ for (const [name, grammarPath] of EXAMPLE_GRAMMARS) {
157
+ const grammar = await readFile(grammarPath);
158
+ const result = validateProductions(buildProductions(grammar));
159
+ expect(result.success, `${name} should be valid`).toBe(true);
160
+ }
161
+ });
162
+
163
+ it("warns about duplicate right hand side names but still succeeds", () => {
164
+ const warnings: ProductionWarning[] = [];
165
+ const result = validateProductions(
166
+ buildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: [NUMBER: n] [EOF: n];\n`),
167
+ { onWarning: (warning) => warnings.push(warning) },
168
+ );
169
+ expect(result.success).toBe(true);
170
+ expect(warnings.map((warning) => warning.kind)).toContain("duplicate-rhs-name");
171
+ });
172
+
173
+ it("warns about unreachable productions but still succeeds", () => {
174
+ const warnings: ProductionWarning[] = [];
175
+ const result = validateProductions(
176
+ buildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: [EOF];\n<ORPHAN: orphan>: [EOF];\n`),
177
+ { onWarning: (warning) => warnings.push(warning) },
178
+ );
179
+ expect(result.success).toBe(true);
180
+ expect(warnings.map((warning) => warning.kind)).toContain("unreachable-production");
181
+ });
182
+
183
+ it("rejects an empty production list", () => {
184
+ const result = validateProductions([]);
185
+ expect(result.success).toBe(false);
186
+ if (result.success === false) expect(result.token.line).toBe(1);
187
+ });
188
+ });
189
+
190
+ describe("generateStates validation", () => {
191
+ it("refuses to generate states from an empty grammar", () => {
192
+ const result = generateStates([]);
193
+ expect(result.success).toBe(false);
194
+ });
195
+
196
+ it("refuses a grammar with an undefined variable instead of building a broken table", () => {
197
+ const grammar = `<S>: <PROGRAM>;\n<PROGRAM: program>: [NUMBER] <NOPE> [EOF];\n`;
198
+
199
+ // Rejected while reading the grammar...
200
+ expect(() => buildProductions(grammar)).toThrow(/Variable "<NOPE>"/);
201
+
202
+ // ...and again when hand-built productions are handed straight to the generator, which used to
203
+ // happily produce a table that crashed every parse with a TypeError.
204
+ const valid = buildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: [NUMBER] [EOF];\n`);
205
+ const withDanglingVariable: Production[] = [
206
+ valid[0],
207
+ valid[1],
208
+ {
209
+ ...valid[1],
210
+ rhs: [...valid[1].rhs.slice(0, 1), { type: "variable", token: valid[1].rhs[0].token, identifier: "<NOPE>", name: null }],
211
+ },
212
+ ];
213
+
214
+ const result = generateStates(withDanglingVariable);
215
+ expect(result.success).toBe(false);
216
+ if (result.success === false) expect(result.reason).toContain(`Variable "<NOPE>"`);
217
+ });
218
+
219
+ it("reports how many states it produced through onProgress", () => {
220
+ let last = 0;
221
+ generateStates(buildProductions(MATH_GRAMMAR), { onProgress: (states) => (last = states) });
222
+ expect(last).toBeGreaterThan(0);
223
+ });
224
+
225
+ it("forwards grammar warnings", () => {
226
+ const warnings: ProductionWarning[] = [];
227
+ generateStates(buildProductions(`<S>: <PROGRAM>;\n<PROGRAM: program>: [EOF];\n<ORPHAN: orphan>: [EOF];\n`), {
228
+ onWarning: (warning) => warnings.push(warning),
229
+ });
230
+ expect(warnings.map((warning) => warning.kind)).toEqual(["unreachable-production"]);
231
+ });
232
+ });
233
+
234
+ describe("grammar round trip", () => {
235
+ it("keeps every production's identifier and name through hydrate/dehydrate", () => {
236
+ const productions: Production[] = buildProductions(kleeneGrammar(""));
237
+ const round = productions.map((production) => hydrateProduction(dehydateProduction(production)));
238
+ expect(round.map((production) => production.identifier)).toEqual(
239
+ productions.map((production) => production.identifier),
240
+ );
241
+ expect(round.map((production) => production.name)).toEqual(productions.map((production) => production.name));
242
+ });
243
+ });
@@ -0,0 +1,59 @@
1
+ import path from "path";
2
+ import fs from "fs/promises";
3
+ import { Slex } from "@scinorandex/slex";
4
+
5
+ // prettier-ignore
6
+ export enum MathTokenType {
7
+ PLUS, MINUS, STAR, SLASH, NUMBER, EOF
8
+ }
9
+
10
+ export type MathMetadata = {};
11
+
12
+ export const mathLexerGenerator = new Slex<MathTokenType, MathMetadata>({
13
+ EOF_TYPE: MathTokenType.EOF,
14
+ isHigherPrecedence: () => false,
15
+ });
16
+
17
+ mathLexerGenerator.addRule("plus", "$+", MathTokenType.PLUS);
18
+ mathLexerGenerator.addRule("minus", "$-", MathTokenType.MINUS);
19
+ mathLexerGenerator.addRule("star", "$*", MathTokenType.STAR);
20
+ mathLexerGenerator.addRule("forward_slash", "$/", MathTokenType.SLASH);
21
+ mathLexerGenerator.addRule("digit", "0|1|2|3|4|5|6|7|8|9");
22
+ mathLexerGenerator.addRule("float_number", "(${digit})+$.(${digit})+");
23
+ mathLexerGenerator.addRule("decimal_number", "(${digit})+");
24
+ mathLexerGenerator.addRule("number_literal", "${float_number}|${decimal_number}", MathTokenType.NUMBER);
25
+
26
+ export const mathLexer = (source: string) => mathLexerGenerator.generate(source, () => ({}));
27
+
28
+ // prettier-ignore
29
+ export enum SemiTokenType {
30
+ NUMBER, SEMICOLON, EOF
31
+ }
32
+
33
+ /** A lexer whose grammar mentions a SEMICOLON, for the error recovery tests. */
34
+ export const semiLexer = (source: string) => {
35
+ const generator = new Slex<SemiTokenType, {}>({
36
+ EOF_TYPE: SemiTokenType.EOF,
37
+ isHigherPrecedence: () => false,
38
+ });
39
+ generator.addRule("semicolon", "$;", SemiTokenType.SEMICOLON);
40
+ generator.addRule("digit", "0|1|2|3|4|5|6|7|8|9");
41
+ generator.addRule("number", "(${digit})+", SemiTokenType.NUMBER);
42
+ return generator.generate(source, () => ({}));
43
+ };
44
+
45
+ export const root = path.join(__dirname, "..");
46
+
47
+ export const examplePath = (...segments: string[]) => path.join(root, "example", ...segments);
48
+
49
+ export const readExample = (...segments: string[]) => fs.readFile(examplePath(...segments), "utf8");
50
+
51
+ export const readFile = (filePath: string) => fs.readFile(path.resolve(root, "example", filePath), "utf8");
52
+
53
+ export const EXAMPLE_GRAMMARS: [string, string][] = [
54
+ ["math", examplePath("math/grammar.txt")],
55
+ ["kleene-test", examplePath("kleene-test/grammar.txt")],
56
+ ["LoLang", examplePath("LoLang/grammar.txt")],
57
+ ["selfhosted meta", examplePath("selfhosted/01-meta-grammar.txt")],
58
+ ["selfhosted table", examplePath("selfhosted/02-table-grammar.txt")],
59
+ ];
@@ -0,0 +1,114 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import fs from "fs/promises";
3
+ import {
4
+ LR1StackSymbol,
5
+ Sparse,
6
+ buildProductions,
7
+ buildStates,
8
+ enumToString,
9
+ generateStates,
10
+ } from "../src/index";
11
+ import { MathTokenType, mathLexer, readFile } from "./helpers";
12
+ import { lexerGenerator as loLangLexerGenerator, LoLangTokenType } from "../example/LoLang/example";
13
+
14
+ type StackSymbol = LR1StackSymbol<any, any, AstNode>;
15
+
16
+ class AstNode {
17
+ constructor(public readonly nodes: StackSymbol[]) {}
18
+ toObject(): unknown[] {
19
+ return this.nodes.map((node) => (node.type === "token" ? node.token.lexeme : (node.node as AstNode).toObject()));
20
+ }
21
+ }
22
+
23
+ const reducer = (_newInput: any, oldInput: { input: LR1StackSymbol<any, any, AstNode>[] }) =>
24
+ new AstNode(oldInput.input);
25
+
26
+ const toStringifiedTokenType = enumToString<MathTokenType>(MathTokenType);
27
+ const MATH_GRAMMAR = `${__dirname}/../example/math/grammar.txt`;
28
+ const EXPRESSIONS = ["1 + 2", "42", "3 * 4 - 5 / 6", "1.5 + 2.25 * 3"];
29
+
30
+ const mathTable = async () => buildStates(await fs.readFile(`${__dirname}/../example/math/table.txt`, "utf8"));
31
+
32
+ describe("LALR(1)", () => {
33
+ it("produces the same trees as the committed LR(1) table for math expressions", async () => {
34
+ const productions = buildProductions(await fs.readFile(MATH_GRAMMAR, "utf8"));
35
+ const states = await mathTable();
36
+
37
+ const fromTable = new Sparse<MathTokenType, {}, AstNode>({ productions, states, toStringifiedTokenType });
38
+ const fromLalr = Sparse.fromProductions<MathTokenType, {}, AstNode>({
39
+ productions,
40
+ toStringifiedTokenType,
41
+ mode: "lalr1",
42
+ quiet: true,
43
+ });
44
+
45
+ for (const expression of EXPRESSIONS) {
46
+ const lr1 = fromTable.generate(mathLexer(expression), { reducer }).parse().result?.toObject();
47
+ const lalr = fromLalr.generate(mathLexer(expression), { reducer }).parse().result?.toObject();
48
+ expect(lalr, `parsing "${expression}"`).toEqual(lr1);
49
+ }
50
+ });
51
+
52
+ it("agrees with LR(1) on the LoLang example", async () => {
53
+ const productions = buildProductions(await readFile("LoLang/grammar.txt"));
54
+ const source = await readFile("LoLang/Features_Array_Methods.lol");
55
+
56
+ const lr1 = generateStates(productions);
57
+ const lalr = generateStates(productions, { mode: "lalr1" });
58
+ if (lr1.success === false || lalr.success === false) throw new Error("generation failed");
59
+
60
+ const parse = (states: any[]) =>
61
+ new Sparse<LoLangTokenType, {}, AstNode>({
62
+ productions,
63
+ states,
64
+ toStringifiedTokenType: enumToString<LoLangTokenType>(LoLangTokenType),
65
+ validate: true,
66
+ })
67
+ .generate(loLangLexerGenerator.generate(source, () => ({})), { reducer })
68
+ .parse().result?.toObject();
69
+
70
+ const fromLr1 = parse(lr1.value.toStates());
71
+ const fromLalr = parse(lalr.value.toStates());
72
+
73
+ expect(fromLalr).toEqual(fromLr1);
74
+ }, 120_000);
75
+
76
+ it("is never larger than LR(1)", async () => {
77
+ const productions = buildProductions(await readFile("LoLang/grammar.txt"));
78
+
79
+ const lr1 = generateStates(productions);
80
+ const lalr = generateStates(productions, { mode: "lalr1" });
81
+ if (lr1.success === false || lalr.success === false) throw new Error("generation failed");
82
+
83
+ expect(lalr.value.toStates().length).toBeLessThan(lr1.value.toStates().length);
84
+ }, 120_000);
85
+
86
+ it("builds the same table in both modes for an unambiguous grammar", () => {
87
+ const productions = buildProductions(`<S>: <A>;
88
+ <A: a>: [A] [PLUS] [A];
89
+ <A: a>: [ID];
90
+ `);
91
+
92
+ const lr1 = generateStates(productions);
93
+ const lalr = generateStates(productions, { mode: "lalr1" });
94
+ if (lr1.success === false || lalr.success === false) throw new Error("generation failed");
95
+
96
+ expect(lalr.value.toTable()).toBe(lr1.value.toTable());
97
+ });
98
+
99
+ it("refuses to serialize a grammar that has a state without actions", () => {
100
+ const productions = buildProductions(`<S>: <A>;
101
+ <A>: <A> <C>;
102
+ <B>: <A> [d] <C>;
103
+ <B>: <C> <C>;
104
+ <C>: <A>;
105
+ `);
106
+ const result = generateStates(productions);
107
+ if (result.success === false) throw new Error(result.reason);
108
+
109
+ // The states are usable in memory...
110
+ expect(result.value.toStates().some((state) => state.actions.size === 0)).toBe(true);
111
+ // ...but the table file format has no way to express a state without actions.
112
+ expect(() => result.value.toTable()).toThrow(/has no actions/);
113
+ });
114
+ });