parseman 0.60.3 → 0.60.4

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.
@@ -5736,7 +5736,7 @@ function projectChild(children2, project, type) {
5736
5736
  }
5737
5737
 
5738
5738
  // src/version.ts
5739
- var PARSEMAN_VERSION = "0.60.3";
5739
+ var PARSEMAN_VERSION = "0.60.4";
5740
5740
 
5741
5741
  // src/table/assemble.ts
5742
5742
  import { PlainBuf as PlainBuf2, PlainField, PlainLeaf as PlainLeaf8, PlainNode as PlainNode2, PlainSpan as PlainSpan9 } from "../cst/plain.js";
package/dist/version.d.ts CHANGED
@@ -13,5 +13,5 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export declare const PARSEMAN_VERSION = "0.60.3";
16
+ export declare const PARSEMAN_VERSION = "0.60.4";
17
17
  //# sourceMappingURL=version.d.ts.map
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "parseman",
3
- "version": "0.60.3",
3
+ "version": "0.60.4",
4
4
  "packageManager": "pnpm@10.15.1",
5
5
  "engines": {
6
6
  "node": "^20.19.0 || >=22.13.0"
@@ -147,9 +147,11 @@ function matcherExpr(entry: DispatchMatcherCase): string {
147
147
  export function serializeRuleMap(
148
148
  ruleMap: ReadonlyArray<readonly [string, Comb]>,
149
149
  scanSkip?: readonly Comb[] | null,
150
+ /** External names the factory read through `g`; any no rule body reaches is kept as `void g[name]`. */
151
+ accessed?: readonly string[],
150
152
  ): string | null {
151
153
  try {
152
- return new Serializer(ruleMap, scanSkip ?? []).run()
154
+ return new Serializer(ruleMap, scanSkip ?? [], accessed ?? []).run()
153
155
  } catch (e) {
154
156
  if (e instanceof Unserializable) {
155
157
  // `compileLinkableTable()` reaches this at run time, so it runs in browsers too.
@@ -190,9 +192,13 @@ class Serializer {
190
192
  private ruleMap: ReadonlyArray<readonly [string, Comb]>
191
193
  private scanSkip: readonly Comb[]
192
194
 
193
- constructor(ruleMap: ReadonlyArray<readonly [string, Comb]>, scanSkip: readonly Comb[] = []) {
195
+ private accessed: readonly string[]
196
+ private referenced = new Set<string>()
197
+
198
+ constructor(ruleMap: ReadonlyArray<readonly [string, Comb]>, scanSkip: readonly Comb[] = [], accessed: readonly string[] = []) {
194
199
  this.ruleMap = ruleMap
195
200
  this.scanSkip = scanSkip
201
+ this.accessed = accessed
196
202
  for (const [name, c] of ruleMap) {
197
203
  this.localRuleNames.set(c, name)
198
204
  this.ruleValues.add(this.body(c))
@@ -294,6 +300,11 @@ class Serializer {
294
300
  const body = this.ruleMap
295
301
  .map(([name, c]) => ` ${JSON.stringify(name)}: ${this.wrap(this.body(c), [])}`)
296
302
  .join(',\n')
303
+ // A name the factory read through `g` that no rule body reaches still creates
304
+ // its slot, so a composition that defines it nowhere refuses it.
305
+ for (const name of this.accessed) {
306
+ if (!this.referenced.has(name)) innerDecls.push(`void g[${JSON.stringify(name)}]`)
307
+ }
297
308
  // Shared consts go INSIDE the factory: they can reference `g[name]` rule refs,
298
309
  // and `g` only exists in the factory scope.
299
310
  const factory = innerDecls.length === 0
@@ -412,7 +423,7 @@ class Serializer {
412
423
  switch (def.tag) {
413
424
  case 'lazy': {
414
425
  const name = this.localRuleNames.get(c) ?? ruleNameOf(c)
415
- if (name !== undefined) return `g[${JSON.stringify(name)}]`
426
+ if (name !== undefined) { this.referenced.add(name); return `g[${JSON.stringify(name)}]` }
416
427
  const target = lazyTarget(c)!
417
428
  // Innermost matching frame wins.
418
429
  for (let i = frames.length - 1; i >= 0; i--) {
@@ -372,24 +372,40 @@ function declaredNamesOf(items: Array<LinkableTable | Record<string, unknown>>):
372
372
  return [...names]
373
373
  }
374
374
 
375
+ /**
376
+ * The reason a linked map is incomplete, or `undefined` when every `g.X` some piece
377
+ * reads is defined by some piece. A linked namespace holds every name a factory
378
+ * merely ACCESSED, so a name nobody defines is a slot that stays undefined. Runtime
379
+ * `compose()` and the macro's build-time fuse both refuse it with these words, naming
380
+ * the rule that reaches the hole when one does.
381
+ */
382
+ export function danglingReference(
383
+ map: Record<string, Combinator<unknown>>,
384
+ /** Names read through `g` that `map` itself does not hold (a merge-by-name fuse drops them). */
385
+ accessed: Iterable<string> = [],
386
+ ): string | undefined {
387
+ const holes = new Set<string>()
388
+ for (const [name, rule] of Object.entries(map)) {
389
+ if (rule._def.tag !== 'lazy') continue
390
+ try { rule._def.thunk() } catch { holes.add(name) }
391
+ }
392
+ for (const name of accessed) if (!Object.hasOwn(map, name)) holes.add(name)
393
+ if (holes.size === 0) return undefined
394
+ const missing = [...holes]
395
+ for (const [name, deps] of ruleDependencies(Object.entries(map).filter(([n]) => !holes.has(n)))) {
396
+ for (const dep of deps) if (holes.has(dep)) return `rule "${name}" references missing rule "${dep}"`
397
+ }
398
+ return `rule(s) ${missing.map(n => `"${n}"`).join(', ')} are referenced but defined by no composed grammar`
399
+ }
400
+
375
401
  /** Link recipes into one interpreter map, refusing a name nobody defines. */
376
402
  function linkComposition(
377
403
  recipes: readonly RulesRecipe[],
378
404
  link: LinkOptions,
379
405
  ): Record<string, Combinator<unknown>> {
380
406
  const map = linkRules(recipes, link)
381
- const missing: string[] = []
382
- for (const [name, rule] of Object.entries(map)) {
383
- if (rule._def.tag !== 'lazy') continue
384
- try { rule._def.thunk() } catch { missing.push(name) }
385
- }
386
- if (missing.length > 0) {
387
- const holes = new Set(missing)
388
- for (const [name, deps] of ruleDependencies(Object.entries(map).filter(([n]) => !holes.has(n)))) {
389
- for (const dep of deps) if (holes.has(dep)) throw new Error(`compose: rule "${name}" references missing rule "${dep}"`)
390
- }
391
- throw new Error(`compose: rule(s) ${missing.map(n => `"${n}"`).join(', ')} are referenced but defined by no composed grammar`)
392
- }
407
+ const dangling = danglingReference(map)
408
+ if (dangling !== undefined) throw new Error(`compose: ${dangling}`)
393
409
  // A borrowed rule object keeps its own grammar's bindings here but would be rebound
394
410
  // by name in `compile()` of this composition: refuse it rather than let the two
395
411
  // engines accept different input.
@@ -1613,9 +1613,20 @@ export function evaluateParserFactory(
1613
1613
  */
1614
1614
  const map = new Map<string, Combinator<unknown>>()
1615
1615
  for (const key of keys) map.set(key, built[key]!)
1616
+ const declared = new Set(keys)
1617
+ externalAccesses.set(map, Object.keys(built).filter(name => !declared.has(name)))
1616
1618
  return map
1617
1619
  }
1618
1620
 
1621
+ /**
1622
+ * Every EXTERNAL name an evaluated factory read through `g`, keyed by the map
1623
+ * `evaluateParserFactory` returned. The map holds declared rules only, so a `g.X`
1624
+ * read outside every rule body (`const unused = g.X`) leaves no trace in it; the
1625
+ * IR serializer reads this to keep that access, so a composition refuses an `X`
1626
+ * nobody defines exactly as runtime `compose()` does.
1627
+ */
1628
+ export const externalAccesses = new WeakMap<ReadonlyMap<string, Combinator<unknown>>, readonly string[]>()
1629
+
1619
1630
  /** A combinator slot created by ref() — has a callable `define`. */
1620
1631
  type DefinableRef = Combinator<unknown> & { define(p: Combinator<unknown>): void }
1621
1632
 
@@ -24,7 +24,7 @@ import { createUnplugin } from 'unplugin'
24
24
  import { parseSync } from 'oxc-parser'
25
25
  import { ResolverFactory } from 'oxc-resolver'
26
26
  import MagicString from 'magic-string'
27
- import { evaluateExpr, evaluateCombinatorArray, evaluateParserFactory, evaluateStaticValue, evaluateWordFactory, evaluateWhenFactory, evaluateRefDeclaration, applyDefineStatement, referencesAny, setReducerResolver, setBuilderImportResolver, propName, ModuleScope, type FreeNameResolution, type Scope, type ScopeEntry } from './evaluator.ts'
27
+ import { evaluateExpr, evaluateCombinatorArray, evaluateParserFactory, externalAccesses, evaluateStaticValue, evaluateWordFactory, evaluateWhenFactory, evaluateRefDeclaration, applyDefineStatement, referencesAny, setReducerResolver, setBuilderImportResolver, propName, ModuleScope, type FreeNameResolution, type Scope, type ScopeEntry } from './evaluator.ts'
28
28
  import { classifyRuleMap } from '../analysis/commitment.ts'
29
29
  import { compile } from '../table/compile.ts'
30
30
  import { compileRuleMap, type CompiledRuleMapTable } from '../table/compile-rule-map.ts'
@@ -37,7 +37,7 @@ import {
37
37
  resolveDegradationLevel, recordDegradation, degradationCaptureDepth, unwindDegradationCapture,
38
38
  } from '../compiler/degradation.ts'
39
39
  import type { HostMode } from '../cst/host-mode.ts'
40
- import { COMPOSED_PIECES, type IRPiece } from '../compiler/linker.ts'
40
+ import { COMPOSED_PIECES, danglingReference, type IRPiece } from '../compiler/linker.ts'
41
41
  import { serializeCombinator, serializeRuleMap } from '../compiler/ir-serialize.ts'
42
42
  import { evalCombinatorIR, evalRulesIR, INERT_BUILDER } from './ir-eval.ts'
43
43
  import { countSiteVisits } from '../table/assemble.ts'
@@ -220,6 +220,15 @@ const DEFAULT_ASSEMBLY_BUDGET = 0.25
220
220
  * ~79 ms against ~14 ms on `benchmark.less`. "Couldn't be inlined" alone does not
221
221
  * tell an author which construct to change; the compiler knows, so it says.
222
222
  */
223
+ /** A build failure the lowering must not soften into a warning and a fallback. */
224
+ class LinkRefusal extends Error {}
225
+
226
+ /** Whether a rule-map entry has a definition (a `g.X` merely read does not). */
227
+ function isResolved(rule: Combinator<unknown>): boolean {
228
+ if (rule._def.tag !== 'lazy') return true
229
+ try { rule._def.thunk(); return true } catch { return false }
230
+ }
231
+
223
232
  function reasonSuffix(runtimeOnly: readonly string[] | undefined): string {
224
233
  return runtimeOnly === undefined || runtimeOnly.length === 0
225
234
  ? ''
@@ -2136,17 +2145,15 @@ function transformMacroImpl(
2136
2145
  const mergedCarriedRules = (
2137
2146
  items: CarriedItem[],
2138
2147
  link: LinkOptions,
2139
- ): { rules: Array<[string, Combinator<unknown>]>; trackLines: boolean } | null => {
2148
+ ): { rules: Array<[string, Combinator<unknown>]>; trackLines: boolean; dangling: string | undefined } | null => {
2140
2149
  const carriedMaps = carriedRuleMaps(items)
2141
2150
  if (carriedMaps === null) return null
2142
2151
  const linked = linkRules(carriedMaps.pieces.flatMap(p => p.recipes), link)
2143
2152
  // A name some piece only REFERENCES and none defines stays an undefined slot in
2144
- // the namespace. It is not a rule, so it is not handed to the encoder as one.
2145
- const rules = Object.entries(linked).filter(([, rule]) => {
2146
- if (rule._def.tag !== 'lazy') return true
2147
- try { rule._def.thunk(); return true } catch { return false }
2148
- })
2149
- return { rules, trackLines: carriedMaps.trackLines }
2153
+ // the namespace. Runtime `compose()` refuses that grammar at link, so the build
2154
+ // refuses it too (the caller throws `dangling`) rather than encode around the hole.
2155
+ const dangling = danglingReference(linked)
2156
+ return { rules: Object.entries(linked), trackLines: carriedMaps.trackLines, dangling }
2150
2157
  }
2151
2158
 
2152
2159
  /**
@@ -2183,7 +2190,7 @@ function transformMacroImpl(
2183
2190
  // `rules({ scanSkip }, …)` options, which stamps `_meta.grammarScanSkip` when the IR
2184
2191
  // is re-lowered (materializePiece → compileLinkable picks it up), and survives to a
2185
2192
  // downstream re-compose. The full-pieces fallback bakes it via the compile option.
2186
- const ir = serializeRuleMap(entries as never, scanSkip)
2193
+ const ir = serializeRuleMap(entries as never, scanSkip, rm instanceof Map ? externalAccesses.get(rm) : undefined)
2187
2194
  if (ir) return { carried: [{ ns, ir, ...(trackLines ? { trackLines: true as const } : {}) }] }
2188
2195
  // NO FULL-PIECES FALLBACK. It used to bake the grammar to lowered source when the IR
2189
2196
  // would not serialize; with one lowering there is nothing to bake to, and IR is not
@@ -2337,6 +2344,7 @@ function transformMacroImpl(
2337
2344
  trivia: composing,
2338
2345
  ...(cHostMode ? { hostMode: cHostMode as HostMode } : {}),
2339
2346
  })
2347
+ if (merged?.dangling !== undefined) throw new Error(`${id}:${lineOf(init.start)} — compose: ${merged.dangling}`)
2340
2348
  if (merged !== null) {
2341
2349
  // Harvest the direct-builder import provenance carried on the merged graph so
2342
2350
  // the re-lower pass at the end of this transform re-emits those imports.
@@ -2559,6 +2567,15 @@ function transformMacroImpl(
2559
2567
  }
2560
2568
  }
2561
2569
  const leafMerged = mergeRuleMaps([...carriedMaps.pieces.map(p => p.rules), localEntries])
2570
+ // The same refusal as `compose()`, over a merge by name: a name some piece only
2571
+ // reads (an unresolved entry of a carried map, or a local factory's external
2572
+ // access) that no piece defines.
2573
+ const accessed = [
2574
+ ...carriedMaps.pieces.flatMap(p => p.rules).filter(([, rule]) => !isResolved(rule)).map(([name]) => name),
2575
+ ...(localRules instanceof Map ? externalAccesses.get(localRules) ?? [] : []),
2576
+ ]
2577
+ const dangling = danglingReference(Object.fromEntries(leafMerged), accessed)
2578
+ if (dangling !== undefined) throw new LinkRefusal(`${id}:${lineOf(init.start)} — composeLeaf: ${dangling}`)
2562
2579
  // Composing-wins governs the leaf fuse too. The local entries are NOT freshly
2563
2580
  // evaluated (they come from this module's own `rules()` factory), so they are
2564
2581
  // excluded from the override — their declared trivia IS the composing candidate,
@@ -2595,6 +2612,7 @@ function transformMacroImpl(
2595
2612
  warn(init.start, 'composeLeaf(): a recognition piece has no re-lowerable IR')
2596
2613
  return null
2597
2614
  } catch (e) {
2615
+ if (e instanceof LinkRefusal) throw e
2598
2616
  warn(init.start, `composeLeaf(): ${(e as Error).message}`)
2599
2617
  return null
2600
2618
  }
@@ -2796,7 +2814,7 @@ function transformMacroImpl(
2796
2814
  // lowering there is no such artifact, and a grammar that cannot serialize
2797
2815
  // simply is not carried: an absent carried list makes the downstream compose
2798
2816
  // say so, where a frozen one did not.
2799
- const ir = serializeRuleMap([...compiledRules.ruleMap] as never, compiledRules.scanSkip)
2817
+ const ir = serializeRuleMap([...compiledRules.ruleMap] as never, compiledRules.scanSkip, externalAccesses.get(compiledRules.ruleMap))
2800
2818
  if (ir) carriedMetadata = [{ ns, ir, ...(compiledRules.trackLines ? { trackLines: true as const } : {}) }]
2801
2819
  // NOT SILENT. An export with no carried IR cannot be composed downstream at
2802
2820
  // all — the consumer's `compose()` will fall back to runtime and say so, but
package/src/version.ts CHANGED
@@ -13,4 +13,4 @@
13
13
  * Kept in sync with `package.json` by `test/unit/version-sync.test.ts`, which fails
14
14
  * the build if this constant drifts from the published version.
15
15
  */
16
- export const PARSEMAN_VERSION = '0.60.3'
16
+ export const PARSEMAN_VERSION = '0.60.4'