@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.
- package/.github/workflows/ci.yml +41 -0
- package/AGENTS.md +60 -0
- package/README.md +286 -30
- package/dist/cli.js +113 -24
- package/dist/cli.js.map +1 -1
- package/dist/generator.d.ts +9 -2
- package/dist/generator.js +179 -50
- package/dist/generator.js.map +1 -1
- package/dist/index.d.ts +17 -4
- package/dist/index.js +34 -1
- package/dist/index.js.map +1 -1
- package/dist/meta/common.d.ts +17 -0
- package/dist/meta/common.js +66 -1
- package/dist/meta/common.js.map +1 -1
- package/dist/meta/selfhosted.d.ts +4 -5
- package/dist/meta/selfhosted.js +51 -38
- package/dist/meta/selfhosted.js.map +1 -1
- package/dist/parser.d.ts +56 -33
- package/dist/parser.js +146 -18
- package/dist/parser.js.map +1 -1
- package/dist/table/selfhosted.d.ts +10 -1
- package/dist/table/selfhosted.js +32 -12
- package/dist/table/selfhosted.js.map +1 -1
- package/dist/table/validate.d.ts +17 -0
- package/dist/table/validate.js +84 -0
- package/dist/table/validate.js.map +1 -0
- package/dist/utils/Stack.d.ts +4 -0
- package/dist/utils/Stack.js +16 -1
- package/dist/utils/Stack.js.map +1 -1
- package/dist/utils/errorWindowBuilder.js +11 -11
- package/dist/utils/errorWindowBuilder.js.map +1 -1
- package/dist/utils/loadFiles.d.ts +8 -0
- package/dist/utils/loadFiles.js +47 -0
- package/dist/utils/loadFiles.js.map +1 -0
- package/dist/utils/reducers.d.ts +9 -0
- package/dist/utils/reducers.js +56 -0
- package/dist/utils/reducers.js.map +1 -0
- package/example/LoLang/example.ts +42 -18
- package/example/LoLang/tablelalr.txt +381 -0
- package/example/kleene-test/example.ts +35 -17
- package/example/math/example.ts +12 -7
- package/example/selfhosted/example.ts +30 -24
- package/opencode.json +18 -0
- package/package.json +9 -3
- package/src/cli.ts +132 -23
- package/src/generator.ts +237 -67
- package/src/index.ts +56 -3
- package/src/meta/common.ts +116 -0
- package/src/meta/selfhosted.ts +42 -11
- package/src/parser.ts +259 -47
- package/src/table/selfhosted.ts +48 -12
- package/src/table/validate.ts +143 -0
- package/src/utils/Stack.ts +22 -2
- package/src/utils/errorWindowBuilder.ts +15 -13
- package/src/utils/loadFiles.ts +54 -0
- package/src/utils/reducers.ts +93 -0
- package/test/cli.test.ts +144 -0
- package/test/codegen.test.ts +75 -0
- package/test/grammar.test.ts +243 -0
- package/test/helpers.ts +59 -0
- package/test/lalr.test.ts +114 -0
- package/test/parser.test.ts +392 -0
- package/test/table.test.ts +144 -0
- package/tsconfig.json +1 -1
- package/example/complicated/grammar.txt +0 -37
package/src/meta/selfhosted.ts
CHANGED
|
@@ -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 {
|
|
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 = (
|
|
316
|
-
|
|
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
|
-
|
|
326
|
-
|
|
327
|
-
|
|
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 = (
|
|
337
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
46
|
-
|
|
47
|
-
|
|
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>(
|
|
52
|
-
|
|
53
|
-
|
|
54
|
-
|
|
55
|
-
|
|
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
|
-
|
|
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 =
|
|
269
|
+
let currentState = currentStates();
|
|
130
270
|
let token = this.lexer.peekNextToken();
|
|
131
|
-
let action =
|
|
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
|
-
|
|
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 =
|
|
158
|
-
action =
|
|
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.
|
|
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
|
|
202
|
-
|
|
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
|
|
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 =
|
|
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.
|
|
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}")`;
|
package/src/table/selfhosted.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
59
|
-
|
|
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
|
-
|
|
66
|
-
|
|
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
|
};
|