@@ -3409,6 +3409,59 @@ function detectExplicitKey(grammar: CstGrammar): { indicator: string; keyScope:
34093409 return { indicator, keyScope, keyBody, prefixGroups } ;
34103410}
34113411
3412+ // ── Block-sequence detection (YAML `- item` block sequence) ──
3413+ //
3414+ // A flat per-line grammar cannot tell a COMPACT nested sequence's sibling from a plain-scalar fold:
3415+ // `- - a\n - b` (the ` - b` is the INNER sequence's 2nd item) and `x: y\n - b` (the ` - b` folds
3416+ // into the plain scalar `y`) share the same continuation line but get OPPOSITE answers — the decider
3417+ // is the ENCLOSING node column (an inner sequence at column 2 vs a mapping at column 0), which the
3418+ // line-relative `\1` of the §2a′ fold region cannot see (both lines start at column 0). monogram#24.
3419+ // The fix is a column-anchored region (§2c below) for the COMPACT block sequence, whose `\G`-anchored
3420+ // `while` (re-anchored each line by the meta.stream wrapper) reclaims a sibling `- ` at the inner
3421+ // indicator's column BEFORE the plain-scalar fold can swallow it. This detects the block-sequence rule
3422+ // + its indicator literal from the grammar so the region is DERIVED, not hardcoded.
3423+ //
3424+ // Signal (all from the grammar): a rule whose body is `[item, (Newline item)*]` — an item then a
3425+ // same-column-NEWLINE-separated run of further items — where `item` is a `ref` to a rule whose body is
3426+ // a `seq` headed by a SINGLE-char punctuation literal (the sequence indicator `-`). Returns the
3427+ // indicator literal, or null when the family has no such block sequence (every non-YAML grammar).
3428+ function detectBlockSequence ( grammar : CstGrammar ) : { indicator : string } | null {
3429+ if ( ! grammar . indent ) return null ;
3430+ const { newlineToken } = grammar . indent ;
3431+ const ruleByName = new Map ( grammar . rules . map ( r => [ r . name , r ] as const ) ) ;
3432+ const headSinglePunct = ( e : RuleExpr ) : string | null =>
3433+ e . type === 'literal' && e . value . length === 1 && ! / [ \w \s ] / . test ( e . value ) ? e . value : null ;
3434+ // The item rule's indicator: unwrap a ref to a rule whose body's first seq element is a 1-char punct.
3435+ const itemIndicator = ( e : RuleExpr ) : string | null => {
3436+ let body = e . type === 'ref' ? ruleByName . get ( e . name ) ?. body : e ;
3437+ if ( ! body ) return null ;
3438+ // a rule body written `[[...]]` is a single-alt seq; unwrap a lone-arm alt
3439+ if ( body . type === 'alt' && body . items . length === 1 ) body = body . items [ 0 ] ;
3440+ return body . type === 'seq' ? headSinglePunct ( body . items [ 0 ] ) : null ;
3441+ } ;
3442+ let indicator : string | null = null ;
3443+ const visit = ( e : RuleExpr ) : void => {
3444+ if ( e . type === 'seq' ) {
3445+ // `[item, (Newline item)*]`: first element + a `*`/`+` over a `[Newline, item]` seq
3446+ if ( e . items . length >= 2 ) {
3447+ const head = e . items [ 0 ] ;
3448+ const q = e . items [ 1 ] ;
3449+ if ( q . type === 'quantifier' && ( q . kind === '*' || q . kind === '+' ) && q . body . type === 'seq'
3450+ && q . body . items . length >= 2 && q . body . items [ 0 ] . type === 'ref' && q . body . items [ 0 ] . name === newlineToken ) {
3451+ const ind = itemIndicator ( head ) ;
3452+ // the repeated element's item must share the head's indicator (a homogeneous sequence)
3453+ if ( ind && itemIndicator ( q . body . items [ 1 ] ) === ind ) indicator = ind ;
3454+ }
3455+ }
3456+ e . items . forEach ( visit ) ;
3457+ } else if ( e . type === 'alt' ) e . items . forEach ( visit ) ;
3458+ else if ( e . type === 'quantifier' || e . type === 'group' || e . type === 'not' ) visit ( e . body ) ;
3459+ else if ( e . type === 'sep' ) visit ( e . element ) ;
3460+ } ;
3461+ for ( const r of grammar . rules ) visit ( r . body ) ;
3462+ return indicator ? { indicator } : null ;
3463+ }
3464+
34123465// ── Flow-collection detection (YAML `{ … }` mapping / `[ … ]` sequence) ──
34133466//
34143467// A flat per-token grammar cannot scope a flow MAPPING's keys: in `{ a: 1 }` the `a` is a key
@@ -4722,6 +4775,7 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
47224775 // the Anchor/Tag tokens, not the indent config) and stays inline.
47234776 const ind = grammar . indent ! ;
47244777 const cmtLit = escapeRegex ( ind . comment ?? '#' ) ;
4778+ const cmtCc = escapeForCharClass ( ind . comment ?? '#' ) ; // the comment introducer, char-class-escaped
47254779 const compactAlt = ( ind . compactIndicators ?? [ ] ) . map ( ( c ) => `${ escapeRegex ( c ) } [\\t ]` ) . join ( '|' ) ;
47264780 const compactCls = `[${ ( ind . compactIndicators ?? [ ] ) . map ( escapeForCharClass ) . join ( '' ) } ]` ;
47274781 const docAlt = ( blockScalar . documentMarkers ?? [ ] ) . map ( escapeRegex ) . join ( '|' ) ;
@@ -5088,6 +5142,96 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
50885142 } ) ;
50895143 topPatterns . push ( { include : '#plain-bare-fold' } ) ;
50905144 }
5145+
5146+ // ── 2c. COMPACT block-sequence — a column-anchored region for nested `- - …` (monogram#24) ──
5147+ // The §2a′/§2a″ plain folds are LINE-relative (their `\1` is the line's leading whitespace), but
5148+ // a YAML continuation is NODE-relative (more indented than the ENCLOSING dash/key). For a single
5149+ // sequence (`- a`) or a mapping (`x: y`) the two coincide — the dash/key sits at column 0 = the
5150+ // line indent — so those folds are correct. They DIVERGE only for a COMPACT nested sequence
5151+ // (`- - a`): the inner sequence's dash is at column 2 (after the outer `- ` prefix, which is NOT
5152+ // whitespace), so a sibling `- b` at column 2 reads — to a line-relative `\1=""` fold — as "indented
5153+ // past column 0", and is wrongly folded into the plain scalar `a`. The decider is the inner
5154+ // indicator's column, which no `\1`-relative backref can express (the prefix is `- `, not spaces) and
5155+ // no possessive `[ \t]++` can split from the deeper-fold case `x: y\n - b` (same line, must fold).
5156+ //
5157+ // The fix mirrors the maintained RedCMD YAML grammar's block-sequence: a `\G`-anchored region whose
5158+ // rule stack carries the indent depth, RE-OPENED per compact level (its body self-includes
5159+ // #block-sequence, so `- - - a` nests three deep). The meta.stream wrapper re-anchors `\G` at every
5160+ // line, so each level's captures pin ITS OWN inner indicator column and reclaim only siblings AT that
5161+ // column. We emit it ONLY for the COMPACT case (a dash followed by ANOTHER dash on the same line) —
5162+ // `begin … (?=([\t ]+)${dash}[\t ])` — so a single `- a`, a `- key: v` mapping item, a `- {…}`/`- "…"`/
5163+ // `- |` value, etc. are UNTOUCHED (still handled by the top-level token includes + the §2a′ fold),
5164+ // confining this region to exactly the bug's shape. The compact re-anchor `(?=((?<=${reanchor}) )?+)`
5165+ // (a FIXED-width lookbehind — portable, unlike RedCMD's variable-length `(?<![^\t ][\t ]*+:|---)`,
5166+ // which is rejected by Onigmo / GitHub-Linguist) lets the inner dash open right after the outer `- `.
5167+ //
5168+ // The inner indicator's column is reconstructed PORTABLY as `\1\2 \4`: the outer indent (`\1\2`) +
5169+ // one literal space for the dash's own column + the captured indicator run `\4` (the run of spaces
5170+ // between the outer dash and the inner one — group 4 in the begin's lookahead, so a multi-space
5171+ // compact `- - x` pins correctly too). The `while` then STAYS on: (arm 1) a dash AT EXACTLY that
5172+ // column `(\1\2 \4)(?=${dash}[\t ]|${dash}$)` — a sibling item, reclaimed as `punctuation`; (arm 2) a
5173+ // line STRICTLY DEEPER than the column `(?=\1\2 \4[\t ])` — a zero-width lookahead that keeps the
5174+ // region alive WITHOUT consuming (so a nested deeper #block-sequence, if its own begin matched on the
5175+ // header line, gets first claim on its own sibling before the fold), with the deeper line scoped by
5176+ // the body's #block-fold rule; or (arm 3) a blank line. It RELEASES on a dedent OR a non-dash line at
5177+ // the column (a sibling mapping/scalar), so a column-0 `key:` after the sequence is NOT swallowed.
5178+ //
5179+ // A deeper line that is NOT a nested sibling folds into the item's plain scalar: the body's
5180+ // #block-fold rule (`^([\t ]+)(?=[^\t\r\n#])(plain run)`, anchored at LINE START so it never fires on
5181+ // the header line's inline inner item, which sits past column 0) scopes it `string.unquoted`. So
5182+ // `- - a\n - b` (`[["a - b"]]` — the deeper `- b` folds) is `string`, while `- - a\n - b` and
5183+ // `- - - a\n - b` (a same-column sibling at the inner OR a deeper-nested level) stay `punctuation`.
5184+ // This resolves monogram#24's deeper-irregular residual without a variable-length lookbehind.
5185+ const blockSeq = detectBlockSequence ( grammar ) ;
5186+ if ( blockSeq ) {
5187+ const dash = escapeRegex ( blockSeq . indicator ) ;
5188+ // The compact re-anchor lookbehind class: the compact indicators (`-`/`?`) plus the key/value
5189+ // separator (`:`) — exactly the single chars a nested node may sit immediately after on one line.
5190+ const reanchor = `[${ [ ...( ind . compactIndicators ?? [ ] ) , ind . keyValueSeparator ?? ':' ] . map ( escapeForCharClass ) . join ( '' ) } ]` ;
5191+ // The item's plain VALUE folds across DEEPER lines (a multi-line plain scalar) but releases at a
5192+ // same-column sibling (reclaimed by the sequence `while`). Opens only on a BARE plain head (not a
5193+ // key/indicator/comment — those are scoped by the dispatch includes), then an inner string region
5194+ // scopes content until an inline ` #` comment (which falls to #comment). Mirrors RedCMD #block-plain-out.
5195+ repository [ 'block-plain-item' ] = {
5196+ begin : `(?=${ plainSrc } )(?!${ structAhead } )` ,
5197+ while : '\\G' ,
5198+ patterns : [
5199+ { begin : '\\G' , end : '(?=(?>[\\t ]++|\\G)#)' , name : plainContent , patterns : [
5200+ { match : '\\G[\\t ]++' , name : plainContent } , { match : '[\\t ]++$' , name : plainContent } ] } ,
5201+ { begin : '(?!\\G)' , while : '\\G' , patterns : commentIncludeKeys . map ( k => ( { include : `#${ k } ` } ) ) } ,
5202+ ] ,
5203+ } ;
5204+ // The region SHELL (begin/while/captures); its body `patterns` is filled at the END (after the
5205+ // top-level dispatch is built + ordered), since the item content reuses that full dispatch. Group 4
5206+ // (`([\t ]+)`, in the begin's lookahead) captures the indicator run between the outer and inner
5207+ // dashes, so the `while` can reconstruct the inner column as `\1\2 \4` (outer indent + the dash's
5208+ // own column + the run). Arm 1 reclaims a same-column sibling (`punctuation`); arm 2 is a zero-width
5209+ // lookahead that keeps the region alive on a strictly-deeper line (deferring to a nested level's
5210+ // sibling-reclaim, then to the body's #block-fold rule); arm 3 is a blank line.
5211+ repository [ 'block-sequence' ] = {
5212+ begin : `(?=((?<=${ reanchor } ) )?+)\\G( *+)(${ dash } )(?=([\\t ]+)${ dash } [\\t ])` ,
5213+ beginCaptures : { '3' : { name : `punctuation.${ langName } ` } } ,
5214+ while : `\\G(?>(\\1\\2 \\4)(?=${ dash } [\\t ]|${ dash } $)|(?=\\1\\2 \\4[\\t ])|[\\t ]*$)` ,
5215+ patterns : [ ] ,
5216+ } ;
5217+ // A deeper line (kept alive by the `while`'s arm 2) that is NOT a nested sibling folds into the
5218+ // current item's scalar. Anchored at LINE START (`^`), so it NEVER fires on the header line's inline
5219+ // inner item (which sits past column 0, after the outer `- `): only a continuation line begins at
5220+ // column 0. A leading `#` (a whitespace-preceded comment) is excluded so it falls to #comment, and
5221+ // `foldExclude` excludes a deeper KEY line (`<scan>: `) so a mapping ITEM VALUE's deeper entry
5222+ // (`- - a: 1\n b: 2`) keeps its #key structure instead of folding — the exclusion DROPS the
5223+ // compact indicators from `structAhead`, since a deeper `- b` (no sequence at its column) IS a fold
5224+ // (`- - a\n - b` = `[["a - b"]]`), the whole point of this rule. The body is one opaque plain run
5225+ // stopping before an inline ` #` (same idiom as the §2a′ continuation / §2a″ bareCont). Listed in
5226+ // the region body right AFTER the self-include so a deeper COMPACT line opens a nested
5227+ // #block-sequence instead of folding. (monogram#24 deeper residual.)
5228+ const foldExclude = `(?:${ cmtLit } |${ flowEx } *?${ kvSep } (?:[\\t ]|$))` ;
5229+ repository [ 'block-fold' ] = {
5230+ match : `^([\\t ]+)(?=[^\\t\\r\\n${ cmtCc } ])(?!${ foldExclude } )((?:[^${ cmtCc } \\n]|${ cmtLit } (?<=[^\\t\\n\\f\\r ]${ cmtLit } ))*)` ,
5231+ captures : { '2' : { name : plainContent } } ,
5232+ } ;
5233+ topPatterns . push ( { include : '#block-sequence' } ) ;
5234+ }
50915235 }
50925236 }
50935237
@@ -7691,6 +7835,13 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
76917835 // continuation as the KEY (entity.name.tag), so it must win for the `?` case; #plain-continuation
76927836 // still handles `key:`/`- ` folds (its lookahead, unlike this one, is not pinned to the `?`).
76937837 if ( key === 'explicit-key-continuation' ) return 0.68 ;
7838+ // The COMPACT block-sequence region (§2c) must out-rank #plain-continuation (0.7): both open on a
7839+ // `- `-led header (the `-` is in compactCls, so the fold's lookahead matches a compact line too), but
7840+ // for `- - a` the sequence region bounds the inner sibling at the inner indicator's column while the
7841+ // fold would swallow it line-relative. Ranked above the fold so the compact case is claimed first;
7842+ // its begin requires a SECOND dash (`(?=[\t ]+-[\t ])`), so a non-compact `- a`/`- key:`/`x: y` line
7843+ // never matches it and still falls through to #plain-continuation. (monogram#24.)
7844+ if ( key === 'block-sequence' ) return 0.69 ;
76947845 if ( key === 'plain-continuation' ) return 0.7 ;
76957846 // The BARE plain-scalar same-column fold (§2a″) likewise begins AT LINE START and must out-rank the
76967847 // scalar tokens (#key/#num/#boolnull/#plain ≥ 0.8) so it opens on a bare value scalar and claims its
@@ -7768,6 +7919,26 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
77687919 . sort ( ( a , b ) => scopeOrder ( a ) - scopeOrder ( b ) )
77697920 . map ( include => ( { include } ) ) ;
77707921
7922+ // Fill the COMPACT block-sequence region's body (§2c). Its item content reuses the FULL top-level
7923+ // dispatch (so a `- - {…}` flow value, a `- - "x"` quoted value, a `- - key: v` nested mapping, a
7924+ // `- - |` block scalar are all scoped correctly) — the same ordered includes meta.stream wraps — with
7925+ // two changes: the two LINE-relative plain folds (§2a′/§2a″) are REMOVED (the sequence's own
7926+ // column-anchored `while` + the bounded #block-plain-item handle the item-value fold node-relatively;
7927+ // leaving them in would re-introduce the line-relative swallow this region exists to prevent), and the
7928+ // bounded #block-plain-item is appended for a bare plain item value. The self-include (#block-sequence,
7929+ // already in the ordered list at rank 0.69) gives deeper compact nesting (`- - - x`); #block-fold is
7930+ // spliced in right AFTER it (region-body-only — never a top-level include) so a deeper line that opens no
7931+ // nested sequence folds into the item's plain scalar (monogram#24 deeper residual), while a deeper
7932+ // COMPACT line still re-opens #block-sequence first.
7933+ if ( repository [ 'block-sequence' ] ) {
7934+ const body = orderedPatterns . filter ( p => p . include !== '#plain-continuation' && p . include !== '#plain-bare-fold' ) ;
7935+ if ( repository [ 'block-fold' ] ) {
7936+ const selfAt = body . findIndex ( p => p . include === '#block-sequence' ) ;
7937+ if ( selfAt >= 0 ) body . splice ( selfAt + 1 , 0 , { include : '#block-fold' } ) ;
7938+ }
7939+ repository [ 'block-sequence' ] . patterns = [ ...body , { include : '#block-plain-item' } ] ;
7940+ }
7941+
77717942 // Additive: a `#expression` sub-grammar for expression-only embeds (Vue `{{ }}`). The
77727943 // top-level `patterns` (orderedPatterns / $self) are left untouched, so standalone
77737944 // tokenization is unchanged — `#expression` is inert unless something includes it.
0 commit comments