Skip to content

Commit 2edf33f

Browse files
committed
Close the YAML value-position depth class: one IR-derived valuePositions drives emission + verification
The flat-TM YAML highlighter mis-scoped a structural indicator (`-`/`?`) at the explicit-key value position (`? k:\n -` → entity.name.tag instead of punctuation) — the latest instance of a recurring class: an indicator whose correct scope depends on indentation depth, mis-handled wherever a per-shape detector didn't cover the value position. Rather than add another per-shape region, derive the class away: - valuePositions(grammar): one IR analysis returning every structural introducer (indent.compactIndicators ∪ keyValueSeparator) that opens an Indent-bounded value/Node block. detectExplicitKey / detectBlockSequence are documented as partial views of it. - The fold regions (#plain-continuation, #explicit-key-continuation) now YIELD at every value-position (a guard derived from the same set) instead of folding the value line as a key/scalar continuation — so the indented block routes to the shared dispatch and the indicator is scoped structurally. explicit-key value falls out as one instance; no special-casing. - test/depth-sites.ts derives its witness positions from the SAME valuePositions, so emission and verification share one source: "0 mis-scoped" is closed by construction over every IR-derivable value-position (16 cells). Added as a check.ts gate — self-contained (the RedCMD oracle is optional, shown for context when present). Zero regressions: 22/22 gates, scope-gap yaml monogramWrong=0 (100% vs official 99.5%), parser 100%, the other 6 grammars byte-identical, Onigmo-portable. Net codegen: 2 begin patterns in yaml.tmLanguage.json. The ledger's minimized gap moved to an orthogonal, pre-existing flow-collection mis-fire (a `[` in a block-context plain key wrongly opens #flow-sequence) — tracked in KNOWN-GAPS.md, the next closure target.
1 parent e404488 commit 2edf33f

6 files changed

Lines changed: 300 additions & 13 deletions

File tree

KNOWN-GAPS.md

Lines changed: 9 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -16,25 +16,25 @@ Regenerate: `node test/gap-ledger.ts --write` · verify up-to-date: `node test/g
1616

1717
**3 gaps** across 7 grammars · 0 dropped.
1818

19-
## `0b90a1776868` — yaml: structural-literal→name
19+
## `3e7e4f47894f` — yaml: #24 structural-literal→content
2020

2121
- **Language:** yaml
22-
- **Minimal repro:** `? a:\n -`
22+
- **Minimal repro:** `? a[:\n - a\n -`
2323
- **Divergent token:** `-` (parser token `$punct`)
24-
- **Role vs scope:** want **punct**, got **name** (highlighter scope `entity.name.tag.yaml`)
25-
- **Fingerprint:** `0b90a1776868`
24+
- **Role vs scope:** want **punct**, got **string** (highlighter scope `string.unquoted.yaml`)
25+
- **Fingerprint:** `3e7e4f47894f`
2626

2727
```json
2828
{
29-
"id": "0b90a1776868",
29+
"id": "3e7e4f47894f",
3030
"language": "yaml",
31-
"kind": "structural-literal→name",
32-
"repro": "? a:\n -",
31+
"kind": "#24 structural-literal→content",
32+
"repro": "? a[:\n - a\n -",
3333
"tokenType": "$punct",
3434
"tokenText": "-",
3535
"want": "punct",
36-
"got": "name",
37-
"gotScope": "entity.name.tag.yaml"
36+
"got": "string",
37+
"gotScope": "string.unquoted.yaml"
3838
}
3939
```
4040

src/gen-tm.ts

Lines changed: 149 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@ import type { CstGrammar, RuleExpr, RuleDecl, InjectClause, TokenDecl } from './
22
import { collectLiterals, isKeywordLiteral } from './grammar-utils.ts';
33
import {
44
tokenEscapePatternSource,
5+
tokenBlockPatternSource,
56
tokenPatternBlockDelimiterSources,
67
tokenPatternBlockDelimiters,
78
tokenPatternIdentifierExtraChars,
@@ -3256,6 +3257,103 @@ function detectDeclarations(grammar: CstGrammar, tokenNames: Set<string>): DeclI
32563257
return results;
32573258
}
32583259

3260+
// ── Value-position derivation (the depth-bug CLASS, one analysis) ──
3261+
//
3262+
// A flat per-line grammar carries no indent stack, so it re-introduces depth only where a region
3263+
// fires. The depth bugs (#23/#24, the `? a:\n -` gap) are all ONE class: a structural INTRODUCER
3264+
// (`-`/`?`/`:`) opens an indented VALUE block, and the indicator at the deeper line must be scoped
3265+
// STRUCTURALLY — but a per-shape detector that did not cover that exact (introducer × enclosing
3266+
// position) combination lets a FOLD region claim the value line instead. detectExplicitKey (the
3267+
// `? key` shape) and detectBlockSequence (the `[item, (Newline item)*]` shape) are PARTIAL VIEWS of
3268+
// the SAME fact: those are two of the introducers that open a value block. This derives the WHOLE
3269+
// set from the IR so emission and verification share one source — a value-position no detector named
3270+
// can no longer be a silent gap.
3271+
//
3272+
// A VALUE-POSITION is an introducer literal `I` — drawn from `indent.compactIndicators ∪
3273+
// {keyValueSeparator}` (the structural single-char literals that lead a nested block) — that is
3274+
// IMMEDIATELY FOLLOWED, in some rule seq, by an (optional) element that REACHES, after an `Indent`
3275+
// boundary, a value/`Node` alternation (`Indent <…alt…> Dedent` — the recursive value). That is:
3276+
// • `:` → MapValue/MapValueScalar (`[Indent, Node, Dedent]`), the block-mapping value;
3277+
// • `-` → Value (`[Indent, Node, Dedent]`), the sequence-item value;
3278+
// • `?` → MapValue (the explicit-key value half), AND the `:` inside ExplicitEntry.
3279+
// Returns null when the family has no such shape (every non-indentation grammar), so the value-
3280+
// position machinery is gated exactly like the detectors it subsumes.
3281+
//
3282+
// The set this returns is, by derivation, EXACTLY the introducer set the fold regions must yield to:
3283+
// at a value-position introducer that ends its line (`key:`-EOL, `?`-EOL, `-`-EOL) the indented block
3284+
// is a FRESH dispatch site (routed to the shared top-level dispatch, which the meta.stream wrapper
3285+
// re-anchors per line), NEVER a fold continuation. `structAhead`/`structRelease` (the fold-bounding
3286+
// lookaheads) are spelled from this same `{compactIndicators, keyValueSeparator}` set — so a fold
3287+
// releases at, and never opens past, a value-position. The explicit-key value (`? k:\n -`) FALLS
3288+
// OUT as one instance: `? k:` is a `?`-led key whose trailing `:` opens a value block, so the
3289+
// explicit-key fold must NOT treat `k` as a foldable scalar (a bare `? a\n b` fold has no such `:`).
3290+
export interface ValuePosition {
3291+
introducer: string; // the structural literal that opens the value block (`:` / `-` / `?`)
3292+
source: 'keyValueSeparator' | 'compactIndicator'; // which config field the introducer came from
3293+
}
3294+
export function valuePositions(grammar: CstGrammar): ValuePosition[] | null {
3295+
if (!grammar.indent) return null;
3296+
const { indentToken } = grammar.indent;
3297+
const ruleByName = new Map(grammar.rules.map(r => [r.name, r] as const));
3298+
// The structural introducer literals: the compact indicators (`-`/`?`) and the key/value
3299+
// separator (`:`) — the single-char literals that, in this family, can precede a nested block.
3300+
const kvSep = grammar.indent.keyValueSeparator ?? ':';
3301+
const introSource = new Map<string, 'keyValueSeparator' | 'compactIndicator'>();
3302+
for (const c of grammar.indent.compactIndicators ?? []) if (c.length === 1) introSource.set(c, 'compactIndicator');
3303+
if (kvSep.length === 1 && !introSource.has(kvSep)) introSource.set(kvSep, 'keyValueSeparator');
3304+
if (!introSource.size) return null;
3305+
3306+
// Does `e` (a rule body, transitively through refs) reach an `Indent <reaches value-alt> …`?
3307+
// i.e. an `Indent` token followed in a seq by an element that contains an `alt` (the value/Node
3308+
// alternation) — the recursive value block. A bounded visited-set guards the rule recursion.
3309+
const reachesIndentedValue = (e: RuleExpr, seen: Set<string>): boolean => {
3310+
const containsAlt = (x: RuleExpr, vseen: Set<string>): boolean =>
3311+
x.type === 'alt' ? true
3312+
: x.type === 'seq' ? x.items.some(it => containsAlt(it, vseen))
3313+
: x.type === 'quantifier' || x.type === 'group' || x.type === 'not' ? containsAlt(x.body, vseen)
3314+
: x.type === 'sep' ? containsAlt(x.element, vseen)
3315+
: x.type === 'ref' ? (vseen.has(x.name) ? false : (vseen.add(x.name), (() => { const r = ruleByName.get(x.name); return r ? containsAlt(r.body, vseen) : false; })()))
3316+
: false;
3317+
const walk = (x: RuleExpr): boolean => {
3318+
if (x.type === 'seq') {
3319+
for (let i = 0; i < x.items.length - 1; i++) {
3320+
const cur = x.items[i];
3321+
if (cur.type === 'ref' && cur.name === indentToken && containsAlt(x.items[i + 1], new Set())) return true;
3322+
}
3323+
return x.items.some(walk);
3324+
}
3325+
if (x.type === 'alt') return x.items.some(walk);
3326+
if (x.type === 'quantifier' || x.type === 'group' || x.type === 'not') return walk(x.body);
3327+
if (x.type === 'sep') return walk(x.element);
3328+
if (x.type === 'ref') { if (seen.has(x.name)) return false; seen.add(x.name); const r = ruleByName.get(x.name); return r ? walk(r.body) : false; }
3329+
return false;
3330+
};
3331+
return walk(e);
3332+
};
3333+
3334+
// An introducer `I` is a value-position when some seq has `I` immediately followed by an element
3335+
// that reaches an indented value block. `I` may be a head literal (`['-', opt(Value)]`,
3336+
// `['?', opt(MapValue), …]`) or an inner one (the `:` inside an explicit entry's `[…, ':', opt(MapValue)]`).
3337+
const found = new Set<string>();
3338+
const visit = (e: RuleExpr): void => {
3339+
if (e.type === 'seq') {
3340+
for (let i = 0; i < e.items.length - 1; i++) {
3341+
const lit = e.items[i];
3342+
if (lit.type === 'literal' && introSource.has(lit.value) && reachesIndentedValue(e.items[i + 1], new Set())) found.add(lit.value);
3343+
}
3344+
e.items.forEach(visit);
3345+
} else if (e.type === 'alt') e.items.forEach(visit);
3346+
else if (e.type === 'quantifier' || e.type === 'group' || e.type === 'not') visit(e.body);
3347+
else if (e.type === 'sep') visit(e.element);
3348+
};
3349+
for (const r of grammar.rules) visit(r.body);
3350+
if (!found.size) return null;
3351+
// Order: key/value separator first, then compact indicators in declaration order (stable, for
3352+
// a deterministic emit). The set — not the order — is what the emitter/verifier consume.
3353+
const order = [kvSep, ...(grammar.indent.compactIndicators ?? [])];
3354+
return [...found].sort((a, b) => order.indexOf(a) - order.indexOf(b)).map(introducer => ({ introducer, source: introSource.get(introducer)! }));
3355+
}
3356+
32593357
/**
32603358
* Explicit mapping-key detection (e.g. YAML `? key` / `: value`).
32613359
*
@@ -3266,6 +3364,12 @@ function detectDeclarations(grammar: CstGrammar, tokenNames: Set<string>): DeclI
32663364
* the shape and lets gen-tm emit a contextual rule scoping the scalar right after the
32673365
* indicator as a key.
32683366
*
3367+
* detectExplicitKey is a PARTIAL VIEW of `valuePositions` (above): the `?` it finds is exactly the
3368+
* `?` value-position introducer, here resolved to its key SCOPE + key BODY pattern (the extra facts
3369+
* the `#explicit-key` match rule needs). It stays a focused helper so the emit site keeps the scope/
3370+
* body it derives, but the value-position FACT (a `?`-led entry opens an indented value block) is
3371+
* owned by `valuePositions`, which the fold regions consult so they yield at a `? key:` value site.
3372+
*
32693373
* Signal (all from the grammar, nothing hardcoded):
32703374
* • the grammar is INDENTATION-mode (`grammar.indent`) — the family this construct lives in;
32713375
* • some rule has an alternative `seq` whose HEAD is a single-char punctuation literal `I`
@@ -5018,6 +5122,33 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
50185122
// a real key separator (`http://x` keeps its glued `:` → still plain content). Used as a NEGATIVE
50195123
// lookahead to bound the fold at the first sibling/comment line, matching the parser's foldedPlain.
50205124
const structAhead = `(?:${cmtLit}|${compactAlt}|${flowEx}*?${kvSep}(?:[\\t ]|$))`;
5125+
// ── VALUE-POSITION guard (the closed transform's hinge) ──
5126+
// The IR-derived value-positions (valuePositions): the structural introducers (`-`/`?`/`:`) that
5127+
// open an indented value block. A FOLD region must YIELD at a value-position — the indented block
5128+
// there is a fresh dispatch site (the shared top-level dispatch, re-anchored per line by the
5129+
// meta.stream wrapper), never a fold continuation. `structAhead` already encodes that for the
5130+
// `:` introducer (a `…: ` / `…:`-EOL line ends/never-opens a fold) and the compact `-`/`?` arm.
5131+
// The one site that still over-claimed was the explicit-key fold: `? k:` is a `?`-led key whose
5132+
// TRAILING `:` (a value-position) opens a value block, yet the fold treated `k` as a foldable
5133+
// bare scalar and swallowed the value line ` - x` (depth-sites' explicit-key-value bug). The
5134+
// guard `keyColonGuard` — derived from the same kvSep value-position introducer — asserts a
5135+
// plain scalar is NOT a `key:` (i.e. does NOT open a `:` value block); the explicit-key fold opens
5136+
// only on a plain scalar that passes it, so a bare `? a\n b` folds the key while `? k:\n -` /
5137+
// `? k: v\n more` route to the shared dispatch / the value-scoped #plain-continuation. Gated on
5138+
// the kvSep being a derived value-position, so a family without a `:` value-position keeps the old
5139+
// (unguarded) behaviour. The `key` token's match (`<plain-body>(?=…kvSep…)`) IS this predicate —
5140+
// a plain scalar that is a mapping key — so the guard is `(?!<key match>)`. It uses the key token's
5141+
// BLOCK-context body (`tokenBlockPatternSource` — `[^:#\n]`, where `[`/`{` are KEY CONTENT) rather
5142+
// than the FLOW snapshot `repository['key'].match` here (which excludes `,[]{}`): the explicit-key
5143+
// value lives in BLOCK context, so a block key WITH a flow char (`? a [a :\n - x` — the key is the
5144+
// plain scalar "a [a", value the sequence) must still be recognised as a `key:` value-position. The
5145+
// flow snapshot would stop the key body at `[` and miss the colon, letting the fold wrongly open.
5146+
const vps = valuePositions(grammar);
5147+
const kvSepIsValuePos = !!vps?.some(v => v.source === 'keyValueSeparator');
5148+
const keyScope = detectExplicitKey(grammar)?.keyScope;
5149+
const blockKeyTok = grammar.tokens.find(t => t.scope === keyScope && !t.string && t.blockPattern && tokenPatternPrefixBeforeTrailingLookahead(t));
5150+
const keyBlockMatch = blockKeyTok ? unicodeWidenIdentPattern(tokenBlockPatternSource(blockKeyTok)!) : undefined;
5151+
const keyColonGuard = (kvSepIsValuePos && keyBlockMatch) ? `(?!${keyBlockMatch})` : '';
50215152
// Value-position lookahead: after the node indent is stripped, the line must carry an INLINE
50225153
// BLOCK plain value — either `<key>: <plain>` (mapping value) or a `-`/`?` indicator + `<plain>`
50235154
// (sequence entry / explicit key). The plain value is confirmed by `(?=plainSrc)`, which only
@@ -5043,7 +5174,16 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
50435174
const keyToSep = quotedScalarToks.length
50445175
? `(?:${quotedScalarToks.join('|')}|${flowEx.slice(0, -1)}${quoteCharCls}])*?`
50455176
: `${flowEx}*?`;
5046-
const plainVp = `(?:(?:${docAlt})[\\t ]+)?(?:(?:${compactCls}[\\t ]+)+(?:${keyToSep}${kvSep}[\\t ]+)?|${keyToSep}${kvSep}[\\t ]+)(?=${plainSrc})`;
5177+
// The indicator-led arm (`-`/`?` then a value) and the bare `key:`-led arm. Each ends in a plain
5178+
// VALUE confirmed by `(?=plainSrc)`. The indicator arm splits at the value-position guard: after
5179+
// `(?:compactCls[\t ]+)+`, either a `key:` + INLINE plain value (`- a: hello`, the colon group
5180+
// consumes `:[\t ]+` so the value really is inline), OR a plain value that is NOT itself a `key:`
5181+
// (`keyColonGuard`). The second sub-arm is what excludes `? k:\n -` / `? k:\n ?`: there `k:` is
5182+
// a `?`-led KEY whose trailing `:` opens a VALUE-POSITION (the value is on the next indented line,
5183+
// a dispatch site), so `(?!key)` fails and the fold does not open — the indented value-block then
5184+
// routes to the shared dispatch (the `-`/`?` indicator scoped structurally). The bare `key:` arm
5185+
// needs `:[\t ]+` (an inline value), so a value-position `key:`-EOL never matches it either.
5186+
const plainVp = `(?:(?:${docAlt})[\\t ]+)?(?:(?:${compactCls}[\\t ]+)+(?:${keyToSep}${kvSep}[\\t ]+(?=${plainSrc})|(?=${plainSrc})${keyColonGuard})|${keyToSep}${kvSep}[\\t ]+(?=${plainSrc}))`;
50475187
// Header-line token includes: the same shape any plain `key: value` line gets, so the header is
50485188
// scoped identically to the top level (only the CONTINUATION changes). Includes the typed-value
50495189
// tokens (`#num`/`#boolnull`) so a SINGLE-line `a: 1` keeps `constant.numeric`, and the full
@@ -5095,8 +5235,15 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
50955235
const ekFold = detectExplicitKey(grammar);
50965236
if (ekFold && fold.hasDeeper) {
50975237
const ekContRule = { match: '\\G[\\t ]+(?:[^#\\n]|#(?<=[^\\t\\n\\f\\r ]#))*', name: `${ekFold.keyScope}.${langName}` };
5238+
// Open on a BARE explicit key only: `?` + ws + a plain scalar that is NOT a `key:` (the
5239+
// `keyColonGuard` value-position bar). `? a\n b` folds the key "a b" (key-scoped continuation);
5240+
// `? k:\n -` and `? k: v\n more` are value-positions — the `?`-led key's trailing `:` opens
5241+
// a value block, so this fold yields and the value line routes to the shared dispatch (the `-`
5242+
// scoped punctuation) / the value-scoped #plain-continuation (`more` → string.unquoted). Before
5243+
// the guard this region swallowed `? k:`'s value line as the KEY scope (the depth-sites bug);
5244+
// the guard is the explicit-key instance of "a fold yields to a value-position".
50985245
repository['explicit-key-continuation'] = emitIndentRegion({
5099-
lookahead: `(?=${escapeRegex(ekFold.indicator)}[\\t ]+(?:${keyToSep}${kvSep}[\\t ]+)?(?=${plainSrc}))`,
5246+
lookahead: `(?=${escapeRegex(ekFold.indicator)}[\\t ]+(?=${plainSrc})${keyColonGuard})`,
51005247
cont: `\\1[ \\t]+(?!${structAhead})`,
51015248
blankFirst: true,
51025249
patterns: [ekContRule, ...plainHeaderIncs],

test/check.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ const GATES: Gate[] = [
3333
{ group: 'vue', name: 'interp-expr', args: ['test/vue-interp-expr.ts'] },
3434
{ group: 'yaml', name: 'issue12-regressions', args: ['test/yaml-issue12-regressions.ts'] },
3535
{ group: 'yaml', name: 'depth-witnesses', args: ['test/yaml-depth-witnesses.ts'] },
36+
{ group: 'yaml', name: 'depth-sites', args: ['test/depth-sites.ts'] },
3637
{ group: 'generative', name: 'scope≡role', args: ['test/generative.ts'] },
3738
{ group: 'generative', name: 'gap-ledger-selftest', args: ['test/gap-ledger-selftest.ts'] },
3839
{ group: 'generative', name: 'gap-ledger-check', args: ['test/gap-ledger.ts', '--check'] },

0 commit comments

Comments
 (0)