Skip to content

Commit 8548602

Browse files
committed
gen-tm: scope contextual operator keywords (as/keyof/is/infer/satisfies) positionally
`as`/`keyof`/`is`/`infer`/`satisfies` are keywords in operator/type position but valid identifiers elsewhere; the flat keyword match scoped `const as = 1` etc. as keywords (official scopes them as variables — a real gap). Unlike `from` (one mechanism), these were scoped by FOUR mechanisms at once, which defeated the earlier naive attempt. Fix reconciles all four: split the keyword.operator.expression group into reserved operators (typeof/new/void/delete/instanceof — unconditional) vs contextual operators (positional guard `(?=\s+OPERAND|\s*$)` — keyword iff followed by whitespace+an operand, or at EOL for multiline casts); apply the same guard to the per-keyword `-typekw` begin rules; and re-inject both the reserved group AND each contextual guard into type-inner so `keyof T`/`p is T`/`infer U` keep keyword scope in type position (the regression the prior attempt caused). The contextual set is derived AGNOSTICALLY from the grammar's not() always-reserved guards (a keyword.operator.expression word absent from every not() set is a valid identifier somewhere); OPERAND is built from the grammar's tokens/literals — no hardcoded words. Verified: const as/keyof/is/infer → variable; x as T / x as (A|B) / x as const / satisfies / import-as / *-as-ns → keyword; keyof/is/infer in type position → keyword.operator.expression (NOT entity.name.type); typeof/string untouched. Coverage 99.3% (589/593) unchanged, test-issues 313→317 (4 new) all pass, sanity 15/15, agnostic 5/5, refactor-guard 112/112, parser/matrix untouched. (Implemented by a delegated agent; independently re-verified.)
1 parent c25f8b2 commit 8548602

3 files changed

Lines changed: 259 additions & 28 deletions

File tree

examples/typescript.tmLanguage.json

Lines changed: 65 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -117,6 +117,24 @@
117117
{
118118
"include": "#scope-keyword-operator-expression"
119119
},
120+
{
121+
"include": "#scope-keyword-operator-expression-is"
122+
},
123+
{
124+
"include": "#scope-keyword-operator-expression-keyof"
125+
},
126+
{
127+
"include": "#scope-keyword-operator-expression-asserts"
128+
},
129+
{
130+
"include": "#scope-keyword-operator-expression-infer"
131+
},
132+
{
133+
"include": "#scope-keyword-operator-expression-as"
134+
},
135+
{
136+
"include": "#scope-keyword-operator-expression-satisfies"
137+
},
120138
{
121139
"include": "#scope-keyword-control-import"
122140
},
@@ -629,6 +647,24 @@
629647
{
630648
"include": "#scope-keyword-operator-expression"
631649
},
650+
{
651+
"include": "#scope-keyword-operator-expression-is"
652+
},
653+
{
654+
"include": "#scope-keyword-operator-expression-keyof"
655+
},
656+
{
657+
"include": "#scope-keyword-operator-expression-asserts"
658+
},
659+
{
660+
"include": "#scope-keyword-operator-expression-infer"
661+
},
662+
{
663+
"include": "#scope-keyword-operator-expression-as"
664+
},
665+
{
666+
"include": "#scope-keyword-operator-expression-satisfies"
667+
},
632668
{
633669
"include": "#simple-type"
634670
},
@@ -1233,7 +1269,7 @@
12331269
},
12341270
"is-typekw": {
12351271
"name": "meta.type.is.typescript",
1236-
"begin": "\\b(is)\\b",
1272+
"begin": "\\b(is)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
12371273
"beginCaptures": {
12381274
"1": {
12391275
"name": "keyword.operator.expression.is.typescript"
@@ -1248,7 +1284,7 @@
12481284
},
12491285
"keyof-typekw": {
12501286
"name": "meta.type.keyof.typescript",
1251-
"begin": "\\b(keyof)\\b",
1287+
"begin": "\\b(keyof)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
12521288
"beginCaptures": {
12531289
"1": {
12541290
"name": "keyword.operator.expression.keyof.typescript"
@@ -1278,7 +1314,7 @@
12781314
},
12791315
"as-typekw": {
12801316
"name": "meta.type.as.typescript",
1281-
"begin": "\\b(as)\\b",
1317+
"begin": "\\b(as)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
12821318
"beginCaptures": {
12831319
"1": {
12841320
"name": "keyword.operator.expression.as.typescript"
@@ -1308,7 +1344,7 @@
13081344
},
13091345
"satisfies-typekw": {
13101346
"name": "meta.type.satisfies.typescript",
1311-
"begin": "\\b(satisfies)\\b",
1347+
"begin": "\\b(satisfies)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
13121348
"beginCaptures": {
13131349
"1": {
13141350
"name": "keyword.operator.expression.satisfies.typescript"
@@ -1358,7 +1394,31 @@
13581394
]
13591395
},
13601396
"scope-keyword-operator-expression": {
1361-
"match": "\\b(is|keyof|typeof|new|asserts|infer|void|as|instanceof|satisfies|delete)\\b",
1397+
"match": "\\b(typeof|new|void|instanceof|delete)\\b",
1398+
"name": "keyword.operator.expression.typescript"
1399+
},
1400+
"scope-keyword-operator-expression-is": {
1401+
"match": "\\b(is)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
1402+
"name": "keyword.operator.expression.typescript"
1403+
},
1404+
"scope-keyword-operator-expression-keyof": {
1405+
"match": "\\b(keyof)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
1406+
"name": "keyword.operator.expression.typescript"
1407+
},
1408+
"scope-keyword-operator-expression-asserts": {
1409+
"match": "\\b(asserts)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
1410+
"name": "keyword.operator.expression.typescript"
1411+
},
1412+
"scope-keyword-operator-expression-infer": {
1413+
"match": "\\b(infer)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
1414+
"name": "keyword.operator.expression.typescript"
1415+
},
1416+
"scope-keyword-operator-expression-as": {
1417+
"match": "\\b(as)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
1418+
"name": "keyword.operator.expression.typescript"
1419+
},
1420+
"scope-keyword-operator-expression-satisfies": {
1421+
"match": "\\b(satisfies)\\b(?=\\s+[[:alpha:][:digit:]_$\"`({\\[\\-]|\\s*$)",
13621422
"name": "keyword.operator.expression.typescript"
13631423
},
13641424
"scope-storage-modifier": {

src/gen-tm.ts

Lines changed: 163 additions & 23 deletions
Original file line numberDiff line numberDiff line change
@@ -343,6 +343,97 @@ function findTypeKeywordPatterns(
343343
});
344344
}
345345

346+
// ── Contextual operator keyword detection ──
347+
348+
/**
349+
* Collect the grammar's "always-reserved" words: the union of all literals
350+
* forbidden by a `not(...)` zero-width guard. These guards encode positions
351+
* where a reserved word may not stand in for an identifier (binding name,
352+
* shorthand property, expression NUD). A word that appears in NO such guard is
353+
* never reserved by the grammar, i.e. it is a valid identifier somewhere.
354+
*
355+
* Language-agnostic: reads only the `not` AST nodes, never specific words.
356+
*/
357+
function collectReservedWords(grammar: CstGrammar): Set<string> {
358+
const reserved = new Set<string>();
359+
function collectLits(e: RuleExpr, out: Set<string>): void {
360+
if (e.type === 'literal') { if (isKeywordLiteral(e.value)) out.add(e.value); return; }
361+
if (e.type === 'seq' || e.type === 'alt') e.items.forEach(i => collectLits(i, out));
362+
else if (e.type === 'quantifier' || e.type === 'group' || e.type === 'not') collectLits(e.body, out);
363+
else if (e.type === 'sep') collectLits(e.element, out);
364+
}
365+
function walk(e: RuleExpr): void {
366+
if (e.type === 'not') collectLits(e.body, reserved);
367+
if (e.type === 'seq' || e.type === 'alt') e.items.forEach(walk);
368+
else if (e.type === 'quantifier' || e.type === 'group' || e.type === 'not') walk(e.body);
369+
else if (e.type === 'sep') walk(e.element);
370+
}
371+
for (const rule of grammar.rules) walk(rule.body);
372+
return reserved;
373+
}
374+
375+
/**
376+
* Find "contextual operator keywords": keyword.operator.expression-class words
377+
* that are NOT always-reserved (per collectReservedWords) and therefore double
378+
* as ordinary identifiers (`const as = 1`, `as()`, `as.x`). These are keywords
379+
* only in operator position — preceded by a value and/or followed by an operand
380+
* (`x as T`, `keyof T`, `p is T`, `infer U`, `x satisfies T`). The flat global
381+
* keyword match would otherwise mis-scope every identifier use as a keyword.
382+
*
383+
* Returns the words; the caller scopes them positionally (operand lookahead).
384+
* Reserved operator words (`typeof`, `new`, `void`, `delete`, `instanceof`) are
385+
* NOT returned — they can never be identifiers, so the flat match is correct.
386+
*/
387+
function findContextualOperatorKeywords(grammar: CstGrammar): Set<string> {
388+
const reserved = collectReservedWords(grammar);
389+
const result = new Set<string>();
390+
for (const [kw, scopes] of grammar.scopeOverrides) {
391+
if (!isKeywordLiteral(kw)) continue;
392+
if (reserved.has(kw)) continue;
393+
if (scopes.some(s => s.startsWith('keyword.operator.expression'))) result.add(kw);
394+
}
395+
return result;
396+
}
397+
398+
/**
399+
* Build an Oniguruma character class matching the first character of any TYPE
400+
* or VALUE operand: identifier-start chars, string/template delimiters, the
401+
* grouping/opening brackets used in types & values (`(`, `{`, `[`), a digit,
402+
* and `-` (negative numeric literal types). Derived from the grammar's tokens
403+
* and rule literals — no hardcoded language keywords.
404+
*
405+
* A contextual operator keyword (see findContextualOperatorKeywords) is a
406+
* keyword exactly when followed by whitespace then one of these — distinguishing
407+
* `x as T` / `keyof U` (keyword) from `const as = 1` / `as()` / `as.x` (identifier:
408+
* the next char is `=` / `(` / `.`, none of which start an operand).
409+
*/
410+
function buildOperandStartClass(grammar: CstGrammar, identRegex: string): string {
411+
const chars = new Set<string>();
412+
// Identifier-start: $/_ plus the non-\w extras from the Ident token.
413+
chars.add('_');
414+
for (const ch of identExtraChars(identRegex)) chars.add(ch);
415+
// String / template delimiters (first char of any string/template token).
416+
for (const tok of grammar.tokens) {
417+
if (tok.string || tok.template) {
418+
const first = tok.pattern[0] === '\\' ? tok.pattern[1] : tok.pattern[0];
419+
if (first && !/[a-zA-Z0-9]/.test(first)) chars.add(first);
420+
}
421+
}
422+
// Grouping/opening brackets that begin a grouped type or value.
423+
const allLits = new Set<string>();
424+
for (const rule of grammar.rules) for (const l of collectLiterals(rule.body)) allLits.add(l);
425+
for (const open of ['(', '{', '[']) if (allLits.has(open)) chars.add(open);
426+
// Negative numeric literal types (`-1`, `-2n`) appear as a `-` prefix in @type rules.
427+
const typeLits = new Set<string>();
428+
for (const rule of grammar.rules) {
429+
if (rule.flags.includes('type')) for (const l of collectLiterals(rule.body)) typeLits.add(l);
430+
}
431+
if (typeLits.has('-')) chars.add('-');
432+
const cls = [...chars].map(escapeForCharClass).join('');
433+
// `[:alpha:]` + `[:digit:]` cover the Unicode-agnostic letter/digit start.
434+
return `[[:alpha:][:digit:]${cls}]`;
435+
}
436+
346437
// ── Angle bracket disambiguation ──
347438

348439
interface AngleBracketAmbiguity {
@@ -1265,6 +1356,18 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
12651356
const identToken = grammar.tokens.find(t => classifyToken(t.pattern, t.flags).scope === 'variable.other');
12661357
const identPattern = identToken ? identToken.pattern : '[a-zA-Z_]\\w*';
12671358

1359+
// Contextual operator keywords (e.g. `as`/`keyof`/`is`/`satisfies`/`infer`):
1360+
// keyword.operator.expression words that double as identifiers, so they are a
1361+
// keyword ONLY in operator position — followed by whitespace then an operand.
1362+
const contextualOps = findContextualOperatorKeywords(grammar);
1363+
const operandStart = buildOperandStartClass(grammar, identPattern);
1364+
// Keyword iff followed by whitespace + an operand, OR at end of line (the
1365+
// operand continues on the next line — a cast/operator split across lines,
1366+
// e.g. `x as\n Foo`). `const as = 1` / `as()` / `as.x` still fall through
1367+
// to `variable` (next char is `=` / `(` / `.`, none start an operand and
1368+
// none is end-of-line).
1369+
const ctxOpGuard = `(?=\\s+${operandStart}|\\s*$)`;
1370+
12681371
// ── 1. Detect angle bracket ambiguity ──
12691372
const angleBracket = detectAngleBracketAmbiguity(grammar);
12701373
const angleBracketExclude = new Set(angleBracket ? ['<', '>'] : []);
@@ -2170,10 +2273,16 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
21702273
const key = `${kw}-typekw`;
21712274
const baseScope = getScope(scopeOverrides,kw) ?? 'keyword.other';
21722275
const kwScope = `${baseScope}.${kw}`;
2276+
// A contextual operator keyword (e.g. `as`/`keyof`/`is`/`satisfies`) opens
2277+
// this type scope ONLY in operator position — followed by an operand. The
2278+
// guard keeps `const as = 1` / `as()` / `as.x` from being mis-scoped as a
2279+
// type keyword. Reserved type keywords (`extends`, `implements`) are
2280+
// unconditional — they are never identifiers.
2281+
const guard = contextualOps.has(kw) ? ctxOpGuard : '';
21732282

21742283
repository[key] = {
21752284
name: `meta.type.${kw}.${langName}`,
2176-
begin: `\\b(${escapeRegex(kw)})\\b`,
2285+
begin: `\\b(${escapeRegex(kw)})\\b${guard}`,
21772286
beginCaptures: {
21782287
'1': { name: `${kwScope}.${langName}` },
21792288
},
@@ -2260,15 +2369,19 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
22602369
keywordGroups.get(scope)!.push(ident);
22612370
}
22622371
}
2263-
// A contextual keyword that the grammar ALWAYS places immediately before the string
2264-
// token (e.g. `'from' String_` in every import/export rule) is a keyword ONLY in that
2265-
// position — everywhere else it is a plain identifier (`const from = 1`, `from()`).
2266-
// Matching it globally mis-scopes those identifier uses, so emit it with a lookahead
2267-
// for a following string literal and let other uses fall through to identifier scoping.
2268-
// Structural + agnostic: keyed on "always-before-the-string-token", never on the word.
2269-
// (Only `from` qualifies. Other contextual keywords — `as`, `keyof`, `is`, … — are
2270-
// scoped by several mechanisms at once here, NOT just this flat group, so they can't be
2271-
// de-keyworded from one place; see docs/upstream-issues.md.)
2372+
// Two classes of contextual keyword are scoped positionally instead of by the
2373+
// flat global match (which would mis-scope their ordinary-identifier uses):
2374+
// 1. Always-before-the-string-token (e.g. `'from' String_`): keyword only
2375+
// right before a string → `(?=\s*["'])` lookahead. `const from = 1`,
2376+
// `from()` fall through to identifier scoping. Keyed on the adjacency.
2377+
// 2. Contextual OPERATOR keywords (`as`/`keyof`/`is`/`satisfies`/`infer`):
2378+
// keyword.operator.expression words that aren't always-reserved, so they
2379+
// double as identifiers. Keyword only in operator position (followed by
2380+
// `\s+` + an operand) → `ctxOpGuard`; `const as = 1`, `as()`, `as.x` fall
2381+
// through to `variable`. Reserved operator words (`typeof`, `new`, `void`,
2382+
// `delete`, `instanceof`) stay in the unconditional flat match.
2383+
// Both are structural + agnostic: keyed on adjacency / the not()-reserved set,
2384+
// never on a specific word.
22722385
const stringTokName = grammar.tokens.find(t => t.string)?.name;
22732386
const alwaysBeforeString = (lit: string): boolean => {
22742387
if (!stringTokName) return false;
@@ -2292,25 +2405,48 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
22922405
for (const r of grammar.rules) walk(r.body);
22932406
return seen && ok;
22942407
};
2408+
// Track scope-group keys that carry keyword.operator.expression matches, so
2409+
// the type-inner injection below can re-include the SAME patterns (the flat
2410+
// group and the per-word contextual-operator guards alike).
2411+
const operatorExprIncludeKeys: string[] = [];
22952412
for (const [scope, kws] of keywordGroups) {
22962413
const key = `scope-${scope.replace(/\./g, '-')}`;
2297-
const globalKws = kws.filter(k => !alwaysBeforeString(k));
2298-
const ctxKws = kws.filter(k => alwaysBeforeString(k));
2414+
const isOperatorExpr = scope.startsWith('keyword.operator.expression');
2415+
// Words always placed immediately before the string token (`from`) → string lookahead.
2416+
// Contextual operator keywords (`as`/`keyof`/…) → operand lookahead.
2417+
// Everything else → unconditional flat match.
2418+
const beforeStringKws = kws.filter(k => alwaysBeforeString(k));
2419+
const ctxOpKws = isOperatorExpr ? kws.filter(k => contextualOps.has(k) && !alwaysBeforeString(k)) : [];
2420+
const ctxOpSet = new Set(ctxOpKws);
2421+
const globalKws = kws.filter(k => !alwaysBeforeString(k) && !ctxOpSet.has(k));
22992422
if (globalKws.length > 0) {
23002423
repository[key] = {
23012424
match: `\\b(${globalKws.map(escapeRegex).join('|')})\\b`,
23022425
name: `${scope}.${langName}`,
23032426
};
23042427
topPatterns.push({ include: `#${key}` });
2428+
if (isOperatorExpr) operatorExprIncludeKeys.push(key);
23052429
}
2306-
for (const kw of ctxKws) {
2430+
for (const kw of beforeStringKws) {
23072431
const ckey = `${key}-${kw.replace(/[^a-z0-9]/gi, '')}`;
23082432
repository[ckey] = {
23092433
match: `\\b${escapeRegex(kw)}\\b(?=\\s*["'])`,
23102434
name: `${scope}.${langName}`,
23112435
};
23122436
topPatterns.push({ include: `#${ckey}` });
23132437
}
2438+
// One positional entry per contextual operator keyword: keyword only when
2439+
// followed by whitespace + an operand (a type/value start); otherwise the
2440+
// word falls through to identifier scoping (variable.other).
2441+
for (const kw of ctxOpKws) {
2442+
const ckey = `${key}-${kw.replace(/[^a-z0-9]/gi, '')}`;
2443+
repository[ckey] = {
2444+
match: `\\b(${escapeRegex(kw)})\\b${ctxOpGuard}`,
2445+
name: `${scope}.${langName}`,
2446+
};
2447+
topPatterns.push({ include: `#${ckey}` });
2448+
if (isOperatorExpr) operatorExprIncludeKeys.push(ckey);
2449+
}
23142450
}
23152451

23162452
// Inject type-related support patterns into type annotation contexts so
@@ -2324,19 +2460,23 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
23242460
// Order matters: TM matches first pattern that matches, so more specific
23252461
// type scopes (support.type.primitive) must come before broader ones
23262462
// (keyword.operator.expression) to give `void` the right scope in types.
2327-
const typeRelatedScopes = [...keywordGroups.keys()]
2463+
// support.type / constant.language map 1:1 to a flat scope-include; the
2464+
// keyword.operator.expression scope is split across operatorExprIncludeKeys
2465+
// (the flat reserved group + each contextual-operator guard), all injected
2466+
// LAST so a contextual operator keeps keyword scope in type position
2467+
// (`keyof T`, `p is T`, `infer U`) yet a bare type name still reaches
2468+
// #simple-type → entity.name.type.
2469+
const supportConstScopes = [...keywordGroups.keys()]
23282470
.filter(scope => scope.startsWith('support.type.')
2329-
|| scope.startsWith('constant.language.')
2330-
|| scope.startsWith('keyword.operator.expression'));
2331-
typeRelatedScopes.sort((a, b) => {
2332-
const order = (s: string) =>
2333-
s.startsWith('support.type.') ? 0 :
2334-
s.startsWith('constant.language.') ? 1 :
2335-
2; // keyword.operator.expression last
2471+
|| scope.startsWith('constant.language.'));
2472+
supportConstScopes.sort((a, b) => {
2473+
const order = (s: string) => s.startsWith('support.type.') ? 0 : 1;
23362474
return order(a) - order(b);
23372475
});
2338-
const typeRelatedIncludes = typeRelatedScopes
2339-
.map(scope => ({ include: `#scope-${scope.replace(/\./g, '-')}` }));
2476+
const typeRelatedIncludes = [
2477+
...supportConstScopes.map(scope => ({ include: `#scope-${scope.replace(/\./g, '-')}` })),
2478+
...operatorExprIncludeKeys.map(key => ({ include: `#${key}` })),
2479+
];
23402480
if (typeRelatedIncludes.length > 0) {
23412481
// Inject type-related scopes into type-inner (non-mutating rebuild).
23422482
// All consumers reference #type-inner via include, so they see the

0 commit comments

Comments
 (0)