Skip to content

Commit 646c3ce

Browse files
committed
gen-tm: model import defer * as ns so defer is a keyword (fixes TS #1058)
TS 5.9 deferred import — `import defer * as ns from "x"` — should scope `defer` as a keyword; both grammars read it as an ordinary alias variable. Two DATA edits in typescript.ts: ImportClause gains `['defer','*','as',Ident]` (models the shape so `defer` is structural only before the namespace `*`), and the scope map gains `keyword.control.import.phase: ['defer']`. `defer` is NOT in any not()/reserved set, so it stays a valid identifier everywhere else. A new agnostic detector in gen-tm (`usedAsKeywordOnlyBeforeStar`) finds any keyword literal whose every occurrence sits right before `*` and emits it positionally via #import-export-all `(import)\s+(?:(defer)\s+)?(\*)` — derived from the rule graph, nothing hardcodes "defer". All four generators re-derive it (TM, Monarch, tree-sitter). Purely additive to the CFG (the parser already accepted the input as a degenerate parse): conformance smoke test shows 0 accept/reject deltas vs master. `defer` as an identifier is preserved (`const defer`, `defer()`, `import defer from`). TS ledger 26->27/27 — only-official 0, both-miss 0. agnostic 8/8, test-issues 350/0, tsx/jsx/js-conformance clean, 56/56, tsc clean; JS/HTML/Vue byte-identical.
1 parent 2cd3c5e commit 646c3ce

12 files changed

Lines changed: 177 additions & 42 deletions

File tree

README.md

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -46,7 +46,7 @@ The same question, every language at once: take the bugs reported against each *
4646

4747
<!-- issues:start -->
4848
<!-- generated by `npm run bench:issues` — do not edit by hand -->
49-
_Each hand-written **official** grammar vs Monogram's **derived** one, on the bugs filed against it: **TypeScript 26/27** (official 9/27) · **TSX 11/11** (official 6/11) · **HTML 20/20** (official 13/20) · **Vue 19/19** (official 15/19). Per-issue detail below — auto-generated by `npm run bench:issues`._
49+
_Each hand-written **official** grammar vs Monogram's **derived** one, on the bugs filed against it: **TypeScript 27/27** (official 9/27) · **TSX 11/11** (official 6/11) · **HTML 20/20** (official 13/20) · **Vue 19/19** (official 15/19). Per-issue detail below — auto-generated by `npm run bench:issues`._
5050

5151
#### TypeScript
5252
| issue | Monogram | official |
@@ -68,7 +68,7 @@ _Each hand-written **official** grammar vs Monogram's **derived** one, on the bu
6868
| [#891](https://github.com/microsoft/TypeScript-TmLanguage/issues/891)`from` as an ordinary variable is not a keyword || · |
6969
| [#814](https://github.com/microsoft/TypeScript-TmLanguage/issues/814)`a instanceof B & c` keeps the operand a value, not a type || · |
7070
| [#950](https://github.com/microsoft/TypeScript-TmLanguage/issues/950) — default import named `type` — the binding is a variable, not the `type` keyword || · |
71-
| [#1058](https://github.com/microsoft/TypeScript-TmLanguage/issues/1058)`import defer` should scope `defer` as a keyword | · | · |
71+
| [#1058](https://github.com/microsoft/TypeScript-TmLanguage/issues/1058)`import defer` should scope `defer` as a keyword | | · |
7272

7373
<details><summary>… and 9 more both grammars already handle (✓ / ✓)</summary>
7474

src/gen-tm.ts

Lines changed: 113 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -4765,9 +4765,41 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
47654765
// grammar/scope map; the rule fires only on the keyword→`*` adjacency that
47664766
// actually occurs in a rule, so an arithmetic `*` is never mis-scoped (import/
47674767
// export keywords are reserved and can never be a multiplication operand).
4768+
// A phase modifier is a keyword literal whose EVERY keyword occurrence in the
4769+
// grammar sits immediately before the namespace `*` (e.g. `defer`, only ever in
4770+
// `import defer * as ns`). Such a word is NOT reserved — it stays a valid binding
4771+
// identifier elsewhere (`const defer = 1`, `defer()`, `import defer from "m"`) — so
4772+
// it must be scoped POSITIONALLY (right before `*`, via #import-export-all below),
4773+
// never in the flat keyword match. Words used as keywords in OTHER positions too
4774+
// (`import`; `export`, before `*` AND in `export default …`) are NOT phase
4775+
// modifiers — they keep their normal flat scoping and may introduce the `*`.
4776+
const usedAsKeywordOnlyBeforeStar = (lit: string): boolean => {
4777+
let beforeStar = false, elsewhere = false;
4778+
const walk = (e: RuleExpr | undefined): void => {
4779+
if (!e) return;
4780+
if (e.type === 'seq') {
4781+
for (let i = 0; i < e.items.length; i++) {
4782+
const it = e.items[i];
4783+
if (it.type === 'literal' && it.value === lit) {
4784+
const nx = e.items[i + 1];
4785+
if (nx && nx.type === 'literal' && nx.value === '*') beforeStar = true; else elsewhere = true;
4786+
}
4787+
walk(it);
4788+
}
4789+
} else if (e.type === 'alt') e.items.forEach(walk);
4790+
else if (e.type === 'quantifier' || e.type === 'group' || e.type === 'not') walk(e.body);
4791+
else if (e.type === 'sep') walk(e.element);
4792+
};
4793+
for (const r of grammar.rules) walk(r.body);
4794+
return beforeStar && !elsewhere;
4795+
};
4796+
// Star-introducing keywords (`import`/`export`): carry a keyword.control.import
4797+
// subtype scope AND introduce a namespace `*`. Phase modifiers (which also carry
4798+
// such a subtype, since `defer` is a deferred-IMPORT marker) are excluded — they
4799+
// are not star-introducers, they sit BETWEEN the keyword and the `*`.
47684800
const importExportKws = new Set<string>();
47694801
for (const [lit, scopes] of scopeOverrides) {
4770-
if (scopes.some(s => s.startsWith('keyword.control.import'))) importExportKws.add(lit);
4802+
if (scopes.some(s => s.startsWith('keyword.control.import')) && !usedAsKeywordOnlyBeforeStar(lit)) importExportKws.add(lit);
47714803
}
47724804
// Does a rule have an alternative whose first item is the `*` literal? (e.g.
47734805
// `import` → ImportClause, whose namespace branch begins `'*' 'as' Ident`.)
@@ -4785,6 +4817,27 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
47854817
return false;
47864818
};
47874819
const starAllKws = new Set<string>(); // import/export keywords that introduce a namespace `*`
4820+
// Does an alternative begin `[K, '*', …]` where K is a keyword literal? (the
4821+
// import phase-modifier shape — `defer * as ns`). Returns each such K reached
4822+
// directly or through a rule-ref (e.g. `import ImportClause`, an ImportClause alt
4823+
// being `['defer','*','as',Ident]`).
4824+
const phaseStarKws = (refName: string, seen: Set<string> = new Set()): string[] => {
4825+
if (seen.has(refName)) return [];
4826+
seen.add(refName);
4827+
const rule = grammar.rules.find(r => r.name === refName);
4828+
if (!rule) return [];
4829+
const out: string[] = [];
4830+
for (const alt of expandAlts(rule.body)) {
4831+
const head = alt[0], next = alt[1];
4832+
if (head?.type === 'literal' && isKeywordLiteral(head.value) && next?.type === 'literal' && next.value === '*') out.push(head.value);
4833+
else if (head?.type === 'ref') out.push(...phaseStarKws(head.name, seen));
4834+
}
4835+
return out;
4836+
};
4837+
// Map each star-introducing import keyword to the phase modifiers that may sit
4838+
// between it and the `*` (e.g. `import` → [`defer`]). `export`'s `*` takes no
4839+
// modifier, so its entry stays empty.
4840+
const phaseModsByKw = new Map<string, string[]>();
47884841
{
47894842
const walk = (e: RuleExpr | undefined): void => {
47904843
if (!e) return;
@@ -4797,6 +4850,18 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
47974850
if ((b.type === 'literal' && b.value === '*') || (b.type === 'ref' && ruleStartsWithStar(b.name))) {
47984851
starAllKws.add(a.value);
47994852
}
4853+
// Phase modifier between the keyword and `*` (`import defer * as ns`):
4854+
// a `[K,'*']`-headed alt reached through the next rule-ref, K used as a
4855+
// keyword ONLY before `*`.
4856+
if (b.type === 'ref') {
4857+
const mods = phaseStarKws(b.name).filter(usedAsKeywordOnlyBeforeStar);
4858+
if (mods.length) {
4859+
starAllKws.add(a.value);
4860+
const cur = phaseModsByKw.get(a.value) ?? [];
4861+
for (const m of mods) if (!cur.includes(m)) cur.push(m);
4862+
phaseModsByKw.set(a.value, cur);
4863+
}
4864+
}
48004865
}
48014866
for (const item of alt) {
48024867
if (item.type === 'quantifier' || item.type === 'group') walk(item.body);
@@ -4806,17 +4871,51 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
48064871
};
48074872
for (const rule of grammar.rules) walk(rule.body);
48084873
}
4874+
// Collect the phase modifiers so the flat keyword match (section 5) can exclude
4875+
// them — they are scoped here, positionally, instead.
4876+
const phaseModifierKws = new Set<string>([...phaseModsByKw.values()].flat());
48094877
if (starAllKws.size > 0) {
48104878
// Keyword keeps the scope it carries elsewhere (read from the scope map), so
4811-
// capture 1 is not hardcoded to a specific scope string.
4879+
// the keyword capture is not hardcoded to a specific scope string.
48124880
const kwScope = getScope(scopeOverrides, [...starAllKws][0]) ?? 'keyword.control.import';
4813-
repository['import-export-all'] = {
4814-
match: `\\b(${[...starAllKws].map(escapeRegex).join('|')})\\s+(\\*)`,
4815-
captures: {
4816-
'1': { name: `${kwScope}.${langName}` },
4817-
'2': { name: `constant.language.import-export-all.${langName}` },
4818-
},
4819-
};
4881+
if (phaseModifierKws.size === 0) {
4882+
// No phase modifier in this language (e.g. JS, or TS without `import defer`):
4883+
// emit the original flat keyword→`*` match verbatim — byte-identical output.
4884+
repository['import-export-all'] = {
4885+
match: `\\b(${[...starAllKws].map(escapeRegex).join('|')})\\s+(\\*)`,
4886+
captures: {
4887+
'1': { name: `${kwScope}.${langName}` },
4888+
'2': { name: `constant.language.import-export-all.${langName}` },
4889+
},
4890+
};
4891+
} else {
4892+
// A phase modifier exists (`import defer * as ns`). One branch per
4893+
// star-introducing keyword: a keyword that admits a modifier emits
4894+
// `(import)\s+(?:(defer)\s+)?(\*)`; a bare one stays `(export)\s+(\*)`.
4895+
// Capture groups number across branches in open-paren order, assigned as the
4896+
// branches are built. The phase modifier is scoped from the map (a
4897+
// keyword.control.import subtype), never a hardcoded word.
4898+
const phaseScope = getScope(scopeOverrides, [...phaseModifierKws][0]) ?? kwScope;
4899+
const captures: Record<string, { name: string }> = {};
4900+
const branches: string[] = [];
4901+
let g = 0;
4902+
for (const kw of starAllKws) {
4903+
const mods = phaseModsByKw.get(kw) ?? [];
4904+
const kwG = ++g; captures[String(kwG)] = { name: `${kwScope}.${langName}` };
4905+
let branch = `(${escapeRegex(kw)})\\s+`;
4906+
if (mods.length) {
4907+
const modG = ++g; captures[String(modG)] = { name: `${phaseScope}.${langName}` };
4908+
branch += `(?:(${mods.map(escapeRegex).join('|')})\\s+)?`;
4909+
}
4910+
const starG = ++g; captures[String(starG)] = { name: `constant.language.import-export-all.${langName}` };
4911+
branch += `(\\*)`;
4912+
branches.push(branch);
4913+
}
4914+
repository['import-export-all'] = {
4915+
match: `\\b(?:${branches.join('|')})`,
4916+
captures,
4917+
};
4918+
}
48204919
topPatterns.push({ include: '#import-export-all' });
48214920
}
48224921

@@ -5322,7 +5421,11 @@ export function generateTmLanguage(grammar: CstGrammar, langName: string): TmGra
53225421
// Drop keywords whose keyword role is owned by a dedicated declaration context
53235422
// (e.g. `constructor` → #constructor-declaration in class bodies). They double
53245423
// as identifiers everywhere else, so the flat match must not paint them.
5325-
const globalKws = kws.filter(k => !alwaysBeforeString(k) && !ctxOpSet.has(k) && !ctxModSet.has(k) && !contextDeclaredKws.has(k));
5424+
// Phase modifiers (`defer`, only ever before the namespace `*`) are likewise
5425+
// scoped positionally by #import-export-all — never in the flat match, which
5426+
// would mis-paint their ordinary-identifier uses (`const defer`, `defer()`,
5427+
// `import defer from "m"`).
5428+
const globalKws = kws.filter(k => !alwaysBeforeString(k) && !ctxOpSet.has(k) && !ctxModSet.has(k) && !contextDeclaredKws.has(k) && !phaseModifierKws.has(k));
53265429
if (globalKws.length > 0) {
53275430
repository[key] = {
53285431
match: `\\b(${globalKws.map(escapeRegex).join('|')})\\b`,

test/issue-cases.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -745,16 +745,14 @@ export const tests: TestCase[] = [
745745
],
746746
},
747747
// TS 5.9 `import defer * as ns` (deferred-import, valid in tsc 5.9.3): the `defer` modifier should
748-
// be a keyword. A PROVEN both-miss: `defer` is a CONTEXTUAL keyword the grammar's vocabulary does
749-
// not include — the CFG (like the official, and like TS before 5.9) parses it as an ordinary
750-
// binding identifier, so both grammars scope it variable.other.readwrite[.alias]. The agnostic
751-
// generator scopes a word as a keyword only when the grammar SAYS it is one; making `defer` a
752-
// keyword ONLY in `import defer *` (it stays a valid identifier in `const defer`, `import defer
753-
// from`) means modeling brand-new deferred-import syntax with a position-only keyword — neither
754-
// grammar does. Both miss new syntax.
748+
// be a keyword. The official grammar still misses it (its vocabulary has no `defer`, so it scopes
749+
// it variable.other.readwrite[.alias]). Monogram MODELS the deferred-import production in the CFG
750+
// (`['defer','*','as',Ident]` in ImportClause) and marks `defer` a keyword.control.import.phase —
751+
// and the agnostic generator scopes it as a keyword ONLY in phase-modifier position (immediately
752+
// before the namespace `*`, via the import-export-all pattern), so `defer` stays an ordinary
753+
// identifier everywhere else (`const defer = 1`, `defer()`, `import defer from "m"`). A Monogram win.
755754
{
756755
label: '#1058: `import defer` should scope `defer` as a keyword',
757-
monoGap: true,
758756
input: `import defer * as ns from "x";`,
759757
checks: [
760758
{ text: 'defer', scope: 'keyword' },

tree-sitter/typescript/grammar.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ module.exports = grammar({
213213

214214
enum_member: $ => seq($.member_name, optional(seq("=", $.expr))),
215215

216-
import_clause: $ => choice(seq($.ident, optional(seq(",", choice(seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident))))), seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident)),
216+
import_clause: $ => choice(seq("defer", "*", "as", $.ident), seq($.ident, optional(seq(",", choice(seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident))))), seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident)),
217217

218218
import_specifier: $ => seq($.ident, optional(seq("as", $.ident))),
219219

tree-sitter/typescript/queries/highlights.scm

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,7 +77,9 @@
7777
[
7878
"constructor" "function" "=>"
7979
] @keyword.function
80-
"import" @keyword.import
80+
[
81+
"import" "defer"
82+
] @keyword.import
8183
[
8284
"else" "if"
8385
] @keyword.conditional

tree-sitter/typescriptreact/grammar.js

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -213,7 +213,7 @@ module.exports = grammar({
213213

214214
enum_member: $ => seq($.member_name, optional(seq("=", $.expr))),
215215

216-
import_clause: $ => choice(seq($.ident, optional(seq(",", choice(seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident))))), seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident)),
216+
import_clause: $ => choice(seq("defer", "*", "as", $.ident), seq($.ident, optional(seq(",", choice(seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident))))), seq("{", optional(seq($.import_specifier, repeat(seq(",", $.import_specifier)), optional(","))), "}"), seq("*", "as", $.ident)),
217217

218218
import_specifier: $ => seq($.ident, optional(seq("as", $.ident))),
219219

tree-sitter/typescriptreact/queries/highlights.scm

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,7 +78,9 @@
7878
[
7979
"constructor" "function" "=>"
8080
] @keyword.function
81-
"import" @keyword.import
81+
[
82+
"import" "defer"
83+
] @keyword.import
8284
[
8385
"else" "if"
8486
] @keyword.conditional

typescript.monarch.json

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -426,6 +426,7 @@
426426
"constructor": "keyword",
427427
"override": "keyword",
428428
"accessor": "keyword",
429+
"defer": "keyword",
429430
"delete": "operator",
430431
"string": "keyword",
431432
"number": "keyword",
@@ -852,6 +853,10 @@
852853
"token": "keyword",
853854
"switchTo": "@root"
854855
},
856+
"defer": {
857+
"token": "keyword",
858+
"switchTo": "@root"
859+
},
855860
"delete": {
856861
"token": "operator",
857862
"switchTo": "@root"
@@ -1180,6 +1185,7 @@
11801185
"constructor": "keyword",
11811186
"override": "keyword",
11821187
"accessor": "keyword",
1188+
"defer": "keyword",
11831189
"delete": "operator",
11841190
"string": "keyword",
11851191
"number": "keyword",

0 commit comments

Comments
 (0)