Skip to content

Commit 73c35d1

Browse files
YAML compact-nesting + TS generic depth correctness, with an IR-driven depth-verification gate (#48)
1 parent a7088f0 commit 73c35d1

13 files changed

Lines changed: 1173 additions & 1627 deletions

src/gen-tm.ts

Lines changed: 371 additions & 231 deletions
Large diffs are not rendered by default.

src/types.ts

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,19 @@ export interface RegexContext {
9898
export interface RawEmbed {
9999
default: string; // embed scope when no (or an unlisted) lang= attribute
100100
lang?: Record<string, string>; // lang attribute value → embed scope (e.g. { ts: 'source.ts' })
101+
// Whether the embedded language can swallow the close tag mid-line or leave an open construct at
102+
// the close that misreads it — i.e. whether the body must FORCE-CLOSE mid-line and FORCE-UNWIND an
103+
// open embedded region at the close tag. TRUE for `<script>` (JS `//</script>` on one line must
104+
// still close — tmbundle#85; an unterminated `type T =` must unwind before `</script>` is read as
105+
// type-args — #5538/#2060): the body uses a `begin/while` open region whose `.*` drops at the first
106+
// line CONTAINING the close, plus separate close-LINE rules. That `while` drop is what makes a non-
107+
// first DIALECT's close-line content land on a lang-INDEPENDENT close rule (only the first fires),
108+
// so a multi-dialect `forceClose` embed cannot keep per-dialect close lines — script accepts this
109+
// (mid-line force-close is the priority for JS). FALSE (the default for a `{ default, lang }` embed)
110+
// is for a well-behaved embed like CSS — no greedy line-comment swallows `</style>`, nothing leaves
111+
// an open construct — so the body uses a lookahead-`end` region (matching the official Vue grammar):
112+
// the embed stays active up to `</tag` so a WITH-CONTENT close line keeps its DIALECT (issue #43).
113+
forceClose?: boolean;
101114
}
102115

103116
/**

test/angle-depth-probe.ts

Lines changed: 141 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,141 @@
1+
// angle-depth-probe.ts — the depth gate for the DELIMITER + LOOKAHEAD nesting class (TS/TSX angle
2+
// brackets), the category that had ZERO depth coverage and where the type-cast `\g<TC>` cliff lived.
3+
//
4+
// Two structural checks over the GENERATED typescript/typescriptreact/javascript/javascriptreact
5+
// grammars, so a new construct cannot be added without this gate seeing it:
6+
//
7+
// PART 1 — DEPTH SWEEP (self-baselining). The angle-bracket disambiguations (generic call, type
8+
// cast, generic arrow, JSX tag type-args, generic type) confirm `<…>` is a type span. A confirm
9+
// built on an Oniguruma `\g<>` subroutine has a ~20-level recursion cap, so a deep nested generic
10+
// flips the discriminating scope at d=20 (the cast did exactly this: `<` → relational at d=20).
11+
// For each construct we sweep d=1,5,19,20,24,30 and assert the probe token's scope at every depth
12+
// EQUALS its shallow (d=1) value — a flip anywhere is a depth cliff. Self-baselining, so it is
13+
// robust to scope renames; it would have caught the cast cliff (gen-tm 1967) and the TSX arrow
14+
// cliff (the formerly-recursive arrowEndConfirm) before they shipped.
15+
//
16+
// PART 2 — `\g<>` CENSUS. Every Oniguruma subroutine `\g<>` reachable from a begin/match/while/end
17+
// in the emitted grammars is enumerated and matched against an ALLOWLIST of graceful-degraders
18+
// (a `\g<>` inside a negative lookahead or an optional group, whose overflow is benign). A `\g<>`
19+
// NOT on the list is a latent sole-confirmer cliff → FAIL, forcing a flat-confirm migration (as
20+
// done for generic-call/arrow/cast) or an explicit, depth-probed allowlist entry. This makes the
21+
// cast-cliff class structurally un-missable: it walks the EMITTED output, not a hand list.
22+
//
23+
// Run (bare node): node test/angle-depth-probe.ts · Exit 0 iff no cliff AND census clean.
24+
import { readFileSync } from 'node:fs';
25+
import { createRequire } from 'node:module';
26+
import vsctm from 'vscode-textmate';
27+
import onig from 'vscode-oniguruma';
28+
29+
const { INITIAL, Registry } = vsctm;
30+
const { loadWASM, OnigScanner, OnigString } = onig;
31+
const require = createRequire(import.meta.url);
32+
const wasm = readFileSync(require.resolve('vscode-oniguruma/release/onig.wasm'));
33+
await loadWASM(wasm.buffer.slice(wasm.byteOffset, wasm.byteOffset + wasm.byteLength));
34+
35+
async function load(path: string) {
36+
const raw = JSON.parse(readFileSync(path, 'utf8'));
37+
const reg = new Registry({
38+
onigLib: Promise.resolve({
39+
createOnigScanner: (ps: string[]) => new OnigScanner(ps),
40+
createOnigString: (s: string) => new OnigString(s),
41+
}),
42+
loadGrammar: async (scope: string) => (scope === raw.scopeName ? raw : null),
43+
});
44+
return reg.loadGrammar(raw.scopeName);
45+
}
46+
47+
const TS = await load('./typescript.tmLanguage.json');
48+
const TSX = await load('./typescriptreact.tmLanguage.json');
49+
50+
// scope of the FIRST token whose text === `needle` (optionally the nth), innermost scope.
51+
function scopeOf(g: any, src: string, needle: string, nth = 1): string | null {
52+
const lines = src.split('\n');
53+
let rs = INITIAL;
54+
let seen = 0;
55+
for (const line of lines) {
56+
const r = g.tokenizeLine(line, rs);
57+
for (const t of r.tokens) {
58+
if (line.slice(t.startIndex, t.endIndex) === needle && ++seen === nth) return t.scopes[t.scopes.length - 1];
59+
}
60+
rs = r.ruleStack;
61+
}
62+
return null;
63+
}
64+
65+
// A d-deep nested generic type span: A0<A1<…<X>…>> (no spaces, the densest form).
66+
const nest = (d: number) => {
67+
let s = 'X';
68+
for (let i = 0; i < d; i++) s = `A${i}<${s}>`;
69+
return s;
70+
};
71+
72+
interface Construct {
73+
name: string;
74+
g: any;
75+
build: (d: number) => string;
76+
needle: string; // token whose scope must stay stable across depth
77+
nth?: number;
78+
}
79+
80+
const CONSTRUCTS: Construct[] = [
81+
{ name: 'generic-call (ts)', g: TS, build: (d) => `const z = f<${nest(d)}>(0);\n`, needle: '<' },
82+
{ name: 'type-cast (ts)', g: TS, build: (d) => `const x = <${nest(d)}>v;\n`, needle: '<' },
83+
{ name: 'generic-type-annotation (ts)', g: TS, build: (d) => `let a: ${nest(d)};\n`, needle: '<' },
84+
{ name: 'generic-arrow (tsx)', g: TSX, build: (d) => `const f = <T extends ${nest(d)}>(p: T) => p;\n`, needle: 'p', nth: 1 },
85+
{ name: 'jsx-tag-type-args (tsx)', g: TSX, build: (d) => `const e = <Comp<${nest(d)}> a={1} />;\n`, needle: 'Comp' },
86+
];
87+
88+
const DEPTHS = [1, 5, 19, 20, 24, 30];
89+
const cliffs: string[] = [];
90+
91+
for (const c of CONSTRUCTS) {
92+
const baseline = scopeOf(c.g, c.build(1), c.needle, c.nth);
93+
const row: string[] = [];
94+
for (const d of DEPTHS) {
95+
const sc = scopeOf(c.g, c.build(d), c.needle, c.nth);
96+
const ok = sc === baseline;
97+
row.push(`d${d}:${ok ? 'ok' : 'FLIP'}`);
98+
if (!ok) cliffs.push(`${c.name} «${c.needle}» d=${d}: ${sc} (shallow was ${baseline})`);
99+
}
100+
console.log(` ${c.name.padEnd(30)} «${c.needle}»→${baseline}\n ${row.join(' ')}`);
101+
}
102+
103+
// PART 2 — \g<> census over the emitted grammars.
104+
// Allowlist: a graceful-degrader is a `\g<>` whose overflow is benign — inside a NEGATIVE lookahead
105+
// (the bail-out just doesn't fire) or an OPTIONAL group (a flat fallback follows). Each entry names
106+
// the repository key + subroutine and WHY it is safe.
107+
const ALLOW: { grammar: RegExp; keyRe: RegExp; sub: string; why: string }[] = [
108+
{ grammar: /typescript(react)?/, keyRe: /generic-call-multiline/, sub: 'B', why: 'inside a NEGATIVE lookahead (?!…) — overflow makes the bail-out not fire (benign), verified stable to d=30' },
109+
{ grammar: /typescriptreact/, keyRe: /jsx/, sub: 'TA', why: 'inside an OPTIONAL group (?:…)? with a flat [^>]* fallback — overflow falls back to the region, verified stable to d=30' },
110+
];
111+
const census: string[] = [];
112+
const unrecognized: string[] = [];
113+
for (const [gf] of [['typescript.tmLanguage.json'], ['typescriptreact.tmLanguage.json'], ['javascript.tmLanguage.json'], ['javascriptreact.tmLanguage.json']] as const) {
114+
const g = JSON.parse(readFileSync(`./${gf}`, 'utf8'));
115+
const walk = (node: any, key: string) => {
116+
if (!node || typeof node !== 'object') return;
117+
for (const f of ['begin', 'match', 'while', 'end'] as const) {
118+
const re = node[f];
119+
if (typeof re === 'string') {
120+
for (const m of re.matchAll(/\\g<([^>]*)>/g)) {
121+
const sub = m[1];
122+
census.push(`${gf}:${key}.${f}:\\g<${sub}>`);
123+
const ok = ALLOW.some((a) => a.grammar.test(gf) && a.keyRe.test(key) && a.sub === sub);
124+
if (!ok) unrecognized.push(`${gf} ${key}.${f} \\g<${sub}>`);
125+
}
126+
}
127+
}
128+
for (const k in node) if (k !== 'repository' && typeof node[k] === 'object') walk(node[k], key);
129+
};
130+
for (const k in (g.repository || {})) walk(g.repository[k], k);
131+
}
132+
133+
console.log(`\n \\g<> census: ${census.length} subroutine use(s); ${unrecognized.length} unrecognized (not a known graceful-degrader)`);
134+
for (const u of unrecognized) console.log(` UNRECOGNIZED \\g<> (latent depth cliff — flat-migrate or allowlist with a depth-probe): ${u}`);
135+
136+
const fail = cliffs.length + unrecognized.length;
137+
if (cliffs.length) { console.log('\n DEPTH CLIFFS:'); for (const c of cliffs) console.log(` ${c}`); }
138+
console.log(fail === 0
139+
? '\n✓ no angle-bracket depth cliff (stable to d=30) and \\g<> census clean'
140+
: `\n✗ ${cliffs.length} cliff(s) + ${unrecognized.length} unrecognized \\g<>`);
141+
process.exit(fail === 0 ? 0 : 1);

test/check.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -34,20 +34,24 @@ const GATES: Gate[] = [
3434
{ group: 'conformance', name: 'html', args: ['test/html-conformance.ts'] },
3535
{ group: 'highlighter', name: 'tm-guards', args: ['test/tm-highlight-guards.ts'] },
3636
{ group: 'highlighter', name: 'tm-diagnostics', args: ['test/redcmd-tm-diagnostics.ts'] },
37+
{ group: 'highlighter', name: 'angle-depth', args: ['test/angle-depth-probe.ts'] },
3738
{ group: 'highlighter', name: 'html-monarch', args: ['test/html-monarch.ts'] },
3839
{ group: 'highlighter', name: 'html-embed-js', args: ['test/html-embed-js.ts'] },
3940
{ group: 'highlighter', name: 'html-lexer-spike', args: ['test/html-lexer-spike.ts'] },
4041
{ group: 'highlighter', name: 'self-close-sites', args: ['test/self-close-sites.ts'] },
4142
{ group: 'highlighter', name: 'raw-text-case-sites', args: ['test/raw-text-case-sites.ts'] },
4243
{ group: 'vue', name: 'directives', args: ['test/vue-directives.ts'] },
4344
{ group: 'vue', name: 'embed-boundary', args: ['test/vue-embed-boundary.ts'] },
45+
{ group: 'vue', name: 'raw-style-embed', args: ['test/vue-raw-style-embed-sites.ts'] },
4446
{ group: 'vue', name: 'interp-expr', args: ['test/vue-interp-expr.ts'] },
4547
{ group: 'core', name: 'indent-extensions', args: ['test/indent-extensions.ts'] },
4648
{ group: 'yaml', name: 'issue12-regressions', args: ['test/yaml-issue12-regressions.ts'] },
4749
{ group: 'yaml', name: 'depth-witnesses', args: ['test/yaml-depth-witnesses.ts'] },
4850
{ group: 'yaml', name: 'depth-sites', args: ['test/depth-sites.ts'] },
4951
{ group: 'yaml', name: 'flow-sites', args: ['test/flow-sites.ts'] },
5052
{ group: 'yaml', name: 'compact-nest-sites', args: ['test/compact-nest-sites.ts'] },
53+
{ group: 'yaml', name: 'deepest-sibling', args: ['test/yaml-deepest-sibling-probe.ts'] },
54+
{ group: 'yaml', name: 'blockscalar-depth', args: ['test/yaml-blockscalar-depth-probe.ts'] },
5155
{ group: 'generative', name: 'scope≡role', args: ['test/generative.ts'] },
5256
{ group: 'generative', name: 'gap-ledger-selftest', args: ['test/gap-ledger-selftest.ts'] },
5357
{ group: 'generative', name: 'gap-ledger-check', args: ['test/gap-ledger.ts', '--check'] },

test/vue-raw-style-embed-sites.ts

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,70 @@
1+
// vue-raw-style-embed-sites.ts — an enumerator of the <style lang="…"> RAW-STYLE EMBED across
2+
// (dialect × structural position), tokenised with the REAL oniguruma engine and graded against an
3+
// INDEPENDENT oracle: the dialect scope the GRAMMAR ITSELF declares for the embed.
4+
//
5+
// THE CLASS (issue #43): a <style lang="X"> body delegates to the CSS dialect grammar source.css.X
6+
// the grammar's own embed map names. The derived TextMate grammar splits that delegation into per-
7+
// dialect open + close rules; the CLOSE rule's match is language-INDEPENDENT (the lang lives on the
8+
// OPEN tag, not on `</style>`), so every dialect's close rule shares one regex and only the first-
9+
// listed fires — a non-first dialect's CLOSE-LINE content (the pre-`</style>` text) is then embedded
10+
// in the WRONG dialect. A bug is exactly: content that should be source.css.X carries some other
11+
// source.css.* instead.
12+
//
13+
// WHY THE EXISTING GATES MISSED IT (see issue #43 discussion): scope-gap-vue grades the Vue shell
14+
// (tags/directives/interpolation) against @vue/compiler-sfc+parse5+tsc — it has NO CSS oracle, so the
15+
// embedded dialect is structurally ungraded. This test adds that missing axis: an oracle that is the
16+
// grammar's DECLARED embed scope (not Monogram's parser, which raw-texts the body as a blob), and
17+
// DERIVED witnesses (each dialect × each structural position) rather than a thin corpus.
18+
//
19+
// CLOSED LOOP / no hardcoding: the dialects + their expected scopes come from the SAME grammar source
20+
// the emitter derives the rules from (`grammar.markup.rawText.embed.style`).
21+
//
22+
// Run: node test/vue-raw-style-embed-sites.ts
23+
import { tokenize } from './vue-grammar-harness.ts';
24+
import grammar from '../vue.ts';
25+
26+
const style = (grammar as any).markup?.rawText?.embed?.style;
27+
if (!style) { console.error('vue grammar has no markup.rawText.embed.style'); process.exit(1); }
28+
29+
// [langAttr | null (default), expectedScope]; closed-loop from the grammar's own embed map.
30+
const dialects: [string | null, string][] = [
31+
[null, style.default],
32+
...Object.entries(style.lang as Record<string, string>).map(([k, v]) => [k, v] as [string, string]),
33+
];
34+
35+
// Structural positions. Each builds a <style> block; `find` is the CSS-content selector token whose
36+
// scope chain MUST contain the dialect's declared scope, and `pos` labels where it sits.
37+
function witnesses(lang: string | null): { pos: string; src: string; find: string }[] {
38+
const open = lang === null ? '<style>' : `<style lang="${lang}">`;
39+
return [
40+
// baseline: content on its OWN line, close on its own line (the path that already works).
41+
{ pos: 'content-line', src: `${open}\n.midline { a: 1 }\n</style>`, find: 'midline' },
42+
// THE BUG: content on the SAME line as the close `</style>` — the per-dialect close rule's capture.
43+
{ pos: 'close-line ', src: `${open}\n.firstline { a: 1 }\n.closeline { b: 2 }</style>`, find: 'closeline' },
44+
// single-line: open, content, and close all on one line.
45+
{ pos: 'single-line ', src: `${open}.oneline { c: 3 }</style>`, find: 'oneline' },
46+
];
47+
}
48+
49+
const cssScope = (chain: string) => chain.split(' ').find(s => s.startsWith('source.css') || s === 'source.sass' || s === 'source.stylus' || s === 'source.postcss') ?? '(none)';
50+
51+
let cells = 0, wrong = 0;
52+
const fails: string[] = [];
53+
for (const [lang, expected] of dialects) {
54+
for (const w of witnesses(lang)) {
55+
cells++;
56+
const toks = await tokenize('mono', w.src);
57+
const t = toks.find(x => x.text.includes(w.find));
58+
const got = t ? cssScope(t.scopes) : '(token not found)';
59+
const ok = t !== undefined && t.scopes.split(' ').includes(expected);
60+
if (!ok) { wrong++; fails.push(`<style ${lang === null ? '(default)' : `lang="${lang}"`}> @ ${w.pos.trim()}: want ${expected}, got ${got}`); }
61+
console.log(` ${ok ? '✓ ok ' : '✗ BUG '}[${(lang ?? 'default').padEnd(7)} × ${w.pos}] want ${expected.padEnd(16)} got ${got}`);
62+
}
63+
}
64+
65+
console.log(`\n ${cells} raw-style-embed cells · ${cells - wrong} ok · ${wrong} wrong`);
66+
if (wrong) {
67+
console.error(`\n RAW-STYLE EMBED BUG — a <style lang="X"> body embedded in the wrong CSS dialect:\n ${fails.join('\n ')}`);
68+
process.exit(1);
69+
}
70+
console.log(' ✓ every <style lang="X"> body — at every structural position — embeds the dialect the grammar declares.');

0 commit comments

Comments
 (0)