@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
@@ -1,6 +1,13 @@
1
1
  import { Slex, Token as SlexToken } from "@scinorandex/slex";
2
- import { buildProductions, LR1StackSymbol, Sparse } from "../../src/index";
3
- import fs from "fs/promises";
2
+ import {
3
+ LR1StackSymbol,
4
+ Sparse,
5
+ assertReducersCoverGrammar,
6
+ defineReducers,
7
+ enumToString,
8
+ loadGrammar,
9
+ } from "../../src/index";
10
+ import path from "path";
4
11
 
5
12
  // prettier-ignore
6
13
  enum TokenType {
@@ -52,32 +59,43 @@ class KleeneNode<T> extends Node {
52
59
  }
53
60
  }
54
61
 
55
- type Reducer = (bag: any, input: LR1StackSymbol<TokenType, Metadata, Node>[]) => Node;
56
- const reducers: { [key: string]: Reducer } = {
57
- "autogenerated-kleene": (bag, input) => {
62
+ // Every reducer receives (newInput, oldInput): newInput is { bag, name } (the bag holds the named
63
+ // symbols of the right hand side), oldInput is { input, index } (the raw symbols in order).
64
+ type Reducer = (
65
+ newInput: { bag: any; name: string | null },
66
+ oldInput: { input: LR1StackSymbol<TokenType, Metadata, Node>[]; index: number },
67
+ ) => Node;
68
+
69
+ const reducers: Record<string, Reducer> = {
70
+ // Every "*" and "+" group produces a list production named "autogenerated-kleene", which has to
71
+ // flatten the list it is handed: the first reduction has no "rest", later ones do.
72
+ "autogenerated-kleene": ({ bag }, { input }) => {
58
73
  const { rest, ...otherNodes } = bag;
59
74
  if (rest == null) return new KleeneNode(input, otherNodes);
60
75
  else if (rest instanceof KleeneNode) return rest.add(input, otherNodes);
61
- // You should not be able to reach this because this function always returns a KleeneNode
62
76
  else throw new Error("Invalid reducer");
63
77
  },
64
78
 
65
- program: (bag: { first_number: Node; rest: KleeneNode<{ op: Token; extra_number: Token }> }, input) => {
66
- return new Node(input);
67
- },
79
+ program: (_, { input }) => new Node(input),
68
80
  };
69
81
 
82
+ const root = path.join(__dirname, "..", "..");
83
+
70
84
  async function main() {
71
- const toStringifiedTokenType = (type: TokenType) => TokenType[type];
72
- const productions = buildProductions(await fs.readFile("./example/kleene-test/grammar.txt", "utf8"));
85
+ const productionsResult = await loadGrammar(path.join(root, "example/kleene-test/grammar.txt"));
86
+ if (productionsResult.success === false) throw new Error(productionsResult.reason);
87
+
88
+ // Fails loudly here if a production in the grammar has no reducer, instead of halfway through a parse.
89
+ assertReducersCoverGrammar(productionsResult.value, reducers);
90
+
91
+ // No table file for this example, so the states are generated from the grammar at startup.
92
+ const parserGenerator = Sparse.fromProductions<TokenType, Metadata, Node>({
93
+ productions: productionsResult.value,
94
+ toStringifiedTokenType: enumToString(TokenType),
95
+ });
73
96
 
74
- const parserGenerator = Sparse.fromProductions<TokenType, Metadata, Node>({ productions, toStringifiedTokenType });
75
97
  const parser = parserGenerator.generate(lexer, {
76
- reducer: ({ bag, name }, { input }) => {
77
- const reducer = reducers[name ?? ""];
78
- if (reducer == null) throw new Error(`No reducer for ${name}`);
79
- return reducer(bag, input);
80
- },
98
+ reducer: defineReducers(reducers),
81
99
  });
82
100
 
83
101
  console.log(parser.parse().result!);
@@ -1,6 +1,6 @@
1
1
  import { Slex } from "@scinorandex/slex";
2
- import { buildProductions, buildStates, LR1StackSymbol, Sparse } from "../../src/index";
3
- import fs from "fs/promises";
2
+ import { LR1StackSymbol, Sparse, enumToString } from "../../src/index";
3
+ import path from "path";
4
4
 
5
5
  // prettier-ignore
6
6
  enum TokenType {
@@ -33,14 +33,19 @@ class Node {
33
33
  }
34
34
  }
35
35
 
36
+ const root = path.join(__dirname, "..", "..");
37
+
36
38
  async function main() {
37
- const toStringifiedTokenType = (type: TokenType) => TokenType[type];
38
- const productions = buildProductions(await fs.readFile("./example/math/grammar.txt", "utf8"));
39
- const states = buildStates(await fs.readFile("./example/math/table.txt", "utf8"));
40
- const parserGenerator = new Sparse<TokenType, Metadata, Node>({ productions, states, toStringifiedTokenType });
39
+ // fromGrammarFile reads the grammar, reads the prebuilt table, and checks that the two match.
40
+ const parserGenerator = await Sparse.fromGrammarFile<TokenType, Metadata, Node>({
41
+ grammarPath: path.join(root, "example/math/grammar.txt"),
42
+ tablePath: path.join(root, "example/math/table.txt"),
43
+ toStringifiedTokenType: enumToString(TokenType),
44
+ onWarning: ({ reason, token }) => console.warn(`${token.line}:${token.column} ${reason}`),
45
+ });
41
46
 
42
47
  const parser = parserGenerator.generate(lexer, { reducer: (_, { input }) => new Node(input) });
43
48
  console.log(parser.parse().result!.toObject());
44
49
  }
45
50
 
46
- main();
51
+ main();
@@ -1,39 +1,45 @@
1
1
  import fs from "fs/promises";
2
- import { generateStates } from "../../src";
3
- import { buildProductions, grammarLexerGenerator } from "../../src/meta/selfhosted";
2
+ import path from "path";
3
+ import { buildErrorWindow, generateStates, tryBuildProductions } from "../../src";
4
+ import { grammarLexerGenerator } from "../../src/meta/selfhosted";
4
5
  import { dehydateProduction } from "../../src/meta/common";
5
6
 
6
- async function codegenMetaGrammar() {
7
- const grammar = await fs.readFile("./example/selfhosted/01-meta-grammar.txt", "utf8");
7
+ const root = path.join(__dirname, "..", "..");
8
8
 
9
- const lexer = grammarLexerGenerator.generate(grammar, () => ({}));
10
- const productions = buildProductions(lexer);
11
- const statesResult = generateStates(productions);
12
- if (statesResult.success === false) throw new Error(statesResult.reason);
13
-
14
- await fs.writeFile(
15
- "./example/selfhosted/codegen-meta.ts",
16
- `export const states = ${JSON.stringify(statesResult.value.toJSObject(), null, 2)};
17
- export const productions = ${JSON.stringify(productions.map(dehydateProduction), null, 2)};
18
- export const selfhosted = { states, productions };`,
19
- );
20
- }
21
-
22
- async function codegenTableGrammar() {
23
- const grammar = await fs.readFile("./example/selfhosted/02-table-grammar.txt", "utf8");
9
+ /**
10
+ * Regenerates the LR(1) states that Sparse uses to read its own grammar and table file formats.
11
+ * `src/meta/states.ts` and `src/table/states.ts` are byte-identical copies of the two files this
12
+ * writes, so after running this you must copy them over.
13
+ */
14
+ async function codegen(name: string, grammarName: string, outputPath: string) {
15
+ const grammarPath = path.join(root, "example/selfhosted", grammarName);
16
+ const grammar = await fs.readFile(grammarPath, "utf8");
24
17
 
25
18
  const lexer = grammarLexerGenerator.generate(grammar, () => ({}));
26
- const productions = buildProductions(lexer);
19
+ const productionsResult = tryBuildProductions(lexer);
20
+ if (productionsResult.success === false) {
21
+ console.error(`${productionsResult.reason}\n${buildErrorWindow(grammar, productionsResult.token)}`);
22
+ process.exitCode = 1;
23
+ return;
24
+ }
25
+ const productions = productionsResult.value;
26
+
27
27
  const statesResult = generateStates(productions);
28
- if (statesResult.success === false) throw new Error(statesResult.reason);
28
+ if (statesResult.success === false) {
29
+ console.error(`${statesResult.reason}\n${buildErrorWindow(grammar, statesResult.token)}`);
30
+ process.exitCode = 1;
31
+ return;
32
+ }
29
33
 
30
34
  await fs.writeFile(
31
- "./example/selfhosted/codegen-table.ts",
35
+ path.join(root, "example/selfhosted", outputPath),
32
36
  `export const states = ${JSON.stringify(statesResult.value.toJSObject(), null, 2)};
33
37
  export const productions = ${JSON.stringify(productions.map(dehydateProduction), null, 2)};
34
38
  export const selfhosted = { states, productions };`,
35
39
  );
40
+
41
+ console.log(`${name}: ${productions.length} productions, ${statesResult.value.states.length} states -> ${outputPath}`);
36
42
  }
37
43
 
38
- codegenMetaGrammar();
39
- codegenTableGrammar();
44
+ codegen("meta grammar", "01-meta-grammar.txt", "codegen-meta.ts");
45
+ codegen("table grammar", "02-table-grammar.txt", "codegen-table.ts");
package/opencode.json ADDED
@@ -0,0 +1,18 @@
1
+ {
2
+ "$schema": "https://opencode.ai/config.json",
3
+ "enabled_providers": ["llama", "openrouter"],
4
+ "provider": {
5
+ "llama": {
6
+ "npm": "@ai-sdk/openai-compatible",
7
+ "name": "llama.cpp",
8
+ "options": { "baseURL": "http://192.168.0.78:8080/v1" },
9
+ "models": {
10
+ "your-model-id": {
11
+ "id": "Qwen3.8-27B-GGUF:UD-IQ3_XXS",
12
+ "name": "Qwen3.8-27B-GGUF:UD-IQ3_XXS",
13
+ "limit": { "context": 163840, "output": 8192 }
14
+ }
15
+ }
16
+ }
17
+ }
18
+ }
package/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@scinorandex/sparse",
3
- "version": "0.0.8",
3
+ "version": "0.1.2",
4
4
  "description": "Yet another parser generator",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "build": "tsc -p tsconfig.build.json",
8
- "watch": "tsc -w"
8
+ "watch": "tsc -w",
9
+ "tc": "tsc --noEmit -p tsconfig.json",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest",
12
+ "bench": "vitest bench"
9
13
  },
10
14
  "bin": {
11
15
  "sparse": "dist/cli.js"
@@ -27,7 +31,9 @@
27
31
  "devDependencies": {
28
32
  "@types/minimist": "^1.2.5",
29
33
  "@types/node": "^26.0.0",
30
- "@scinorandex/slex": "^0.0.4"
34
+ "@scinorandex/slex": "^0.1.2",
35
+ "typescript": "^5.6.0",
36
+ "vitest": "^3.0.0"
31
37
  },
32
38
  "peerDependencies": {
33
39
  "@scinorandex/slex": "*"
package/src/cli.ts CHANGED
@@ -1,49 +1,158 @@
1
1
  #! /usr/bin/env node
2
2
  import fs from "fs/promises";
3
- import { tryBuildProductions } from "./index";
4
- import { generateStates } from "./generator";
5
- import minimist from "minimist";
6
3
  import path from "path";
7
- import { buildErrorWindow } from "./utils/errorWindowBuilder";
4
+ import minimist from "minimist";
5
+ import { tryBuildProductions, tryBuildStates, generateStates, validateTable, buildErrorWindow } from "./index";
6
+ import type { GeneratorResult, ProductionWarning } from "./index";
8
7
 
9
8
  const args = minimist(process.argv.slice(2));
10
9
 
11
- function checkFileExists(filepath: string) {
12
- return new Promise<boolean>((resolve, reject) => {
13
- fs.access(filepath, fs.constants.F_OK)
14
- .then(() => resolve(true))
15
- .catch(() => resolve(false));
16
- });
10
+ const BINARY_NAME = "@scinorandex/sparse";
11
+
12
+ const USAGE = `Usage: npx @scinorandex/sparse --input=<grammar> --output=<table> [options]
13
+
14
+ Generates an LR(1) parsing table from a grammar file.
15
+
16
+ Required:
17
+ --input=<file> Grammar file to read
18
+ --output=<file> Where to write the parsing table (created if the directory is missing)
19
+
20
+ Options:
21
+ --lalr Generate LALR(1) states instead of LR(1) (smaller table, ~35% faster parsing)
22
+ --check Do not write anything: verify that <output> already matches the grammar
23
+ --stdout Print the table instead of writing it to a file
24
+ --quiet Do not print warnings about suspicious grammar rules
25
+ --help Show this message
26
+
27
+ Run with npx ${BINARY_NAME} (the package is @scinorandex/sparse).`;
28
+
29
+ function fail(message: string) {
30
+ console.error(message);
31
+ process.exitCode = 1;
32
+ }
33
+
34
+ /** Serializing can fail for grammars the table file format cannot express; report that, not a stack trace. */
35
+ function toTableOrFail(result: GeneratorResult): string | null {
36
+ try {
37
+ return result.toTable();
38
+ } catch (err) {
39
+ fail(err instanceof Error ? err.message : String(err));
40
+ return null;
41
+ }
17
42
  }
18
43
 
19
44
  async function main() {
20
- if (typeof args.input !== "string" || typeof args.output !== "string") {
21
- console.log("Usage: npx @scinorandex/sparse --input=<input> --output=<output>");
45
+ if (args.help === true || args.h === true) {
46
+ console.log(USAGE);
47
+ return;
48
+ }
49
+
50
+ if (typeof args.input !== "string") {
51
+ fail(`Missing --input.\n\n${USAGE}`);
52
+ return;
53
+ }
54
+
55
+ const useStdout = args.stdout === true;
56
+ const check = args.check === true;
57
+ if (typeof args.output !== "string" && !useStdout) {
58
+ fail(`Missing --output.\n\n${USAGE}`);
22
59
  return;
23
60
  }
24
61
 
25
62
  const inputFile = path.resolve(args.input);
26
- const outputFile = path.resolve(args.output);
27
- console.log(`Reading grammar from ${inputFile} and outputting table to ${outputFile}\n`);
63
+ const outputFile = typeof args.output === "string" ? path.resolve(args.output) : undefined;
64
+
65
+ let grammar: string;
66
+ try {
67
+ grammar = await fs.readFile(inputFile, "utf8");
68
+ } catch {
69
+ fail(`Cannot read grammar file ${inputFile}: it does not exist.`);
70
+ return;
71
+ }
28
72
 
29
- if (!(await checkFileExists(inputFile))) return console.log(`File ${inputFile} does not exist`);
73
+ const onWarning = (warning: ProductionWarning) => {
74
+ if (args.quiet === true) return;
75
+ console.error(`Warning in ${inputFile} at ${warning.token.line}:${warning.token.column}: ${warning.reason}`);
76
+ };
30
77
 
31
- const grammar = await fs.readFile(args.input, "utf8");
32
- const productionsResult = tryBuildProductions(grammar);
78
+ const productionsResult = tryBuildProductions(grammar, { onWarning });
33
79
  if (productionsResult.success === false) {
34
- console.log(productionsResult.reason);
35
- console.log(buildErrorWindow(grammar, productionsResult.token));
80
+ fail(`${productionsResult.reason}\n\n${buildErrorWindow(grammar, productionsResult.token)}`);
81
+ return;
82
+ }
83
+ const productions = productionsResult.value;
84
+
85
+ if (check) {
86
+ if (outputFile == null) {
87
+ fail(`--check needs --output to know which table to check.\n\n${USAGE}`);
88
+ return;
89
+ }
90
+
91
+ let existingTable: string;
92
+ try {
93
+ existingTable = await fs.readFile(outputFile, "utf8");
94
+ } catch {
95
+ fail(`Cannot check ${outputFile}: it does not exist.`);
96
+ return;
97
+ }
98
+
99
+ const statesResult = tryBuildStates(existingTable, { productions });
100
+ if (statesResult.success === false) {
101
+ fail(`The existing table is not a valid parsing table:\n${statesResult.reason}`);
102
+ return;
103
+ }
104
+
105
+ const tableCheckResult = validateTable(productions, statesResult.value, { source: outputFile });
106
+ if (tableCheckResult.success === false)
107
+ fail(`The existing table does not match the grammar:\n${tableCheckResult.reason}`);
108
+
109
+ const expected = generateStates(productions, { mode: args.lalr === true ? "lalr1" : "lr1" });
110
+ if (expected.success === false) {
111
+ fail(`${expected.reason}\n\n${buildErrorWindow(grammar, expected.token)}`);
112
+ return;
113
+ }
114
+
115
+ const expectedTable = toTableOrFail(expected.value);
116
+ if (expectedTable == null) return;
117
+ const actualTable = existingTable.trim();
118
+
119
+ if (expectedTable !== actualTable) {
120
+ fail(
121
+ `${outputFile} is out of date with respect to ${inputFile}.\n` +
122
+ `Re-run without --check to regenerate it${args.lalr === true ? " (with --lalr)" : ""}.`,
123
+ );
124
+ return;
125
+ }
126
+
127
+ console.log(
128
+ `${outputFile} is up to date with respect to ${inputFile} (${expectedTable.split("\n").length} states).`,
129
+ );
36
130
  return;
37
131
  }
38
132
 
39
- const generatorResult = generateStates(productionsResult.value);
133
+ const generatorResult = generateStates(productions, { mode: args.lalr === true ? "lalr1" : "lr1" });
40
134
  if (generatorResult.success === false) {
41
- console.log(generatorResult.reason);
42
- console.log(buildErrorWindow(grammar, generatorResult.token));
135
+ fail(`${generatorResult.reason}\n\n${buildErrorWindow(grammar, generatorResult.token)}`);
43
136
  return;
44
137
  }
45
138
 
46
- await fs.writeFile(outputFile, generatorResult.value.toTable(), { encoding: "utf-8" });
139
+ const table = toTableOrFail(generatorResult.value);
140
+ if (table == null) return;
141
+
142
+ if (useStdout) {
143
+ console.log(table);
144
+ return;
145
+ }
146
+
147
+ if (outputFile == null) return;
148
+
149
+ await fs.mkdir(path.dirname(outputFile), { recursive: true });
150
+ await fs.writeFile(outputFile, table, { encoding: "utf-8" });
151
+
152
+ const stateCount = table === "" ? 0 : table.split("\n").length;
153
+ console.log(
154
+ `Wrote ${stateCount} ${args.lalr === true ? "LALR(1)" : "LR(1)"} states from ${inputFile} to ${outputFile}.`,
155
+ );
47
156
  }
48
157
 
49
158
  main();