import type { Combinator, FieldMap, ParserDef } from '../types.ts' import { confirmedArityForDef } from './build-arity.ts' type NodeDef = Extract export function buildReadsFields(def: NodeDef): boolean { if (!def.build) return true const arity = confirmedArityForDef(def) if (arity === null) return true return arity >= 2 } export function parserHasOwnFields(p: Combinator, seen: Set> = new Set()): boolean { if (seen.has(p)) return false seen.add(p) const d = p._def switch (d.tag) { case 'field': return true case 'node': return false case 'lazy': { try { return parserHasOwnFields(d.thunk(), seen) } catch { return false } } case 'sequence': case 'choice': return d.parsers.some(x => parserHasOwnFields(x, seen)) case 'dispatch': return parserHasOwnFields(d.selector, seen) || d.cases.some(x => parserHasOwnFields(x.parser, seen)) || (d.matchers ? d.matchers.some(entry => parserHasOwnFields(entry.parser, seen)) : false) || (d.otherwise ? parserHasOwnFields(d.otherwise, seen) : false) case 'sepBy': return parserHasOwnFields(d.parser, seen) || parserHasOwnFields(d.separator, seen) case 'grammar': return parserHasOwnFields(d.parser, seen) || (d.triviaParser ? parserHasOwnFields(d.triviaParser, seen) : false) case 'scanTo': return parserHasOwnFields(d.sentinel, seen) || d.skip.some(x => parserHasOwnFields(x, seen)) case 'recover': return parserHasOwnFields(d.parser, seen) || parserHasOwnFields(d.sentinel, seen) case 'routed': return d.fallback ? parserHasOwnFields(d.fallback, seen) : false case 'many': case 'oneOrMore': case 'optional': case 'attempt': case 'transform': case 'trivia': case 'token': case 'leaf': case 'label': case 'expect': case 'withCtx': case 'not': case 'peek': return parserHasOwnFields(d.parser, seen) default: return false } } /** * Does the node's OWN parse frame ever have a trivia run logged into it? * Trivia is logged only by the trivia-skip fn, which runs at `sequence`/`many`/ * repeat boundaries. A bare terminal (regex/literal/keywords/…) has NO trivia * site, so its `_cstTriviaLog` stays empty and its `captureTrivia`/`_cstTriviaLog`/ * `_triviaCaptureMask` save+install+restore is dead work — the per-node scope * trivia frame can be elided for it (see emitNode `needsTriviaFrame`). * * Stops at a nested `node()` (it manages its OWN trivia frame; trivia inside it * never logs to THIS node's frame). CONSERVATIVE: any unknown / trivia-bearing * shape returns `true` (keep the frame) — we only elide when provably safe. */ export function parserHasTriviaSite(p: Combinator, seen: Set> = new Set()): boolean { return hasTriviaSite(p, seen, false) } /** * The same question asked of the ROOT trivia log (`_ctx._rootTriviaLog`) instead * of the current node's frame. * * The two differ in exactly one case, and it is the case that matters: a nested * `node()` manages its own FRAME, so nothing inside it reaches the enclosing * frame's log — but the root log is not a frame, and every trivia run inside that * node still lands in it. Reusing `parserHasTriviaSite` for the root sink * therefore under-reports: it dropped the mark on `node()` arms whose trivia does * accumulate, and a parse-tree diff caught it as trivia entries that were no * longer rewound on a failed choice arm (`length: 3` becoming `length: 6`). */ export function parserHasRootTriviaSite(p: Combinator, seen: Set> = new Set()): boolean { return hasTriviaSite(p, seen, true) } function hasTriviaSite(p: Combinator, seen: Set>, throughNode: boolean): boolean { if (seen.has(p)) return true // cycle → conservative (keep the frame) seen.add(p) const parserHasTriviaSite = ( x: Combinator, s: Set> = new Set(), ): boolean => hasTriviaSite(x, s, throughNode) const d = p._def switch (d.tag) { // Trivia is skipped between/around elements or iterations → a site. case 'sequence': case 'many': case 'oneOrMore': case 'sepBy': case 'scanTo': case 'recover': return true // Nested node manages its own trivia frame; none logs into THIS frame. The // root log has no frames, so it must look inside. case 'node': return throughNode ? parserHasTriviaSite(d.parser, seen) : false // Pure terminals + trivia-suppressing token: no site. case 'regex': case 'literal': case 'keywords': case 'guard': // The adjacency probe scans with capture OFF, so it logs nothing. case 'adjacency': case 'token': case 'leaf': return false // In dispatch position routed() reuses the already-consumed selector and has // no trivia boundary. Outside dispatch, only its concrete fallback runs. case 'routed': return d.fallback ? parserHasTriviaSite(d.fallback, seen) : false // Transparent single-child wrappers: recurse. case 'optional': case 'attempt': case 'transform': case 'trivia': case 'label': case 'expect': case 'withCtx': case 'not': case 'peek': case 'field': return parserHasTriviaSite(d.parser, seen) case 'choice': return d.parsers.some(x => parserHasTriviaSite(x, seen)) case 'dispatch': return parserHasTriviaSite(d.selector, seen) || d.cases.some(x => parserHasTriviaSite(x.parser, seen)) || (d.matchers ? d.matchers.some(entry => parserHasTriviaSite(entry.parser, seen)) : false) || (d.otherwise ? parserHasTriviaSite(d.otherwise, seen) : false) case 'grammar': return parserHasTriviaSite(d.parser, seen) case 'lazy': { try { return parserHasTriviaSite(d.thunk(), seen) } catch { return true } } default: return true // unknown shape → conservative } } /** * Can this node's inner grammar explicitly enable trivia capture? A surrounding * node owns the collector, so a nested `parser({ captureTrivia: true })` must * allocate that collector before it enters the grammar scope. Nested node() * rules own their separate collector and therefore do not contribute here. */ export function parserEnablesTriviaCapture(p: Combinator, seen: Set> = new Set()): boolean { if (seen.has(p)) return false seen.add(p) const d = p._def switch (d.tag) { case 'grammar': return d.captureTrivia === true || parserEnablesTriviaCapture(d.parser, seen) case 'node': return false case 'sequence': case 'choice': return d.parsers.some(x => parserEnablesTriviaCapture(x, seen)) case 'dispatch': return parserEnablesTriviaCapture(d.selector, seen) || d.cases.some(x => parserEnablesTriviaCapture(x.parser, seen)) || (d.matchers ? d.matchers.some(entry => parserEnablesTriviaCapture(entry.parser, seen)) : false) || (d.otherwise ? parserEnablesTriviaCapture(d.otherwise, seen) : false) case 'sepBy': return parserEnablesTriviaCapture(d.parser, seen) || parserEnablesTriviaCapture(d.separator, seen) case 'scanTo': return parserEnablesTriviaCapture(d.sentinel, seen) || d.skip.some(x => parserEnablesTriviaCapture(x, seen)) case 'recover': return parserEnablesTriviaCapture(d.parser, seen) || parserEnablesTriviaCapture(d.sentinel, seen) case 'routed': return d.fallback ? parserEnablesTriviaCapture(d.fallback, seen) : false case 'many': case 'oneOrMore': case 'optional': case 'attempt': case 'transform': case 'trivia': case 'token': case 'label': case 'field': case 'expect': case 'withCtx': case 'not': case 'peek': return parserEnablesTriviaCapture(d.parser, seen) // leaf() clears the inner CST/trivia collector and publishes only its own // terminal result. An inner parser() capture cannot reach this node. case 'leaf': return false case 'lazy': { try { return parserEnablesTriviaCapture(d.thunk(), seen) } catch { return false } } default: return false } } export function buildFieldMap(captures: ReadonlyArray<{ name: string; value: unknown; span: { start: number; end: number } }> | undefined): FieldMap | undefined { if (!captures?.length) return undefined const out: FieldMap = {} for (const cap of captures) { const entry = { value: cap.value, span: cap.span } const current = out[cap.name] if (current === undefined) out[cap.name] = entry else if (Array.isArray(current)) current.push(entry) else out[cap.name] = [current, entry] } return out }