parseman 0.60.3 → 0.60.5

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.
@@ -16,7 +16,7 @@ import type {
16
16
  import type { Combinator } from '../types.ts'
17
17
  import type { DispatchArm } from '../combinators/dispatch.ts'
18
18
  import { ref } from '../combinators/ref.ts'
19
- import { rules, type RulesOptions } from '../combinators/parser.ts'
19
+ import { rules, RULES_RECIPE, type PriorRules, type RulesOptions, type RulesRecipe } from '../combinators/parser.ts'
20
20
  import * as parseman from '../index.ts'
21
21
  import { confirmedBuildParamUnused } from '../compiler/build-arity.ts'
22
22
  import { directBuilderBindings } from './direct-builder-static.ts'
@@ -1478,11 +1478,17 @@ export function evaluateParserFactory(
1478
1478
 
1479
1479
  const factory = factoryNode as unknown as ArrowFunctionExpression | OxcFunction
1480
1480
  const params = factory.params
1481
- if (params.length !== 1) return null
1482
- const param = params[0] as unknown as { type: string; name?: string }
1481
+ if (params.length !== 1 && params.length !== 2) return null
1483
1482
  // FormalParameter is { decorators? } & BindingPattern — BindingIdentifier has type "Identifier"
1484
- const proxyName = param.type === 'Identifier' ? param.name ?? null : null
1483
+ const paramName = (param: unknown): string | null => {
1484
+ const p = param as { type: string; name?: string }
1485
+ return p.type === 'Identifier' ? p.name ?? null : null
1486
+ }
1487
+ const proxyName = paramName(params[0])
1485
1488
  if (!proxyName) return null
1489
+ // `(g, prior)`: the second parameter is the `prior` handle (`PriorRules`).
1490
+ const priorName = params.length === 2 ? paramName(params[1]) : undefined
1491
+ if (priorName === null) return null
1486
1492
 
1487
1493
  const body = factory.body
1488
1494
  if (!body) return null
@@ -1566,11 +1572,12 @@ export function evaluateParserFactory(
1566
1572
  const ABORT = Symbol('parseman: factory not statically evaluable')
1567
1573
  let built: Record<string, Combinator<unknown>>
1568
1574
  try {
1569
- built = rules(options ?? {}, (g: Record<string, Combinator<unknown>>) => {
1575
+ built = rules(options ?? {}, (g: Record<string, Combinator<unknown>>, prior: PriorRules) => {
1570
1576
  // Outer ScopeEntry values carry their mfSrcs and are replayed by scopeGet()
1571
1577
  // when body statements or return expressions reference them.
1572
1578
  const localScope: XScope = scope instanceof ModuleScope ? scope.fork() : new Map(scope as XScope)
1573
1579
  localScope.set(proxyName, g)
1580
+ if (priorName !== undefined) localScope.set(priorName, prior)
1574
1581
 
1575
1582
  // ── Phase 1: the factory's own body statements ────────────────────────
1576
1583
  if (!evalBodyStatements(statements, localScope, code, out)) throw ABORT
@@ -1613,9 +1620,24 @@ export function evaluateParserFactory(
1613
1620
  */
1614
1621
  const map = new Map<string, Combinator<unknown>>()
1615
1622
  for (const key of keys) map.set(key, built[key]!)
1623
+ const declared = new Set(keys)
1624
+ externalAccesses.set(map, Object.keys(built).filter(name => !declared.has(name)))
1625
+ ruleMapRecipes.set(map, (built as unknown as Record<symbol, readonly RulesRecipe[]>)[RULES_RECIPE]!)
1616
1626
  return map
1617
1627
  }
1618
1628
 
1629
+ /** The recipe an evaluated rule map came from, for a composition that links it. */
1630
+ export const ruleMapRecipes = new WeakMap<ReadonlyMap<string, Combinator<unknown>>, readonly RulesRecipe[]>()
1631
+
1632
+ /**
1633
+ * Every EXTERNAL name an evaluated factory read through `g`, keyed by the map
1634
+ * `evaluateParserFactory` returned. The map holds declared rules only, so a `g.X`
1635
+ * read outside every rule body (`const unused = g.X`) leaves no trace in it; the
1636
+ * IR serializer reads this to keep that access, so a composition refuses an `X`
1637
+ * nobody defines exactly as runtime `compose()` does.
1638
+ */
1639
+ export const externalAccesses = new WeakMap<ReadonlyMap<string, Combinator<unknown>>, readonly string[]>()
1640
+
1619
1641
  /** A combinator slot created by ref() — has a callable `define`. */
1620
1642
  type DefinableRef = Combinator<unknown> & { define(p: Combinator<unknown>): void }
1621
1643
 
@@ -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, ruleMapRecipes, 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,9 @@ 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
+
223
226
  function reasonSuffix(runtimeOnly: readonly string[] | undefined): string {
224
227
  return runtimeOnly === undefined || runtimeOnly.length === 0
225
228
  ? ''
@@ -2052,7 +2055,7 @@ function transformMacroImpl(
2052
2055
  /**
2053
2056
  * Evaluate a carried list back to combinators, one entry per piece: its rule map
2054
2057
  * and the `rules()` recipe it was built from. `compose()` links the recipes into
2055
- * one namespace (`mergedCarriedRules`); `composeLeaf()` merges the maps by name.
2058
+ * one namespace (`mergedCarriedRules`), and `composeLeaf()` does the same.
2056
2059
  * Either way the merged grammar is encoded ONCE by `compileRuleMap` — no relocation
2057
2060
  * of code offsets and no merging of const / fn / expected pools between two
2058
2061
  * already-encoded programs.
@@ -2088,38 +2091,6 @@ function transformMacroImpl(
2088
2091
  return { pieces, trackLines }
2089
2092
  }
2090
2093
 
2091
- /**
2092
- * Fold ordered rule maps into the composed map: a later piece's name WINS, and
2093
- * `Map` keeps each name at its first-sighting position so the encoded rule order
2094
- * tracks the source lowering's rather than drifting per compose.
2095
- *
2096
- * A REFERENCE IS NOT A DEFINITION. A `rules(g => …)` cache also holds every `g.X`
2097
- * that was merely ACCESSED, so a delta that calls `g.Pair` carries a `Pair` entry
2098
- * that is an unresolved named lazy. Merged in name order that entry lands LAST and
2099
- * shadows the base piece which actually defines `Pair` — the encoder then finds a
2100
- * hole where the winner should be and refuses the whole grammar with "ref() used
2101
- * before .define()". The same filter guards `compileLinkableTable`'s `isLocal`
2102
- * (`compile-linkable-table.ts`) and `recoverComposedRules`; this site needs it
2103
- * for the same reason, and skipping it is why cross-piece composition looked blocked.
2104
- *
2105
- * The reference itself is not lost: it stays inside the delta's own rule bodies and
2106
- * `enc.winners` binds it BY NAME to whichever piece supplies the definition.
2107
- */
2108
- const mergeRuleMaps = (
2109
- maps: ReadonlyArray<ReadonlyArray<readonly [string, Combinator<unknown>]>>,
2110
- ): Array<[string, Combinator<unknown>]> => {
2111
- const winners = new Map<string, Combinator<unknown>>()
2112
- for (const map of maps) {
2113
- for (const [name, rule] of map) {
2114
- if (rule._def.tag === 'lazy') {
2115
- try { rule._def.thunk() } catch { continue }
2116
- }
2117
- winners.set(name, rule)
2118
- }
2119
- }
2120
- return [...winners]
2121
- }
2122
-
2123
2094
  /**
2124
2095
  * LINK a carried list into the composed map, exactly as runtime `compose()` does:
2125
2096
  * every piece's carried `rules()` recipe runs again against ONE namespace
@@ -2136,36 +2107,15 @@ function transformMacroImpl(
2136
2107
  const mergedCarriedRules = (
2137
2108
  items: CarriedItem[],
2138
2109
  link: LinkOptions,
2139
- ): { rules: Array<[string, Combinator<unknown>]>; trackLines: boolean } | null => {
2110
+ ): { rules: Array<[string, Combinator<unknown>]>; trackLines: boolean; dangling: string | undefined } | null => {
2140
2111
  const carriedMaps = carriedRuleMaps(items)
2141
2112
  if (carriedMaps === null) return null
2142
2113
  const linked = linkRules(carriedMaps.pieces.flatMap(p => p.recipes), link)
2143
2114
  // 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 }
2150
- }
2151
-
2152
- /**
2153
- * COMPOSING-WINS for `composeLeaf()`'s carried pieces, as an OVERRIDE rather than
2154
- * a gap-fill: `compileRuleMap`'s `applyAmbient` only fills a rule that carries no
2155
- * trivia of its own. (`compose()` gets the same override from `linkRules`.)
2156
- *
2157
- * Safe to mutate: every rule here came from `evalRuleMapIR`, which constructs FRESH
2158
- * combinators per piece per compose, so this cannot leak into another compose of the
2159
- * same base grammar.
2160
- */
2161
- const applyComposingTrivia = (
2162
- ruleMap: ReadonlyArray<readonly [string, Combinator<unknown>]>,
2163
- composing: Combinator<unknown>,
2164
- ): void => {
2165
- for (const [, rule] of ruleMap) {
2166
- if (rule._meta.isTrivia) continue
2167
- ;(rule._meta as { grammarTrivia?: Combinator<unknown> }).grammarTrivia = composing
2168
- }
2115
+ // the namespace. Runtime `compose()` refuses that grammar at link, so the build
2116
+ // refuses it too (the caller throws `dangling`) rather than encode around the hole.
2117
+ const dangling = danglingReference(linked)
2118
+ return { rules: Object.entries(linked), trackLines: carriedMaps.trackLines, dangling }
2169
2119
  }
2170
2120
 
2171
2121
  /** Resolve one `compose([...])` argument to its RE-LOWERABLE carried items (IR
@@ -2183,7 +2133,7 @@ function transformMacroImpl(
2183
2133
  // `rules({ scanSkip }, …)` options, which stamps `_meta.grammarScanSkip` when the IR
2184
2134
  // is re-lowered (materializePiece → compileLinkable picks it up), and survives to a
2185
2135
  // downstream re-compose. The full-pieces fallback bakes it via the compile option.
2186
- const ir = serializeRuleMap(entries as never, scanSkip)
2136
+ const ir = serializeRuleMap(entries as never, scanSkip, rm instanceof Map ? externalAccesses.get(rm) : undefined)
2187
2137
  if (ir) return { carried: [{ ns, ir, ...(trackLines ? { trackLines: true as const } : {}) }] }
2188
2138
  // NO FULL-PIECES FALLBACK. It used to bake the grammar to lowered source when the IR
2189
2139
  // would not serialize; with one lowering there is nothing to bake to, and IR is not
@@ -2333,10 +2283,18 @@ function transformMacroImpl(
2333
2283
  // does — one encode, one `tableRules(…)` expression, no linker. `carried` is
2334
2284
  // unchanged either way: it is the re-lowerable IR list, not a lowering artifact,
2335
2285
  // so a downstream re-compose behaves identically whichever engine emitted here.
2336
- const merged = mergedCarriedRules(carried, {
2337
- trivia: composing,
2338
- ...(cHostMode ? { hostMode: cHostMode as HostMode } : {}),
2339
- })
2286
+ let merged: ReturnType<typeof mergedCarriedRules>
2287
+ try {
2288
+ merged = mergedCarriedRules(carried, {
2289
+ trivia: composing,
2290
+ ...(cHostMode ? { hostMode: cHostMode as HostMode } : {}),
2291
+ })
2292
+ } catch (e) {
2293
+ // Linking refuses the composition (a `prior.X` no earlier piece defines): a
2294
+ // runtime `compose()` would refuse it the same way, so the build fails, here.
2295
+ throw new Error(`${id}:${lineOf(init.start)} — ${(e as Error).message}`, { cause: e })
2296
+ }
2297
+ if (merged?.dangling !== undefined) throw new Error(`${id}:${lineOf(init.start)} — compose: ${merged.dangling}`)
2340
2298
  if (merged !== null) {
2341
2299
  // Harvest the direct-builder import provenance carried on the merged graph so
2342
2300
  // the re-lower pass at the end of this transform re-emits those imports.
@@ -2467,22 +2425,15 @@ function transformMacroImpl(
2467
2425
  return null
2468
2426
  }
2469
2427
  const localArg = elements[elements.length - 1]!
2470
- let localRules: Iterable<readonly [string, unknown]> | null = null
2471
- // The LOCAL leaf grammar's own ambient scanSkip (from its `rules({ scanSkip })`)
2472
- // governs the scanTo/balanced sites IT defines — unlike trivia (composing-wins),
2473
- // opaque units are dialect-specific, so the local declaration is threaded to the
2474
- // local piece's compile.
2475
- let localScanSkip: Combinator<unknown>[] | undefined
2428
+ let localRules: ReadonlyMap<string, Combinator<unknown>> | null = null
2476
2429
  const importedFactories: string[] = []
2477
2430
  if (isRulesCall(localArg)) {
2478
2431
  const evaluated = evaluateRulesFactory(localArg, `composeLeaf${init.start}`)
2479
2432
  localRules = evaluated?.ruleMap ?? null
2480
- localScanSkip = evaluated?.scanSkip
2481
2433
  if (evaluated?.importedFactory) importedFactories.push(evaluated.importedFactory)
2482
2434
  } else if (localArg.type === 'Identifier') {
2483
2435
  const name = (localArg as unknown as { name: string }).name
2484
2436
  localRules = localRuleMaps.get(name) ?? null
2485
- localScanSkip = localGrammarScanSkip.get(name)
2486
2437
  }
2487
2438
  if (!localRules) {
2488
2439
  // Distinguish "the argument is the wrong SHAPE" from "the argument is the right
@@ -2517,13 +2468,6 @@ function transformMacroImpl(
2517
2468
  // may place one beneath a direct node(). Re-lower their terminals with
2518
2469
  // capture enabled so that node receives the imported token values in its
2519
2470
  // normal child collector; the pieces still contain no semantic callback.
2520
- const localNs = nsFor(`composeLeaf${init.start}`)
2521
- const localEntries = [...localRules] as Array<[string, Combinator<unknown>]>
2522
-
2523
- // TABLE FIRST — same merge as `compose()`, with the local leaf map appended LAST
2524
- // so it wins every name and binds the imported shapes' holes (`enc.winners`
2525
- // resolves those by name; see `mergedCarriedRules`).
2526
- //
2527
2471
  // The recognition-only gate is preserved EXACTLY, and is the reason this path
2528
2472
  // needs no `compileLinkable`: `hasDirectBuilders` / `isRecognitionOnly` are
2529
2473
  // predicates over the combinator graph, not products of lowering it, so
@@ -2546,27 +2490,23 @@ function transformMacroImpl(
2546
2490
  warn(init.start, 'composeLeaf(): every pre-final grammar must explicitly prove recognition-only')
2547
2491
  return null
2548
2492
  }
2549
- // The local grammar's OWN ambient scanSkip is stamped onto the LOCAL entries
2550
- // only. Passing it as a merged-map option instead would let `applyAmbient`
2551
- // hand it to every imported rule that happens to carry no stamp of its own —
2552
- // opaque units are dialect-specific, and that is precisely the leak the
2553
- // per-piece threading in the source path exists to avoid.
2554
- if (localScanSkip) {
2555
- for (const [, rule] of localEntries) {
2556
- if (rule._meta.isTrivia) continue
2557
- const meta = rule._meta as { grammarScanSkip?: Combinator<unknown>[] }
2558
- if (meta.grammarScanSkip === undefined) meta.grammarScanSkip = localScanSkip
2559
- }
2493
+ // The same link as `compose()` and runtime `composeLeaf()`: the leaf is the last
2494
+ // recipe, so it wins every name, binds the imported shapes' holes and fills its
2495
+ // `prior.X`. Each recipe keeps its own scanSkip; the composing trivia governs all.
2496
+ let linked: Record<string, Combinator<unknown>>
2497
+ try {
2498
+ linked = linkRules(
2499
+ [...carriedMaps.pieces.flatMap(p => p.recipes), ...ruleMapRecipes.get(localRules)!],
2500
+ { trivia: composing },
2501
+ )
2502
+ } catch (e) {
2503
+ throw new LinkRefusal(`${id}:${lineOf(init.start)} — composeLeaf: ${(e as Error).message.replace(/^compose: /, '')}`, { cause: e })
2560
2504
  }
2561
- const leafMerged = mergeRuleMaps([...carriedMaps.pieces.map(p => p.rules), localEntries])
2562
- // Composing-wins governs the leaf fuse too. The local entries are NOT freshly
2563
- // evaluated (they come from this module's own `rules()` factory), so they are
2564
- // excluded from the override — their declared trivia IS the composing candidate,
2565
- // and rewriting `_meta` on them would mutate the module-level grammar object.
2566
- if (composing) applyComposingTrivia(carriedMaps.pieces.flatMap(p => p.rules), composing)
2505
+ const dangling = danglingReference(linked)
2506
+ if (dangling !== undefined) throw new LinkRefusal(`${id}:${lineOf(init.start)} — composeLeaf: ${dangling}`)
2567
2507
  const refusals: string[] = []
2568
2508
  const compiled = compileRuleMap(
2569
- leafMerged,
2509
+ Object.entries(linked),
2570
2510
  {
2571
2511
  ...(composing ? { trivia: composing } : {}),
2572
2512
  ...(carriedMaps.trackLines ? { trackLines: true } : {}),
@@ -2595,6 +2535,7 @@ function transformMacroImpl(
2595
2535
  warn(init.start, 'composeLeaf(): a recognition piece has no re-lowerable IR')
2596
2536
  return null
2597
2537
  } catch (e) {
2538
+ if (e instanceof LinkRefusal) throw e
2598
2539
  warn(init.start, `composeLeaf(): ${(e as Error).message}`)
2599
2540
  return null
2600
2541
  }
@@ -2796,7 +2737,7 @@ function transformMacroImpl(
2796
2737
  // lowering there is no such artifact, and a grammar that cannot serialize
2797
2738
  // simply is not carried: an absent carried list makes the downstream compose
2798
2739
  // say so, where a frozen one did not.
2799
- const ir = serializeRuleMap([...compiledRules.ruleMap] as never, compiledRules.scanSkip)
2740
+ const ir = serializeRuleMap([...compiledRules.ruleMap] as never, compiledRules.scanSkip, externalAccesses.get(compiledRules.ruleMap))
2800
2741
  if (ir) carriedMetadata = [{ ns, ir, ...(compiledRules.trackLines ? { trackLines: true as const } : {}) }]
2801
2742
  // NOT SILENT. An export with no carried IR cannot be composed downstream at
2802
2743
  // all — the consumer's `compose()` will fall back to runtime and say so, but
package/src/spec/model.ts CHANGED
@@ -507,7 +507,9 @@ class Builder {
507
507
 
508
508
  case 'lazy': {
509
509
  const inner = resolveLazy(self)
510
- return inner ? this.walk(inner) : { kind: 'annotation', text: '?' }
510
+ if (inner) return this.walk(inner)
511
+ const prior = (self as { _priorOf?: string })._priorOf
512
+ return { kind: 'annotation', text: prior === undefined ? '?' : `prior.${prior}` }
511
513
  }
512
514
 
513
515
  case 'unknown':
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.5'