Skip to content

Commit ee3c7af

Browse files
committed
Add contextual scopes for class heritage and import/export star
Two more language-agnostic gen-tm scope refinements: - The identifier after an extends-class keyword now gets entity.other.inherited-class (was variable.other), derived from the scope map (keyword.other.extends), so `class X extends Base` colors Base as an inherited class. Class heritage only: type-position extends (conditional types, type-param constraints, interface extends) keep entity.name.type via the higher-precedence type regions. - The `*` in `import * as ns` / `export *` / `export * as ns` now gets constant.language.import-export-all (was keyword.operator.arithmetic). The trigger is a `*` adjacent to an import/export keyword, derived from the scope map; `a * b` and `a ** b` still scope as arithmetic. Object-literal keys and labeled-statement labels (both `Ident :`) are left: they are lexically indistinguishable without begin/end region tracking, which would risk TS regressions for marginal gain. JS highlighter visual accuracy 98.0% -> 98.4% (real-gap 9 -> 7). TS unaffected: coverage 99.3%, valid-code 100% (FN=0), bidirectional 97.84%. agnostic 5/5.
1 parent 6541fae commit ee3c7af

3 files changed

Lines changed: 170 additions & 10 deletions

File tree

examples/javascript.tmLanguage.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -51,6 +51,12 @@
5151
{
5252
"include": "#class-declaration"
5353
},
54+
{
55+
"include": "#extends-definition"
56+
},
57+
{
58+
"include": "#import-export-all"
59+
},
5460
{
5561
"include": "#hexnumber"
5662
},
@@ -545,6 +551,28 @@
545551
}
546552
]
547553
},
554+
"extends-definition": {
555+
"match": "\\b(extends)\\s+((?:[a-zA-Z_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})(?:[a-zA-Z0-9_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})*)",
556+
"captures": {
557+
"1": {
558+
"name": "keyword.other.extends.javascript"
559+
},
560+
"2": {
561+
"name": "entity.other.inherited-class.javascript"
562+
}
563+
}
564+
},
565+
"import-export-all": {
566+
"match": "\\b(export|import)\\s+(\\*)",
567+
"captures": {
568+
"1": {
569+
"name": "keyword.control.import.javascript"
570+
},
571+
"2": {
572+
"name": "constant.language.import-export-all.javascript"
573+
}
574+
}
575+
},
548576
"function-call": {
549577
"match": "((?:[a-zA-Z_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})(?:[a-zA-Z0-9_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})*)(?=\\s*\\()",
550578
"captures": {

examples/typescript.tmLanguage.json

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,12 @@
8181
{
8282
"include": "#module-declaration"
8383
},
84+
{
85+
"include": "#extends-definition"
86+
},
87+
{
88+
"include": "#import-export-all"
89+
},
8490
{
8591
"include": "#is-typekw"
8692
},
@@ -1148,6 +1154,28 @@
11481154
}
11491155
]
11501156
},
1157+
"extends-definition": {
1158+
"match": "\\b(extends)\\s+((?:[a-zA-Z_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})(?:[a-zA-Z0-9_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})*)",
1159+
"captures": {
1160+
"1": {
1161+
"name": "keyword.other.extends.typescript"
1162+
},
1163+
"2": {
1164+
"name": "entity.other.inherited-class.typescript"
1165+
}
1166+
}
1167+
},
1168+
"import-export-all": {
1169+
"match": "\\b(export|import)\\s+(\\*)",
1170+
"captures": {
1171+
"1": {
1172+
"name": "keyword.control.import.typescript"
1173+
},
1174+
"2": {
1175+
"name": "constant.language.import-export-all.typescript"
1176+
}
1177+
}
1178+
},
11511179
"function-call": {
11521180
"match": "((?:[a-zA-Z_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})(?:[a-zA-Z0-9_$]|\\\\u[0-9a-fA-F]{4}|\\\\u\\{[0-9a-fA-F]+\\})*)(?=\\s*\\()",
11531181
"captures": {

src/gen-tm.ts

Lines changed: 114 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import type { CstGrammar, RuleExpr } from './types.ts';
1+
import type { CstGrammar, RuleExpr, RuleDecl } from './types.ts';
22
import { collectLiterals, isKeywordLiteral } from './grammar-utils.ts';
33

44
interface TmPattern {
@@ -162,23 +162,62 @@ function inferIdentScope(keyword: string, scopeOverrides: Map<string, string[]>)
162162
if (!scope) return null;
163163
if (scope.startsWith('storage.type.function')) return 'entity.name.function';
164164
if (scope.startsWith('storage.type.') && scope !== 'storage.type') return 'entity.name.type';
165+
// Heritage keyword (TextMate convention `*.extends`, e.g. `keyword.other.extends`
166+
// / `storage.modifier.extends`): the identifier it introduces names a superclass.
167+
// Scope-convention driven (like the storage.type.* mappings above), not keyed on
168+
// any specific word — a grammar that scopes its inheritance keyword `*.extends`
169+
// gets `entity.other.inherited-class` for the following identifier automatically.
170+
if (/(^|\.)extends$/.test(scope)) return 'entity.other.inherited-class';
165171
return null;
166172
}
167173

168-
function findContextualPatterns(expr: RuleExpr, tokenNames: Set<string>, scopeOverrides: Map<string, string[]>): ContextualPattern[] {
174+
/**
175+
* Does a rule, when expanded, have an alternative that begins with the given
176+
* identifier token? Used so a `keyword Ident` pattern is still detected when the
177+
* identifier is reached through a rule-ref (e.g. `extends ClassHeritage`, whose
178+
* base alternative is `Ident`). Bounded depth guards against ref cycles.
179+
*/
180+
function ruleStartsWithIdent(
181+
refName: string,
182+
identTokenName: string,
183+
rules: RuleDecl[],
184+
seen: Set<string> = new Set(),
185+
): boolean {
186+
if (refName === identTokenName) return true;
187+
if (seen.has(refName)) return false;
188+
seen.add(refName);
189+
const rule = rules.find(r => r.name === refName);
190+
if (!rule) return false;
191+
for (const alt of expandAlts(rule.body)) {
192+
const head = alt[0];
193+
if (!head) continue;
194+
if (head.type === 'ref' && ruleStartsWithIdent(head.name, identTokenName, rules, seen)) return true;
195+
}
196+
return false;
197+
}
198+
199+
function findContextualPatterns(
200+
expr: RuleExpr,
201+
tokenNames: Set<string>,
202+
scopeOverrides: Map<string, string[]>,
203+
rules: RuleDecl[],
204+
identTokenName: string | null,
205+
): ContextualPattern[] {
169206
const patterns: ContextualPattern[] = [];
170207

171208
function walkSeq(items: RuleExpr[]) {
172209
for (let i = 0; i < items.length - 1; i++) {
173210
const a = items[i];
174211
const b = items[i + 1];
175-
if (a.type === 'literal' && isKeywordLiteral(a.value) &&
176-
b.type === 'ref' && tokenNames.has(b.name)) {
177-
const scope = inferIdentScope(a.value, scopeOverrides);
178-
if (scope) {
179-
patterns.push({ keyword: a.value, identScope: scope });
180-
}
181-
}
212+
if (a.type !== 'literal' || !isKeywordLiteral(a.value) || b.type !== 'ref') continue;
213+
const scope = inferIdentScope(a.value, scopeOverrides);
214+
if (!scope) continue;
215+
// Direct identifier-token adjacency, or the identifier reached through a
216+
// rule-ref whose base alternative starts with the identifier token.
217+
const adjacent =
218+
tokenNames.has(b.name) ||
219+
(identTokenName !== null && ruleStartsWithIdent(b.name, identTokenName, rules));
220+
if (adjacent) patterns.push({ keyword: a.value, identScope: scope });
182221
}
183222
}
184223

@@ -2026,7 +2065,7 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
20262065
// ── 4. Contextual patterns (keyword + Ident → entity.name.*) ──
20272066
const contextualPatterns: ContextualPattern[] = [];
20282067
for (const rule of grammar.rules) {
2029-
contextualPatterns.push(...findContextualPatterns(rule.body, tokenNames, scopeOverrides));
2068+
contextualPatterns.push(...findContextualPatterns(rule.body, tokenNames, scopeOverrides, grammar.rules, identToken?.name ?? null));
20302069
}
20312070

20322071
const seenContextual = new Set<string>();
@@ -2048,6 +2087,68 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
20482087
topPatterns.push({ include: `#${key}` });
20492088
}
20502089

2090+
// ── 4a. Import/export namespace `*` → constant.language.import-export-all ──
2091+
// A `*` directly after an import/export keyword (`import * as ns`, `export *`,
2092+
// `export * as ns`) names the whole module, not multiplication. Both the trigger
2093+
// keyword (scope `keyword.control.import`) and the `*` literal are read from the
2094+
// grammar/scope map; the rule fires only on the keyword→`*` adjacency that
2095+
// actually occurs in a rule, so an arithmetic `*` is never mis-scoped (import/
2096+
// export keywords are reserved and can never be a multiplication operand).
2097+
const importExportKws = new Set<string>();
2098+
for (const [lit, scopes] of scopeOverrides) {
2099+
if (scopes.some(s => s.startsWith('keyword.control.import'))) importExportKws.add(lit);
2100+
}
2101+
// Does a rule have an alternative whose first item is the `*` literal? (e.g.
2102+
// `import` → ImportClause, whose namespace branch begins `'*' 'as' Ident`.)
2103+
const ruleStartsWithStar = (refName: string, seen: Set<string> = new Set()): boolean => {
2104+
if (seen.has(refName)) return false;
2105+
seen.add(refName);
2106+
const rule = grammar.rules.find(r => r.name === refName);
2107+
if (!rule) return false;
2108+
for (const alt of expandAlts(rule.body)) {
2109+
const head = alt[0];
2110+
if (!head) continue;
2111+
if (head.type === 'literal' && head.value === '*') return true;
2112+
if (head.type === 'ref' && ruleStartsWithStar(head.name, seen)) return true;
2113+
}
2114+
return false;
2115+
};
2116+
const starAllKws = new Set<string>(); // import/export keywords that introduce a namespace `*`
2117+
{
2118+
const walk = (e: RuleExpr | undefined): void => {
2119+
if (!e) return;
2120+
for (const alt of expandAlts(e)) {
2121+
for (let i = 0; i < alt.length - 1; i++) {
2122+
const a = alt[i], b = alt[i + 1];
2123+
if (a.type !== 'literal' || !importExportKws.has(a.value)) continue;
2124+
// `*` directly after the keyword, or reached through the next rule-ref
2125+
// (e.g. `import ImportClause`, whose namespace branch starts with `*`).
2126+
if ((b.type === 'literal' && b.value === '*') || (b.type === 'ref' && ruleStartsWithStar(b.name))) {
2127+
starAllKws.add(a.value);
2128+
}
2129+
}
2130+
for (const item of alt) {
2131+
if (item.type === 'quantifier' || item.type === 'group') walk(item.body);
2132+
else if (item.type === 'sep') walk(item.element);
2133+
}
2134+
}
2135+
};
2136+
for (const rule of grammar.rules) walk(rule.body);
2137+
}
2138+
if (starAllKws.size > 0) {
2139+
// Keyword keeps the scope it carries elsewhere (read from the scope map), so
2140+
// capture 1 is not hardcoded to a specific scope string.
2141+
const kwScope = getScope(scopeOverrides, [...starAllKws][0]) ?? 'keyword.control.import';
2142+
repository['import-export-all'] = {
2143+
match: `\\b(${[...starAllKws].map(escapeRegex).join('|')})\\s+(\\*)`,
2144+
captures: {
2145+
'1': { name: `${kwScope}.${langName}` },
2146+
'2': { name: `constant.language.import-export-all.${langName}` },
2147+
},
2148+
};
2149+
topPatterns.push({ include: '#import-export-all' });
2150+
}
2151+
20512152
// ── 4b. Function call detection ──
20522153
const hasCallExpr = detectCallExpression(grammar);
20532154
if (hasCallExpr) {
@@ -2706,6 +2807,9 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
27062807
if (key === 'arrow-function-params') return 1.95;
27072808
if (key === 'ternary-expression') return 1.97;
27082809
if (key.endsWith('-declaration') || key.endsWith('-definition') || key.endsWith('-typekw')) return 2;
2810+
// import/export namespace `*` must beat both the import/export keyword group
2811+
// (which would consume the keyword alone) and the arithmetic-operator match.
2812+
if (key === 'import-export-all') return 2;
27092813
if (scope.includes('constant.numeric')) return 3; // stable sort preserves DSL token order
27102814
if (scope.includes('keyword.operator') && key.startsWith('scope-')) return 4;
27112815
if (scope.includes('keyword.control')) return 5;

0 commit comments

Comments
 (0)