Skip to content

Commit 79711e8

Browse files
committed
indent: un-overload YAML semantics off generic flags/literals (#44)
A non-YAML indentation grammar inherited three YAML behaviors derived from flags/literals that mean something else, with no opt-out short of mis-declaring the grammar. Detach each onto its own explicit, mode-neutral IndentConfig field that defaults OFF; yaml.ts opts into each. (A) Flow `:` key/value separator carve-out was derived from the `string` flag (`stringTokenNames`), silently enlisting every string-region token. New `flowSeparatorAfterTokens: string[]` names the membership explicitly (carve-out OFF when empty); `string: true` keeps its region-scoping / auto-close-derivation jobs without dragging a token into separator emission. PR #41's wholesale `flowColonSeparator` boolean is removed — an empty list is the neutral-off it provided, without re-overloading. (B) Plain-scalar continuation folding was derived from `blockPattern`, giving YAML folding to any block-pattern token. New `foldTokens: string[]` names the fold participants explicitly (folding OFF when empty); the last-named token is the catch-all continuation type. A grammar can now carry a `blockPattern` token without inheriting the fold. (C) `keyValueSeparator` was honored by gen-tm but the lexer hardcoded `:` (and `-`/`?`) in its key-line sniffs, a latent parser/highlighter split. Route every lexer key-separator sniff through `indent.keyValueSeparator` (via a shared `keySepAt` helper) and every compact-indicator sniff through `compactIndicators`, so the lexer and gen-tm share one source of truth for the separator for any value. Deferred: (D) the §6.1 tab-in-indentation errors and the value/item-position classification (seq-item `-` vs explicit-key `?`) still hardcode a few YAML indicators; cleanly splitting them needs `startsBlockStructuralNode`'s property/flow/alias indicator set parameterized — a larger sub-task, noted in-code at each site. yaml.ts opts in field-by-field (flowSeparatorAfterTokens + foldTokens) and tokenizes byte-identically: `npm run gen` produces zero generated-file diff across yaml + ts/js/jsx/tsx/html. test/indent-extensions.ts gains toy non-YAML grammars proving each un-overload (a `string:true` token that keeps its `:name` after values; a `blockPattern` token that does not fold; a `keyValueSeparator:'='` grammar whose lexer treats `=` as structural).
1 parent a9bb22e commit 79711e8

4 files changed

Lines changed: 224 additions & 79 deletions

File tree

src/gen-lexer.ts

Lines changed: 65 additions & 39 deletions
Original file line numberDiff line numberDiff line change
@@ -256,33 +256,37 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
256256
// exclusions, filtered ONCE here instead of re-tested per matcher per position.
257257
const scanMatchers = tokenMatchers.filter(tm =>
258258
tm.name !== templateTokenName && !markupTokenNames.has(tm.name) && !indentTokenNames.has(tm.name));
259-
// String-literal token names (the `string`-flagged tokens — quoted scalars in YAML). Used by the
260-
// flow mapping-separator guard below: a quoted scalar can never run past its closing quote, so a
261-
// `:` immediately after one (inside flow) is ALWAYS the mapping `key: value` separator, never the
262-
// start of a plain scalar — derived from the `string` flag, not a hardcoded token name.
263-
const stringTokenNames = new Set(grammar.tokens.filter(t => t.string).map(t => t.name));
264-
// Plain-scalar token names: the tokens carrying a block-context pattern variant (`blockPattern`).
265-
// In YAML these are exactly the UNQUOTED scalar family (plain / key / number / boolean-null) — the
266-
// ones whose flow-vs-block forms differ because flow indicators are content in block. Used by the
267-
// flow multi-line-plain FOLD post-pass: a plain scalar folded across a flow-internal newline arrives
268-
// as ADJACENT plain tokens (a space-separated plain is already one token; only a NEWLINE splits it),
269-
// which the post-pass re-merges. Derived from `blockPattern`, not a hardcoded token name.
270-
const plainScalarTokenNames = new Set(grammar.tokens.filter(t => tokenBlockPatternSource(t)).map(t => t.name));
271-
// The generic (catch-all) plain-scalar token: the LAST-declared blockPattern token. Declaration
272-
// order is specific-before-general (YAML: Key, Num, BoolNull, Plain — the typed/key shapes win
273-
// earlier, so the broadest string-valued plain is necessarily last). Used as the type emitted for
274-
// a folded plain-scalar CONTINUATION line — a more-indented line after a plain LEAF whose leading
275-
// glyph (`-`/`&`/`!`/`[`/`?`/`*`) is plain CONTENT here, not structure (so it can't be lexed by
276-
// the plain head pattern, which forbids those starts). Null when no blockPattern token exists.
277-
const plainContinuationTokenName = [...grammar.tokens].reverse().find(t => tokenBlockPatternSource(t))?.name ?? null;
259+
// Flow mapping-separator carve-out MEMBERSHIP (IndentConfig.flowSeparatorAfterTokens). A `:` glued
260+
// (inside flow) right after one of these tokens is ALWAYS the mapping `key: value` separator, never
261+
// the start of a `:`-led plain scalar — a quoted scalar / flow-close can never run past its closer.
262+
// EXPLICIT list now (was derived from the `string` flag, which silently enlisted every string-region
263+
// token); the carve-out is OFF when the list is absent. See the flow `:` guard below.
264+
const flowSeparatorAfterTokens = new Set((indent ?? newline)?.flowSeparatorAfterTokens ?? []);
265+
// Plain-scalar FOLD MEMBERSHIP (IndentConfig.foldTokens). The token TYPES that participate in YAML's
266+
// plain-scalar continuation folding — in YAML the UNQUOTED scalar family (plain / key / number /
267+
// boolean-null). EXPLICIT list now (was derived from `blockPattern`, which gave folding to ANY
268+
// block-pattern token); folding is OFF when the list is absent. Used by: the block-context fold (a
269+
// deeper line after a plain leaf), the flow illegal-head continuation, and the flow multi-line merge
270+
// post-pass — a plain scalar folded across a flow-internal newline arrives as ADJACENT plain tokens
271+
// (a NEWLINE splits it), which the post-pass re-merges.
272+
const foldTokens = indent?.foldTokens ?? [];
273+
const plainScalarTokenNames = new Set(foldTokens);
274+
// The generic (catch-all) plain-scalar token: the LAST-named fold token. Declaration order is
275+
// specific-before-general (YAML: Key, Num, BoolNull, Plain — the typed/key shapes win earlier, so
276+
// the broadest string-valued plain is necessarily last). Used as the type emitted for a folded
277+
// plain-scalar CONTINUATION line — a more-indented line after a plain LEAF whose leading glyph
278+
// (`-`/`&`/`!`/`[`/`?`/`*`) is plain CONTENT here, not structure (so it can't be lexed by the plain
279+
// head pattern, which forbids those starts). Null when no fold token is declared.
280+
const plainContinuationTokenName = foldTokens.length ? foldTokens[foldTokens.length - 1] : null;
278281
// The generic plain token's FLOW pattern (its `pattern`, not the block variant) — used by the flow
279282
// illegal-head continuation fallback: a char that no token can START here (e.g. YAML's `%`/`@`/backtick,
280283
// illegal as a plain START) is, when it follows a plain scalar inside a flow collection, mid-scalar
281284
// CONTENT. We then consume that one head char plus whatever plain BODY follows (matched by this
282285
// pattern at the next position), emit it as a plain-continuation token, and let the flow fold post-pass
283286
// merge it with the preceding scalar. Compiled once; null when no generic plain token exists.
284287
const plainFlowRe = (() => {
285-
const t = [...grammar.tokens].reverse().find(t => tokenBlockPatternSource(t));
288+
if (!plainContinuationTokenName) return null;
289+
const t = grammar.tokens.find(t => t.name === plainContinuationTokenName);
286290
return t ? new RegExp(`^(?:${tokenPatternSource(t)})`) : null;
287291
})();
288292
// Does the line content starting at `start` carry a KEY SEPARATOR — an unquoted `:` followed by
@@ -306,7 +310,22 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
306310
const kBlockScalarTok = kOf(indent?.blockScalar?.token ?? null);
307311
const kRawBlockTok = kOf(indent?.rawBlock?.token ?? null);
308312
const kPlainCont = kOf(plainContinuationTokenName);
309-
const tColon = puLitOf.get(':') ?? 0;
313+
// The mapping KEY/VALUE separator (IndentConfig.keyValueSeparator, default `:`) — the ONE source of
314+
// truth shared with gen-tm for every "is this a mapping-key line" sniff in the lexer. `kKvSep` is its
315+
// punctuation-literal intern, for the flow-`:` carve-out push.
316+
const keyValueSep = indent?.keyValueSeparator ?? ':';
317+
const kKvSep = puLitOf.get(keyValueSep) ?? 0;
318+
// Is `src` at `i` a mapping KEY separator — the `keyValueSeparator` literal followed by whitespace /
319+
// EOL / a flow indicator (`,`/`[`/`]`/`{`/`}`)? The single shared test behind every key-line sniff
320+
// (`lineHasKeySeparator`, `startsBlockStructuralNode`) so they read the separator from ONE place
321+
// (the config) rather than each hardcoding `:`. Returns the index PAST the separator on a hit (so
322+
// the caller can resume), or -1 on no match.
323+
function keySepAt(src: string, i: number): number {
324+
if (!src.startsWith(keyValueSep, i)) return -1;
325+
const n = src[i + keyValueSep.length];
326+
return (n === undefined || n === ' ' || n === '\t' || n === '\n' || n === '\r'
327+
|| n === ',' || n === '[' || n === ']' || n === '{' || n === '}') ? i + keyValueSep.length : -1;
328+
}
310329

311330
function lineHasKeySeparator(src: string, start: number): boolean {
312331
for (let i = start; i < src.length; i++) {
@@ -327,7 +346,7 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
327346
if (src[i] !== "'") break; continue;
328347
}
329348
if ((ch === ' ' || ch === '\t') && src[i + 1] === '#') break; // trailing comment → any sep would be earlier
330-
if (ch === ':') { const n = src[i + 1]; if (n === undefined || n === ' ' || n === '\t' || n === '\n' || n === '\r' || n === ',' || n === '[' || n === ']' || n === '{' || n === '}') return true; }
349+
if (keySepAt(src, i) >= 0) return true;
331350
}
332351
return false;
333352
}
@@ -392,18 +411,19 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
392411
function startsBlockStructuralNode(src: string, start: number, allowProperty = true): boolean {
393412
const c0 = src[start];
394413
if (c0 === '[' || c0 === '{' || c0 === '*') return false; // flow collection / alias → not indentation
395-
if ((c0 === '-' || c0 === '?' || c0 === ':') && sepAfter(src[start + 1])) return true; // indicator / empty key
414+
if (c0 !== undefined && compactIndicatorSet.has(c0) && sepAfter(src[start + 1])) return true; // compact indicator (`-`/`?`)
415+
if (src.startsWith(keyValueSep, start) && sepAfter(src[start + keyValueSep.length])) return true; // empty key (`:` then ws/EOL)
396416
if ((c0 === '&' || c0 === '!') && allowProperty) return true; // node property → establishes a node here
397417
if (c0 === '&' || c0 === '!') return false; // property after `:` → inline value, legal
398-
// Scalar key sniff: scan the line for an unquoted `:` followed by ws/EOL/flow-indicator (a
399-
// block key separator), skipping over "…"/'…' regions and stopping at a ` #` comment / EOL.
418+
// Scalar key sniff: scan the line for an unquoted key separator followed by ws/EOL/flow-indicator,
419+
// skipping over "…"/'…' regions and stopping at a ` #` comment / EOL.
400420
for (let i = start; i < src.length; i++) {
401421
const ch = src[i];
402422
if (ch === '\n' || ch === '\r') break;
403423
if (ch === '"') { i++; while (i < src.length && src[i] !== '"' && src[i] !== '\n') { if (src[i] === '\\') i++; i++; } continue; }
404424
if (ch === "'") { i++; while (i < src.length && src[i] !== '\n') { if (src[i] === "'" && src[i + 1] !== "'") break; if (src[i] === "'") i++; i++; } continue; }
405425
if ((ch === ' ' || ch === '\t') && src[i + 1] === '#') break; // trailing comment → key sep would be earlier
406-
if (ch === ':') { const n = src[i + 1]; if (n === undefined || n === ' ' || n === '\t' || n === '\n' || n === '\r' || n === ',' || n === '[' || n === ']' || n === '{' || n === '}') return true; }
426+
if (keySepAt(src, i) >= 0) return true;
407427
}
408428
return false;
409429
}
@@ -424,8 +444,8 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
424444
} else break;
425445
}
426446
if (i >= src.length || src[i] === '\n' || src[i] === '\r') return false; // property alone on the line → no nest
427-
if ((src[i] === '-' || src[i] === '?') && sepAfter(src[i + 1])) return true; // nested indicator
428-
return startsBlockStructuralNode(src, i, false); // a mapping key (the `:`-sniff)
447+
if (src[i] !== undefined && compactIndicatorSet.has(src[i]) && sepAfter(src[i + 1])) return true; // nested compact indicator
448+
return startsBlockStructuralNode(src, i, false); // a mapping key (the key-separator sniff)
429449
}
430450

431451
// Scan from inside a template span to its next boundary: an interpolation hole
@@ -544,7 +564,11 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
544564
// The §7.4 / multi-line-flow bookkeeping is indent-only (a newline grammar has no stack).
545565
if (flowDepth === 0 && indent) {
546566
const prevTok = tokens[tokens.length - 2]; // the token before this just-pushed open
547-
flowValueIndent = (prevTok && prevTok.type === '' && (prevTok.text === ':' || prevTok.text === '-'))
567+
// value/item position: a flow opened right after the key/value separator (map value) or a
568+
// sequence-item indicator. The `-` here is the seq-item lead specifically (NOT every
569+
// compactIndicator — `?` is an explicit KEY, not a value position); classifying it from
570+
// config is the (D) indicator-role split, deferred — see issue #44.
571+
flowValueIndent = (prevTok && prevTok.type === '' && (prevTok.text === keyValueSep || prevTok.text === '-'))
548572
? indentStack[indentStack.length - 1] : -1;
549573
flowSawNewline = false; // start tracking whether this flow spans >1 line
550574
}
@@ -776,10 +800,10 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
776800
// while `-`/`?` include them (`-\t&a x` IS an error). Block context only (flowDepth===0).
777801
if (indent && flowDepth === 0) { // §6.1 tab-after-indicator error is YAML-specific
778802
const prev = tokens[tokens.length - 1];
779-
const isIndicator = prev && prev.type === '' && (prev.text === '-' || prev.text === '?' || prev.text === ':');
803+
const isIndicator = prev && prev.type === '' && (compactIndicatorSet.has(prev.text) || prev.text === keyValueSep);
780804
if (isIndicator) {
781805
let q = pos; while (q < source.length && (source[q] === ' ' || source[q] === '\t')) q++;
782-
if (source.slice(pos, q).includes('\t') && startsBlockStructuralNode(source, q, prev!.text !== ':')) {
806+
if (source.slice(pos, q).includes('\t') && startsBlockStructuralNode(source, q, prev!.text !== keyValueSep)) {
783807
throw new Error(`Tab character used in indentation at offset ${pos}`);
784808
}
785809
}
@@ -1046,14 +1070,16 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
10461070
// the separator — emit it as the `:` punctuation literal here. Gated on flow (block-context `:`
10471071
// separators are handled by the KEY-position lookaheads). yaml-test-suite 5MUD / 5T43 / 9MMW
10481072
// / C2DT / K3WX (quoted key) and the flow-collection-key cohort.
1049-
// flowColonSeparator: false disables the YAML `"key":value` / `}: value` flow
1050-
// separator carve-out, for indentation grammars with `:name`-shaped tokens that
1051-
// may legally follow a quoted value or a flow-close delimiter.
1052-
if (indent && indent.flowColonSeparator !== false && flowDepth > 0 && source[pos] === ':') {
1073+
// Declaring flowSeparatorAfterTokens (a non-empty list — YAML: the quoted-key tokens) ENABLES
1074+
// the carve-out; it then fires after a NAMED token OR after any flow-CLOSE delimiter (`]`/`}`,
1075+
// which structurally can't run past its closer either). An indentation grammar that declares no
1076+
// such tokens gets no carve-out at all, so a `:name`-shaped token survives after values in flow.
1077+
// The separator glyph is keyValueSeparator (default `:`).
1078+
if (indent && flowDepth > 0 && flowSeparatorAfterTokens.size && source.startsWith(keyValueSep, pos)) {
10531079
const prevTok = tokens[tokens.length - 1];
1054-
if (prevTok && (stringTokenNames.has(prevTok.type) || (prevTok.type === '' && flowCloseSet.has(prevTok.text)))) {
1055-
push(mkPu(':', pos, tColon));
1056-
pos += 1;
1080+
if (prevTok && (flowSeparatorAfterTokens.has(prevTok.type) || (prevTok.type === '' && flowCloseSet.has(prevTok.text)))) {
1081+
push(mkPu(keyValueSep, pos, kKvSep));
1082+
pos += keyValueSep.length;
10571083
continue;
10581084
}
10591085
}
@@ -1213,7 +1239,7 @@ export function createLexer(grammar: CstGrammar, intern?: LexerIntern) {
12131239
let q = i; while (q < source.length && source[q] === ' ') q++;
12141240
return q > i && source[q] === '-' && sepAfter(source[q + 1]);
12151241
};
1216-
const colonPairsExplicit = wasLineLead && lit === ':' && currentLineCol === lastExplicitKeyCol;
1242+
const colonPairsExplicit = wasLineLead && lit === keyValueSep && currentLineCol === lastExplicitKeyCol;
12171243
const compactColon = colonPairsExplicit && dashAfter(pos);
12181244
// A line-lead `:` at its `?`'s column USES UP that pairing — the explicit entry now has its
12191245
// value, so a SECOND `: …` at the same column (`? a\n: - b\n: - c`, yaml-test-suite cousin) is

src/types.ts

Lines changed: 29 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -324,10 +324,12 @@ export interface IndentConfig {
324324
explicitKey?: string; // the flow `?` explicit-key indicator (e.g. punctuation.definition.key-value)
325325
};
326326
comment?: string; // line-comment introducer ignored for indentation (e.g. '#')
327-
// The mapping KEY/VALUE separator literal (YAML `:`). Used by the derived highlighter's multi-line
328-
// plain-scalar fold regions (gen-tm §2a′/§2a″) to recognise a `key:`-led line as STRUCTURAL (a
329-
// sibling that ends a fold) vs a bare plain-scalar continuation. Declared here (not hardcoded in the
330-
// generator) so the YAML region code stays data-driven. Absent → defaults to ':'.
327+
// The mapping KEY/VALUE separator literal (YAML `:`). The ONE source of truth for "what glyph
328+
// separates a mapping key from its value": BOTH the lexer's key-line sniffs (`lineHasKeySeparator`,
329+
// `startsBlockStructuralNode`, the compact-key pairing) AND the derived highlighter's multi-line
330+
// plain-scalar fold regions (gen-tm §2a′/§2a″) recognise a `key:`-led line as STRUCTURAL from this
331+
// field — so parser and highlighter agree for ANY separator. Declared here (not hardcoded) so the
332+
// region code stays data-driven. Absent → defaults to ':'.
331333
keyValueSeparator?: string;
332334
// Block scalars (YAML `|` / `>`): when the rest of a line is an introducer + indicators, the
333335
// following more-indented lines are verbatim content emitted as ONE token (like raw-text, but
@@ -339,10 +341,29 @@ export interface IndentConfig {
339341
// control sigil, not content; absent → the block-scalar token's own scope (introducer reads as the
340342
// body string). The body always keeps the token scope; only the introducer capture is re-scoped.
341343
blockScalar?: { introducers: string[]; token: string; documentMarkers?: string[]; indicatorScope?: string };
342-
// Set false to disable the YAML flow `:` key-separator carve-out (a `:` glued after a quoted
343-
// scalar / flow-close is forced punctuation). Indentation grammars with `:name`-shaped tokens
344-
// (bound-attribute shorthand) need those to survive after values. Default true (YAML behavior).
345-
flowColonSeparator?: boolean;
344+
// Flow `:` key-separator carve-out MEMBERSHIP: token TYPES after which a `:` glued inside a flow
345+
// collection is the `key: value` SEPARATOR (forced `:` punctuation), never the start of a `:`-led
346+
// plain scalar. A quoted scalar / flow-close can never run past its closer, so a `:` immediately
347+
// after one is unambiguously the separator (YAML: the quoted-key tokens). This is an EXPLICIT,
348+
// mode-neutral list — the carve-out is OFF unless a token is named here. (Was derived from the
349+
// `string` flag, which silently enlisted every string-region token; an indentation grammar with
350+
// `:name`-shaped tokens after values keeps `string: true` for region scoping / auto-close
351+
// derivation WITHOUT being dragged into separator emission.) Flow-CLOSE delimiters (`flowClose`)
352+
// are always part of the carve-out — a `:` after `]`/`}` is structurally the separator regardless.
353+
// Absent / empty → no carve-out (the `:` lexes normally). The separator glyph itself is
354+
// `keyValueSeparator`. yaml-test-suite 5MUD / 5T43 / 9MMW / C2DT / K3WX.
355+
flowSeparatorAfterTokens?: string[];
356+
// Plain-scalar CONTINUATION fold MEMBERSHIP: the token TYPES that participate in YAML's plain-scalar
357+
// folding — a more-indented line right after one of these LEAF scalars (or an adjacent one inside a
358+
// flow collection) is a CONTINUATION of that scalar, not a new node. Drives the block-context fold
359+
// (a deeper line after a plain leaf), the flow illegal-head continuation, and the flow multi-line
360+
// merge post-pass. The LAST-named token is the generic catch-all used as the emitted CONTINUATION
361+
// token type and whose `pattern` matches a folded body (declaration order is specific-before-general,
362+
// so the broadest plain is last). This is an EXPLICIT, mode-neutral list — folding is OFF unless a
363+
// token is named here. (Was derived from `blockPattern`, which gave YAML plain-scalar folding to ANY
364+
// block-pattern token; an indentation grammar can now carry a `blockPattern` token WITHOUT inheriting
365+
// the fold.) Absent / empty → no folding. yaml-test-suite 3MYT / A2M4 / AB8U / FBC9 / JTV5 / UT92.
366+
foldTokens?: string[];
346367
// A comment introducer immediately followed by this string is NOT a comment line — it falls
347368
// through to ordinary tokenization (e.g. comment '//' + commentExcept '!' → `//!` doc-comment
348369
// lines lex as real tokens and stay visible to the indent stack, while `//` lines vanish).

0 commit comments

Comments
 (0)