Skip to content

Commit 5abb37f

Browse files
Scope multi-line YAML plain-scalar continuations as string (#12 #6/#7) (#19)
A plain scalar may fold across a more-indented continuation line (`key: a\n b` → "a b"; `? e\n 42` → the key "e 42"). The parser's lexer folds these into one scalar token, but the derived line-by-line TextMate highlighter cannot see that a deeper line is the continuation of the scalar above it — so a continuation line that opens with a token-like char (`!` / a digit) was re-scanned at top level as a tag / number (RedCMD monogram#12 items #6 and #7). The fix is highlighter-only (`src/gen-tm.ts`); `yaml.ts` and the parser are unchanged. A new derived region `#plain-continuation` (gated on a plain-scalar token, so the other six grammars regenerate byte-identical) mirrors the parser's `foldedPlain`: its begin fires only on a line carrying an inline block plain value (`key: v` / `- v` / `? v`, never a bare `key:` or a flow line), captures the node indent with a forward group, and its `while` continues into blank / deeper bare- plain lines while releasing at a sibling at the node column and at the first deeper structural line (a `key:` / `- ` / `? ` / comment) so nested mappings, nested block scalars, and comments still fall to the top-level rules. Deeper continuation lines are scoped string.unquoted before `#tag` / `#num` / `#directive` can fire. All regex is portable (fixed-width lookbehind only). The same-column document-body fold (#9) is intentionally not handled here (the region requires strictly-deeper continuation), so a same-column sibling (`a: b\nc: d`) never folds. Verified: yaml-issue12-regressions #6 and #7 PASS (5 pass / 5 known-bug / 0 regression); sanity 15/15; scope-roles 47/47 + 9/9; tm-highlight-guards 22/0; RedCMD Onigmo diagnostics clean (10 grammars); the other six grammars byte-identical; YAML scope-gap Monogram-wrong did not grow. Refs #12
1 parent 32deb03 commit 5abb37f

2 files changed

Lines changed: 159 additions & 0 deletions

File tree

src/gen-tm.ts

Lines changed: 98 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4790,6 +4790,96 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
47904790
patterns: [bsIntroRule(bsIndicator, bsContent), ...bsHeaderIncs],
47914791
};
47924792
topPatterns.push({ include: `#${bsKey}-seq` });
4793+
4794+
// ── 2a′. Multi-line PLAIN scalar continuation (monogram#12 §6/§7) ──
4795+
// A plain scalar may FOLD across a more-indented continuation line (`key: a\n b` → "a b";
4796+
// `? e\n 42` → the key "e 42"). The parser's lexer folds these into one scalar token, but a
4797+
// flat per-line TextMate grammar cannot see that a deeper line is the CONTINUATION of the scalar
4798+
// above it — so a continuation line that opens with a token-like char (`!`/digit/`%`) is wrongly
4799+
// re-scanned as a tag / number / directive (`#tag`/`#num`/`#directive` fire at top level). The
4800+
// HEADER line is already scoped correctly by the normal token includes (`#plain` paints the value),
4801+
// so the fix is a REGION that consumes only the DEEPER continuation lines as opaque
4802+
// `string.unquoted`, BEFORE those token rules get a chance. This mirrors the parser's `foldedPlain`
4803+
// (`Plain Indent Plain* Dedent`): a value-position plain scalar followed by a MORE-indented run of
4804+
// BARE plain lines (a continuation line is never a `key:`/`-`/`?` node — that would be a sibling).
4805+
// Structure reuses the block-scalar region (§2a): the begin captures the node indent with a FORWARD
4806+
// group `^([ \t]*)`; the `while` continues into BLANK lines and DEEPER lines that are BARE plain
4807+
// content, and RELEASES at a SIBLING at the node column (so a same-column doc-body fold —
4808+
// monogram#12 §9 — never over-fires) AND at the first deeper STRUCTURAL line (a mapping `key:`, seq
4809+
// `- `, explicit `? `, or a comment). Releasing at a structural line is what lets a compact nested
4810+
// mapping (`- a: x\n b: 1`), a nested block scalar (`a: x\n b: |`), or a comment line fall back to
4811+
// the TOP-LEVEL patterns (which scope `b`/`|`/`#…` correctly) instead of being swallowed as string —
4812+
// it mirrors the parser's foldedPlain, whose continuation is bare `Plain` lines only. The header
4813+
// line is re-scoped by the normal token includes (`plainHeaderIncs`, matched at the value position
4814+
// the begin's indent-consume leaves the engine at); a `continuationRule` matches each DEEPER
4815+
// bare-plain line (`\G[\t ]+…`, which only fires at a re-anchored line start, so it never touches the
4816+
// header line) and scopes it as string, stopping before an inline ` #` comment (which then falls to
4817+
// `#comment`). Gated on a plain-scalar token, so the OTHER six grammars (no `#plain`) regenerate
4818+
// byte-identical.
4819+
if (repository['plain']) {
4820+
// The plain-scalar match, used ONLY as a zero-width `(?=plainSrc)` value-head probe below. The
4821+
// flow→block body loosening (`loosenBlockScalar`, in the flow section further down) runs AFTER
4822+
// this point, so this is the flow-body snapshot — irrelevant here, since a lookahead only needs
4823+
// the HEAD char class (identical in both variants) to confirm a plain value opens the line.
4824+
const plainSrc = repository['plain'].match!;
4825+
// The continuation is PLAIN-scalar content, so it takes the plain token's own scope
4826+
// (`string.unquoted.<lang>`) — not the block-scalar body scope `bsContent`
4827+
// (`string.unquoted.block.<lang>`), which would mis-label a folded plain run as a block scalar.
4828+
const plainContent = repository['plain'].name!;
4829+
// A line (after its leading indent) that opens a STRUCTURAL node or comment, NOT plain content:
4830+
// a `#` comment, a `- `/`? ` indicator (a `-1`/`?x` plain scalar is NOT one — the indicator needs
4831+
// a trailing space), or a mapping key (`…: ` / `…:`-EOL). `[^\n{}\[\]]*?` stops the key scan at a
4832+
// flow bracket (a `{`/`[` is flow, handled elsewhere), and `:(?:[\t ]|$)` requires the colon to be
4833+
// a real key separator (`http://x` keeps its glued `:` → still plain content). Used as a NEGATIVE
4834+
// lookahead to bound the fold at the first sibling/comment line, matching the parser's foldedPlain.
4835+
const structAhead = `(?:#|-[\\t ]|\\?[\\t ]|[^\\n{}\\[\\]]*?:(?:[\\t ]|$))`;
4836+
// Value-position lookahead: after the node indent is stripped, the line must carry an INLINE
4837+
// BLOCK plain value — either `<key>: <plain>` (mapping value) or a `-`/`?` indicator + `<plain>`
4838+
// (sequence entry / explicit key). The plain value is confirmed by `(?=plainSrc)`, which only
4839+
// succeeds on a real plain-scalar head — so a tag/anchor/quoted/`|`/`>` value never triggers it,
4840+
// and a BARE `key:` introducing a NESTED block (no inline value, EOL after `:`) is excluded
4841+
// because both arms REQUIRE a value: the `:` arm needs `:[\t ]+` (colon + space + plain), and the
4842+
// indicator arm needs `[-?][\t ]+` + plain. This is what keeps `key:\n nested: v` and the bare
4843+
// `a: b\nc: d` sibling out of the fold. The mapping-key run is `[^\n{}\[\]]*?` (NO flow brackets):
4844+
// a flow collection (`{ a: b,\n c }` / `a: { b: c,\n d }`) is a multi-line begin/end region of
4845+
// its own — a `{`/`[` before the `:` means the `:` is a FLOW separator, not a block one, so the
4846+
// region must NOT open and steal those lines from #flow-mapping/#flow-sequence.
4847+
const plainVp = `(?:(?:---|\\.\\.\\.)[\\t ]+)?(?:(?:[-?][\\t ]+)+(?:[^\\n{}\\[\\]]*?:[\\t ]+)?|[^\\n{}\\[\\]]*?:[\\t ]+)(?=${plainSrc})`;
4848+
// Header-line token includes: the same shape any plain `key: value` line gets, so the header is
4849+
// scoped identically to the top level (only the CONTINUATION changes). Includes the typed-value
4850+
// tokens (`#num`/`#boolnull`) so a SINGLE-line `a: 1` keeps `constant.numeric`, and the full
4851+
// key/sequence/comment set so a deeper STRUCTURAL line (which continuationRule skips) is scoped
4852+
// correctly. Listed only when the key exists.
4853+
const plainHeaderIncs = [
4854+
...(repository['docstart'] ? [{ include: '#docstart' }] : []),
4855+
...(repository['docend'] ? [{ include: '#docend' }] : []),
4856+
...(bsHasExplicitKey ? [{ include: '#explicit-key' }, { include: '#explicit-key-indicator' }] : []),
4857+
...(repository['dquotekey'] ? [{ include: '#dquotekey' }] : []),
4858+
...(repository['squotekey'] ? [{ include: '#squotekey' }] : []),
4859+
...(repository['key'] ? [{ include: '#key' }] : []),
4860+
...(repository['anchor'] ? [{ include: '#anchor' }] : []),
4861+
...(repository['alias'] ? [{ include: '#alias' }] : []),
4862+
...(repository['tag'] ? [{ include: '#tag' }] : []),
4863+
...(repository['num'] ? [{ include: '#num' }] : []),
4864+
...(repository['boolnull'] ? [{ include: '#boolnull' }] : []),
4865+
{ include: '#plain' },
4866+
...(repository['comment'] ? [{ include: '#comment' }] : []),
4867+
{ include: '#punctuation' },
4868+
];
4869+
// The continuation-line consumer: `\G[\t ]+` anchors at a re-anchored LINE START with ≥1 leading
4870+
// space (so it never matches the header line, where the engine sits past the indent) — the `while`
4871+
// has already proven the line is a bare-plain continuation — and the body `(?:[^#\n]|#(?<=\S#))*`
4872+
// swallows the line as one opaque plain run, stopping before an inline ` #` comment (a `#` is
4873+
// content only when glued to a non-space, exactly as the plain-scalar body treats it) so the
4874+
// comment falls to `#comment`. Scoped with the plain string scope.
4875+
const continuationRule = { match: '\\G[\\t ]+(?:[^#\\n]|#(?<=[^\\t\\n\\f\\r ]#))*', name: plainContent };
4876+
repository['plain-continuation'] = {
4877+
begin: `^([ \\t]*)(?=${plainVp})`,
4878+
while: `\\G(?=[ \\t]*$|\\1[ \\t]+(?!${structAhead}))`,
4879+
patterns: [continuationRule, ...plainHeaderIncs],
4880+
};
4881+
topPatterns.push({ include: '#plain-continuation' });
4882+
}
47934883
}
47944884

47954885
// ── 2b. Explicit mapping key (`? key`) ──
@@ -7344,6 +7434,14 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
73447434
if (key === 'blockscalar-key') return 0.55;
73457435
if (bsRank && key === `${bsRank}-doc`) return 0.58;
73467436
if (bsRank && key === bsRank) return 0.6;
7437+
// The multi-line plain-scalar continuation region (§2a′) also begins AT LINE START (`^([ \t]*)`),
7438+
// so it competes at column 0 with #key / quoted-keys / #docstart / #explicit-key (rank ≥ 0.8) and
7439+
// MUST out-rank them: on a same-start tie oniguruma picks the FIRST listed pattern, and the region
7440+
// must open so it can claim the continuation lines (else #key/#explicit-key win the header and a
7441+
// deeper `!`/digit/`%` line falls through to #tag/#num/#directive). It ranks BELOW the
7442+
// block-scalar regions (≤ 0.6) so a `key: |` keeps its block-scalar region — its lookahead requires
7443+
// a real plain VALUE head (never `|`/`>`), so the two never collide on the same line anyway.
7444+
if (key === 'plain-continuation') return 0.7;
73477445
// A flow collection (`{ … }` / `[ … ]`) is a begin/end region opened by a bracket; it must be
73487446
// tried before #punctuation (which would otherwise claim the `{`/`[` as a bare bracket) and
73497447
// before the scalar tokens. Its `{`/`[` can never lead a plain scalar, so this ranking is safe.

yaml.tmLanguage.json

Lines changed: 61 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,9 @@
2323
{
2424
"include": "#blockscalar"
2525
},
26+
{
27+
"include": "#plain-continuation"
28+
},
2629
{
2730
"include": "#explicit-key"
2831
},
@@ -114,6 +117,9 @@
114117
{
115118
"include": "#blockscalar"
116119
},
120+
{
121+
"include": "#plain-continuation"
122+
},
117123
{
118124
"include": "#explicit-key"
119125
},
@@ -593,6 +599,61 @@
593599
}
594600
]
595601
},
602+
"plain-continuation": {
603+
"begin": "^([ \\t]*)(?=(?:(?:---|\\.\\.\\.)[\\t ]+)?(?:(?:[-?][\\t ]+)+(?:[^\\n{}\\[\\]]*?:[\\t ]+)?|[^\\n{}\\[\\]]*?:[\\t ]+)(?=(?:[^\\t\\n\\f\\r \\-?:,\\[\\]{}#&*!|>'\"%@`]|[\\-?:](?=[^\\t\\n\\f\\r ,\\[\\]{}]))(?:[^:#\\n,\\[\\]{}]|:(?=[^\\t\\n\\f\\r ,\\]}])|#(?<=[^\\t\\n\\f\\r ]#))*))",
604+
"while": "\\G(?=[ \\t]*$|\\1[ \\t]+(?!(?:#|-[\\t ]|\\?[\\t ]|[^\\n{}\\[\\]]*?:(?:[\\t ]|$))))",
605+
"patterns": [
606+
{
607+
"match": "\\G[\\t ]+(?:[^#\\n]|#(?<=[^\\t\\n\\f\\r ]#))*",
608+
"name": "string.unquoted.yaml"
609+
},
610+
{
611+
"include": "#docstart"
612+
},
613+
{
614+
"include": "#docend"
615+
},
616+
{
617+
"include": "#explicit-key"
618+
},
619+
{
620+
"include": "#explicit-key-indicator"
621+
},
622+
{
623+
"include": "#dquotekey"
624+
},
625+
{
626+
"include": "#squotekey"
627+
},
628+
{
629+
"include": "#key"
630+
},
631+
{
632+
"include": "#anchor"
633+
},
634+
{
635+
"include": "#alias"
636+
},
637+
{
638+
"include": "#tag"
639+
},
640+
{
641+
"include": "#num"
642+
},
643+
{
644+
"include": "#boolnull"
645+
},
646+
{
647+
"include": "#plain"
648+
},
649+
{
650+
"include": "#comment"
651+
},
652+
{
653+
"include": "#punctuation"
654+
}
655+
]
656+
},
596657
"explicit-key": {
597658
"match": "(\\?)([\\t ]+)(?:(?:(&[^\\t\\n\\f\\r \\[\\]{},]+)|(!(?:<[^>]*>|[^\\t\\n\\f\\r \\[\\]{},]*)))[\\t ]+)*((?:[^\\t\\n\\f\\r \\-?:,\\[\\]{}#&*!|>'\"%@`]|[\\-?:](?=[^\\t\\n\\f\\r ,\\[\\]{}]))(?:[^:#\\n,\\[\\]{}]|:(?=[^\\t\\n\\f\\r ,\\]}])|#(?<=[^\\t\\n\\f\\r ]#))*)",
598659
"captures": {

0 commit comments

Comments
 (0)