@@ -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 - z A - Z 0 - 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
348439interface 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 - z 0 - 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 - z 0 - 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