parseman 0.52.0 → 0.54.0

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 (108) hide show
  1. package/dist/analysis/diagnostics.js +636 -126
  2. package/dist/analysis/diagnostics.js.map +3 -3
  3. package/dist/cli/index.js +6191 -6274
  4. package/dist/cli/index.js.map +3 -3
  5. package/dist/combinators/field-trivia.d.ts +40 -0
  6. package/dist/combinators/field-trivia.d.ts.map +1 -0
  7. package/dist/combinators/map.d.ts +19 -2
  8. package/dist/combinators/map.d.ts.map +1 -1
  9. package/dist/combinators/parser.d.ts +34 -0
  10. package/dist/combinators/parser.d.ts.map +1 -1
  11. package/dist/combinators/ref.d.ts +0 -13
  12. package/dist/combinators/ref.d.ts.map +1 -1
  13. package/dist/combinators/repeat.d.ts.map +1 -1
  14. package/dist/combinators/sequence.d.ts.map +1 -1
  15. package/dist/compiler/borrowed-rules.d.ts +30 -0
  16. package/dist/compiler/borrowed-rules.d.ts.map +1 -0
  17. package/dist/compiler/compile-linkable-table.d.ts +1 -1
  18. package/dist/compiler/fields.d.ts +2 -0
  19. package/dist/compiler/fields.d.ts.map +1 -1
  20. package/dist/compiler/grammar-coverage-ids.d.ts.map +1 -1
  21. package/dist/compiler/ir-serialize.d.ts +10 -4
  22. package/dist/compiler/ir-serialize.d.ts.map +1 -1
  23. package/dist/compiler/linker.d.ts +48 -91
  24. package/dist/compiler/linker.d.ts.map +1 -1
  25. package/dist/coverage.d.ts +1 -1
  26. package/dist/coverage.d.ts.map +1 -1
  27. package/dist/cst/reflection.d.ts.map +1 -1
  28. package/dist/cst/trivia-charscan.d.ts +11 -3
  29. package/dist/cst/trivia-charscan.d.ts.map +1 -1
  30. package/dist/cst/trivia-kinds.d.ts +11 -0
  31. package/dist/cst/trivia-kinds.d.ts.map +1 -1
  32. package/dist/index.d.ts +1 -0
  33. package/dist/index.d.ts.map +1 -1
  34. package/dist/index.js +764 -888
  35. package/dist/index.js.map +3 -3
  36. package/dist/language-service/index.js.map +1 -1
  37. package/dist/plugin/evaluator.d.ts +38 -2
  38. package/dist/plugin/evaluator.d.ts.map +1 -1
  39. package/dist/plugin/index.d.ts.map +1 -1
  40. package/dist/plugin/index.js +1443 -729
  41. package/dist/plugin/index.js.map +3 -3
  42. package/dist/plugin/ir-eval.d.ts +21 -0
  43. package/dist/plugin/ir-eval.d.ts.map +1 -0
  44. package/dist/plugin/reducer-resolver.d.ts +6 -0
  45. package/dist/plugin/reducer-resolver.d.ts.map +1 -1
  46. package/dist/spec/index.js +555 -6109
  47. package/dist/spec/index.js.map +3 -3
  48. package/dist/spec/railroad-lib.d.ts +7 -2
  49. package/dist/spec/railroad-lib.d.ts.map +1 -1
  50. package/dist/spec/railroad.d.ts.map +1 -1
  51. package/dist/table/assemble.d.ts +9 -0
  52. package/dist/table/assemble.d.ts.map +1 -1
  53. package/dist/table/compile-rule-map.d.ts +11 -4
  54. package/dist/table/compile-rule-map.d.ts.map +1 -1
  55. package/dist/table/compile.d.ts +16 -0
  56. package/dist/table/compile.d.ts.map +1 -1
  57. package/dist/table/emit-assembly.d.ts +1 -1
  58. package/dist/table/emit-assembly.d.ts.map +1 -1
  59. package/dist/table/emit.d.ts +6 -2
  60. package/dist/table/emit.d.ts.map +1 -1
  61. package/dist/table/encode.d.ts.map +1 -1
  62. package/dist/table/exec.d.ts.map +1 -1
  63. package/dist/table/field-spec.d.ts +13 -0
  64. package/dist/table/field-spec.d.ts.map +1 -0
  65. package/dist/table/index.js +480 -199
  66. package/dist/table/index.js.map +2 -2
  67. package/dist/table/scalar-terminal.d.ts +11 -0
  68. package/dist/table/scalar-terminal.d.ts.map +1 -1
  69. package/dist/types.d.ts +14 -0
  70. package/dist/types.d.ts.map +1 -1
  71. package/dist/version.d.ts +1 -1
  72. package/package.json +1 -1
  73. package/src/analysis/duplication.ts +1 -1
  74. package/src/analysis/rebuild.ts +1 -1
  75. package/src/combinators/field-trivia.ts +44 -0
  76. package/src/combinators/map.ts +48 -6
  77. package/src/combinators/parser.ts +133 -67
  78. package/src/combinators/ref.ts +10 -1
  79. package/src/combinators/repeat.ts +6 -1
  80. package/src/combinators/sequence.ts +9 -1
  81. package/src/compiler/borrowed-rules.ts +88 -0
  82. package/src/compiler/compile-linkable-table.ts +1 -1
  83. package/src/compiler/fields.ts +5 -2
  84. package/src/compiler/grammar-coverage-ids.ts +35 -6
  85. package/src/compiler/ir-serialize.ts +19 -119
  86. package/src/compiler/linker.ts +190 -666
  87. package/src/coverage.ts +7 -5
  88. package/src/cst/reflection.ts +6 -1
  89. package/src/cst/trivia-charscan.ts +17 -56
  90. package/src/cst/trivia-kinds.ts +24 -8
  91. package/src/index.ts +1 -0
  92. package/src/plugin/evaluator.ts +114 -14
  93. package/src/plugin/index.ts +588 -110
  94. package/src/plugin/ir-eval.ts +165 -0
  95. package/src/plugin/reducer-resolver.ts +20 -3
  96. package/src/spec/railroad-lib.ts +18 -8
  97. package/src/spec/railroad.ts +3 -4
  98. package/src/table/assemble.ts +126 -22
  99. package/src/table/compile-rule-map.ts +18 -8
  100. package/src/table/compile.ts +61 -0
  101. package/src/table/emit-assembly.ts +38 -18
  102. package/src/table/emit.ts +15 -4
  103. package/src/table/encode.ts +18 -2
  104. package/src/table/exec.ts +15 -1
  105. package/src/table/field-spec.ts +17 -0
  106. package/src/table/scalar-terminal.ts +25 -0
  107. package/src/types.ts +12 -2
  108. package/src/version.ts +1 -1
package/src/coverage.ts CHANGED
@@ -18,7 +18,7 @@ import { scanTo } from './combinators/scanTo.ts'
18
18
  import { sequence } from './combinators/sequence.ts'
19
19
  import { token, leaf, sourceLeaf } from './combinators/token.ts'
20
20
  import { withCtx } from './combinators/withCtx.ts'
21
- import { composedCoverageRules } from './compiler/linker.ts'
21
+ import { composedCoverageRules, linkedWinnersOf } from './compiler/linker.ts'
22
22
  import { buildGrammarPlan, type GrammarCoverageDefinition, type GrammarCoveragePlan } from './compiler/grammar-coverage-ids.ts'
23
23
 
24
24
  export type { GrammarCoverageDefinition } from './compiler/grammar-coverage-ids.ts'
@@ -156,11 +156,11 @@ export function grammarCoverageDefinitions(entry: Combinator<unknown>, winners?:
156
156
  }
157
157
 
158
158
  /** Definitions for a runtime `compose()` result, normalized through its final
159
- * IR winner map. Opaque precompiled pieces intentionally fail rather than
159
+ * winner map. Anything else — a compiled table, a plain map — fails rather than
160
160
  * reporting source-piece identities as though they were final grammar IDs. */
161
161
  export function composedGrammarCoverageDefinitions(grammar: Record<string, unknown>, startRule: string): readonly GrammarCoverageDefinition[] {
162
162
  const winners = composedCoverageRules(grammar)
163
- if (!winners) throw new TypeError('semantic coverage needs re-lowerable composed IR; this composition contains an opaque artifact')
163
+ if (!winners) throw new TypeError('semantic coverage needs a runtime compose() result (a linked interpreter grammar); this is not one')
164
164
  const entry = winners[startRule]
165
165
  if (!entry) throw new TypeError(`semantic coverage start rule ${JSON.stringify(startRule)} is not a final winner`)
166
166
  return grammarCoverageDefinitions(entry, winners)
@@ -343,7 +343,7 @@ function coverageEntry(entry: Combinator<unknown>, collector: GrammarCoverageCol
343
343
  case 'trivia': return trivia(build(def.parser))
344
344
  case 'token': return token(build(def.parser))
345
345
  case 'leaf': return def.cstValue === 'source' ? sourceLeaf(build(def.parser), def.fn) : leaf(build(def.parser), def.fn)
346
- case 'field': return field(def.name, build(def.parser))
346
+ case 'field': return field(def.name, build(def.parser), { leadingTrivia: def.leadingTrivia === true, trailingTrivia: def.trailingTrivia === true })
347
347
  case 'grammar': return grammarParser({
348
348
  ...(def.triviaParser === undefined ? (def.clearTrivia ? { trivia: null } : {}) : { trivia: build(def.triviaParser) }),
349
349
  ...(def.captureTrivia ? { captureTrivia: true } : {}),
@@ -380,7 +380,9 @@ function coverageEntry(entry: Combinator<unknown>, collector: GrammarCoverageCol
380
380
 
381
381
  export function runWithGrammarCoverage(entry: Runnable, input: string, options: RunOptions & { collector?: GrammarCoverageCollector; trace?: GrammarTraceSink } = {}): { result: RunResult; coverage: GrammarCoverageSnapshot } {
382
382
  if (typeof entry === 'function') throw new TypeError('runWithGrammarCoverage currently requires an interpreter combinator entry')
383
- const plan = buildGrammarPlan(entry)
383
+ // A runtime composition's rule is planned through its winner map, so its IDs are
384
+ // the ones `composedGrammarCoverageDefinitions` reports.
385
+ const plan = buildGrammarPlan(entry, linkedWinnersOf(entry))
384
386
  const definitions = plan.definitions
385
387
  const collector = options.collector ?? createGrammarCoverageCollector(definitions)
386
388
  const { collector: _collector, trace, ...runOptions } = options
@@ -115,6 +115,11 @@ export function collectGrammarReflection(
115
115
  ): GrammarReflection {
116
116
  const byType = new Map<string, string[]>()
117
117
  const followLazy = opts?.followLazy !== false
118
+ // ONE walk over the whole map. A node already visited from an earlier rule had
119
+ // its whole subgraph visited then, and `addNode` is idempotent, so a fresh set
120
+ // per rule only re-walked shared structure — O(rules × graph), and every unbound
121
+ // cross-piece hole threw (a stack-capturing Error) once per rule reaching it.
122
+ const seen = new Set<Combinator<unknown>>()
118
123
  for (const [, rule] of ruleMap) {
119
124
  if (!followLazy && rule._def.tag === 'lazy') {
120
125
  try {
@@ -123,7 +128,7 @@ export function collectGrammarReflection(
123
128
  } catch {}
124
129
  continue
125
130
  }
126
- collectFromCombinator(rule, byType, new Set(), followLazy)
131
+ collectFromCombinator(rule, byType, seen, followLazy)
127
132
  }
128
133
  return { nodes: [...byType].map(([type, tags]) => ({ type, tags })) }
129
134
  }
@@ -226,74 +226,35 @@ function plainRegexSource(p: Combinator<unknown>): string | null {
226
226
  return d.tag === 'regex' && d.flags === '' ? d.source : null
227
227
  }
228
228
 
229
- let visitSpec0: LabeledTriviaSpec | undefined
230
- let visitSpec1: LabeledTriviaSpec | undefined
231
- let visitSpec2: LabeledTriviaSpec | undefined
232
- let visitSpec3: LabeledTriviaSpec | undefined
233
- let visitValue0: CssTriviaVisitor | null | undefined
234
- let visitValue1: CssTriviaVisitor | null | undefined
235
- let visitValue2: CssTriviaVisitor | null | undefined
236
- let visitValue3: CssTriviaVisitor | null | undefined
229
+ /** The fields of a spec the char-level lowerings below are computed from. */
230
+ type TriviaArmsSpec = Pick<LabeledTriviaSpec, 'arms' | 'minRepeats'>
237
231
 
238
- /** A direct classified scanner for the four canonical CSS/Less trivia tuples. */
239
- export function commonLabeledTriviaVisitor(spec: LabeledTriviaSpec): CssTriviaVisitor | null {
240
- if (spec === visitSpec0) return visitValue0!
241
- if (spec === visitSpec1) return visitValue1!
242
- if (spec === visitSpec2) return visitValue2!
243
- if (spec === visitSpec3) return visitValue3!
244
- const sources = spec.minRepeats <= 1 ? spec.arms.map(arm => plainRegexSource(arm.parser)) : []
245
- const visitor = commonCssTriviaVisitor(sources)
246
- visitSpec3 = visitSpec2
247
- visitValue3 = visitValue2
248
- visitSpec2 = visitSpec1
249
- visitValue2 = visitValue1
250
- visitSpec1 = visitSpec0
251
- visitValue1 = visitValue0
252
- visitSpec0 = spec
253
- visitValue0 = visitor
254
- return visitor
232
+ /**
233
+ * A direct classified scanner for the four canonical CSS/Less trivia tuples.
234
+ * Computed once per spec, by `analyzeLabeledTrivia`, and read off it as
235
+ * `spec.visitor`: a scan never asks again.
236
+ */
237
+ export function commonLabeledTriviaVisitor(spec: TriviaArmsSpec): CssTriviaVisitor | null {
238
+ return commonCssTriviaVisitor(spec.minRepeats <= 1 ? spec.arms.map(arm => plainRegexSource(arm.parser)) : [])
255
239
  }
256
240
 
257
- let armSpec0: LabeledTriviaSpec | undefined
258
- let armSpec1: LabeledTriviaSpec | undefined
259
- let armSpec2: LabeledTriviaSpec | undefined
260
- let armSpec3: LabeledTriviaSpec | undefined
261
- let armValue0: readonly TriviaArmMatcher[] | null | undefined
262
- let armValue1: readonly TriviaArmMatcher[] | null | undefined
263
- let armValue2: readonly TriviaArmMatcher[] | null | undefined
264
- let armValue3: readonly TriviaArmMatcher[] | null | undefined
265
-
266
241
  /**
267
242
  * The character-level matchers for a spec's arms — index-parallel to `spec.arms`,
268
243
  * so a matcher's index IS its kind index — or `null` when any arm has no lowering.
244
+ * Computed once per spec, as `spec.charArms`.
269
245
  *
270
246
  * `spec.minRepeats > 1` is excluded: the loops below report only an END, and a run
271
247
  * that fails a minimum of two must report NOTHING. `classifiedTrivia()` never
272
248
  * produces one, and the combinator path still handles it if a host writes one.
273
249
  */
274
- export function charArmsFor(spec: LabeledTriviaSpec): readonly TriviaArmMatcher[] | null {
275
- if (spec === armSpec0) return armValue0!
276
- if (spec === armSpec1) return armValue1!
277
- if (spec === armSpec2) return armValue2!
278
- if (spec === armSpec3) return armValue3!
279
- let arms: TriviaArmMatcher[] | null = []
280
- if (spec.minRepeats > 1) {
281
- arms = null
282
- } else {
283
- for (const arm of spec.arms) {
284
- const c = classifyParser(arm.parser)
285
- if (!c) { arms = null; break }
286
- arms.push(c)
287
- }
250
+ export function charArmsFor(spec: TriviaArmsSpec): readonly TriviaArmMatcher[] | null {
251
+ if (spec.minRepeats > 1) return null
252
+ const arms: TriviaArmMatcher[] = []
253
+ for (const arm of spec.arms) {
254
+ const c = classifyParser(arm.parser)
255
+ if (!c) return null
256
+ arms.push(c)
288
257
  }
289
- armSpec3 = armSpec2
290
- armValue3 = armValue2
291
- armSpec2 = armSpec1
292
- armValue2 = armValue1
293
- armSpec1 = armSpec0
294
- armValue1 = armValue0
295
- armSpec0 = spec
296
- armValue0 = arms
297
258
  return arms
298
259
  }
299
260
 
@@ -2,7 +2,10 @@ import type { Combinator, ParseContext } from '../types.ts'
2
2
  import { pushCstTriviaEntry, pushTriviaLogEntry } from './capture-buffer.ts'
3
3
  import { startsFirstSet } from '../combinators/first-set.ts'
4
4
  import { createDetachedParseContext } from '../parse-context.ts'
5
- import { charArmsFor, charTriviaEnd, charTriviaKindMask, charTriviaVisit, commonLabeledTriviaVisitor } from './trivia-charscan.ts'
5
+ import {
6
+ charArmsFor, charTriviaEnd, charTriviaKindMask, charTriviaVisit, commonLabeledTriviaVisitor, type TriviaArmMatcher,
7
+ } from './trivia-charscan.ts'
8
+ import type { CssTriviaVisitor } from './trivia-css-scanner.ts'
6
9
 
7
10
  export type TriviaChunk = { start: number; end: number; kindIndex: number }
8
11
 
@@ -10,6 +13,13 @@ export type LabeledTriviaSpec = {
10
13
  readonly labels: readonly string[]
11
14
  readonly arms: ReadonlyArray<{ label: string; kindIndex: number; parser: Combinator<unknown> }>
12
15
  readonly minRepeats: number
16
+ /**
17
+ * The spec's char-level scanners, built with it. A scan reads them here: the
18
+ * four-slot memo they used to sit behind thrashed on a grammar with more trivia
19
+ * tables than slots, and rebuilt one on every miss.
20
+ */
21
+ readonly visitor: CssTriviaVisitor | null
22
+ readonly charArms: readonly TriviaArmMatcher[] | null
13
23
  }
14
24
 
15
25
  /** See `trivia-skip.ts`: bounded scalar memoisation avoids a WeakMap in the
@@ -93,12 +103,18 @@ export function analyzeLabeledTrivia(trivia: Combinator<unknown>): LabeledTrivia
93
103
  arms.push({ label: peeled.label, kindIndex: 0, parser: peeled.parser })
94
104
  }
95
105
 
96
- const spec = {
106
+ return cacheLabeledTrivia(trivia, labeledTriviaSpec(arms, minRepeats))
107
+ }
108
+
109
+ /** A spec, with the char-level scanners every scan of it reads. */
110
+ export function labeledTriviaSpec(arms: LabeledTriviaSpec['arms'], minRepeats: number): LabeledTriviaSpec {
111
+ return {
97
112
  labels: arms.map(a => a.label),
98
113
  arms,
99
114
  minRepeats,
115
+ visitor: commonLabeledTriviaVisitor({ arms, minRepeats }),
116
+ charArms: charArmsFor({ arms, minRepeats }),
100
117
  }
101
- return cacheLabeledTrivia(trivia, spec)
102
118
  }
103
119
 
104
120
  /** Label table on a `trivia()` combinator, if all arms are labeled. */
@@ -169,9 +185,9 @@ export function scanLabeledTriviaEnd(
169
185
  spec: LabeledTriviaSpec,
170
186
  state?: unknown,
171
187
  ): number {
172
- // `charArmsFor` only lowers `minRepeats <= 1`, where "no chunk matched" and
188
+ // `charArms` only lowers `minRepeats <= 1`, where "no chunk matched" and
173
189
  // "the minimum was not met" are the same position — so an end IS the answer.
174
- const chars = charArmsFor(spec)
190
+ const chars = spec.charArms
175
191
  if (chars !== null) return charTriviaEnd(input, cur, chars)
176
192
 
177
193
  let pos = cur
@@ -205,9 +221,9 @@ export function visitLabeledTrivia(
205
221
  visit: (start: number, end: number, kindIndex: number) => void,
206
222
  ): number | undefined {
207
223
  if (spec.minRepeats > 1) return undefined
208
- const common = commonLabeledTriviaVisitor(spec)
224
+ const common = spec.visitor
209
225
  if (common !== null) return common(input, cur, visit)
210
- const chars = charArmsFor(spec)
226
+ const chars = spec.charArms
211
227
  if (chars !== null) return charTriviaVisit(input, cur, chars, visit)
212
228
 
213
229
  let pos = cur
@@ -331,7 +347,7 @@ export function triviaKindMaskAt(
331
347
  spec: LabeledTriviaSpec,
332
348
  state?: unknown,
333
349
  ): number {
334
- const chars = charArmsFor(spec)
350
+ const chars = spec.charArms
335
351
  if (chars !== null) return charTriviaKindMask(input, pos, chars)
336
352
 
337
353
  let cur = pos
package/src/index.ts CHANGED
@@ -30,6 +30,7 @@ export { node } from './combinators/node.ts'
30
30
  export type { BuildNode, NodeCombinator, NodeOptions, NodeProjectOptions } from './combinators/node.ts'
31
31
  // lazy() is intentionally NOT exported.
32
32
  export { transform, trivia, classifiedTrivia, label, field } from './combinators/map.ts'
33
+ export type { FieldOptions } from './combinators/map.ts'
33
34
  export { parse, parser, noTrivia } from './combinators/grammar.ts'
34
35
  export type { ParseOptions, ParserOptions, ParsemanParser } from './combinators/grammar.ts'
35
36
  export { token, leaf, sourceLeaf } from './combinators/token.ts'
@@ -154,6 +154,69 @@ export type ScopeEntry = {
154
154
  }
155
155
  export type Scope = Map<string, ScopeEntry>
156
156
 
157
+ /** How a {@link ModuleScope} answers a name it does not hold: a value, the reason a
158
+ * binding the module DOES have cannot be known at build time, or `null` — not a
159
+ * binding this scope can speak for (an ordinary miss). */
160
+ export type FreeNameResolution = { value: unknown } | { unresolved: string } | null
161
+
162
+ /**
163
+ * A module's scope whose IMPORTED (and, for a foreign module, top-level) bindings
164
+ * resolve on first read instead of being absent.
165
+ *
166
+ * Absent was the defect. `anyValue` answered an imported name with `null`, so
167
+ * `rules({ scanSkip: [importedUnit] })` evaluated to nothing, the option quietly
168
+ * became `undefined`, and the build succeeded with no ambient skip — and a terminal
169
+ * imported into a rule body could not be lowered at all. Lazy, because a grammar
170
+ * module imports far more than combinators (AST classes, helpers) and resolving each
171
+ * one eagerly would read every one of those modules for nothing.
172
+ *
173
+ * A binding that exists but cannot be resolved is RECORDED in `unresolved` (shared by
174
+ * every scope of one transform) as "`name` <reason>", so the plugin can fail the build
175
+ * naming it rather than warn about an anonymous "isn't statically evaluable". Only a
176
+ * VALUE read records: `has()` is how the evaluator probes a name it may legitimately
177
+ * leave to runtime (a named reducer), so a probe that misses is not a failure.
178
+ */
179
+ export class ModuleScope extends Map<string, unknown> {
180
+ private readonly tried = new Map<string, string | undefined>()
181
+ private readonly resolveFree: (name: string) => FreeNameResolution
182
+ readonly unresolved: Set<string>
183
+ constructor(resolveFree: (name: string) => FreeNameResolution, unresolved: Set<string>) {
184
+ super()
185
+ this.resolveFree = resolveFree
186
+ this.unresolved = unresolved
187
+ }
188
+
189
+ override has(name: string): boolean {
190
+ return super.has(name) || this.resolve(name)
191
+ }
192
+
193
+ override get(name: string): unknown {
194
+ if (super.has(name) || this.resolve(name)) return super.get(name)
195
+ const why = this.tried.get(name)
196
+ if (why !== undefined) this.unresolved.add(`\`${name}\` ${why}`)
197
+ return undefined
198
+ }
199
+
200
+ /** A child scope (a factory body) that keeps resolving this module's free names. */
201
+ fork(): ModuleScope {
202
+ const child = new ModuleScope(this.resolveFree, this.unresolved)
203
+ for (const [k, v] of super.entries()) child.set(k, v)
204
+ return child
205
+ }
206
+
207
+ private resolve(name: string): boolean {
208
+ if (this.tried.has(name)) return false
209
+ const r = this.resolveFree(name)
210
+ if (r !== null && 'value' in r) {
211
+ super.set(name, r.value)
212
+ return true
213
+ }
214
+ // A name nothing binds YET is not remembered: a later declaration may bind it.
215
+ if (r !== null) this.tried.set(name, r.unresolved)
216
+ return false
217
+ }
218
+ }
219
+
157
220
  // Internal XScope also holds non-Combinator values (g proxy objects etc.)
158
221
  type XScopeVal = ScopeEntry | unknown
159
222
  type XScope = Map<string, XScopeVal>
@@ -251,7 +314,7 @@ const SUPPORTED: Record<string, (...args: unknown[]) => Combinator<unknown>> = {
251
314
  a[0] as Readonly<Record<string, Combinator<unknown>>>,
252
315
  ),
253
316
  label: (...a) => parseman.label(a[0] as string, a[1] as Combinator<unknown>),
254
- field: (...a) => parseman.field(a[0] as string, a[1] as Combinator<unknown>),
317
+ field: (...a) => parseman.field(a[0] as string, a[1] as Combinator<unknown>, a[2] as parseman.FieldOptions | undefined),
255
318
  noTrivia: (...a) => parseman.noTrivia(a[0] as Combinator<unknown>),
256
319
  token: (...a) => parseman.token(a[0] as Combinator<unknown>),
257
320
  // `routed(fallback)` — the fallback must be forwarded. A zero-arg entry here read
@@ -556,6 +619,20 @@ function staticNodeOptions(expr: Expression, scope: XScope): StaticNodeOptions {
556
619
  return opts.unwrap || opts.collapse || opts.project !== undefined || opts.captureTrivia || opts.trailingTrivia || opts.tags !== undefined || opts.buildArity !== undefined ? opts : undefined
557
620
  }
558
621
 
622
+ /**
623
+ * `scanTo`/`balanced` options: `undefined` when absent, `UNKNOWN_OPTIONS` when present
624
+ * but not statically known. Passing the unknown value on as `null` is what built a
625
+ * region with NO skip units out of `balanced('(', ')', importedOpts)` — the options
626
+ * silently vanished.
627
+ */
628
+ const UNKNOWN_OPTIONS = Symbol('parseman.unknownOptions')
629
+ function scanOptions(optsArg: Expression | { type: 'SpreadElement' } | undefined, scope: XScope, code?: string): unknown {
630
+ if (optsArg === undefined) return undefined
631
+ if (optsArg.type === 'SpreadElement') return UNKNOWN_OPTIONS
632
+ const opts = anyValue(optsArg as Expression, scope, code, [])
633
+ return isHole(optsArg as Expression, opts, scope) ? UNKNOWN_OPTIONS : opts
634
+ }
635
+
559
636
  /**
560
637
  * Evaluate a call expression to a Combinator.
561
638
  * `mfs` accumulates mapFn source texts in depth-first order — must match
@@ -893,9 +970,8 @@ function exprToCombi(node: Expression, scope: XScope, code?: string, mfs?: strin
893
970
  const open = anyValue(openArg as Expression, scope, code, [])
894
971
  const close = anyValue(closeArg as Expression, scope, code, [])
895
972
  if (typeof open !== 'string' || typeof close !== 'string') return null
896
- const opts = optsArg && optsArg.type !== 'SpreadElement'
897
- ? anyValue(optsArg as Expression, scope, code, [])
898
- : undefined
973
+ const opts = scanOptions(optsArg, scope, code)
974
+ if (opts === UNKNOWN_OPTIONS) return null
899
975
  try { return parseman.balanced(open, close, opts as parseman.ScanToOptions | undefined) } catch { return null }
900
976
  }
901
977
 
@@ -906,9 +982,8 @@ function exprToCombi(node: Expression, scope: XScope, code?: string, mfs?: strin
906
982
  if (!sentinelArg || sentinelArg.type === 'SpreadElement') return null
907
983
  const sentinel = anyValue(sentinelArg as Expression, scope, code, [])
908
984
  if (!isCombinator(sentinel)) return null
909
- const opts = optsArg && optsArg.type !== 'SpreadElement'
910
- ? anyValue(optsArg as Expression, scope, code, [])
911
- : undefined
985
+ const opts = scanOptions(optsArg, scope, code)
986
+ if (opts === UNKNOWN_OPTIONS) return null
912
987
  try { return parseman.scanTo(sentinel, opts as parseman.ScanToOptions | undefined) } catch { return null }
913
988
  }
914
989
 
@@ -1039,6 +1114,22 @@ function exprToCombi(node: Expression, scope: XScope, code?: string, mfs?: strin
1039
1114
  } catch { return null }
1040
1115
  }
1041
1116
 
1117
+ /**
1118
+ * An element that evaluated to `null` without BEING `null` — an unresolved name, a
1119
+ * call the evaluator could not build. Kept as a `null` slot, it made the container
1120
+ * look evaluated: `{ skip: [unknownUnit] }` became `{ skip: [null] }`, and an options
1121
+ * object went missing piece by piece with no warning. So a hole fails the whole
1122
+ * container. A function literal is exempt: it evaluates to `null` by design, its
1123
+ * source being captured by the caller that needs it.
1124
+ */
1125
+ function isHole(node: Expression, value: unknown, scope: XScope): boolean {
1126
+ if (value !== null) return false
1127
+ const n = unwrapStaticExpr(node) as { type: string; value?: unknown; name?: string }
1128
+ if (n.type === 'Identifier') return !scope.has(n.name!) // a bound nullish const is a value
1129
+ return !(n.type === 'Literal' && n.value === null)
1130
+ && n.type !== 'ArrowFunctionExpression' && n.type !== 'FunctionExpression'
1131
+ }
1132
+
1042
1133
  /** Evaluate any expression to its JS value (not necessarily a Combinator). */
1043
1134
  function anyValue(node: Expression, scope: XScope, code?: string, mfs?: string[]): unknown {
1044
1135
  if (node.type === 'TSAsExpression'
@@ -1064,7 +1155,9 @@ function anyValue(node: Expression, scope: XScope, code?: string, mfs?: string[]
1064
1155
  for (const el of arr.elements) {
1065
1156
  if (el === null) { out.push(null); continue }
1066
1157
  if ((el as { type: string }).type === 'SpreadElement') return null
1067
- out.push(anyValue(el as Expression, scope, code, mfs))
1158
+ const v = anyValue(el as Expression, scope, code, mfs)
1159
+ if (isHole(el as Expression, v, scope)) return null
1160
+ out.push(v)
1068
1161
  }
1069
1162
  return out
1070
1163
  }
@@ -1074,7 +1167,10 @@ function anyValue(node: Expression, scope: XScope, code?: string, mfs?: string[]
1074
1167
  for (const prop of node.properties) {
1075
1168
  const key = propName(prop as never)
1076
1169
  if (key === null) return null
1077
- obj[key] = anyValue((prop as unknown as ObjectProperty).value as Expression, scope, code, mfs)
1170
+ const valueNode = (prop as unknown as ObjectProperty).value as Expression
1171
+ const v = anyValue(valueNode, scope, code, mfs)
1172
+ if (isHole(valueNode, v, scope)) return null
1173
+ obj[key] = v
1078
1174
  }
1079
1175
  return obj
1080
1176
  }
@@ -1184,15 +1280,16 @@ export function evaluateExpr(
1184
1280
  * Evaluate a `const X = [combinator, …]` array literal into an array of
1185
1281
  * Combinators. Lets a shared option array (e.g. a `skip` set reused across
1186
1282
  * `scanTo`/`balanced` calls) be referenced by name — `{ skip: X }` — instead of
1187
- * inlining the array at every call site. Returns null when `node` isn't an array
1188
- * literal of statically-resolvable combinators.
1283
+ * inlining the array at every call site. Returns null when `node` doesn't evaluate
1284
+ * to an array of statically-resolvable combinators.
1189
1285
  */
1190
1286
  export function evaluateCombinatorArray(
1191
1287
  node: Expression,
1192
1288
  scope: Scope,
1193
1289
  code?: string,
1194
1290
  ): Combinator<unknown>[] | null {
1195
- if (node.type !== 'ArrayExpression') return null
1291
+ // Any expression, not just an array literal: `rules({ scanSkip: sharedSkip })`
1292
+ // names the array, and requiring a literal here is what dropped it.
1196
1293
  const val = anyValue(node, scope as XScope, code, [])
1197
1294
  if (!Array.isArray(val) || val.length === 0) return null
1198
1295
  if (!val.every(isCombinator)) return null
@@ -1453,7 +1550,7 @@ export function evaluateParserFactory(
1453
1550
  built = rules(options ?? {}, (g: Record<string, Combinator<unknown>>) => {
1454
1551
  // Outer ScopeEntry values carry their mfSrcs and are replayed by scopeGet()
1455
1552
  // when body statements or return expressions reference them.
1456
- const localScope: XScope = new Map(scope as XScope)
1553
+ const localScope: XScope = scope instanceof ModuleScope ? scope.fork() : new Map(scope as XScope)
1457
1554
  localScope.set(proxyName, g)
1458
1555
 
1459
1556
  // ── Phase 1: the factory's own body statements ────────────────────────
@@ -1564,7 +1661,10 @@ export function applyDefineStatement(
1564
1661
  /** Check if an AST node references any name from the given scope or names set. */
1565
1662
  export function referencesAny(node: Node, names: Set<string>, scope: Scope): boolean {
1566
1663
  if (node.type === 'Identifier') {
1567
- return names.has(node.name) || scope.has(node.name)
1664
+ // The names the module has BOUND so far, not every import it could resolve: this
1665
+ // decides whether a declaration is macro code at all, and an ordinary
1666
+ // `const x = importedThing` must not start reading modules to find out.
1667
+ return names.has(node.name) || Map.prototype.has.call(scope, node.name)
1568
1668
  }
1569
1669
  for (const key of Object.keys(node) as (keyof typeof node)[]) {
1570
1670
  const child = node[key]