Skip to content

Commit 4fef1df

Browse files
committed
Extend YAML role-oracle to grade block-indicator/tag/directive/flow scopes
The scope-gap role-oracle emitted tokens for only 7 coarse roles, so four YAML construct classes were never graded — any grammar scope silently passed: block-scalar indicator (|/>), node tags (!!str), directives (%YAML), and flow punctuation ([ ] { } ,). Add a role + neutral grading entry for each (public TextMate convention = exact, the coarse alternative each grammar may use = family) and split the block-scalar introducer from its body so the introducer's scope is graded on its own. The YAML headline drops from a blind-spot-inflated 100% to an honest 99.1% (Monogram, vs official 98.9%) over 2034 graded tokens (was 1479): the block-indicator role confirms the keyword.control.flow fix, tag/directive/ flow are family-correct (coarse but right family), and one real bug surfaces — a document-root block scalar with column-0 content (--- | or bare |) leaks its body to the directive rule.
1 parent b5ef6c6 commit 4fef1df

2 files changed

Lines changed: 69 additions & 2 deletions

File tree

test/scope-roles.ts

Lines changed: 28 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -84,6 +84,13 @@ export const R = {
8484
alias: 'alias',
8585
docMarker: 'doc.marker',
8686
escape: 'escape',
87+
// YAML scopes the coarse key/value/comment oracle never emitted a token for, so ANY grammar
88+
// scope silently passed: block-scalar indicator (|/>), node tags (!!str), directives (%YAML),
89+
// flow punctuation ([ ] { } ,). Emitting these makes those blind spots graded.
90+
blockIndicator: 'block.indicator',
91+
tagType: 'tag.type',
92+
directive: 'directive',
93+
flowPunct: 'flow.punct',
8794
// lexical floor
8895
op: 'op',
8996
punct: 'punct',
@@ -255,6 +262,16 @@ export const ROLE_SPEC: Record<RoleName, RoleSpec> = {
255262
// A string escape (`\n`, `\t`, `\\`, `\uXXXX`): both engines use constant.character.escape — for a
256263
// VALUE; a mis-scope (e.g. inside a quoted KEY painted flat entity.name.tag) reads WRONG.
257264
[R.escape]: { tier: 'strict', desc: 'string escape sequence (\\n, \\t, …)', exact: ['constant.character.escape'], family: ['constant.character', 'constant.other'] },
265+
// ── YAML blind-spot roles (block indicator / tag / directive / flow punctuation) ───────────────
266+
// Written from the public YAML TextMate convention, NOT from either grammar: the block-scalar
267+
// `|`/`>` (+chomp/indent) is keyword.control.flow.block-scalar; a node tag is storage.type.tag;
268+
// a directive is keyword.other.directive; flow brackets/separators are punctuation.definition/
269+
// .separator. family accepts the defensible alternatives each grammar may legitimately use
270+
// (e.g. a coarse generic `punctuation` for flow, the split punctuation.definition.tag for a tag).
271+
[R.blockIndicator]: { tier: 'strict', desc: 'YAML block-scalar indicator (|/>, chomping, indent)', exact: ['keyword.control.flow.block-scalar'], family: ['keyword', 'storage.modifier', 'constant.numeric', 'punctuation.definition'] },
272+
[R.tagType]: { tier: 'strict', desc: 'YAML node tag (!!str / !foo / !<verbatim>)', exact: ['storage.type.tag'], family: ['storage.type', 'storage', 'entity.name.type', 'support.type', 'keyword.other', 'punctuation.definition.tag'] },
273+
[R.directive]: { tier: 'strict', desc: 'YAML directive name (%YAML / %TAG / %…)', exact: ['keyword.other.directive'], family: ['keyword.other', 'keyword', 'punctuation.definition.directive'] },
274+
[R.flowPunct]: { tier: 'strict', desc: 'YAML flow punctuation ([ ] { } ,)', exact: ['punctuation.definition', 'punctuation.separator'], family: ['punctuation'] },
258275

259276
// ── lexical floor: reported, excluded from the headline ───────────────────────
260277
[R.op]: { tier: 'lexical', desc: 'operator punctuation (+ - * = => …)', exact: ['keyword.operator'], family: ['keyword', 'punctuation', 'storage', 'meta'] },
@@ -363,7 +380,9 @@ export function roleFamily(role: RoleName): Family {
363380
case R.propAccess: case R.propDecl: case R.methodCall: return 'property';
364381
case R.litString: case R.litNumber: case R.litRegex: case R.litBigint: case R.litTemplate: case R.escape: return 'literal';
365382
case R.comment: return 'comment';
366-
case R.docMarker: return 'keyword';
383+
case R.docMarker: case R.blockIndicator: case R.directive: return 'keyword';
384+
case R.tagType: return 'type';
385+
case R.flowPunct: return 'punct';
367386
case R.kwControl: case R.kwOperator: case R.kwStorage: case R.kwOther: case R.constBuiltin: case R.thisSuper: return 'keyword';
368387
case R.op: case R.punct: return 'punct';
369388
default: return 'value'; // funcDecl, parameter, varDecl, valueRef, classRef, namespace, enumMember, importBinding
@@ -436,6 +455,14 @@ if (import.meta.url === `file://${process.argv[1]}`) {
436455
[R.opCompare, 'string.regexp.ts', 'wrong'],
437456
// class.ref is the one value spot where a type scope is right (new X)
438457
[R.classRef, 'entity.name.type.ts', 'exact'],
458+
// YAML blind-spot roles: public convention = exact; the coarse alternative each grammar may use = family
459+
[R.blockIndicator, 'keyword.control.flow.block-scalar.yaml', 'exact'],
460+
[R.blockIndicator, 'storage.modifier.chomping-indicator.yaml', 'family'],
461+
[R.tagType, 'storage.type.tag.yaml', 'exact'],
462+
[R.tagType, 'punctuation.definition.tag.begin.yaml', 'family'],
463+
[R.directive, 'keyword.other.directive.yaml', 'exact'],
464+
[R.flowPunct, 'punctuation.definition.sequence.begin.yaml', 'exact'],
465+
[R.flowPunct, 'punctuation.yaml', 'family'],
439466
];
440467
let pass = 0;
441468
const fails: string[] = [];

test/yaml-oracle.ts

Lines changed: 41 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,9 +37,29 @@ export function yamlOracle(text: string): GoldToken[] {
3737
ESCAPE.lastIndex = 0;
3838
while ((m = ESCAPE.exec(seg))) out.push({ start: r[0] + m.index, end: r[0] + m.index + m[0].length, text: m[0], role: R.escape });
3939
};
40+
// A VALUE-position block scalar (`|`/`>`): split the introducer (a structural control sigil) from
41+
// the verbatim body. introducer = `|`/`>` + chomping/indent on the header line; body = the lines
42+
// below. (A block scalar in KEY position stays ONE tagName token — the whole scalar is the key name.)
43+
const isBlockScalar = (n: any) => n?.type === 'BLOCK_LITERAL' || n?.type === 'BLOCK_FOLDED';
44+
const pushBlockScalar = (node: any): void => {
45+
const r = node?.range;
46+
if (!r || r[1] <= r[0]) return;
47+
const introLen = /^[|>][-+0-9]*/.exec(text.slice(r[0], r[1]))?.[0].length ?? 1;
48+
out.push({ start: r[0], end: r[0] + introLen, text: text.slice(r[0], r[0] + introLen), role: R.blockIndicator });
49+
// content body = the first NON-BLANK char after the header line (skip leading indent + blank
50+
// lines — they are structural whitespace; grading them would test the INDENT's scope, not the
51+
// scalar content, and whitespace is visually colourless either way).
52+
const nl = text.indexOf('\n', r[0]);
53+
let contentStart = nl >= 0 ? nl + 1 : r[0] + introLen;
54+
while (contentStart < r[1] && (text[contentStart] === ' ' || text[contentStart] === '\t' || text[contentStart] === '\n')) contentStart++;
55+
if (r[1] > contentStart) out.push({ start: contentStart, end: r[1], text: text.slice(contentStart, r[1]), role: R.litString });
56+
};
4057
const walk = (node: any, isKey: boolean): void => {
4158
if (!node) return;
42-
if (isScalar(node)) { push(node, isKey ? R.tagName : valueRole(node.value)); pushEscapes(node); }
59+
if (isScalar(node)) {
60+
if (!isKey && isBlockScalar(node)) pushBlockScalar(node);
61+
else { push(node, isKey ? R.tagName : valueRole(node.value)); pushEscapes(node); }
62+
}
4363
else if (isMap(node)) for (const p of node.items) { walk(p.key, true); walk(p.value, false); }
4464
else if (isSeq(node)) for (const it of node.items) walk(it, false);
4565
};
@@ -84,6 +104,26 @@ export function yamlOracle(text: string): GoldToken[] {
84104
if (inSpan(m.index)) continue;
85105
out.push({ start: m.index, end: m.index + m[0].length, text: m[0], role: R.comment });
86106
}
107+
108+
// Node tags (!!str / !foo / !<verbatim> / ! non-specific): the sigil + handle/suffix at a node
109+
// boundary (line start / whitespace / flow open). A `!` inside a scalar span is content, not a tag.
110+
for (const tm of text.matchAll(/(?<=^|[\s[{,])!(?:<[^>\n]*>|[^\s[\]{},]*)/gm)) {
111+
if (tm.index === undefined || inSpan(tm.index)) continue;
112+
out.push({ start: tm.index, end: tm.index + tm[0].length, text: tm[0], role: R.tagType });
113+
}
114+
// Directives (%YAML / %TAG / %FOO): the directive NAME after `%`, line-start only (a directive
115+
// owns its line). A `%`-led line folded into a plain scalar body sits in a span → inSpan skips it.
116+
for (const dm of text.matchAll(/^%(\w[\w-]*)/gm)) {
117+
if (dm.index === undefined || inSpan(dm.index)) continue;
118+
out.push({ start: dm.index + 1, end: dm.index + 1 + dm[1].length, text: dm[1], role: R.directive });
119+
}
120+
// Flow punctuation ([ ] { } ,): structural only OUTSIDE a scalar span — a `,` inside a plain scalar
121+
// is content (`a, b` is one plain scalar), and `[`/`{` cannot start a plain scalar, so a bare one
122+
// always opens a flow collection. inSpan excludes the in-scalar occurrences.
123+
for (const fm of text.matchAll(/[[\]{},]/g)) {
124+
if (fm.index === undefined || inSpan(fm.index)) continue;
125+
out.push({ start: fm.index, end: fm.index + 1, text: fm[0], role: R.flowPunct });
126+
}
87127
out.sort((a, b) => a.start - b.start);
88128
return out;
89129
}

0 commit comments

Comments
 (0)