Skip to content

Commit c9d6f4d

Browse files
committed
Fix YAML block scalars with empty lines via nested meta.stream emission
A block scalar with leading/internal EMPTY lines (e.g. `>\n\n folded`) collapsed: the body fell to plain `string.unquoted` and a following `* bullet` mis-parsed. A flat begin/end region loses its `\G` anchor at an empty line (the line isn't contiguous with the begin's captured end-of-line), and the `(?!\G)` end — which is load-bearing for bounding nested siblings — then fires on the next line. The role metric masked it (plain `string.unquoted` ≈ `string.unquoted.block` at the string-role level). Emit the minimal structural nesting the official grammar uses, all gated to YAML: - a `meta.stream` parent wrapping the top patterns in a two-arm `while:\G`/`while:^` region that re-anchors `\G` at the start of every line (including blank ones), so a nested `while:\G` body survives empty lines instead of collapsing; - the block-scalar body as the maintained-grammar "funky wrapper" (auto-detects the content indent, scopes it `string.unquoted.block`, releases at a shallower comment); - sibling bounding by the node indentation captured with a FORWARD `^([ \t]*)` group (NOT a lookbehind): `while: \G(?=\1[ \t]|[ \t]*$)`. The emitted grammar keeps 0 variable-length lookbehinds (8 fixed-width, same as before), staying portable to TextMate 2.0 / GitHub-Linguist and preserving the num/boolnull portability fix. YAML scope-gap 99.86% -> 100.0% (exact 99.8%); parser 100% aligned; the other six grammars byte-identical; agnostic 9/9. This completes the YAML issues reported in #12 (document markers, block-scalar comment/punctuation/premature-end, anchors, escapes in quoted keys, explicit multiline keys, num/boolnull portability). Closes #12
1 parent fa50f82 commit c9d6f4d

2 files changed

Lines changed: 572 additions & 156 deletions

File tree

src/gen-tm.ts

Lines changed: 155 additions & 53 deletions
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,10 @@ interface TmGrammar {
4848
$schema: string;
4949
name: string;
5050
scopeName: string;
51-
patterns: ({ include: string })[];
51+
// Usually a flat include list; an indentation grammar with a block scalar wraps these in a
52+
// line-spanning `meta.stream` region (a `TmPattern` with begin/while/patterns) — see the grammar
53+
// root return — so the top level admits region wrappers too, like `TmPattern.patterns` does.
54+
patterns: (TmPattern | { include: string })[];
5255
repository: Record<string, TmPattern>;
5356
}
5457

@@ -4627,6 +4630,12 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
46274630
// since the lookahead requires the rest of the header line to be only indicators + an optional
46284631
// ` #comment`.
46294632
const blockScalar = grammar.indent?.blockScalar;
4633+
// Block-scalar helpers shared with §2b (the `? |` explicit-key block scalar): the introducer
4634+
// sub-pattern, the funky body builder, the inner introducer rule, and the header-prefix includes.
4635+
// Assigned inside §2a; reused in §2b so both block-scalar shapes use one portable structure.
4636+
let bsIntro = '';
4637+
let bsFunkyIntroRule: ((content: string) => TmPattern) | null = null;
4638+
let bsHeaderIncludes: { include: string }[] = [];
46304639
if (blockScalar) {
46314640
const bsTok = grammar.tokens.find(t => t.name === blockScalar.token);
46324641
const bsKey = blockScalar.token.toLowerCase();
@@ -4635,37 +4644,110 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
46354644
// introducer + indentation/chomping indicators (a digit and a `+`/`-`, in either order, or a
46364645
// lone `+`/`-`), then a lookahead requiring the rest of the header line to be blank or a comment.
46374646
const indicators = '(?:[1-9][-+]?|[-+][1-9]?|[-+])?';
4647+
const intro = `${introClass}${indicators}`;
46384648
const commentIncs = commentIncludeKeys.map(k => ({ include: `#${k}` }));
4639-
// This is the proven textmate/yaml.tmbundle structure (a `begin`/`end` region, NOT `begin`/
4640-
// `while`): a flat grammar has no enclosing line-spanning rule, and a top-level `while: \G`
4641-
// region cannot sustain itself across lines — vscode-textmate sets the while-anchor to -1
4642-
// unless a PARENT begin captured the end-of-line, so the chain collapses after the header line
4643-
// (the maintained RedCMD grammar's `while: \G` only survives because it is nested many regions
4644-
// deep). Two load-bearing details:
4645-
// • the begin's trailing `(.*\n?)` group CAPTURES the newline. Without it the `(?!\G)` arm of
4646-
// the `end` (below) fires at the very start of line 2 and the body is never scoped.
4647-
// • `end: ^(?=\S)|(?!\G)` — the body ends at a line that dedents to a non-space at column 0
4648-
// (a sibling key, a new node, a `---`/`...` marker), and `(?!\G)` guards the contiguity the
4649-
// captured newline establishes.
4650-
// The inner rule auto-detects the body indentation (§8.1.1): `^([ \t]+)(?! )` captures the
4651-
// first content line's full indent as `\1`, and `end: ^(?!\1|[ \t]*$)` holds the body for every
4652-
// line that re-matches `\1` (or is blank) and releases at the first shallower non-blank line —
4653-
// so a deeper-nested scalar's siblings (`- a: |` … ` b: |`) are NOT swallowed.
4654-
repository[bsKey] = {
4655-
begin: `(${introClass}${indicators})(?=[\\t ]*(?:#|$))(.*\\n?)`,
4656-
beginCaptures: {
4657-
'1': { name: `${bsScope}.${langName}` },
4658-
'2': { patterns: commentIncs },
4649+
const bsContent = `${bsScope}.${langName}`;
4650+
// A block scalar BODY must scope `string.unquoted.block` across EMPTY lines. A flat `begin`/`end`
4651+
// region (the old textmate/yaml.tmbundle shape) collapses at the first LEADING empty line: the
4652+
// inner indent rule has not opened yet, nothing is consumed, and the `(?!\G)` arm of the `end`
4653+
// fires (vscode-textmate#114 — a top-level region's `\G` anchor dies once a line is not contiguous
4654+
// with the begin's captured EOL). The maintained RedCMD grammar survives empties only because its
4655+
// block scalar sits many `while: \G` regions deep and each parent re-anchors `\G` at every line,
4656+
// blank ones included. We replicate the MINIMAL slice of that nesting, in three PORTABLE pieces
4657+
// (no variable-length lookbehind — those are rejected by TextMate 2.0/Onigmo and GitHub-Linguist):
4658+
// 1. a `meta.stream` parent (`begin: ^(?!\G)`/`while: ^`) wraps ALL top patterns (added at the
4659+
// grammar root below) so `\G` is re-anchored every line — the empty-line survival lever.
4660+
// 2. the BODY is RedCMD's "funky wrapper" (`begin: $`/`while: \G`): three sub-rules auto-detect
4661+
// the content indent `\1`, scope it `string.unquoted.block`, and — via the middle rule's
4662+
// `end: \G(?!\1)(?=[\t ]*#)` — release at a SHALLOWER comment line (so a dedented `# c` is a
4663+
// real comment, not swallowed). Empty-line-proof.
4664+
// 3. the OUTER region bounds SIBLINGS by the NODE indentation, captured by a FORWARD group: the
4665+
// begin starts AT LINE START `^([ \t]*)` and CONSUMES the indent into `\1`, with a lookahead
4666+
// (the value-prefix `bsVp` below) confirming the line actually carries a value-position block
4667+
// scalar. `while: \G(?=\1[ \t]|[ \t]*$)` continues while a line is blank or indented past the
4668+
// node and ends at a sibling at the node column. The header line's key / `:` / anchor / tag
4669+
// are re-scoped by the normal token includes (`bsHeaderIncs`) since the indent consume put
4670+
// them INSIDE the region; an inner `bsIntroRule` matches the `|`/`>` introducer (+ trailing
4671+
// comment) and runs the funky body.
4672+
// Because the begin matches at LINE START it competes at column 0 with `#key`/quoted-keys (which
4673+
// also start there); on a same-start tie oniguruma picks the FIRST listed pattern, so these rules
4674+
// are ranked ABOVE the key/scalar tokens in scopeOrder. Their lookahead requires a real
4675+
// `[|>]…(#|$)` value-position header, so they never steal a non-block-scalar line.
4676+
const funkyBody = (content: string) => [
4677+
{
4678+
begin: '$',
4679+
while: '\\G',
4680+
patterns: [
4681+
{ begin: '\\G( ++)$', while: '\\G(?>(\\1)$|(?!\\1)( *+)($|.))', contentName: content },
4682+
{
4683+
begin: '\\G(?!$)(?=( *+))',
4684+
end: '\\G(?!\\1)(?=[\\t ]*+#)',
4685+
patterns: [
4686+
{ begin: '\\G( *+)', while: '\\G(?>(\\1)|( *+)($|[^\\t#]|[\\t ]++[^#]))', contentName: content },
4687+
],
4688+
},
4689+
{ begin: '(?!\\G)(?=[\\t ]*+#)', while: '\\G', patterns: commentIncs },
4690+
],
46594691
},
4660-
end: '^(?=\\S)|(?!\\G)',
4661-
patterns: [
4662-
{
4663-
begin: '^([ \\t]+)(?! )',
4664-
end: '^(?!\\1|[ \\t]*$)',
4665-
contentName: `${bsScope}.${langName}`,
4666-
},
4667-
],
4692+
];
4693+
// Inner introducer rule: leading `[\t ]*` skips the separator whitespace (the space after `:`/`-`),
4694+
// captures the `|`/`>` (+indicators) and the rest-of-line trailing comment, then runs the funky
4695+
// body (its `begin: $` opens at the header-line EOL). `while: \G` keeps it alive across the body.
4696+
const bsIntroRule = (content: string) => ({
4697+
begin: `[\\t ]*(${intro})(?=[\\t ]*(?:#|$))([\\t ]*.*)`,
4698+
beginCaptures: { '1': { name: content }, '2': { patterns: commentIncs } },
4699+
while: '\\G',
4700+
patterns: funkyBody(content),
4701+
});
4702+
// Header-prefix token includes: re-scope the part of the header line BEFORE the introducer (a doc
4703+
// marker / key / `:` / anchor / tag), since the line-start indent consume swallowed the engine
4704+
// position past them. Derived from what this grammar actually emits (an unresolved include is a
4705+
// no-op in vscode-textmate, but we list only the keys that exist to keep the grammar clean). The
4706+
// explicit-key entries are emitted in §2b (after this block) so they are gated by the same
4707+
// `detectExplicitKey` predicate; punctuation is emitted late so it is included unconditionally
4708+
// here (an indentation grammar with a block scalar always has the `:`/`-` punctuation token).
4709+
const bsHasExplicitKey = !!detectExplicitKey(grammar);
4710+
const bsHeaderIncs = [
4711+
...(repository['docstart'] ? [{ include: '#docstart' }] : []),
4712+
...(repository['docend'] ? [{ include: '#docend' }] : []),
4713+
...(bsHasExplicitKey ? [{ include: '#explicit-key' }, { include: '#explicit-key-indicator' }] : []),
4714+
...(repository['dquotekey'] ? [{ include: '#dquotekey' }] : []),
4715+
...(repository['squotekey'] ? [{ include: '#squotekey' }] : []),
4716+
...(repository['key'] ? [{ include: '#key' }] : []),
4717+
...(repository['anchor'] ? [{ include: '#anchor' }] : []),
4718+
...(repository['tag'] ? [{ include: '#tag' }] : []),
4719+
{ include: '#punctuation' },
4720+
];
4721+
// Value-PREFIX: the structural lead-in that may precede a value-position introducer on its header
4722+
// line AFTER the node indent is stripped. A genuine `|`/`>` introducer is the FIRST value token, so
4723+
// only separators (sequence dash `-`, explicit-key `?`, doc markers `---`/`...`), an optional
4724+
// mapping key + `:` separator, and node properties (anchors `&` / tags `!`) may sit before it —
4725+
// never plain-scalar content. This is what stops `a: foo|` / `a: foo |` (plain scalars that merely
4726+
// END in a pipe) from opening a region: after `a: ` the next token is `foo`, not a separator/
4727+
// key-colon/property, so the lookahead fails. The key arm matches up to the FIRST `: ` separator.
4728+
const bsProp = '(?:[&!][^\\t\\n\\f\\r \\[\\]{},]*[\\t ]+)*';
4729+
const bsVp = `(?:(?:---|\\.\\.\\.)[\\t ]+)?(?:[-?][\\t ]+)*(?:[^\\n]*?:[\\t ]+)?${bsProp}`;
4730+
// Expose the introducer / inner-rule / header-includes to §2b (the `? |` explicit-key variant).
4731+
bsIntro = intro;
4732+
bsFunkyIntroRule = bsIntroRule;
4733+
bsHeaderIncludes = bsHeaderIncs;
4734+
repository[bsKey] = {
4735+
begin: `^([ \\t]*)(?=${bsVp}${intro}[\\t ]*(?:#|$))`,
4736+
while: '\\G(?=\\1[ \\t]|[ \\t]*$)',
4737+
patterns: [bsIntroRule(bsContent), ...bsHeaderIncs],
46684738
};
4739+
// Sequence entry whose mapping VALUE is the block scalar (`- a: |` … ` b:`): bound siblings at the
4740+
// KEY column, not the dash column, else the next entry key is swallowed. The begin consumes the
4741+
// leading indent `\1` AND the dash + its trailing spaces `\3`, and the bound `\1[ \t]\3[ \t]` is
4742+
// one column past the key. A pure-space backref (`\1`, `\3`) standing in for the dash column keeps
4743+
// it portable (no literal `- ` backref, which would never match space-indented body lines).
4744+
repository[`${bsKey}-seq`] = {
4745+
begin: `^([ \\t]*)(-)([ \\t]+)(?=(?:[-?][\\t ]+)*[^\\n]*?:[\\t ]+${bsProp}${intro}[\\t ]*(?:#|$))`,
4746+
beginCaptures: { '2': { name: `punctuation.${langName}` } },
4747+
while: '\\G(?=\\1[ \\t]\\3[ \\t]|[ \\t]*$)',
4748+
patterns: [bsIntroRule(bsContent), ...bsHeaderIncs],
4749+
};
4750+
topPatterns.push({ include: `#${bsKey}-seq` });
46694751
}
46704752

46714753
// ── 2b. Explicit mapping key (`? key`) ──
@@ -4714,26 +4796,18 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
47144796
// A block scalar can ALSO be an explicit key (`? |` / `? >`). An implicit key must be a single
47154797
// line, so a multi-line block scalar key is ALWAYS `?`-introduced — and like every other scalar
47164798
// key (plain / quoted, both already `entity.name.tag`) its content is the KEY NAME, not a value
4717-
// string. The §2a region scopes a block body `string.unquoted.block`; here the SAME begin/end
4718-
// machinery is gated on the `?` indicator and scopes the introducer + body with the key scope.
4719-
// Leftmost-match makes the `?`-anchored begin win over the bare §2a block scalar; a block scalar
4720-
// in VALUE position (`: |`) has no leading `?`, so it is untouched.
4721-
if (blockScalar) {
4722-
const introClass = `[${blockScalar.introducers.map(escapeForCharClass).join('')}]`;
4723-
const indicators = '(?:[1-9][-+]?|[-+][1-9]?|[-+])?';
4724-
const commentIncs = commentIncludeKeys.map(k => ({ include: `#${k}` }));
4799+
// string. Same PORTABLE structure as §2a (forward-captured node indent + funky body), but gated on
4800+
// the `?` indicator and scoping the introducer + body with the KEY scope. The `?` is captured as
4801+
// map-key punctuation; the inner introducer rule scopes the `|`/`>` and the body as the key name.
4802+
// Ranked above the value-position block scalar (scopeOrder) so `? |` wins; a `: |` value has no
4803+
// leading `?`, so it is untouched.
4804+
if (blockScalar && bsFunkyIntroRule) {
47254805
const keyScope = `${explicitKey.keyScope}.${langName}`;
47264806
repository['blockscalar-key'] = {
4727-
begin: `(${escapeRegex(explicitKey.indicator)})([\\t ]+)(${introClass}${indicators})(?=[\\t ]*(?:#|$))(.*\\n?)`,
4728-
beginCaptures: {
4729-
'1': { name: `punctuation.definition.map.key.${langName}` },
4730-
'3': { name: keyScope },
4731-
'4': { patterns: commentIncs },
4732-
},
4733-
end: '^(?=\\S)|(?!\\G)',
4734-
patterns: [
4735-
{ begin: '^([ \\t]+)(?! )', end: '^(?!\\1|[ \\t]*$)', contentName: keyScope },
4736-
],
4807+
begin: `^([ \\t]*)(${escapeRegex(explicitKey.indicator)})([\\t ]+)(?=${bsIntro}[\\t ]*(?:#|$))`,
4808+
beginCaptures: { '2': { name: `punctuation.definition.map.key.${langName}` } },
4809+
while: '\\G(?=\\1[ \\t]|[ \\t]*$)',
4810+
patterns: [bsFunkyIntroRule(keyScope), ...bsHeaderIncludes],
47374811
};
47384812
topPatterns.push({ include: '#blockscalar-key' });
47394813
}
@@ -7178,11 +7252,22 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
71787252
// The bare explicit-key indicator (`?` alone on its line) must beat the generic `?` punctuation
71797253
// token (rank 9) so it scopes as the map-key punctuation, not a plain bracket.
71807254
if (key === 'explicit-key-indicator') return 0.82;
7181-
// A block scalar that is an explicit key (`? |` / `? >`) — its `?`-anchored begin must beat the
7182-
// bare `?` punctuation token AND the value-position block scalar so the body scopes as the key
7183-
// name. Its begin is highly specific (`?` + block introducer + blank/comment-only header), so
7184-
// ranking it with #explicit-key is safe. Mutually exclusive with #explicit-key (plain key body).
7185-
if (key === 'blockscalar-key') return 0.8;
7255+
// The value-position block scalars (`key: |` / `- |` / `--- |`) and the `? |` explicit-key
7256+
// variant now begin AT LINE START (`^([ \t]*)`), so they compete at column 0 with #key /
7257+
// quoted-keys / #docstart / #explicit-key (all of which also start there). On a same-start tie
7258+
// oniguruma's scanner picks the FIRST listed pattern, so these MUST out-rank every key/scalar/
7259+
// doc-marker token (rank ≥ 0.8) — their lookahead requires a real `[|>]…(#|$)` value-position
7260+
// header, so they never steal a non-block-scalar line. Three precedence facts decide the order:
7261+
// • `-seq` (dash + KEY + `:`) and the plain rule BOTH match `- a: |`, but `-seq` bounds siblings
7262+
// at the deeper KEY column, so it must be tried first → lowest rank.
7263+
// • `blockscalar-key` (`?`-anchored) and the plain rule BOTH match `? |` (the plain VP admits a
7264+
// leading `?`), but the key variant scopes the body as the key NAME, so it must win → below
7265+
// the plain rule.
7266+
// • the plain `blockscalar` is the fallback (bare `|`, `key: |`, `--- |`).
7267+
const bsRank = grammar.indent?.blockScalar?.token.toLowerCase();
7268+
if (bsRank && key === `${bsRank}-seq`) return 0.5;
7269+
if (key === 'blockscalar-key') return 0.55;
7270+
if (bsRank && key === bsRank) return 0.6;
71867271
// A flow collection (`{ … }` / `[ … ]`) is a begin/end region opened by a bracket; it must be
71877272
// tried before #punctuation (which would otherwise claim the `{`/`[` as a bare bracket) and
71887273
// before the scalar tokens. Its `{`/`[` can never lead a plain scalar, so this ranking is safe.
@@ -7260,11 +7345,28 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
72607345
// clobbers a key that already matches by name. Pure rename → tokenization unchanged.
72617346
applyCanonicalRepoNames(grammar, repository, orderedPatterns);
72627347

7348+
// ── meta.stream wrapper (indentation grammars with a block scalar only) ──
7349+
// A block scalar BODY must survive EMPTY lines (see §2a). The mechanism is a line-spanning
7350+
// `while: \G` parent that RE-ANCHORS `\G` at the start of every line — including blank ones — so a
7351+
// nested `while: \G` body region stays alive across blanks instead of collapsing. We wrap ALL top
7352+
// patterns in RedCMD's two-arm `meta.stream` region: the first arm (`begin: ^(?!\G)`/`while: ^`)
7353+
// drives normal top-of-stream tokenisation; the second (`begin: \G(?!$)`/`while: \G`) is the
7354+
// embedded-start case (YAML inside a Markdown fence). Tokenisation of every construct is unchanged
7355+
// — the wrapper only adds the persistent `\G` anchor the block scalar needs. Gated to grammars that
7356+
// actually emit a block scalar so non-indentation languages (TS/HTML/…) stay byte-identical.
7357+
let finalPatterns: ({ include: string } | TmPattern)[] = orderedPatterns;
7358+
if (grammar.indent?.blockScalar) {
7359+
finalPatterns = [
7360+
{ begin: '^(?!\\G)', while: '^', name: `meta.stream.${langName}`, patterns: orderedPatterns },
7361+
{ begin: '\\G(?!$)', while: '\\G', name: `meta.stream.${langName}`, patterns: orderedPatterns },
7362+
];
7363+
}
7364+
72637365
return {
72647366
$schema: 'https://raw.githubusercontent.com/martinring/tmlanguage/master/tmlanguage.json',
72657367
name: grammarName,
72667368
scopeName,
7267-
patterns: orderedPatterns,
7369+
patterns: finalPatterns,
72687370
repository,
72697371
};
72707372
}

0 commit comments

Comments
 (0)