@scinorandex/sparse 0.0.8 → 0.1.1

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/package.json CHANGED
@@ -1,11 +1,15 @@
1
1
  {
2
2
  "name": "@scinorandex/sparse",
3
- "version": "0.0.8",
3
+ "version": "0.1.1",
4
4
  "description": "Yet another parser generator",
5
5
  "main": "dist/index.js",
6
6
  "scripts": {
7
7
  "build": "tsc -p tsconfig.build.json",
8
- "watch": "tsc -w"
8
+ "watch": "tsc -w",
9
+ "tc": "tsc --noEmit -p tsconfig.json",
10
+ "test": "vitest run",
11
+ "test:watch": "vitest",
12
+ "bench": "vitest bench"
9
13
  },
10
14
  "bin": {
11
15
  "sparse": "dist/cli.js"
@@ -27,7 +31,9 @@
27
31
  "devDependencies": {
28
32
  "@types/minimist": "^1.2.5",
29
33
  "@types/node": "^26.0.0",
30
- "@scinorandex/slex": "^0.0.4"
34
+ "@scinorandex/slex": "^0.0.4",
35
+ "typescript": "^5.6.0",
36
+ "vitest": "^3.0.0"
31
37
  },
32
38
  "peerDependencies": {
33
39
  "@scinorandex/slex": "*"
package/src/cli.ts CHANGED
@@ -18,7 +18,7 @@ function checkFileExists(filepath: string) {
18
18
 
19
19
  async function main() {
20
20
  if (typeof args.input !== "string" || typeof args.output !== "string") {
21
- console.log("Usage: npx @scinorandex/sparse --input=<input> --output=<output>");
21
+ console.log("Usage: npx @scinorandex/sparse --input=<input> --output=<output> [--lalr]");
22
22
  return;
23
23
  }
24
24
 
@@ -36,7 +36,7 @@ async function main() {
36
36
  return;
37
37
  }
38
38
 
39
- const generatorResult = generateStates(productionsResult.value);
39
+ const generatorResult = generateStates(productionsResult.value, { mode: args.lalr === true ? "lalr1" : "lr1" });
40
40
  if (generatorResult.success === false) {
41
41
  console.log(generatorResult.reason);
42
42
  console.log(buildErrorWindow(grammar, generatorResult.token));
package/src/generator.ts CHANGED
@@ -121,10 +121,14 @@ function computeFollowSets(
121
121
  return { success: true, value: { firstSets, followSets: ret } };
122
122
  }
123
123
 
124
- type Item = Production & { dot: number; lookahead: string[] };
124
+ const KEY_SEP = "\x00";
125
+ const FIELD_SEP = "\x01";
126
+
127
+ type Item = Production & { dot: number; lookahead: string[]; prodIndex: number; key: string };
125
128
  type State = { itemSet: Item[]; kernel: Item; count: number };
126
129
 
127
- export const generateStates = (productions: Production[]): Result<GeneratorResult> => {
130
+ export type GeneratorOptions = { mode?: "lr1" | "lalr1" };
131
+ export const generateStates = (productions: Production[], options: GeneratorOptions = {}): Result<GeneratorResult> => {
128
132
  const followSetsResult = computeFollowSets(productions);
129
133
  if (followSetsResult.success == false) return followSetsResult;
130
134
 
@@ -161,15 +165,45 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
161
165
  }
162
166
  };
163
167
 
168
+ const productionIndicesByLhs = new Map<string, number[]>();
169
+ for (let i = 0; i < productions.length; i++) {
170
+ const indexList = productionIndicesByLhs.get(productions[i].identifier);
171
+ if (indexList == undefined) productionIndicesByLhs.set(productions[i].identifier, [i]);
172
+ else indexList.push(i);
173
+ }
174
+
175
+ const rhsEntrySignature = (rhsToken: Production["rhs"][number]) =>
176
+ `${rhsToken.type}${FIELD_SEP}${rhsToken.identifier}${FIELD_SEP}${rhsToken.name ?? ""}${FIELD_SEP}${rhsToken.token.line}${FIELD_SEP}${rhsToken.token.column}`;
177
+
178
+ const reduceValue: number[] = [];
179
+ {
180
+ const lowestIndexBySignature = new Map<string, number>();
181
+ for (let i = 0; i < productions.length; i++) {
182
+ const production = productions[i];
183
+ const signature = `${production.identifier}${FIELD_SEP}${production.rhs.map(rhsEntrySignature).join(FIELD_SEP)}`;
184
+ const lowest = lowestIndexBySignature.get(signature);
185
+ if (lowest == undefined) lowestIndexBySignature.set(signature, i);
186
+ reduceValue[i] = lowestIndexBySignature.get(signature)!;
187
+ }
188
+ }
189
+
190
+ const itemKey = (prodIndex: number, dot: number, lookahead: string[]) =>
191
+ `${prodIndex}${KEY_SEP}${dot}${KEY_SEP}${lookahead.join(FIELD_SEP)}`;
192
+
193
+ const itemSetSignature = (itemSet: Item[]) => itemSet.map((item) => item.key).join(FIELD_SEP);
194
+
164
195
  // This fnuction creates the initial item set based on the
165
196
  // production provided. It creating the initial item and expands it
166
- function generateInitialItemSet(production: Production) {
197
+ function generateInitialItemSet(production: Production, prodIndex: number) {
198
+ const lookahead = [EOF_STRING];
167
199
  const initialItem = {
168
200
  lhs: production.lhs,
169
201
  identifier: production.identifier,
170
202
  rhs: production.rhs,
171
203
  dot: 0,
172
- lookahead: [EOF_STRING],
204
+ lookahead,
205
+ prodIndex,
206
+ key: itemKey(prodIndex, 0, lookahead),
173
207
  } as Item;
174
208
 
175
209
  return expandItemSet([initialItem]);
@@ -178,20 +212,19 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
178
212
  // This function expands the item sets provided to it
179
213
  function expandItemSet(items: Item[]): Result<{ itemSet: Item[]; kernel: Item }> {
180
214
  const itemSet: Item[] = [...items];
215
+ const knownItems = new Set(items.map((item) => item.key));
181
216
 
182
- // Create a queue of unprocessed items and keep shifting until there are no more items left
217
+ // Create a queue of unprocessed items and keep going until there are no more items left
183
218
  const unprocesssedItems: Item[] = [...items];
184
- while (unprocesssedItems.length > 0) {
185
- const currentItem = unprocesssedItems.shift()!;
219
+ let queuePosition = 0;
220
+ while (queuePosition < unprocesssedItems.length) {
221
+ const currentItem = unprocesssedItems[queuePosition++];
186
222
 
187
223
  // Check if the symbol after the dot is a non terminal
188
224
  const after = currentItem.rhs[currentItem.dot];
189
225
  if (after == null) continue; // TODO: check if this should be here
190
226
 
191
227
  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
228
  // Compute the lookahead for the new productions to be added to the item set
196
229
  const rest = currentItem.rhs.slice(currentItem.dot + 1);
197
230
 
@@ -199,19 +232,22 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
199
232
  if (lookaheadResult.success === false) return lookaheadResult;
200
233
  const lookahead = lookaheadResult.value;
201
234
 
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) {
235
+ const productionIndices = productionIndicesByLhs.get(after.identifier) ?? [];
236
+ for (const prodIndex of productionIndices) {
237
+ const newProduction = productions[prodIndex];
238
+ const key = itemKey(prodIndex, 0, lookahead);
239
+
240
+ if (knownItems.has(key) == false) {
241
+ knownItems.add(key);
242
+ const newItem = {
243
+ lhs: newProduction.lhs,
244
+ identifier: newProduction.identifier,
245
+ rhs: newProduction.rhs,
246
+ dot: 0,
247
+ lookahead,
248
+ prodIndex,
249
+ key,
250
+ } as Item;
215
251
  unprocesssedItems.push(newItem);
216
252
  itemSet.push(newItem);
217
253
  }
@@ -223,7 +259,7 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
223
259
  return { success: true, value: { itemSet, kernel: items[0] } };
224
260
  }
225
261
 
226
- const initialItemSetResult = generateInitialItemSet(productions[0]);
262
+ const initialItemSetResult = generateInitialItemSet(productions[0], 0);
227
263
  if (initialItemSetResult.success === false) return initialItemSetResult;
228
264
  const initialItemSet = initialItemSetResult.value;
229
265
 
@@ -242,9 +278,14 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
242
278
  // array of maps whose keys is a terminal is a value of either to shift or reduce
243
279
  const ActionTable = [] as Map<string, { action: "shift" | "reduce"; value: number }>[];
244
280
 
281
+ // map from an item set signature to the index of the state holding it
282
+ const stateIndexBySignature = new Map<string, number>();
283
+ stateIndexBySignature.set(itemSetSignature(initialState.itemSet), 0);
284
+
245
285
  // While there is an unprocessed state, visit it
246
- while (unprocessedStates.length > 0) {
247
- const currentState = unprocessedStates.shift()!;
286
+ let statePosition = 0;
287
+ while (statePosition < unprocessedStates.length) {
288
+ const currentState = unprocessedStates[statePosition++];
248
289
  const nextStates_Goto = new Map<string, Item[]>();
249
290
  const nextStates_Shift = new Map<string, Item[]>();
250
291
 
@@ -256,25 +297,21 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
256
297
 
257
298
  // no next symbol is available, therefore we need to create a reduction
258
299
  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
300
  // for each lookahead in the item, create a new entry in the action table to reduce to the production found
268
301
  for (const lookahead of item.lookahead) {
269
302
  if (ActionTable[currentState.count] === undefined) ActionTable[currentState.count] = new Map();
270
- ActionTable[currentState.count]!.set(lookahead, { action: "reduce", value: productionToReduceTo });
303
+ ActionTable[currentState.count]!.set(lookahead, { action: "reduce", value: reduceValue[item.prodIndex] });
271
304
  }
272
305
 
273
306
  continue calculateNextItem;
274
307
  }
275
308
 
276
309
  // Create the next item by shifting the dot by 1 place
277
- const newItem: Item = { ...item, dot: item.dot + 1 };
310
+ const newItem: Item = {
311
+ ...item,
312
+ dot: item.dot + 1,
313
+ key: itemKey(item.prodIndex, item.dot + 1, item.lookahead),
314
+ };
278
315
 
279
316
  // Determine if we're going to create a GOTO or SHIFT based on the type of the symbol after the dot
280
317
  if (nextSymbol.type === "variable") {
@@ -300,19 +337,11 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
300
337
  const expandedItems = expandedItemsResult.value;
301
338
 
302
339
  // 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
- });
340
+ const signature = itemSetSignature(expandedItems.itemSet);
341
+ let existingStateIndex = stateIndexBySignature.get(signature);
313
342
 
314
343
  if (GotoTable[currentState.count] === undefined) GotoTable[currentState.count] = new Map();
315
- if (existingState == undefined) {
344
+ if (existingStateIndex == undefined) {
316
345
  const newState: State = {
317
346
  itemSet: expandedItems.itemSet,
318
347
  kernel: expandedItems.kernel,
@@ -320,10 +349,11 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
320
349
  };
321
350
  states.push(newState);
322
351
  unprocessedStates.push(newState);
323
- existingState = newState;
352
+ stateIndexBySignature.set(signature, newState.count);
353
+ existingStateIndex = newState.count;
324
354
  }
325
355
 
326
- GotoTable[currentState.count]!.set(gotoTransition, existingState.count);
356
+ GotoTable[currentState.count]!.set(gotoTransition, existingStateIndex);
327
357
  }
328
358
 
329
359
  // For each SHIFT transition from the current state, expand the item set that it points to
@@ -336,22 +366,14 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
336
366
  const expandedItems = expandedItemsResult.value;
337
367
 
338
368
  // 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
- });
369
+ const signature = itemSetSignature(expandedItems.itemSet);
370
+ let existingStateIndex = stateIndexBySignature.get(signature);
349
371
 
350
372
  // If there is no entry in the ActionTable for the current state, create one
351
373
  if (ActionTable[currentState.count] === undefined) ActionTable[currentState.count] = new Map();
352
374
 
353
375
  // If no such state exists, create a new state and add it to the list of states
354
- if (existingState == undefined) {
376
+ if (existingStateIndex == undefined) {
355
377
  const newState: State = {
356
378
  itemSet: expandedItems.itemSet,
357
379
  kernel: expandedItems.kernel,
@@ -359,10 +381,11 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
359
381
  };
360
382
  states.push(newState);
361
383
  unprocessedStates.push(newState);
362
- existingState = newState;
384
+ stateIndexBySignature.set(signature, newState.count);
385
+ existingStateIndex = newState.count;
363
386
  }
364
387
 
365
- ActionTable[currentState.count]!.set(shiftTransition, { action: "shift", value: existingState.count });
388
+ ActionTable[currentState.count]!.set(shiftTransition, { action: "shift", value: existingStateIndex });
366
389
  }
367
390
  }
368
391
 
@@ -373,9 +396,115 @@ export const generateStates = (productions: Production[]): Result<GeneratorResul
373
396
  const StatesResult = generateStates(initialItemSet);
374
397
  if (StatesResult.success === false) return StatesResult;
375
398
  const { states, ActionTable, GotoTable } = StatesResult.value;
399
+
400
+ if (options.mode === "lalr1") {
401
+ const merged = mergeStatesForLALR(states, ActionTable, GotoTable);
402
+ if (merged.success === false) return merged;
403
+ return {
404
+ success: true,
405
+ value: new GeneratorResult(merged.value.states, merged.value.ActionTable, merged.value.GotoTable),
406
+ };
407
+ }
408
+
376
409
  return { success: true, value: new GeneratorResult(states, ActionTable, GotoTable) };
377
410
  };
378
411
 
412
+ // Merges the LR(1) states into LALR(1) states by grouping states that have the same
413
+ // "same-core" item set (items compared without their lookahead sets) and unioning
414
+ // their item sets, action tables and goto tables. Returns an error if merging any
415
+ // group produces a shift/reduce, reduce/reduce, shift or goto conflict, which
416
+ // means the grammar is not LALR(1).
417
+ function mergeStatesForLALR(
418
+ states: State[],
419
+ ActionTable: Map<string, { action: "shift" | "reduce"; value: number }>[],
420
+ GotoTable: Map<string, number>[],
421
+ ): Result<{
422
+ states: State[];
423
+ ActionTable: Map<string, { action: "shift" | "reduce"; value: number }>[];
424
+ GotoTable: Map<string, number>[];
425
+ }> {
426
+ type Action = { action: "shift" | "reduce"; value: number };
427
+
428
+ const itemSkeleton = (item: Item) => `${item.prodIndex}${KEY_SEP}${item.dot}`;
429
+ const stateSkeleton = (state: State) => state.itemSet.map(itemSkeleton).sort().join(FIELD_SEP);
430
+
431
+ // Group states by same-core signature, assigning merged indices in first-occurrence order
432
+ const groupIndices: number[][] = [];
433
+ const groupBySkeleton = new Map<string, number>();
434
+ const oldToNew = new Array<number>(states.length);
435
+ for (let i = 0; i < states.length; i++) {
436
+ const skeleton = stateSkeleton(states[i]);
437
+ let group = groupBySkeleton.get(skeleton);
438
+ if (group == undefined) {
439
+ group = groupIndices.length;
440
+ groupBySkeleton.set(skeleton, group);
441
+ groupIndices.push([]);
442
+ }
443
+ groupIndices[group].push(i);
444
+ oldToNew[i] = group;
445
+ }
446
+
447
+ const describeAction = (a: Action) =>
448
+ a.action === "reduce" ? `reduce by production ${a.value}` : `shift to state ${a.value}`;
449
+ const conflictToken = (members: number[]) => states[members[0]].kernel.lhs;
450
+ const conflict = (g: number, members: number[], reason: string): Result<never> => ({
451
+ success: false,
452
+ reason: `LALR(1) conflict in state ${g} on ${reason} between LR(1) states ${members.join(", ")}: the grammar is not LALR(1)`,
453
+ token: conflictToken(members),
454
+ });
455
+
456
+ const newStates: State[] = [];
457
+ const newActionTable: Map<string, Action>[] = [];
458
+ const newGotoTable: Map<string, number>[] = [];
459
+
460
+ for (let g = 0; g < groupIndices.length; g++) {
461
+ const members = groupIndices[g];
462
+ const actions = new Map<string, Action>();
463
+ const gotos = new Map<string, number>();
464
+
465
+ for (const member of members) {
466
+ for (const [key, entry] of ActionTable[member].entries()) {
467
+ const value: Action = {
468
+ action: entry.action,
469
+ value: entry.action === "shift" ? oldToNew[entry.value] : entry.value,
470
+ };
471
+ const existing = actions.get(key);
472
+ if (existing == undefined) actions.set(key, value);
473
+ else if (existing.action !== value.action || existing.value !== value.value)
474
+ return conflict(g, members, `"${key}": ${describeAction(existing)} conflicts with ${describeAction(value)}`);
475
+ }
476
+
477
+ if (GotoTable[member] !== undefined)
478
+ for (const [key, target] of GotoTable[member]!.entries()) {
479
+ const value = oldToNew[target];
480
+ const existing = gotos.get(key);
481
+ if (existing == undefined) gotos.set(key, value);
482
+ else if (existing !== value)
483
+ return conflict(g, members, `"${key}": goto to state ${existing} conflicts with goto to state ${value}`);
484
+ }
485
+ }
486
+
487
+ // Union the item sets of all members, merging the lookahead sets of
488
+ // items that differ only in their lookahead
489
+ const itemsBySkeleton = new Map<string, Item>();
490
+ for (const member of members)
491
+ for (const item of states[member].itemSet) {
492
+ const skeleton = itemSkeleton(item);
493
+ const existing = itemsBySkeleton.get(skeleton);
494
+ if (existing == undefined) itemsBySkeleton.set(skeleton, { ...item, lookahead: [...item.lookahead] });
495
+ else
496
+ for (const lookahead of item.lookahead)
497
+ if (!existing.lookahead.includes(lookahead)) existing.lookahead.push(lookahead);
498
+ }
499
+
500
+ newStates.push({ itemSet: [...itemsBySkeleton.values()], kernel: states[members[0]].kernel, count: g });
501
+ newActionTable.push(actions);
502
+ newGotoTable.push(gotos);
503
+ }
504
+
505
+ return { success: true, value: { states: newStates, ActionTable: newActionTable, GotoTable: newGotoTable } };
506
+ }
507
+
379
508
  export class GeneratorResult {
380
509
  constructor(
381
510
  public readonly states: State[],
package/src/index.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  export { GeneratorResult, generateStates } from "./generator";
2
+ export type { GeneratorOptions } from "./generator";
2
3
  export { buildProductions, tryBuildProductions } from "./meta/selfhosted";
3
4
  export { Result } from "./utils/Result";
4
5
  export {
package/src/parser.ts CHANGED
@@ -51,8 +51,9 @@ export class Sparse<TokenType, Metadata, Node> {
51
51
  public static tryFromProductions<TokenType, Metadata, Node>(options: {
52
52
  productions: Production[];
53
53
  toStringifiedTokenType: (tokenType: TokenType) => string;
54
+ mode?: "lr1" | "lalr1";
54
55
  }): Result<Sparse<TokenType, Metadata, Node>> {
55
- const statesResult = generateStates(options.productions);
56
+ const statesResult = generateStates(options.productions, { mode: options.mode });
56
57
  if (statesResult.success === false) return statesResult;
57
58
  const states = statesResult.value;
58
59
 
@@ -68,6 +69,7 @@ export class Sparse<TokenType, Metadata, Node> {
68
69
  public static fromProductions<TokenType, Metadata, Node>(options: {
69
70
  productions: Production[];
70
71
  toStringifiedTokenType: (tokenType: TokenType) => string;
72
+ mode?: "lr1" | "lalr1";
71
73
  }) {
72
74
  const result = Sparse.tryFromProductions<TokenType, Metadata, Node>(options);
73
75
  if (result.success === false)