@@ -2,6 +2,7 @@ import type { CstGrammar, RuleExpr, RuleDecl, InjectClause, TokenDecl } from './
22import { collectLiterals , isKeywordLiteral } from './grammar-utils.ts' ;
33import {
44 tokenEscapePatternSource ,
5+ tokenBlockPatternSource ,
56 tokenPatternBlockDelimiterSources ,
67 tokenPatternBlockDelimiters ,
78 tokenPatternIdentifierExtraChars ,
@@ -3256,6 +3257,103 @@ function detectDeclarations(grammar: CstGrammar, tokenNames: Set<string>): DeclI
32563257 return results ;
32573258}
32583259
3260+ // ── Value-position derivation (the depth-bug CLASS, one analysis) ──
3261+ //
3262+ // A flat per-line grammar carries no indent stack, so it re-introduces depth only where a region
3263+ // fires. The depth bugs (#23/#24, the `? a:\n -` gap) are all ONE class: a structural INTRODUCER
3264+ // (`-`/`?`/`:`) opens an indented VALUE block, and the indicator at the deeper line must be scoped
3265+ // STRUCTURALLY — but a per-shape detector that did not cover that exact (introducer × enclosing
3266+ // position) combination lets a FOLD region claim the value line instead. detectExplicitKey (the
3267+ // `? key` shape) and detectBlockSequence (the `[item, (Newline item)*]` shape) are PARTIAL VIEWS of
3268+ // the SAME fact: those are two of the introducers that open a value block. This derives the WHOLE
3269+ // set from the IR so emission and verification share one source — a value-position no detector named
3270+ // can no longer be a silent gap.
3271+ //
3272+ // A VALUE-POSITION is an introducer literal `I` — drawn from `indent.compactIndicators ∪
3273+ // {keyValueSeparator}` (the structural single-char literals that lead a nested block) — that is
3274+ // IMMEDIATELY FOLLOWED, in some rule seq, by an (optional) element that REACHES, after an `Indent`
3275+ // boundary, a value/`Node` alternation (`Indent <…alt…> Dedent` — the recursive value). That is:
3276+ // • `:` → MapValue/MapValueScalar (`[Indent, Node, Dedent]`), the block-mapping value;
3277+ // • `-` → Value (`[Indent, Node, Dedent]`), the sequence-item value;
3278+ // • `?` → MapValue (the explicit-key value half), AND the `:` inside ExplicitEntry.
3279+ // Returns null when the family has no such shape (every non-indentation grammar), so the value-
3280+ // position machinery is gated exactly like the detectors it subsumes.
3281+ //
3282+ // The set this returns is, by derivation, EXACTLY the introducer set the fold regions must yield to:
3283+ // at a value-position introducer that ends its line (`key:`-EOL, `?`-EOL, `-`-EOL) the indented block
3284+ // is a FRESH dispatch site (routed to the shared top-level dispatch, which the meta.stream wrapper
3285+ // re-anchors per line), NEVER a fold continuation. `structAhead`/`structRelease` (the fold-bounding
3286+ // lookaheads) are spelled from this same `{compactIndicators, keyValueSeparator}` set — so a fold
3287+ // releases at, and never opens past, a value-position. The explicit-key value (`? k:\n -`) FALLS
3288+ // OUT as one instance: `? k:` is a `?`-led key whose trailing `:` opens a value block, so the
3289+ // explicit-key fold must NOT treat `k` as a foldable scalar (a bare `? a\n b` fold has no such `:`).
3290+ export interface ValuePosition {
3291+ introducer : string ; // the structural literal that opens the value block (`:` / `-` / `?`)
3292+ source : 'keyValueSeparator' | 'compactIndicator' ; // which config field the introducer came from
3293+ }
3294+ export function valuePositions ( grammar : CstGrammar ) : ValuePosition [ ] | null {
3295+ if ( ! grammar . indent ) return null ;
3296+ const { indentToken } = grammar . indent ;
3297+ const ruleByName = new Map ( grammar . rules . map ( r => [ r . name , r ] as const ) ) ;
3298+ // The structural introducer literals: the compact indicators (`-`/`?`) and the key/value
3299+ // separator (`:`) — the single-char literals that, in this family, can precede a nested block.
3300+ const kvSep = grammar . indent . keyValueSeparator ?? ':' ;
3301+ const introSource = new Map < string , 'keyValueSeparator' | 'compactIndicator' > ( ) ;
3302+ for ( const c of grammar . indent . compactIndicators ?? [ ] ) if ( c . length === 1 ) introSource . set ( c , 'compactIndicator' ) ;
3303+ if ( kvSep . length === 1 && ! introSource . has ( kvSep ) ) introSource . set ( kvSep , 'keyValueSeparator' ) ;
3304+ if ( ! introSource . size ) return null ;
3305+
3306+ // Does `e` (a rule body, transitively through refs) reach an `Indent <reaches value-alt> …`?
3307+ // i.e. an `Indent` token followed in a seq by an element that contains an `alt` (the value/Node
3308+ // alternation) — the recursive value block. A bounded visited-set guards the rule recursion.
3309+ const reachesIndentedValue = ( e : RuleExpr , seen : Set < string > ) : boolean => {
3310+ const containsAlt = ( x : RuleExpr , vseen : Set < string > ) : boolean =>
3311+ x . type === 'alt' ? true
3312+ : x . type === 'seq' ? x . items . some ( it => containsAlt ( it , vseen ) )
3313+ : x . type === 'quantifier' || x . type === 'group' || x . type === 'not' ? containsAlt ( x . body , vseen )
3314+ : x . type === 'sep' ? containsAlt ( x . element , vseen )
3315+ : x . type === 'ref' ? ( vseen . has ( x . name ) ? false : ( vseen . add ( x . name ) , ( ( ) => { const r = ruleByName . get ( x . name ) ; return r ? containsAlt ( r . body , vseen ) : false ; } ) ( ) ) )
3316+ : false ;
3317+ const walk = ( x : RuleExpr ) : boolean => {
3318+ if ( x . type === 'seq' ) {
3319+ for ( let i = 0 ; i < x . items . length - 1 ; i ++ ) {
3320+ const cur = x . items [ i ] ;
3321+ if ( cur . type === 'ref' && cur . name === indentToken && containsAlt ( x . items [ i + 1 ] , new Set ( ) ) ) return true ;
3322+ }
3323+ return x . items . some ( walk ) ;
3324+ }
3325+ if ( x . type === 'alt' ) return x . items . some ( walk ) ;
3326+ if ( x . type === 'quantifier' || x . type === 'group' || x . type === 'not' ) return walk ( x . body ) ;
3327+ if ( x . type === 'sep' ) return walk ( x . element ) ;
3328+ if ( x . type === 'ref' ) { if ( seen . has ( x . name ) ) return false ; seen . add ( x . name ) ; const r = ruleByName . get ( x . name ) ; return r ? walk ( r . body ) : false ; }
3329+ return false ;
3330+ } ;
3331+ return walk ( e ) ;
3332+ } ;
3333+
3334+ // An introducer `I` is a value-position when some seq has `I` immediately followed by an element
3335+ // that reaches an indented value block. `I` may be a head literal (`['-', opt(Value)]`,
3336+ // `['?', opt(MapValue), …]`) or an inner one (the `:` inside an explicit entry's `[…, ':', opt(MapValue)]`).
3337+ const found = new Set < string > ( ) ;
3338+ const visit = ( e : RuleExpr ) : void => {
3339+ if ( e . type === 'seq' ) {
3340+ for ( let i = 0 ; i < e . items . length - 1 ; i ++ ) {
3341+ const lit = e . items [ i ] ;
3342+ if ( lit . type === 'literal' && introSource . has ( lit . value ) && reachesIndentedValue ( e . items [ i + 1 ] , new Set ( ) ) ) found . add ( lit . value ) ;
3343+ }
3344+ e . items . forEach ( visit ) ;
3345+ } else if ( e . type === 'alt' ) e . items . forEach ( visit ) ;
3346+ else if ( e . type === 'quantifier' || e . type === 'group' || e . type === 'not' ) visit ( e . body ) ;
3347+ else if ( e . type === 'sep' ) visit ( e . element ) ;
3348+ } ;
3349+ for ( const r of grammar . rules ) visit ( r . body ) ;
3350+ if ( ! found . size ) return null ;
3351+ // Order: key/value separator first, then compact indicators in declaration order (stable, for
3352+ // a deterministic emit). The set — not the order — is what the emitter/verifier consume.
3353+ const order = [ kvSep , ...( grammar . indent . compactIndicators ?? [ ] ) ] ;
3354+ return [ ...found ] . sort ( ( a , b ) => order . indexOf ( a ) - order . indexOf ( b ) ) . map ( introducer => ( { introducer, source : introSource . get ( introducer ) ! } ) ) ;
3355+ }
3356+
32593357/**
32603358 * Explicit mapping-key detection (e.g. YAML `? key` / `: value`).
32613359 *
@@ -3266,6 +3364,12 @@ function detectDeclarations(grammar: CstGrammar, tokenNames: Set<string>): DeclI
32663364 * the shape and lets gen-tm emit a contextual rule scoping the scalar right after the
32673365 * indicator as a key.
32683366 *
3367+ * detectExplicitKey is a PARTIAL VIEW of `valuePositions` (above): the `?` it finds is exactly the
3368+ * `?` value-position introducer, here resolved to its key SCOPE + key BODY pattern (the extra facts
3369+ * the `#explicit-key` match rule needs). It stays a focused helper so the emit site keeps the scope/
3370+ * body it derives, but the value-position FACT (a `?`-led entry opens an indented value block) is
3371+ * owned by `valuePositions`, which the fold regions consult so they yield at a `? key:` value site.
3372+ *
32693373 * Signal (all from the grammar, nothing hardcoded):
32703374 * • the grammar is INDENTATION-mode (`grammar.indent`) — the family this construct lives in;
32713375 * • some rule has an alternative `seq` whose HEAD is a single-char punctuation literal `I`
@@ -5018,6 +5122,33 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
50185122 // a real key separator (`http://x` keeps its glued `:` → still plain content). Used as a NEGATIVE
50195123 // lookahead to bound the fold at the first sibling/comment line, matching the parser's foldedPlain.
50205124 const structAhead = `(?:${ cmtLit } |${ compactAlt } |${ flowEx } *?${ kvSep } (?:[\\t ]|$))` ;
5125+ // ── VALUE-POSITION guard (the closed transform's hinge) ──
5126+ // The IR-derived value-positions (valuePositions): the structural introducers (`-`/`?`/`:`) that
5127+ // open an indented value block. A FOLD region must YIELD at a value-position — the indented block
5128+ // there is a fresh dispatch site (the shared top-level dispatch, re-anchored per line by the
5129+ // meta.stream wrapper), never a fold continuation. `structAhead` already encodes that for the
5130+ // `:` introducer (a `…: ` / `…:`-EOL line ends/never-opens a fold) and the compact `-`/`?` arm.
5131+ // The one site that still over-claimed was the explicit-key fold: `? k:` is a `?`-led key whose
5132+ // TRAILING `:` (a value-position) opens a value block, yet the fold treated `k` as a foldable
5133+ // bare scalar and swallowed the value line ` - x` (depth-sites' explicit-key-value bug). The
5134+ // guard `keyColonGuard` — derived from the same kvSep value-position introducer — asserts a
5135+ // plain scalar is NOT a `key:` (i.e. does NOT open a `:` value block); the explicit-key fold opens
5136+ // only on a plain scalar that passes it, so a bare `? a\n b` folds the key while `? k:\n -` /
5137+ // `? k: v\n more` route to the shared dispatch / the value-scoped #plain-continuation. Gated on
5138+ // the kvSep being a derived value-position, so a family without a `:` value-position keeps the old
5139+ // (unguarded) behaviour. The `key` token's match (`<plain-body>(?=…kvSep…)`) IS this predicate —
5140+ // a plain scalar that is a mapping key — so the guard is `(?!<key match>)`. It uses the key token's
5141+ // BLOCK-context body (`tokenBlockPatternSource` — `[^:#\n]`, where `[`/`{` are KEY CONTENT) rather
5142+ // than the FLOW snapshot `repository['key'].match` here (which excludes `,[]{}`): the explicit-key
5143+ // value lives in BLOCK context, so a block key WITH a flow char (`? a [a :\n - x` — the key is the
5144+ // plain scalar "a [a", value the sequence) must still be recognised as a `key:` value-position. The
5145+ // flow snapshot would stop the key body at `[` and miss the colon, letting the fold wrongly open.
5146+ const vps = valuePositions ( grammar ) ;
5147+ const kvSepIsValuePos = ! ! vps ?. some ( v => v . source === 'keyValueSeparator' ) ;
5148+ const keyScope = detectExplicitKey ( grammar ) ?. keyScope ;
5149+ const blockKeyTok = grammar . tokens . find ( t => t . scope === keyScope && ! t . string && t . blockPattern && tokenPatternPrefixBeforeTrailingLookahead ( t ) ) ;
5150+ const keyBlockMatch = blockKeyTok ? unicodeWidenIdentPattern ( tokenBlockPatternSource ( blockKeyTok ) ! ) : undefined ;
5151+ const keyColonGuard = ( kvSepIsValuePos && keyBlockMatch ) ? `(?!${ keyBlockMatch } )` : '' ;
50215152 // Value-position lookahead: after the node indent is stripped, the line must carry an INLINE
50225153 // BLOCK plain value — either `<key>: <plain>` (mapping value) or a `-`/`?` indicator + `<plain>`
50235154 // (sequence entry / explicit key). The plain value is confirmed by `(?=plainSrc)`, which only
@@ -5043,7 +5174,16 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
50435174 const keyToSep = quotedScalarToks . length
50445175 ? `(?:${ quotedScalarToks . join ( '|' ) } |${ flowEx . slice ( 0 , - 1 ) } ${ quoteCharCls } ])*?`
50455176 : `${ flowEx } *?` ;
5046- const plainVp = `(?:(?:${ docAlt } )[\\t ]+)?(?:(?:${ compactCls } [\\t ]+)+(?:${ keyToSep } ${ kvSep } [\\t ]+)?|${ keyToSep } ${ kvSep } [\\t ]+)(?=${ plainSrc } )` ;
5177+ // The indicator-led arm (`-`/`?` then a value) and the bare `key:`-led arm. Each ends in a plain
5178+ // VALUE confirmed by `(?=plainSrc)`. The indicator arm splits at the value-position guard: after
5179+ // `(?:compactCls[\t ]+)+`, either a `key:` + INLINE plain value (`- a: hello`, the colon group
5180+ // consumes `:[\t ]+` so the value really is inline), OR a plain value that is NOT itself a `key:`
5181+ // (`keyColonGuard`). The second sub-arm is what excludes `? k:\n -` / `? k:\n ?`: there `k:` is
5182+ // a `?`-led KEY whose trailing `:` opens a VALUE-POSITION (the value is on the next indented line,
5183+ // a dispatch site), so `(?!key)` fails and the fold does not open — the indented value-block then
5184+ // routes to the shared dispatch (the `-`/`?` indicator scoped structurally). The bare `key:` arm
5185+ // needs `:[\t ]+` (an inline value), so a value-position `key:`-EOL never matches it either.
5186+ const plainVp = `(?:(?:${ docAlt } )[\\t ]+)?(?:(?:${ compactCls } [\\t ]+)+(?:${ keyToSep } ${ kvSep } [\\t ]+(?=${ plainSrc } )|(?=${ plainSrc } )${ keyColonGuard } )|${ keyToSep } ${ kvSep } [\\t ]+(?=${ plainSrc } ))` ;
50475187 // Header-line token includes: the same shape any plain `key: value` line gets, so the header is
50485188 // scoped identically to the top level (only the CONTINUATION changes). Includes the typed-value
50495189 // tokens (`#num`/`#boolnull`) so a SINGLE-line `a: 1` keeps `constant.numeric`, and the full
@@ -5095,8 +5235,15 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
50955235 const ekFold = detectExplicitKey ( grammar ) ;
50965236 if ( ekFold && fold . hasDeeper ) {
50975237 const ekContRule = { match : '\\G[\\t ]+(?:[^#\\n]|#(?<=[^\\t\\n\\f\\r ]#))*' , name : `${ ekFold . keyScope } .${ langName } ` } ;
5238+ // Open on a BARE explicit key only: `?` + ws + a plain scalar that is NOT a `key:` (the
5239+ // `keyColonGuard` value-position bar). `? a\n b` folds the key "a b" (key-scoped continuation);
5240+ // `? k:\n -` and `? k: v\n more` are value-positions — the `?`-led key's trailing `:` opens
5241+ // a value block, so this fold yields and the value line routes to the shared dispatch (the `-`
5242+ // scoped punctuation) / the value-scoped #plain-continuation (`more` → string.unquoted). Before
5243+ // the guard this region swallowed `? k:`'s value line as the KEY scope (the depth-sites bug);
5244+ // the guard is the explicit-key instance of "a fold yields to a value-position".
50985245 repository [ 'explicit-key-continuation' ] = emitIndentRegion ( {
5099- lookahead : `(?=${ escapeRegex ( ekFold . indicator ) } [\\t ]+(?: ${ keyToSep } ${ kvSep } [\\t ]+)?(?= ${ plainSrc } ) )` ,
5246+ lookahead : `(?=${ escapeRegex ( ekFold . indicator ) } [\\t ]+(?= ${ plainSrc } ) ${ keyColonGuard } )` ,
51005247 cont : `\\1[ \\t]+(?!${ structAhead } )` ,
51015248 blankFirst : true ,
51025249 patterns : [ ekContRule , ...plainHeaderIncs ] ,
0 commit comments