@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
package/src/generator.ts CHANGED
@@ -1,5 +1,5 @@
1
1
  import { TableState } from "./parser";
2
- import { GrammarToken, Production } from "./meta/common";
2
+ import { GrammarToken, Production, ProductionWarning, validateProductions } from "./meta/common";
3
3
  import { Result } from "./utils/Result";
4
4
 
5
5
  const xContainsAllOfY = <T>(xs: Set<T>, ys: Set<T>) => [...ys].every((x) => xs.has(x));
@@ -20,6 +20,13 @@ function computeFirstSets(allProductions: Production[]): Result<Map<string, Set<
20
20
  const toBeModified = ret.get(production.identifier)!;
21
21
 
22
22
  const firstRhsToken = production.rhs[0];
23
+ if (firstRhsToken == null)
24
+ return {
25
+ success: false,
26
+ reason: `Production "${production.identifier}" has an empty right hand side, which Sparse does not support`,
27
+ token: production.lhs,
28
+ };
29
+
23
30
  if (firstRhsToken.type === "variable") {
24
31
  const add = ret.get(firstRhsToken.identifier);
25
32
 
@@ -121,10 +128,26 @@ function computeFollowSets(
121
128
  return { success: true, value: { firstSets, followSets: ret } };
122
129
  }
123
130
 
124
- type Item = Production & { dot: number; lookahead: string[] };
131
+ const KEY_SEP = "\x00";
132
+ const FIELD_SEP = "\x01";
133
+
134
+ type Item = Production & { dot: number; lookahead: string[]; prodIndex: number; key: string };
125
135
  type State = { itemSet: Item[]; kernel: Item; count: number };
126
136
 
127
- export const generateStates = (productions: Production[]): Result<GeneratorResult> => {
137
+ export type GeneratorOptions = {
138
+ mode?: "lr1" | "lalr1";
139
+ onWarning?: (warning: ProductionWarning) => void;
140
+ /** Called every time a new state is discovered, useful to show progress on big grammars. */
141
+ onProgress?: (statesGenerated: number) => void;
142
+ };
143
+
144
+ export const generateStates = (
145
+ productions: Production[],
146
+ options: GeneratorOptions = {},
147
+ ): Result<GeneratorResult> => {
148
+ const validationResult = validateProductions(productions, { onWarning: options.onWarning });
149
+ if (validationResult.success === false) return validationResult;
150
+
128
151
  const followSetsResult = computeFollowSets(productions);
129
152
  if (followSetsResult.success == false) return followSetsResult;
130
153
 
@@ -161,15 +184,45 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
161
184
  }
162
185
  };
163
186
 
187
+ const productionIndicesByLhs = new Map<string, number[]>();
188
+ for (let i = 0; i < productions.length; i++) {
189
+ const indexList = productionIndicesByLhs.get(productions[i].identifier);
190
+ if (indexList == undefined) productionIndicesByLhs.set(productions[i].identifier, [i]);
191
+ else indexList.push(i);
192
+ }
193
+
194
+ const rhsEntrySignature = (rhsToken: Production["rhs"][number]) =>
195
+ `${rhsToken.type}${FIELD_SEP}${rhsToken.identifier}${FIELD_SEP}${rhsToken.name ?? ""}${FIELD_SEP}${rhsToken.token.line}${FIELD_SEP}${rhsToken.token.column}`;
196
+
197
+ const reduceValue: number[] = [];
198
+ {
199
+ const lowestIndexBySignature = new Map<string, number>();
200
+ for (let i = 0; i < productions.length; i++) {
201
+ const production = productions[i];
202
+ const signature = `${production.identifier}${FIELD_SEP}${production.rhs.map(rhsEntrySignature).join(FIELD_SEP)}`;
203
+ const lowest = lowestIndexBySignature.get(signature);
204
+ if (lowest == undefined) lowestIndexBySignature.set(signature, i);
205
+ reduceValue[i] = lowestIndexBySignature.get(signature)!;
206
+ }
207
+ }
208
+
209
+ const itemKey = (prodIndex: number, dot: number, lookahead: string[]) =>
210
+ `${prodIndex}${KEY_SEP}${dot}${KEY_SEP}${lookahead.join(FIELD_SEP)}`;
211
+
212
+ const itemSetSignature = (itemSet: Item[]) => itemSet.map((item) => item.key).join(FIELD_SEP);
213
+
164
214
  // This fnuction creates the initial item set based on the
165
215
  // production provided. It creating the initial item and expands it
166
- function generateInitialItemSet(production: Production) {
216
+ function generateInitialItemSet(production: Production, prodIndex: number) {
217
+ const lookahead = [EOF_STRING];
167
218
  const initialItem = {
168
219
  lhs: production.lhs,
169
220
  identifier: production.identifier,
170
221
  rhs: production.rhs,
171
222
  dot: 0,
172
- lookahead: [EOF_STRING],
223
+ lookahead,
224
+ prodIndex,
225
+ key: itemKey(prodIndex, 0, lookahead),
173
226
  } as Item;
174
227
 
175
228
  return expandItemSet([initialItem]);
@@ -178,20 +231,19 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
178
231
  // This function expands the item sets provided to it
179
232
  function expandItemSet(items: Item[]): Result<{ itemSet: Item[]; kernel: Item }> {
180
233
  const itemSet: Item[] = [...items];
234
+ const knownItems = new Set(items.map((item) => item.key));
181
235
 
182
- // Create a queue of unprocessed items and keep shifting until there are no more items left
236
+ // Create a queue of unprocessed items and keep going until there are no more items left
183
237
  const unprocesssedItems: Item[] = [...items];
184
- while (unprocesssedItems.length > 0) {
185
- const currentItem = unprocesssedItems.shift()!;
238
+ let queuePosition = 0;
239
+ while (queuePosition < unprocesssedItems.length) {
240
+ const currentItem = unprocesssedItems[queuePosition++];
186
241
 
187
242
  // Check if the symbol after the dot is a non terminal
188
243
  const after = currentItem.rhs[currentItem.dot];
189
244
  if (after == null) continue; // TODO: check if this should be here
190
245
 
191
246
  if (after.type === "variable") {
192
- // Find prodctions whose left hand side is the symbol after the dot
193
- const newProductions = productions.filter((p) => p.identifier === after.identifier);
194
-
195
247
  // Compute the lookahead for the new productions to be added to the item set
196
248
  const rest = currentItem.rhs.slice(currentItem.dot + 1);
197
249
 
@@ -199,19 +251,22 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
199
251
  if (lookaheadResult.success === false) return lookaheadResult;
200
252
  const lookahead = lookaheadResult.value;
201
253
 
202
- for (const newProduction of newProductions) {
203
- // Create the new item and check if it already exists in the item set
204
- // If it doesn't exist, add it to the item set and the queue of unprocessed items
205
- const newItem = {
206
- lhs: newProduction.lhs,
207
- identifier: newProduction.identifier,
208
- rhs: newProduction.rhs,
209
- dot: 0,
210
- lookahead,
211
- } as Item;
212
- const encoding = JSON.stringify(newItem);
213
-
214
- if (itemSet.some((i) => JSON.stringify(i) === encoding) == false) {
254
+ const productionIndices = productionIndicesByLhs.get(after.identifier) ?? [];
255
+ for (const prodIndex of productionIndices) {
256
+ const newProduction = productions[prodIndex];
257
+ const key = itemKey(prodIndex, 0, lookahead);
258
+
259
+ if (knownItems.has(key) == false) {
260
+ knownItems.add(key);
261
+ const newItem = {
262
+ lhs: newProduction.lhs,
263
+ identifier: newProduction.identifier,
264
+ rhs: newProduction.rhs,
265
+ dot: 0,
266
+ lookahead,
267
+ prodIndex,
268
+ key,
269
+ } as Item;
215
270
  unprocesssedItems.push(newItem);
216
271
  itemSet.push(newItem);
217
272
  }
@@ -223,7 +278,7 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
223
278
  return { success: true, value: { itemSet, kernel: items[0] } };
224
279
  }
225
280
 
226
- const initialItemSetResult = generateInitialItemSet(productions[0]);
281
+ const initialItemSetResult = generateInitialItemSet(productions[0], 0);
227
282
  if (initialItemSetResult.success === false) return initialItemSetResult;
228
283
  const initialItemSet = initialItemSetResult.value;
229
284
 
@@ -242,9 +297,14 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
242
297
  // array of maps whose keys is a terminal is a value of either to shift or reduce
243
298
  const ActionTable = [] as Map<string, { action: "shift" | "reduce"; value: number }>[];
244
299
 
300
+ // map from an item set signature to the index of the state holding it
301
+ const stateIndexBySignature = new Map<string, number>();
302
+ stateIndexBySignature.set(itemSetSignature(initialState.itemSet), 0);
303
+
245
304
  // While there is an unprocessed state, visit it
246
- while (unprocessedStates.length > 0) {
247
- const currentState = unprocessedStates.shift()!;
305
+ let statePosition = 0;
306
+ while (statePosition < unprocessedStates.length) {
307
+ const currentState = unprocessedStates[statePosition++];
248
308
  const nextStates_Goto = new Map<string, Item[]>();
249
309
  const nextStates_Shift = new Map<string, Item[]>();
250
310
 
@@ -256,25 +316,21 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
256
316
 
257
317
  // no next symbol is available, therefore we need to create a reduction
258
318
  if (nextSymbol == null) {
259
- // Find the prodction that this item reduces to using the symbols on its right hand side
260
- // TODO: check if the find can fail, honestly this might be invariant
261
- const productionToReduceTo = productions
262
- .map((p, i) => [p, i] as const)
263
- .find(
264
- ([p, _]) => JSON.stringify(p.rhs) === JSON.stringify(item.rhs) && p.identifier === item.identifier,
265
- )![1];
266
-
267
319
  // for each lookahead in the item, create a new entry in the action table to reduce to the production found
268
320
  for (const lookahead of item.lookahead) {
269
321
  if (ActionTable[currentState.count] === undefined) ActionTable[currentState.count] = new Map();
270
- ActionTable[currentState.count]!.set(lookahead, { action: "reduce", value: productionToReduceTo });
322
+ ActionTable[currentState.count]!.set(lookahead, { action: "reduce", value: reduceValue[item.prodIndex] });
271
323
  }
272
324
 
273
325
  continue calculateNextItem;
274
326
  }
275
327
 
276
328
  // Create the next item by shifting the dot by 1 place
277
- const newItem: Item = { ...item, dot: item.dot + 1 };
329
+ const newItem: Item = {
330
+ ...item,
331
+ dot: item.dot + 1,
332
+ key: itemKey(item.prodIndex, item.dot + 1, item.lookahead),
333
+ };
278
334
 
279
335
  // Determine if we're going to create a GOTO or SHIFT based on the type of the symbol after the dot
280
336
  if (nextSymbol.type === "variable") {
@@ -300,19 +356,11 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
300
356
  const expandedItems = expandedItemsResult.value;
301
357
 
302
358
  // check if a state exist with the same item sets
303
- let existingState = states.find((s) => {
304
- return (
305
- s.kernel.lhs === expandedItems.kernel.lhs &&
306
- s.kernel.rhs.length === expandedItems.kernel.rhs.length &&
307
- JSON.stringify(s.kernel.rhs) === JSON.stringify(expandedItems.kernel.rhs) &&
308
- s.kernel.lookahead.length === expandedItems.kernel.lookahead.length &&
309
- JSON.stringify(s.kernel.lookahead) === JSON.stringify(expandedItems.kernel.lookahead) &&
310
- JSON.stringify(s.itemSet) === JSON.stringify(expandedItems.itemSet)
311
- );
312
- });
359
+ const signature = itemSetSignature(expandedItems.itemSet);
360
+ let existingStateIndex = stateIndexBySignature.get(signature);
313
361
 
314
362
  if (GotoTable[currentState.count] === undefined) GotoTable[currentState.count] = new Map();
315
- if (existingState == undefined) {
363
+ if (existingStateIndex == undefined) {
316
364
  const newState: State = {
317
365
  itemSet: expandedItems.itemSet,
318
366
  kernel: expandedItems.kernel,
@@ -320,10 +368,12 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
320
368
  };
321
369
  states.push(newState);
322
370
  unprocessedStates.push(newState);
323
- existingState = newState;
371
+ stateIndexBySignature.set(signature, newState.count);
372
+ existingStateIndex = newState.count;
324
373
  }
325
374
 
326
- GotoTable[currentState.count]!.set(gotoTransition, existingState.count);
375
+ options.onProgress?.(states.length);
376
+ GotoTable[currentState.count]!.set(gotoTransition, existingStateIndex);
327
377
  }
328
378
 
329
379
  // For each SHIFT transition from the current state, expand the item set that it points to
@@ -336,22 +386,14 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
336
386
  const expandedItems = expandedItemsResult.value;
337
387
 
338
388
  // check if a state exist with the same item sets
339
- let existingState = states.find((s) => {
340
- return (
341
- s.kernel.lhs === expandedItems.kernel.lhs &&
342
- s.kernel.rhs.length === expandedItems.kernel.rhs.length &&
343
- JSON.stringify(s.kernel.rhs) === JSON.stringify(expandedItems.kernel.rhs) &&
344
- s.kernel.lookahead.length === expandedItems.kernel.lookahead.length &&
345
- JSON.stringify(s.kernel.lookahead) === JSON.stringify(expandedItems.kernel.lookahead) &&
346
- JSON.stringify(s.itemSet) === JSON.stringify(expandedItems.itemSet)
347
- );
348
- });
389
+ const signature = itemSetSignature(expandedItems.itemSet);
390
+ let existingStateIndex = stateIndexBySignature.get(signature);
349
391
 
350
392
  // If there is no entry in the ActionTable for the current state, create one
351
393
  if (ActionTable[currentState.count] === undefined) ActionTable[currentState.count] = new Map();
352
394
 
353
395
  // If no such state exists, create a new state and add it to the list of states
354
- if (existingState == undefined) {
396
+ if (existingStateIndex == undefined) {
355
397
  const newState: State = {
356
398
  itemSet: expandedItems.itemSet,
357
399
  kernel: expandedItems.kernel,
@@ -359,10 +401,12 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
359
401
  };
360
402
  states.push(newState);
361
403
  unprocessedStates.push(newState);
362
- existingState = newState;
404
+ stateIndexBySignature.set(signature, newState.count);
405
+ existingStateIndex = newState.count;
363
406
  }
364
407
 
365
- ActionTable[currentState.count]!.set(shiftTransition, { action: "shift", value: existingState.count });
408
+ options.onProgress?.(states.length);
409
+ ActionTable[currentState.count]!.set(shiftTransition, { action: "shift", value: existingStateIndex });
366
410
  }
367
411
  }
368
412
 
@@ -373,9 +417,117 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
373
417
  const StatesResult = generateStates(initialItemSet);
374
418
  if (StatesResult.success === false) return StatesResult;
375
419
  const { states, ActionTable, GotoTable } = StatesResult.value;
420
+
421
+ if (options.mode === "lalr1") {
422
+ const merged = mergeStatesForLALR(states, ActionTable, GotoTable);
423
+ if (merged.success === false) return merged;
424
+ return {
425
+ success: true,
426
+ value: new GeneratorResult(merged.value.states, merged.value.ActionTable, merged.value.GotoTable),
427
+ };
428
+ }
429
+
376
430
  return { success: true, value: new GeneratorResult(states, ActionTable, GotoTable) };
377
431
  };
378
432
 
433
+ // Merges the LR(1) states into LALR(1) states by grouping states that have the same
434
+ // "same-core" item set (items compared without their lookahead sets) and unioning
435
+ // their item sets, action tables and goto tables. Returns an error if merging any
436
+ // group produces a shift/reduce, reduce/reduce, shift or goto conflict, which
437
+ // means the grammar is not LALR(1).
438
+ function mergeStatesForLALR(
439
+ states: State[],
440
+ ActionTable: Map<string, { action: "shift" | "reduce"; value: number }>[],
441
+ GotoTable: Map<string, number>[],
442
+ ): Result<{
443
+ states: State[];
444
+ ActionTable: Map<string, { action: "shift" | "reduce"; value: number }>[];
445
+ GotoTable: Map<string, number>[];
446
+ }> {
447
+ type Action = { action: "shift" | "reduce"; value: number };
448
+
449
+ const itemSkeleton = (item: Item) => `${item.prodIndex}${KEY_SEP}${item.dot}`;
450
+ const stateSkeleton = (state: State) => state.itemSet.map(itemSkeleton).sort().join(FIELD_SEP);
451
+
452
+ // Group states by same-core signature, assigning merged indices in first-occurrence order
453
+ const groupIndices: number[][] = [];
454
+ const groupBySkeleton = new Map<string, number>();
455
+ const oldToNew = new Array<number>(states.length);
456
+ for (let i = 0; i < states.length; i++) {
457
+ const skeleton = stateSkeleton(states[i]);
458
+ let group = groupBySkeleton.get(skeleton);
459
+ if (group == undefined) {
460
+ group = groupIndices.length;
461
+ groupBySkeleton.set(skeleton, group);
462
+ groupIndices.push([]);
463
+ }
464
+ groupIndices[group].push(i);
465
+ oldToNew[i] = group;
466
+ }
467
+
468
+ const describeAction = (a: Action) =>
469
+ a.action === "reduce" ? `reduce by production ${a.value}` : `shift to state ${a.value}`;
470
+ const conflictToken = (members: number[]) => states[members[0]].kernel.lhs;
471
+ const conflict = (g: number, members: number[], reason: string): Result<never> => ({
472
+ success: false,
473
+ reason: `LALR(1) conflict in state ${g} on ${reason} between LR(1) states ${members.join(", ")}: the grammar is not LALR(1)`,
474
+ token: conflictToken(members),
475
+ });
476
+
477
+ const newStates: State[] = [];
478
+ const newActionTable: Map<string, Action>[] = [];
479
+ const newGotoTable: Map<string, number>[] = [];
480
+
481
+ for (let g = 0; g < groupIndices.length; g++) {
482
+ const members = groupIndices[g];
483
+ const actions = new Map<string, Action>();
484
+ const gotos = new Map<string, number>();
485
+
486
+ for (const member of members) {
487
+ // A state whose items only move to other states (no shifts, no completed items) has no
488
+ // entry in the action table at all.
489
+ for (const [key, entry] of (ActionTable[member] ?? new Map()).entries()) {
490
+ const value: Action = {
491
+ action: entry.action,
492
+ value: entry.action === "shift" ? oldToNew[entry.value] : entry.value,
493
+ };
494
+ const existing = actions.get(key);
495
+ if (existing == undefined) actions.set(key, value);
496
+ else if (existing.action !== value.action || existing.value !== value.value)
497
+ return conflict(g, members, `"${key}": ${describeAction(existing)} conflicts with ${describeAction(value)}`);
498
+ }
499
+
500
+ if (GotoTable[member] !== undefined)
501
+ for (const [key, target] of GotoTable[member]!.entries()) {
502
+ const value = oldToNew[target];
503
+ const existing = gotos.get(key);
504
+ if (existing == undefined) gotos.set(key, value);
505
+ else if (existing !== value)
506
+ return conflict(g, members, `"${key}": goto to state ${existing} conflicts with goto to state ${value}`);
507
+ }
508
+ }
509
+
510
+ // Union the item sets of all members, merging the lookahead sets of
511
+ // items that differ only in their lookahead
512
+ const itemsBySkeleton = new Map<string, Item>();
513
+ for (const member of members)
514
+ for (const item of states[member].itemSet) {
515
+ const skeleton = itemSkeleton(item);
516
+ const existing = itemsBySkeleton.get(skeleton);
517
+ if (existing == undefined) itemsBySkeleton.set(skeleton, { ...item, lookahead: [...item.lookahead] });
518
+ else
519
+ for (const lookahead of item.lookahead)
520
+ if (!existing.lookahead.includes(lookahead)) existing.lookahead.push(lookahead);
521
+ }
522
+
523
+ newStates.push({ itemSet: [...itemsBySkeleton.values()], kernel: states[members[0]].kernel, count: g });
524
+ newActionTable.push(actions);
525
+ newGotoTable.push(gotos);
526
+ }
527
+
528
+ return { success: true, value: { states: newStates, ActionTable: newActionTable, GotoTable: newGotoTable } };
529
+ }
530
+
379
531
  export class GeneratorResult {
380
532
  constructor(
381
533
  public readonly states: State[],
@@ -384,7 +536,15 @@ export class GeneratorResult {
384
536
  ) {}
385
537
 
386
538
  toTable(): string {
387
- return this.ActionTable.map((actions, idx) => {
539
+ const actionless = this.ActionTable.findIndex((actions) => actions === undefined || actions.size === 0);
540
+ if (actionless !== -1)
541
+ throw new Error(
542
+ `State ${actionless} of this grammar has no actions, so it cannot be written in the parsing table format. ` +
543
+ `Use Sparse.fromProductions to keep the states in memory instead.`,
544
+ );
545
+
546
+ // Array.from, not map: the action table is sparse and map would keep the holes.
547
+ return Array.from(this.ActionTable, (actions, idx) => {
388
548
  return [
389
549
  ...[...actions.entries()].map(([k, { action, value }]) => `${k}=${action === "reduce" ? "r" : "s"}${value}`),
390
550
  ...(this.GotoTable[idx] === undefined ? [] : [...this.GotoTable[idx].entries()].map(([k, v]) => `${k}=${v}`)),
@@ -393,14 +553,24 @@ export class GeneratorResult {
393
553
  }
394
554
 
395
555
  toStates(): TableState[] {
396
- return this.ActionTable.map((actions, idx) => {
556
+ // Array.from, not map: a state without actions leaves a hole in the action table, and map would
557
+ // hand back an array that is missing that state and shifts every later state number.
558
+ return Array.from(this.ActionTable, (actions, idx) => {
397
559
  const tableState = new TableState();
398
560
 
399
- for (const [k, { action, value }] of actions.entries())
561
+ // See toTable(): a state can legitimately have no actions, it just cannot be serialized.
562
+ if (actions === undefined) return tableState;
563
+
564
+ for (const [k, { action, value }] of actions.entries()) {
400
565
  tableState.actions.set(k, { type: action === "reduce" ? "reduce" : "shift", value });
566
+ tableState.rawActions.set(k, `${action === "reduce" ? "r" : "s"}${value}`);
567
+ }
401
568
 
402
569
  if (this.GotoTable[idx] !== undefined)
403
- for (const [k, value] of this.GotoTable[idx].entries()) tableState.actions.set(k, { type: "goto", value });
570
+ for (const [k, value] of this.GotoTable[idx].entries()) {
571
+ tableState.actions.set(k, { type: "goto", value });
572
+ tableState.rawActions.set(k, `${value}`);
573
+ }
404
574
 
405
575
  return tableState;
406
576
  });
package/src/index.ts CHANGED
@@ -1,13 +1,66 @@
1
1
  export { GeneratorResult, generateStates } from "./generator";
2
- export { buildProductions, tryBuildProductions } from "./meta/selfhosted";
2
+ export type { GeneratorOptions } from "./generator";
3
+
4
+ export {
5
+ buildProductions,
6
+ tryBuildProductions,
7
+ grammarLexerGenerator,
8
+ getSelfHostedParserGenerator,
9
+ } from "./meta/selfhosted";
10
+ export type { LexerInterface } from "./meta/selfhosted";
11
+
3
12
  export { Result } from "./utils/Result";
4
13
  export {
5
14
  ParserRecoveryFunction,
6
15
  Sparse,
16
+ SparseGrammarError,
7
17
  LR1ParserGraveError,
8
18
  LR1StackSymbol,
19
+ LR1Parser,
9
20
  ParserResult,
10
21
  ParserError,
22
+ TableAction,
23
+ TableState,
11
24
  } from "./parser";
12
- export { buildStates } from "./table/selfhosted";
13
- export { Production } from "./meta/common";
25
+ export type { ReducerType, SparseOptions, FromProductionsOptions } from "./parser";
26
+
27
+ export { buildStates, tryBuildStates } from "./table/selfhosted";
28
+ export type { BuildStatesOptions } from "./table/selfhosted";
29
+ export { validateTable, validateTableStates } from "./table/validate";
30
+ export type { ValidateTableOptions, ValidateTableStatesOptions, TableFormatWarning } from "./table/validate";
31
+
32
+ export {
33
+ dehydateProduction,
34
+ hydrateProduction,
35
+ isVariableIdentifier,
36
+ syntheticGrammarToken,
37
+ terminalIdentifier,
38
+ validateProductions,
39
+ variableIdentifier,
40
+ GrammarTokenType,
41
+ } from "./meta/common";
42
+ export type {
43
+ GrammarToken,
44
+ GrammarTokenMetadata,
45
+ Production,
46
+ ProductionRhsItem,
47
+ ProductionWarning,
48
+ ProductionWarningKind,
49
+ ValidateProductionsOptions,
50
+ } from "./meta/common";
51
+
52
+ export {
53
+ assertReducersCoverGrammar,
54
+ defineReducers,
55
+ enumToString,
56
+ missingReducerMessage,
57
+ missingReducerNames,
58
+ namedProductions,
59
+ KLEENE_REDUCER_NAME,
60
+ } from "./utils/reducers";
61
+ export type { ReducerMap } from "./utils/reducers";
62
+
63
+ export { loadGrammar, loadTable } from "./utils/loadFiles";
64
+ export type { LoadGrammarOptions, LoadTableOptions } from "./utils/loadFiles";
65
+ export { buildErrorWindow } from "./utils/errorWindowBuilder";
66
+ export { Stack } from "./utils/Stack";
@@ -1,4 +1,5 @@
1
1
  import { ColumnAndRow, Token } from "@scinorandex/slex";
2
+ import { Result } from "../utils/Result";
2
3
 
3
4
  export type Production = {
4
5
  lhs: GrammarToken;
@@ -8,6 +9,8 @@ export type Production = {
8
9
  rhs: { type: "terminal" | "variable"; token: GrammarToken; identifier: string; name: string | null }[];
9
10
  };
10
11
 
12
+ export type ProductionRhsItem = Production["rhs"][number];
13
+
11
14
  const dehydateGrammarToken = ({ column, lexeme, line, type }: GrammarToken) => {
12
15
  return { column, lexeme, line, type };
13
16
  };
@@ -50,6 +53,119 @@ export const hydrateProduction = (production: ReturnType<typeof dehydateProducti
50
53
  };
51
54
 
52
55
  export type GrammarTokenMetadata = {};
56
+
57
+ /** A readable one-liner for an unknown thrown value, used to turn it into a `Result` failure. */
58
+ export const describeThrowable = (err: unknown) =>
59
+ err instanceof Error ? err.message : `Unexpected error: ${String(err)}`;
60
+
61
+ /** A stand-in token used to report errors that do not belong to a specific place in a grammar. */
62
+ export const syntheticGrammarToken = () =>
63
+ new Token(GrammarTokenType.EOF, "", new ColumnAndRow(0, 0), {}) as GrammarToken;
64
+
65
+ export type ProductionWarningKind = "duplicate-rhs-name" | "unreachable-production";
66
+
67
+ export type ProductionWarning = {
68
+ kind: ProductionWarningKind;
69
+ reason: string;
70
+ token: GrammarToken;
71
+ };
72
+
73
+ export type ValidateProductionsOptions = {
74
+ /** Called once per warning. Warnings never stop the parser generator, they only flag suspicious grammars. */
75
+ onWarning?: (warning: ProductionWarning) => void;
76
+ };
77
+
78
+ const at = (token: GrammarToken) => `${token.line}:${token.column}`;
79
+
80
+ /**
81
+ * Checks a set of unrolled productions for the mistakes that would otherwise surface as an
82
+ * inscrutable `TypeError` deep inside state generation or parsing:
83
+ *
84
+ * - a variable used on the right hand side that no production defines
85
+ * - a production with an empty right hand side (Sparse has no support for empty productions)
86
+ * - the start symbol being used on the right hand side (it must only appear on the left hand side)
87
+ *
88
+ * Suspicious-but-legal grammars (duplicate symbol names, unreachable productions) are reported
89
+ * through `onWarning` instead of failing.
90
+ */
91
+ export const validateProductions = (
92
+ productions: Production[],
93
+ options: ValidateProductionsOptions = {},
94
+ ): Result<Production[]> => {
95
+ const warn = (kind: ProductionWarningKind, reason: string, token: GrammarToken) => {
96
+ options.onWarning?.({ kind, reason, token });
97
+ };
98
+
99
+ if (productions.length === 0)
100
+ return {
101
+ success: false,
102
+ reason: "The grammar does not contain any productions. Is the grammar file empty?",
103
+ token: syntheticGrammarToken(),
104
+ };
105
+
106
+ const lhsIdentifiers = new Set(productions.map((production) => production.identifier));
107
+ const referencedIdentifiers = new Set<string>();
108
+
109
+ for (const production of productions) {
110
+ if (production.rhs.length === 0)
111
+ return {
112
+ success: false,
113
+ reason: `Production "${production.identifier}" (line ${at(production.lhs)}) has an empty right hand side. Sparse does not support empty productions, so a lone "(...)? " group such as "<X: x>: ([A])?;" is not a valid production.`,
114
+ token: production.lhs,
115
+ };
116
+
117
+ const seenNames = new Set<string>();
118
+ for (const rhsItem of production.rhs) {
119
+ if (rhsItem.type === "variable") {
120
+ referencedIdentifiers.add(rhsItem.identifier);
121
+ if (lhsIdentifiers.has(rhsItem.identifier) === false)
122
+ return {
123
+ success: false,
124
+ reason: `Variable "${rhsItem.identifier}" is used on the right hand side of production "${production.identifier}" (line ${at(production.lhs)}) but no production defines it as its left hand side.`,
125
+ token: rhsItem.token,
126
+ };
127
+ }
128
+
129
+ if (rhsItem.name != null) {
130
+ if (seenNames.has(rhsItem.name))
131
+ warn(
132
+ "duplicate-rhs-name",
133
+ `Production "${production.identifier}" (line ${at(production.lhs)}) names more than one symbol "${rhsItem.name}". Only the last one ends up on the reducer's "bag", use the positional input to reach the others.`,
134
+ rhsItem.token,
135
+ );
136
+ seenNames.add(rhsItem.name);
137
+ }
138
+ }
139
+ }
140
+
141
+ // The first production is the accept production: its left hand side is the start symbol, which
142
+ // by definition cannot be referenced from the right hand side of anything.
143
+ const startProduction = productions[0];
144
+ if (referencedIdentifiers.has(startProduction.identifier))
145
+ return {
146
+ success: false,
147
+ reason: `"${startProduction.identifier}" is the start symbol of the grammar, so it cannot be used on the right hand side of a production. It is used at line ${at(startProduction.lhs)}.`,
148
+ token: startProduction.lhs,
149
+ };
150
+
151
+ for (let i = 1; i < productions.length; i++) {
152
+ const production = productions[i];
153
+ if (referencedIdentifiers.has(production.identifier)) continue;
154
+ warn(
155
+ "unreachable-production",
156
+ `Production "${production.identifier}" (line ${at(production.lhs)}) is never used on the right hand side of another production, so the parser can never reach it.`,
157
+ production.lhs,
158
+ );
159
+ }
160
+
161
+ return { success: true, value: productions };
162
+ };
163
+
164
+ /** True when the identifier is a variable (`<FOO>`) rather than a terminal (`[FOO]`). */
165
+ export const isVariableIdentifier = (identifier: string) => identifier.startsWith("<") && identifier.endsWith(">");
166
+
167
+ export const terminalIdentifier = (name: string) => `[${name}]`;
168
+ export const variableIdentifier = (name: string) => `<${name}>`;
53
169
  export enum GrammarTokenType {
54
170
  IDENTIFIER,
55
171
  L_ANGLE,