Skip to content

Commit ef3e3e7

Browse files
Fix flat YAML highlighter depth/position bugs (#23, #24) + a depth-witness gate (#27)
1 parent 887308e commit ef3e3e7

6 files changed

Lines changed: 531 additions & 12 deletions

File tree

.github/workflows/ci.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -56,6 +56,7 @@ jobs:
5656
node test/vue-embed-boundary.ts
5757
node test/vue-interp-expr.ts
5858
node test/yaml-issue12-regressions.ts
59+
node test/yaml-depth-witnesses.ts
5960
6061
# The derived tree-sitter highlighter is the strongest thesis proof (a real GLR
6162
# parser from the same grammar, beating the official hand-written one). Build its

src/gen-lexer.ts

Lines changed: 9 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import type { CstGrammar } from './types.ts';
22
import { collectLiterals, isKeywordLiteral } from './grammar-utils.ts';
3-
import { tokenBlockPatternFirstCharSet, tokenBlockPatternSource, tokenEscapeValidPatternSource, tokenPatternFirstCharSet, tokenPatternSource } from './token-pattern.ts';
3+
import { tokenBlockPatternFirstCharSet, tokenBlockPatternSource, tokenEscapeValidPatternSource, tokenPatternFirstCharSet, tokenPatternHasStartAnchor, tokenPatternSource } from './token-pattern.ts';
44

55
// A lexer token: a declared token (type = its name) or a punctuation literal (type = '').
66
// `$templateHead/$templateMiddle/$templateTail` are synthetic types the lexer emits for
@@ -45,10 +45,16 @@ export function createLexer(grammar: CstGrammar) {
4545
const tokenMatchers = grammar.tokens.map(t => {
4646
const pattern = tokenPatternSource(t);
4747
const blockPattern = tokenBlockPatternSource(t);
48+
// A token whose pattern carries a line-START anchor (`start()` → `^`, e.g. YAML's `---`/`...`
49+
// document markers, a shebang) needs the `m` flag: under the sticky `y` matcher a bare `^`
50+
// matches only at index 0 (file start), so a marker at the start of a LATER line (`# c\n---\n…`,
51+
// `%TAG …\n---\n…`) would fail to lex. With `m`, `^` matches at every line start, so a sticky
52+
// match at `lastIndex = pos` succeeds iff `pos` is a line start — exactly `start()`'s meaning.
53+
const flags = tokenPatternHasStartAnchor(t) ? 'ym' : 'y';
4854
return {
4955
name: t.name,
50-
regex: new RegExp(`(?:${pattern})`, 'y'),
51-
blockRegex: blockPattern ? new RegExp(`(?:${blockPattern})`, 'y') : null,
56+
regex: new RegExp(`(?:${pattern})`, flags),
57+
blockRegex: blockPattern ? new RegExp(`(?:${blockPattern})`, flags) : null,
5258
skip: t.flags.includes('skip'),
5359
isRegex: t.flags.includes('regex'),
5460
isString: !!t.string,

src/gen-tm.ts

Lines changed: 171 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -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

Comments
 (0)