@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,8 +1,17 @@
1
1
  import { RegexEngine, Slex } from "@scinorandex/slex";
2
+ import { describeThrowable, syntheticGrammarToken } from "../meta/common";
2
3
  import { Result, Sparse } from "../index";
3
4
  import { selfhosted } from "./states";
4
- import { TableState } from "../parser";
5
- import { GrammarToken, GrammarTokenMetadata, GrammarTokenType, hydrateProduction, Production } from "./common";
5
+ import { LR1ParserGraveError, TableState } from "../parser";
6
+ import {
7
+ GrammarToken,
8
+ GrammarTokenMetadata,
9
+ GrammarTokenType,
10
+ hydrateProduction,
11
+ Production,
12
+ ValidateProductionsOptions,
13
+ validateProductions,
14
+ } from "./common";
6
15
 
7
16
  export const grammarLexerGenerator = new Slex<GrammarTokenType, GrammarTokenMetadata>({
8
17
  EOF_TYPE: GrammarTokenType.EOF,
@@ -21,9 +30,12 @@ grammarLexerGenerator.addRule(
21
30
  grammarLexerGenerator.addRule("letter", "${lowercase} | ${uppercase}");
22
31
  grammarLexerGenerator.addRule("digit", "0 | 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9");
23
32
  grammarLexerGenerator.addRule("alphanumeric", "${letter} | ${digit}");
33
+ // Dashes are allowed inside identifiers (but not first) so that the autogenerated productions of a
34
+ // "*" or "+" group, which are named "autogen-0", "autogen-1", ... survive a round trip through a
35
+ // table file.
24
36
  grammarLexerGenerator.addRule(
25
37
  "identifier",
26
- "(${letter} | $_) (${letter} | ${digit} | $_)*",
38
+ "(${letter} | $_) (${letter} | ${digit} | $_ | $-)*",
27
39
  GrammarTokenType.IDENTIFIER,
28
40
  );
29
41
  grammarLexerGenerator.addRule("colon", "$:", GrammarTokenType.COLON);
@@ -312,8 +324,15 @@ const reducers: { [key: string]: Reducer } = {
312
324
  },
313
325
  };
314
326
 
315
- export const tryBuildProductions = (lexer: LexerInterface | string): Result<Production[]> => {
316
- if (typeof lexer === "string") lexer = grammarLexerGenerator.generate(lexer, () => ({}));
327
+ export const tryBuildProductions = (
328
+ lexer: LexerInterface | string,
329
+ options: ValidateProductionsOptions = {},
330
+ ): Result<Production[]> => {
331
+ try {
332
+ if (typeof lexer === "string") lexer = grammarLexerGenerator.generate(lexer, () => ({}));
333
+ } catch (err) {
334
+ return { success: false, reason: describeThrowable(err), token: syntheticGrammarToken() };
335
+ }
317
336
 
318
337
  const parserGenerator = getSelfHostedParserGenerator();
319
338
  const parser = parserGenerator.generate(lexer as RegexEngine<GrammarTokenType, GrammarTokenMetadata>, {
@@ -322,19 +341,31 @@ export const tryBuildProductions = (lexer: LexerInterface | string): Result<Prod
322
341
  },
323
342
  });
324
343
 
325
- const parsingResult = parser.parse().result as ProgramNode;
326
- const productions = parsingResult.getProductions();
327
- return { success: true, value: productions };
344
+ let programNode: ProgramNode;
345
+ try {
346
+ programNode = parser.parse().result as ProgramNode;
347
+ } catch (err) {
348
+ // The grammar parser has no error recovery, so a malformed grammar surfaces as a
349
+ // LR1ParserGraveError. Translate it into the Result failure every caller already handles.
350
+ if (err instanceof LR1ParserGraveError && err.currentToken != null)
351
+ return { success: false, reason: err.reason, token: err.currentToken as GrammarToken };
352
+ return { success: false, reason: describeThrowable(err), token: syntheticGrammarToken() };
353
+ }
354
+
355
+ return validateProductions(programNode.getProductions(), options);
328
356
  };
329
357
 
330
- interface LexerInterface {
358
+ export interface LexerInterface {
331
359
  peekNextToken(): GrammarToken;
332
360
  getNextToken(): GrammarToken;
333
361
  hasNextToken(): boolean;
334
362
  }
335
363
 
336
- export const buildProductions = (lexer: LexerInterface | string): Production[] => {
337
- const result = tryBuildProductions(lexer);
364
+ export const buildProductions = (
365
+ lexer: LexerInterface | string,
366
+ options: ValidateProductionsOptions = {},
367
+ ): Production[] => {
368
+ const result = tryBuildProductions(lexer, options);
338
369
  if (result.success === false)
339
370
  throw new Error(
340
371
  `Encountered error "${result.reason}" while parsing grammar at ${result.token.line}:${result.token.column}`,
package/src/parser.ts CHANGED
@@ -2,15 +2,27 @@ import { Slex, Token } from "@scinorandex/slex";
2
2
  import { generateStates } from "./generator";
3
3
  import { Stack } from "./utils/Stack";
4
4
  import { Result } from "./utils/Result";
5
- import { Production } from "./meta/common";
5
+ import { GrammarToken, Production, ProductionWarning } from "./meta/common";
6
+ import { loadGrammar, loadTable } from "./utils/loadFiles";
7
+ import { validateTable } from "./table/validate";
6
8
 
7
9
  export type TableAction = { type: "shift" | "reduce" | "goto"; value: number };
8
10
 
9
11
  export class TableState {
10
12
  public readonly actions: Map<string, TableAction>;
11
13
 
12
- constructor(actions = new Map<string, TableAction>()) {
14
+ /**
15
+ * The actions exactly as they were written in a table file (`"s12"`, `"r3"`, `"7"`), kept so that
16
+ * malformed entries can be reported with the text the user actually typed.
17
+ */
18
+ public readonly rawActions: Map<string, string>;
19
+
20
+ constructor(
21
+ actions = new Map<string, TableAction>(),
22
+ rawActions = new Map<string, string>(),
23
+ ) {
13
24
  this.actions = actions;
25
+ this.rawActions = rawActions;
14
26
  }
15
27
 
16
28
  public getTerminalAction(terminal: string): TableAction | undefined {
@@ -21,16 +33,41 @@ export class TableState {
21
33
  return this.actions.get(variable);
22
34
  }
23
35
 
36
+ /** Every terminal this state can consume, without the surrounding brackets, sorted. */
37
+ public getTerminalKeys(): string[] {
38
+ return this.getKeys("[", "]");
39
+ }
40
+
41
+ /** Every variable this state can reduce past, without the surrounding angle brackets, sorted. */
42
+ public getVariableKeys(): string[] {
43
+ return this.getKeys("<", ">");
44
+ }
45
+
46
+ private getKeys(prefix: string, suffix: string): string[] {
47
+ const keys: string[] = [];
48
+ for (const key of this.actions.keys())
49
+ if (key.startsWith(prefix) && key.endsWith(suffix))
50
+ keys.push(key.substring(prefix.length, key.length - suffix.length));
51
+ return keys.sort();
52
+ }
53
+
24
54
  toJSObject() {
25
55
  return Object.fromEntries(this.actions);
26
56
  }
27
57
 
28
58
  static fromJSObject(json: ReturnType<TableState["toJSObject"]>) {
29
- return new TableState(new Map(Object.entries(json)));
59
+ const actions = new Map(Object.entries(json));
60
+ const rawActions = new Map(
61
+ [...actions].map(([key, action]) => [
62
+ key,
63
+ action.type === "goto" ? `${action.value}` : `${action.type === "shift" ? "s" : "r"}${action.value}`,
64
+ ]),
65
+ );
66
+ return new TableState(actions, rawActions);
30
67
  }
31
68
  }
32
69
 
33
- type ReducerType<TokenType, Metadata, Node> = (
70
+ export type ReducerType<TokenType, Metadata, Node> = (
34
71
  newInput: { bag: any; name: string | null },
35
72
  oldInput: { input: LR1StackSymbol<TokenType, Metadata, Node>[]; index: number },
36
73
  ) => Node;
@@ -39,20 +76,50 @@ export type LR1StackSymbol<TokenType, Metadata, Node> =
39
76
  | { type: "token"; token: Token<TokenType, Metadata> }
40
77
  | { type: "node"; node: Node };
41
78
 
79
+ export type SparseOptions<TokenType> = {
80
+ productions: Production[];
81
+ states: TableState[];
82
+ toStringifiedTokenType: (tokenType: TokenType) => string;
83
+ /**
84
+ * Cross-check the productions against the parsing table right away, so a stale table is reported
85
+ * here instead of as an opaque crash in the middle of a parse. Off by default.
86
+ */
87
+ validate?: boolean;
88
+ /** Name of the grammar file, used to make validation errors actionable. */
89
+ source?: string;
90
+ };
91
+
92
+ export type FromProductionsOptions<TokenType> = {
93
+ productions: Production[];
94
+ toStringifiedTokenType: (tokenType: TokenType) => string;
95
+ mode?: "lr1" | "lalr1";
96
+ /** Generating states can take a while for big grammars; set to true to silence the timing log. */
97
+ quiet?: boolean;
98
+ onWarning?: (warning: ProductionWarning) => void;
99
+ onProgress?: (statesGenerated: number) => void;
100
+ };
101
+
42
102
  export class Sparse<TokenType, Metadata, Node> {
43
103
  public constructor(
44
- public readonly options: {
45
- productions: Production[];
46
- states: TableState[];
47
- toStringifiedTokenType: (tokenType: TokenType) => string;
48
- },
49
- ) {}
104
+ public readonly options: SparseOptions<TokenType>,
105
+ ) {
106
+ if (options.validate === true) {
107
+ const result = validateTable(options.productions, options.states, { source: options.source });
108
+ if (result.success === false)
109
+ throw new Error(`The parsing table does not match the grammar: ${result.reason}`);
110
+ }
111
+ }
50
112
 
51
- public static tryFromProductions<TokenType, Metadata, Node>(options: {
52
- productions: Production[];
53
- toStringifiedTokenType: (tokenType: TokenType) => string;
54
- }): Result<Sparse<TokenType, Metadata, Node>> {
55
- const statesResult = generateStates(options.productions);
113
+ public static tryFromProductions<TokenType, Metadata, Node>(
114
+ options: FromProductionsOptions<TokenType>,
115
+ ): Result<Sparse<TokenType, Metadata, Node>> {
116
+ const startedAt = Date.now();
117
+
118
+ const statesResult = generateStates(options.productions, {
119
+ mode: options.mode,
120
+ onWarning: options.onWarning,
121
+ onProgress: options.onProgress,
122
+ });
56
123
  if (statesResult.success === false) return statesResult;
57
124
  const states = statesResult.value;
58
125
 
@@ -62,16 +129,63 @@ export class Sparse<TokenType, Metadata, Node> {
62
129
  toStringifiedTokenType: options.toStringifiedTokenType,
63
130
  });
64
131
 
132
+ if (options.quiet !== true) {
133
+ const elapsed = Date.now() - startedAt;
134
+ const label = options.mode === "lalr1" ? "LALR(1)" : "LR(1)";
135
+ console.error(`Sparse generated ${sparse.options.states.length} ${label} states in ${elapsed}ms`);
136
+ }
137
+
65
138
  return { success: true, value: sparse };
66
139
  }
67
140
 
68
- public static fromProductions<TokenType, Metadata, Node>(options: {
69
- productions: Production[];
70
- toStringifiedTokenType: (tokenType: TokenType) => string;
71
- }) {
141
+ public static fromProductions<TokenType, Metadata, Node>(options: FromProductionsOptions<TokenType>) {
72
142
  const result = Sparse.tryFromProductions<TokenType, Metadata, Node>(options);
73
- if (result.success === false)
74
- throw new Error(`Encountered error "${result.reason}" at ${result.token.line}:${result.token.column}`);
143
+ if (result.success === false) throw new SparseGrammarError(result.reason, result.token);
144
+ return result.value;
145
+ }
146
+
147
+ /** Builds a parser generator from a grammar file and a prebuilt parsing table file. */
148
+ public static async tryFromGrammarFile<TokenType, Metadata, Node>(options: {
149
+ grammarPath: string;
150
+ tablePath: string;
151
+ toStringifiedTokenType: (tokenType: TokenType) => string;
152
+ onWarning?: (warning: ProductionWarning) => void;
153
+ /** Set to false to skip cross-checking the table against the grammar. */
154
+ validate?: boolean;
155
+ }): Promise<Result<Sparse<TokenType, Metadata, Node>>> {
156
+ const productionsResult = await loadGrammar(options.grammarPath, { onWarning: options.onWarning });
157
+ if (productionsResult.success === false) return productionsResult;
158
+
159
+ const statesResult = await loadTable(options.tablePath, { productions: productionsResult.value });
160
+ if (statesResult.success === false) return statesResult;
161
+
162
+ if (options.validate !== false) {
163
+ const tableResult = validateTable(productionsResult.value, statesResult.value, {
164
+ source: options.tablePath,
165
+ });
166
+ if (tableResult.success === false) return tableResult;
167
+ }
168
+
169
+ return {
170
+ success: true,
171
+ value: new Sparse<TokenType, Metadata, Node>({
172
+ productions: productionsResult.value,
173
+ states: statesResult.value,
174
+ toStringifiedTokenType: options.toStringifiedTokenType,
175
+ source: options.grammarPath,
176
+ }),
177
+ };
178
+ }
179
+
180
+ public static async fromGrammarFile<TokenType, Metadata, Node>(options: {
181
+ grammarPath: string;
182
+ tablePath: string;
183
+ toStringifiedTokenType: (tokenType: TokenType) => string;
184
+ onWarning?: (warning: ProductionWarning) => void;
185
+ validate?: boolean;
186
+ }): Promise<Sparse<TokenType, Metadata, Node>> {
187
+ const result = await Sparse.tryFromGrammarFile<TokenType, Metadata, Node>(options);
188
+ if (result.success === false) throw new SparseGrammarError(result.reason, result.token);
75
189
  return result.value;
76
190
  }
77
191
 
@@ -97,6 +211,12 @@ export type ParserRecoveryFunction<TokenType, Metadata, Node> = (options: {
97
211
  success: true;
98
212
  token?: Token<TokenType, Metadata>;
99
213
  };
214
+ /**
215
+ * Pushes a token that the input did not contain (a synthesized semicolon, a closing brace, ...)
216
+ * onto both stacks, exactly as if the lexer had produced it. Unlike `finish({ newToken })`, which
217
+ * only hands the token back to the parser as a lookahead, this one actually shifts it.
218
+ */
219
+ insertToken: (token: Token<TokenType, Metadata>) => { success: false; reason: string } | undefined;
100
220
  states: TableState[];
101
221
  }) => { success: true; token?: Token<TokenType, Metadata> } | { success: false; reason: string };
102
222
 
@@ -106,38 +226,52 @@ export type ParserResult<TokenType, Metadata, Node> = {
106
226
  errors: ParserError<TokenType, Metadata>[];
107
227
  };
108
228
 
109
- class LR1Parser<TokenType, Metadata, Node> {
229
+ export class LR1Parser<TokenType, Metadata, Node> {
110
230
  statesStack: Stack<number> = new Stack([0]);
111
231
  symbolsStack: Stack<LR1StackSymbol<TokenType, Metadata, Node>> = new Stack();
112
232
  exceptions: ParserError<TokenType, Metadata>[] = [];
113
233
 
114
234
  public constructor(
115
- public readonly options: {
116
- productions: Production[];
117
- states: TableState[];
118
- toStringifiedTokenType: (tokenType: TokenType) => string;
119
- },
235
+ public readonly options: SparseOptions<TokenType>,
120
236
  public readonly reducer: ReducerType<TokenType, Metadata, Node>,
121
237
  public readonly recover: ParserRecoveryFunction<TokenType, Metadata, Node> | null,
122
238
  public readonly lexer: ReturnType<Slex<TokenType, Metadata>["generate"]>,
123
239
  ) {}
124
240
 
241
+ /**
242
+ * Clears the stacks and the collected errors so the parser can be handed to a fresh lexer.
243
+ * A parser instance consumes its lexer, so the usual way to parse a second input is to call
244
+ * `generator.generate(newLexer, options)` again.
245
+ */
246
+ public reset() {
247
+ this.statesStack = new Stack([0]);
248
+ this.symbolsStack = new Stack();
249
+ this.exceptions = [];
250
+ }
251
+
125
252
  public parse(): ParserResult<TokenType, Metadata, Node> {
126
253
  const { productions, states, toStringifiedTokenType } = this.options;
127
254
 
255
+ if (states.length === 0)
256
+ throw new LR1ParserGraveError("The parsing table is empty, so no token can be parsed", null as any);
257
+
258
+ const currentStates = () => {
259
+ const state = states[this.statesStack.peek()];
260
+ if (state == null)
261
+ throw new LR1ParserGraveError(
262
+ `The parsing table is out of sync with the grammar: state ${this.statesStack.peek()} does not exist (the table has ${states.length} states)`,
263
+ null as any,
264
+ );
265
+ return state;
266
+ };
267
+
128
268
  while (true) {
129
- let currentState = this.statesStack.peek();
269
+ let currentState = currentStates();
130
270
  let token = this.lexer.peekNextToken();
131
- let action = states[currentState].getTerminalAction(toStringifiedTokenType(token.type));
271
+ let action = currentState.getTerminalAction(toStringifiedTokenType(token.type));
132
272
 
133
273
  if (action == null) {
134
- if (this.recover === null)
135
- throw new LR1ParserGraveError(
136
- `Invalid syntax. Expected ${[...states[currentState].actions.keys()].join(
137
- ",",
138
- )} but got ${toStringifiedTokenType(token.type)}`,
139
- token,
140
- );
274
+ if (this.recover === null) throw new LR1ParserGraveError(this.syntaxErrorMessage(currentState, token), token);
141
275
 
142
276
  const recoveryResult = this.recover({
143
277
  lexer: this.lexer,
@@ -147,22 +281,25 @@ class LR1Parser<TokenType, Metadata, Node> {
147
281
  addError: (reason: string) => this.exceptions.push({ message: reason, token: this.lexer.peekNextToken() }),
148
282
  crash: (reason: string) => ({ success: false, reason }),
149
283
  finish: (options?: { newToken: Token<TokenType, Metadata> }) => ({ success: true, token: options?.newToken }),
284
+ insertToken: (newToken: Token<TokenType, Metadata>) => this.insertToken(newToken),
150
285
  isSafe: () =>
151
- states[this.statesStack.peek()].actions.has(`[${toStringifiedTokenType(this.lexer.peekNextToken().type)}]`),
286
+ currentStates().actions.has(`[${toStringifiedTokenType(this.lexer.peekNextToken().type)}]`),
152
287
  });
153
288
 
154
289
  if (recoveryResult.success === false) throw new LR1ParserGraveError(recoveryResult.reason, token);
155
290
 
156
291
  token = recoveryResult.token ?? this.lexer.peekNextToken();
157
- currentState = this.statesStack.peek();
158
- action = states[currentState].getTerminalAction(toStringifiedTokenType(token.type));
292
+ currentState = currentStates();
293
+ action = currentState.getTerminalAction(toStringifiedTokenType(token.type));
294
+
295
+ if (action == null) throw new LR1ParserGraveError(this.syntaxErrorMessage(currentState, token), token);
159
296
  }
160
297
 
161
298
  const fixedAction = action as TableAction;
162
299
  if (fixedAction.type === "shift") {
163
300
  // add current token to the stack and push the next state
164
301
  token = this.lexer.getNextToken();
165
- this.statesStack.push(fixedAction.value);
302
+ this.pushState(fixedAction.value);
166
303
  this.symbolsStack.push({ type: "token", token: token });
167
304
  }
168
305
 
@@ -171,6 +308,12 @@ class LR1Parser<TokenType, Metadata, Node> {
171
308
  if (fixedAction.value === 0) break;
172
309
  const production = productions[fixedAction.value];
173
310
 
311
+ if (production == null)
312
+ throw new LR1ParserGraveError(
313
+ `The parsing table is out of sync with the grammar: it reduces by production ${fixedAction.value}, but the grammar only has ${productions.length} productions`,
314
+ token,
315
+ );
316
+
174
317
  // pop the stack and reduce by this production
175
318
  const popped: LR1StackSymbol<TokenType, Metadata, Node>[] = [];
176
319
  for (let i = 0; i < production.rhs.length; i++) {
@@ -198,17 +341,18 @@ class LR1Parser<TokenType, Metadata, Node> {
198
341
  const node = this.reducer(newInput, oldInput);
199
342
  this.symbolsStack.push({ type: "node", node });
200
343
  } catch (err) {
201
- const input = JSON.stringify(popped, null, 2);
202
- console.log(err);
344
+ const cause = err instanceof Error ? err.message : String(err);
345
+ const input = truncate(JSON.stringify(popped), 500);
203
346
  throw new LR1ParserGraveError(
204
- `Error while performing reduction for production: ${productionIndex}. Reduction input: ${input}`,
347
+ `Error while performing the reduction for production ${productionIndex}${describeProduction(production)}: ${cause}\nReduction input: ${input}`,
205
348
  token,
349
+ err instanceof Error ? err : undefined,
206
350
  );
207
351
  }
208
352
 
209
353
  // get the top node and figure out what state to add to state stack
210
354
  const topNode = this.statesStack.peek();
211
- const gotoAction = states[topNode].getVariableAction(production.identifier);
355
+ const gotoAction = currentStates().getVariableAction(production.identifier);
212
356
 
213
357
  if (gotoAction == undefined)
214
358
  throw new LR1ParserGraveError(
@@ -221,21 +365,89 @@ class LR1Parser<TokenType, Metadata, Node> {
221
365
  token,
222
366
  );
223
367
 
224
- this.statesStack.push(gotoAction.value);
368
+ this.pushState(gotoAction.value);
225
369
  }
226
370
  }
227
371
 
372
+ // A table can accept the empty input by reducing to production 0 straight away, in which case
373
+ // nothing was ever shifted and there is no tree to hand back.
374
+ if (this.symbolsStack.isEmpty) return { result: null, errors: this.exceptions };
375
+
228
376
  const top = this.symbolsStack.peek();
229
377
  return { result: top.type === "node" ? top.node : null, errors: this.exceptions };
230
378
  }
379
+
380
+ private pushState(state: number) {
381
+ if (state === undefined || this.options.states[state] == null)
382
+ throw new LR1ParserGraveError(
383
+ `The parsing table is out of sync with the grammar: it points at state ${state}, but the table only has ${this.options.states.length} states`,
384
+ null as any,
385
+ );
386
+ this.statesStack.push(state);
387
+ }
388
+
389
+ /** Shifts a token that was not in the input, used by error recovery. */
390
+ private insertToken(token: Token<TokenType, Metadata>): { success: false; reason: string } | undefined {
391
+ const state = this.options.states[this.statesStack.peek()];
392
+ const action = state?.getTerminalAction(this.options.toStringifiedTokenType(token.type));
393
+
394
+ if (action == null)
395
+ return {
396
+ success: false,
397
+ reason: `Cannot insert ${this.options.toStringifiedTokenType(token.type)} here: state ${
398
+ this.statesStack.peek()
399
+ } does not accept it. Expected ${state?.getTerminalKeys().join(", ") ?? "nothing"}`,
400
+ };
401
+
402
+ if (action.type !== "shift")
403
+ return {
404
+ success: false,
405
+ reason: `Cannot insert ${this.options.toStringifiedTokenType(
406
+ token.type,
407
+ )} here: state ${this.statesStack.peek()} reduces on it instead of shifting it`,
408
+ };
409
+
410
+ this.pushState(action.value);
411
+ this.symbolsStack.push({ type: "token", token });
412
+ return undefined;
413
+ }
414
+
415
+ private syntaxErrorMessage(state: TableState, token: Token<TokenType, Metadata>) {
416
+ const expected = state.getTerminalKeys();
417
+ const got = this.options.toStringifiedTokenType(token.type);
418
+ const lexeme = token.lexeme === "" ? "" : ` ("${token.lexeme}")`;
419
+ return (
420
+ `Invalid syntax at ${token.line}:${token.column}: got ${got}${lexeme}, but expected ${
421
+ expected.length === 0 ? "the end of the input" : `one of [${expected.join("], [")}]`
422
+ }`
423
+ );
424
+ }
231
425
  }
232
426
 
233
427
  export class LR1ParserGraveError<TokenType, Metadata> extends Error {
234
428
  constructor(
235
429
  public readonly reason: string,
236
- public readonly currentToken: Token<TokenType, Metadata>,
430
+ public readonly currentToken: Token<TokenType, Metadata> | null,
237
431
  err?: Error,
238
432
  ) {
239
- super(reason);
433
+ super(reason, err === undefined ? undefined : { cause: err });
434
+ this.name = "LR1ParserGraveError";
240
435
  }
241
436
  }
437
+
438
+ /** A grammar (or table) problem found before parsing started, always tied to a place in the file. */
439
+ export class SparseGrammarError extends Error {
440
+ constructor(
441
+ public readonly reason: string,
442
+ public readonly token: GrammarToken,
443
+ ) {
444
+ super(`Encountered error "${reason}" at ${token.line}:${token.column}`);
445
+ this.name = "SparseGrammarError";
446
+ }
447
+ }
448
+
449
+ const truncate = (value: string, max: number) =>
450
+ value.length <= max ? value : `${value.slice(0, max)}... (${value.length} characters)`;
451
+
452
+ const describeProduction = (production: Production) =>
453
+ production.name == null ? ` (${production.identifier})` : ` ("${production.name}")`;
@@ -1,7 +1,9 @@
1
- import { GrammarToken } from "../meta/common";
1
+ import { GrammarToken, Production, describeThrowable, syntheticGrammarToken } from "../meta/common";
2
2
  import { BaseNode, getSelfHostedParserGenerator, grammarLexerGenerator, ListNode } from "../meta/selfhosted";
3
- import { TableAction, TableState } from "../parser";
3
+ import { LR1ParserGraveError, TableAction, TableState } from "../parser";
4
4
  import { selfhosted } from "./states";
5
+ import { Result } from "../utils/Result";
6
+ import { TableFormatWarning, validateTableStates } from "./validate";
5
7
 
6
8
  class StateNode extends BaseNode {
7
9
  constructor(public readonly items: ListNode<ItemNode>) {
@@ -10,9 +12,11 @@ class StateNode extends BaseNode {
10
12
 
11
13
  toTableState(): TableState {
12
14
  const state = new TableState();
13
- for (const item of this.items.getItems()) {
15
+ // The item list is built right-recursively (rest.add(item)), so it comes out reversed.
16
+ for (const item of this.items.getItemsReversed()) {
14
17
  const key = item.type === "terminal" ? `[${item.name.lexeme}]` : `<${item.name.lexeme}>`;
15
18
  state.actions.set(key, item.action);
19
+ state.rawActions.set(key, item.rawAction);
16
20
  }
17
21
  return state;
18
22
  }
@@ -23,6 +27,7 @@ class ItemNode extends BaseNode {
23
27
  public readonly type: "terminal" | "variable",
24
28
  public readonly name: GrammarToken,
25
29
  public readonly action: TableAction,
30
+ public readonly rawAction: string,
26
31
  ) {
27
32
  super();
28
33
  }
@@ -46,22 +51,53 @@ const reducers: { [key: string]: Reducer } = {
46
51
  type: bag.action.lexeme.startsWith("s") ? "shift" : "reduce",
47
52
  value: parseInt(bag.action.lexeme.substring(1)),
48
53
  };
49
- return new ItemNode("terminal", bag.name, action);
54
+ return new ItemNode("terminal", bag.name, action, bag.action.lexeme);
50
55
  },
51
56
 
52
57
  variable: (bag: { name: GrammarToken; state: GrammarToken }) => {
53
58
  const action: TableAction = { type: "goto", value: parseInt(bag.state.lexeme) };
54
- return new ItemNode("variable", bag.name, action);
59
+ return new ItemNode("variable", bag.name, action, bag.state.lexeme);
55
60
  },
56
61
  };
57
62
 
58
- export const buildStates = (grammar: string): TableState[] => {
59
- const lexer = grammarLexerGenerator.generate(grammar, () => ({}));
63
+ export type BuildStatesOptions = {
64
+ /** Pass the grammar's productions to also check every "reduce" action against them. */
65
+ productions?: Production[];
66
+ /** Name of the file the table was read from, used to make error messages actionable. */
67
+ source?: string;
68
+ onWarning?: (warning: TableFormatWarning) => void;
69
+ };
70
+
71
+ /**
72
+ * Reads a parsing table written by the CLI (`sparse --input=grammar.txt --output=table.txt`).
73
+ * Returns a `Result` instead of throwing so that table problems can be reported alongside the
74
+ * offending grammar line.
75
+ */
76
+ export const tryBuildStates = (table: string, options: BuildStatesOptions = {}): Result<TableState[]> => {
77
+ const lexer = grammarLexerGenerator.generate(table, () => ({}));
60
78
  const parserGenerator = getSelfHostedParserGenerator(selfhosted as any);
61
- const parser = parserGenerator.generate(lexer, {
62
- reducer: ({ bag, name }) => reducers[name ?? ""](bag),
63
- });
64
79
 
65
- const parsingResult = (parser.parse().result as ListNode<StateNode>).getItemsReversed();
66
- return parsingResult.map((state) => state.toTableState());
80
+ let states: ListNode<StateNode>;
81
+ try {
82
+ const parser = parserGenerator.generate(lexer, {
83
+ reducer: ({ bag, name }) => reducers[name ?? ""](bag),
84
+ });
85
+ states = parser.parse().result as ListNode<StateNode>;
86
+ } catch (err) {
87
+ if (err instanceof LR1ParserGraveError && err.currentToken != null)
88
+ return { success: false, reason: err.reason, token: err.currentToken as GrammarToken };
89
+ return { success: false, reason: describeThrowable(err), token: syntheticGrammarToken() };
90
+ }
91
+
92
+ const tableStates = states.getItemsReversed().map((state) => state.toTableState());
93
+ return validateTableStates(tableStates, options);
94
+ };
95
+
96
+ export const buildStates = (table: string, options: BuildStatesOptions = {}): TableState[] => {
97
+ const result = tryBuildStates(table, options);
98
+ if (result.success === false)
99
+ throw new Error(
100
+ `Encountered error "${result.reason}" while parsing the parsing table at ${result.token.line}:${result.token.column}`,
101
+ );
102
+ return result.value;
67
103
  };