fix(js,ts): a call on an element of a class field reaches the element's type

A TypeScript or JavaScript method called on an element of a class field,
like this.handlers[type].handle() or this.items[0].save(), kept the bare
method name: a member chain rooted at this collapses to it so the resolver
can read the owner off the enclosing class. A subscript in the chain leaves
the resolver nothing to read, so the bare name exact-matched any project
method of that name, often another class's (this.listeners[type].push(h)
reached an unrelated UndoStack.push).

Both engines now emit this.<field>[k].<m>() as this.<field>[].<m>, and
this['svc'].m() as this.svc.m; any other subscript in a this-rooted chain,
or under window, emits nothing, as arr[0].run() already did. The resolver
reads the element type the field's declaration writes (X[], Array<X>,
ReadonlyArray<X>, Record<K, X>), on the calling class or a base, and
resolves the method on it or leaves the call unresolved: a type parameter
of the declaring class, a union with another type, an array of arrays or a
non-plain element hold no one type. The class type-parameter reader now
reads from the keyword to the list's own >, over several lines and through
braces in constraints.

Our changes: kept escapeRegExp from PR #2769 in the conflict; the
type-parameter reader also accepts the type keyword, since the T['p'] path
passes type-alias owners and the PR as written read State<Item>['current']
as the project class Item (new case in ts-array-annotated-receivers);
CHANGELOG covers JavaScript and asks for a re-index.

Validation: the element tests fail 10 of 11 on main with both engines; the
type-alias case fails on the PR as written and its brace half on main. A/B
against main, kernel built per arm: flask and gin byte-identical. TS:
pixijs 7 links added, 5 retargeted and 5 typed, all right, 15 removed (9
wrong, 6 right only by name: a cast, a mixin's this, a T[] parameter, a
merged mixin member, an index-signature type, an any field); typeorm 3
wrong removed, 1 right lost, 2 retargeted to the declared interface;
excalidraw 1 wrong removed; vscode's base and editor 19 added, 24
retargeted and 35 typed, all right, 45 removed (37 wrong, 8 right only by
name: a getter, an index-signature field, deep chains, a T[] parameter, a
union alias). JS: three.js, playcanvas and pdf.js
lose 288 bare-name guesses, about 230 of them wrong (array, Map and DOM
calls on project classes). Kernel parity holds on every repo with
deferrals unchanged. Index CPU time within noise (pixijs 32.4 s to 33.3 s,
three.js 64.7 s to 67.9 s, medians of 3 interleaved).

Original PR #2766 by @inth3shadows.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Eric Minish
2026-10-23 17:50:28 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 5c5bfd4051
commit 449eefa7da
8 changed files with 376 additions and 16 deletions
+1
View File
@@ -29,6 +29,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- The Claude Code prompt hook now goes by what you typed. Claude Code's desktop app puts status notes, like which other sessions are linked, in front of your prompt, and the hook read them as part of it, so every turn, even a one-word "yes", could get code context about the note's words, and so could a task notification, a subagent's report or a message from another session that came after one. The notes are now skipped, and a message from another session gets no context, as task notifications and subagent reports already didn't. Thanks @WonderMr for the report and @yang0228 for the first fix. (#2635)
- `codegraph_explore` and `codegraph context` now find your project's busiest file, and favor the code around it, even when twenty or more test or generated files are busier still, such as a package of protobuf stubs. Before, the search looked only at the 20 busiest files and then set the tests and generated ones aside, so it could find no file at all, or judge one far ahead of the next when it was not. `getTopRouteFile()` in the library counts every file's routes the same way. Thanks @inth3shadows for the report. (#2578)
- In JavaScript and TypeScript, a call written as the body of an `if`, `while`, `for` or `do` without braces, like `if (e.key === "Escape") setOpen(false);`, no longer links to a same-named property or method in another file when the name is the file's own, such as a `setOpen` from `useState` (#2585).
- In JavaScript and TypeScript, a method called on an element of a class field, like `this.handlers[type].handle()` or `this.items[0].save()`, now links to the method of the type the field is declared to hold, such as `Item[]` or `Record<string, Handler>`, instead of to any project method that shares its name, often another class's, and is left unlinked when that type isn't written down, as in plain JavaScript. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows. (#2766)
- `codegraph sync` no longer fails with "FOREIGN KEY constraint failed" in a project whose code lazy-loads a workspace package, like Supabase Studio's `dynamic(() => import('icons').then((mod) => mod.EdgeFunctions))` or vben's `() => import('@vben/layouts').then((m) => m.IFrameView)`. Before, every sync after an edit in such a project rolled back, so the index stopped following your changes until a full re-index. Thanks @bompus for the report and @inth3shadows. (#2565)
- In VB.NET, every member of a `Structure` is now indexed, including its fields, properties, methods, constructors and nested enums. Before, only the first member was, so the rest could not be found and their callers looked empty.
- In Delphi, a routine with an attribute on one of its parameters, like `const [ref] AValue: Integer` or DelphiMVCFramework's `const [MVCFromBody] Person: TPerson`, is now indexed with its calls, and so is every class member declared after it. Before, the routine was lost or its calls were credited to the file, the parameter showed up as a field of the class, and the members after it were missing. Thanks @stalexteam for the report. (#2545)
+15 -1
View File
@@ -24,6 +24,13 @@ const files: Record<string, string> = {
'export class GraphAdapter { async map(req?: string) { return req ?? \'\'; } }\n' +
'export class Runner { run() { return 1; } }\n' +
'export class Base { hello() { return 1; } }\n',
// A project `push` an element of a field must not reach (`this.listeners[type].push`).
'src/stack.ts': 'export interface UndoStack { push: (entry: string) => void; }\n',
'src/bus.ts':
'export class Bus {\n' +
' private listeners: Record<string, string[]> = {};\n' +
' on(type: string, handler: string) { this.listeners[type].push(handler); }\n' +
'}\n',
'src/use.ts':
"import { GraphAdapter, Runner, Base } from './adapter';\n" +
"import { helper } from './helper.js';\n" +
@@ -84,6 +91,10 @@ describe('expression receivers', () => {
expect(cg.getCallers(node('map', 'adapter.ts').id).map(({ node: n }) => n.name)).toEqual(['typed']);
});
it('TS: a method on an element of a field does not bind by its bare name', () => {
expect(calleesOf('on', 'bus.ts')).toEqual([]);
});
it('Rust: `v.iter().map()` does not bind to a TypeScript class method', () => {
expect(calleesOf('lens', 'lib.rs')).toEqual([]);
expect(calleesOf('opt', 'lib.rs')).toEqual([]);
@@ -116,6 +127,9 @@ describe('expression receivers', () => {
' f().list.map(g);\n' +
' (() => 1).call(null);\n' +
' this.a.b.run();\n' +
' this.listeners[type].push(h);\n' +
' this[k].stop();\n' +
" this['svc'].start();\n" +
' new Runner().go();\n' +
' window.Api.start();\n' +
'}\n';
@@ -124,7 +138,7 @@ describe('expression receivers', () => {
.map((r) => r.referenceName);
expect(refs).toEqual([
'list().map', 'list', 'x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget',
'f', 'run', 'go', 'start',
'f', 'run', 'this.listeners[].push', 'this.svc.start', 'go', 'start',
]);
});
});
+7 -1
View File
@@ -294,6 +294,10 @@ async function exprReceivers(x, y) {
f().list.map(g);
(() => 1).call(null);
this.a.b.run();
this.listeners[type].push(h);
this[k].stop();
this['svc'].start();
${typed ? 'this.items![0].save(); (this.items[1] as X).save();' : ''}
super.stop();
new Runner().go();
window.Api.start();
@@ -304,7 +308,9 @@ async function exprReceivers(x, y) {
.map((r) => r.referenceName)).toEqual([
'list().map', 'list', 'x.run',
...(typed ? ['x.run', 'y.run', 'x.stop', 'getTarget().install', 'getTarget', 'has'] : []),
'f', 'run', 'stop', 'go', 'start',
'f', 'run', 'this.listeners[].push', 'this.svc.start',
...(typed ? ['this.items[].save', 'this.items[].save'] : []),
'stop', 'go', 'start',
]);
});
@@ -291,6 +291,24 @@ describe("a call on a TypeScript local annotated as one of a type's properties",
expect(calls.filter((c) => c.startsWith('onDone.'))).toEqual(['onDone.fire -> Emitter::fire']);
}, 60_000);
it("reads a property typed by a type parameter of the indexed type as no type, whatever class shares the parameter's name", async () => {
// `Item` is each type's own parameter here, not the project class `Item`:
// a generic type alias, and a class whose parameter list holds braces.
// `Stack` has a `flush` too, so the call's name alone picks neither.
const calls = await callsOf({
...CLASSES,
'src/stack.ts': 'export class Stack {\n findLast() { return 1; }\n flush() { return 2; }\n}\n',
'src/state.ts':
'export type State<Item = unknown> = { current: Item };\n' +
'export class Shaped<Item extends { id: string } = any> { current!: Item; }\n',
'src/use.ts':
"import { State, Shaped } from './state';\n" +
"export function viaAlias(x: State['current']) { return x.flush(); }\n" +
"export function viaShaped(x: Shaped['current']) { return x.flush(); }\n",
});
expect(calls.filter((c) => c.startsWith('x.'))).toEqual([]);
}, 60_000);
it("is not matched to a method by the call's name when the property's type isn't written in the project", async () => {
// vscode's `invocation: Parameters<typeof rawFinalizeToolInvocation>[0]`:
// left untyped, the receiver's name alone would pick `Logger.info`.
+141
View File
@@ -0,0 +1,141 @@
/**
* A TS/JS method called on an element of a field of the enclosing class —
* `this.handlers[type].handle()`, `this.items[0].save()` — reaches the
* element's type as the field's declaration writes it (`Item[]`,
* `Array<Item>`, `Record<string, Handler>`), or nothing. It used to keep the
* bare method name, which exact-matched any project method of that name:
* `this.listeners[type].push(h)` reached a stack class's `push` in another
* file. `this['svc'].start()` is the field `svc` itself.
*/
import { describe, it, expect, beforeAll, afterAll } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import { CodeGraph } from '../src';
let dir: string;
let cg: CodeGraph;
const files: Record<string, string> = {
'src/model.ts':
'export class Item { save() { return 1; } }\n' +
'export class Handler { handle(t: string) { return t; } start() { return 0; } }\n',
// Same-named methods a bare-name match would reach.
'src/decoy.ts':
'export class UndoStack { push(entry: string) { return entry; } }\n' +
'export class Other { save() { return 2; } handle(t: string) { return t; } }\n',
'src/holder.ts':
"import { Item, Handler } from './model';\n" +
'export class Holder {\n' +
' private items: Item[] = [];\n' +
' private list: Array<Item> = [];\n' +
' private handlers: Record<string, Handler> = {};\n' +
' private listeners: Record<string, string[]> = {};\n' +
' private names: string[] = [];\n' +
' private svc: Handler = new Handler();\n' +
' #store: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
' fromList(i: number) { return this.list[i].save(); }\n' +
' fire(t: string) { return this.handlers[t].handle(t); }\n' +
' keyed() { return this.handlers["main"].handle("x"); }\n' +
' cast() { return (this.items[1] as Item).save(); }\n' +
' asserted() { return this.items![0]!.save(); }\n' +
' privately() { return this.#store[0].save(); }\n' +
" byKey() { return this['svc'].start(); }\n" +
' on(type: string, h: string) { this.listeners[type].push(h); }\n' +
' builtin(i: number) { return this.names[i].trim(); }\n' +
'}\n',
// `Item` here is the class's type parameter, not the project class.
'src/store.ts':
'export class Store<Item> {\n' +
' private items: Item[] = [];\n' +
' private byKey: Record<string, Item> = {};\n' +
' first() { return this.items[0].save(); }\n' +
' keyed(k: string) { return this.byKey[k].save(); }\n' +
'}\n' +
// A header over several lines, and a constraint holding braces.
'export class Wide<\n' +
' A,\n' +
' B,\n' +
' Item\n' +
'> {\n' +
' private items: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
'}\n' +
'export class Shaped<Item extends { id: string }> {\n' +
' private items: Item[] = [];\n' +
' first() { return this.items[0].save(); }\n' +
'}\n',
// Either array's element: no one type to resolve on.
'src/mixed.ts':
"import { Item } from './model';\n" +
"import { Other } from './decoy';\n" +
'export class Mixed {\n' +
' private entries: Item[] | Other[] = [];\n' +
' private maybe: Item[] | null = null;\n' +
' either() { return this.entries[0].save(); }\n' +
' nullable() { return this.maybe![0].save(); }\n' +
' private below: Array<Item>\n' +
' | Other = [];\n' +
' private joined: Item[] & Other[] = [];\n' +
' continued() { return this.below[0].save(); }\n' +
' both() { return this.joined[0].save(); }\n' +
'}\n',
};
beforeAll(async () => {
dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-this-element-'));
for (const [rel, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(dir, rel)), { recursive: true });
fs.writeFileSync(path.join(dir, rel), text);
}
cg = CodeGraph.initSync(dir);
await cg.indexAll();
});
afterAll(() => {
cg.destroy();
fs.rmSync(dir, { recursive: true, force: true });
});
const calleesOf = (name: string, file = 'holder.ts') => {
const caller = cg.getNodesByName(name).find((n) => n.filePath.endsWith(file) && n.kind === 'method')!;
return cg.getCallees(caller.id)
.filter(({ edge }) => edge.kind === 'calls')
.map(({ node: n }) => `${path.basename(n.filePath)}:${n.qualifiedName}`)
.sort();
};
describe('a call on an element of a this-field', () => {
it.each([
['first', 'model.ts:Item::save'],
['fromList', 'model.ts:Item::save'],
['fire', 'model.ts:Handler::handle'],
['keyed', 'model.ts:Handler::handle'],
['cast', 'model.ts:Item::save'],
['asserted', 'model.ts:Item::save'],
['privately', 'model.ts:Item::save'],
['byKey', 'model.ts:Handler::start'],
])('%s reaches the element type', (caller, target) => {
expect(calleesOf(caller)).toEqual([target]);
});
it('an element of a builtin type reaches no project method', () => {
expect(calleesOf('on')).toEqual([]);
expect(calleesOf('builtin')).toEqual([]);
});
it("a class's type parameter is not the project class of that name", () => {
expect(calleesOf('first', 'store.ts')).toEqual([]);
expect(calleesOf('keyed', 'store.ts')).toEqual([]);
const caller = (cls: string) => cg.getNodesByName('first').find((n) => n.qualifiedName === `${cls}::first`)!;
for (const cls of ['Wide', 'Shaped']) expect(cg.getCallees(caller(cls).id).filter(({ edge }) => edge.kind === 'calls')).toEqual([]);
});
it('a union of element types reaches none of them; `| null` keeps the one', () => {
expect(calleesOf('either', 'mixed.ts')).toEqual([]);
expect(calleesOf('nullable', 'mixed.ts')).toEqual(['model.ts:Item::save']);
expect(calleesOf('continued', 'mixed.ts')).toEqual([]);
expect(calleesOf('both', 'mixed.ts')).toEqual([]);
});
});
+41 -2
View File
@@ -1658,6 +1658,12 @@ impl<'t> Walker<'t> {
// resolver can read its declared type (#1496). Mirrors
// TreeSitterExtractor.extractCall.
callee_name = format!("this.{field}.{method_name}");
} else if let Some(element) = receiver.and_then(|r| self.this_subscript_of(r)) {
// `this.<field>[k].<method>()` — an element of the
// field, typed by the resolver from the field's
// declaration; `this['svc']` is the field itself.
// Mirrors TreeSitterExtractor.extractCall.
callee_name = format!("{element}.{method_name}");
} else if let Some(r) = receiver.filter(|r| r.kind() == "call_expression") {
// Call receiver — `make().run()` (#1683): keep the inner
// callee as `<inner>().<method>`, or emit nothing when it
@@ -1739,6 +1745,36 @@ impl<'t> Walker<'t> {
Some(self.text(property).to_string())
}
/// A receiver subscripting the enclosing class's object: `this.<field>[k]`
/// → `this.<field>[]`, `this['svc']` → `this.svc`; None for any other
/// subscript. Mirrors tsThisSubscriptReceiver.
fn this_subscript_of(&self, receiver: Node<'t>) -> Option<String> {
if receiver.kind() != "subscript_expression" {
return None;
}
let object = peel_receiver(receiver.child_by_field_name("object")?);
if object.kind() == "this" {
let index = receiver.child_by_field_name("index")?;
if index.kind() != "string" {
return None;
}
let text = self.text(index);
let quoted = text.len() >= 2
&& (text.starts_with('\'') || text.starts_with('"'))
&& text.ends_with(&text[..1]);
let key = if quoted { &text[1..text.len() - 1] } else { return None };
let mut chars = key.chars();
let first = chars.next()?;
if !(first.is_ascii_alphabetic() || first == '_' || first == '$')
|| !chars.all(|c| c.is_ascii_alphanumeric() || c == '_' || c == '$')
{
return None;
}
return Some(format!("this.{key}"));
}
self.this_field_of(object).map(|field| format!("this.{field}[]"))
}
/// `await f<T>(x)` parses with `await f` as the callee (#2586), and a
/// prefix operator over a generic call the same way
/// (`!settings.get<boolean>('X')`, `void apiRequest<T>()`); the call is to
@@ -2084,11 +2120,14 @@ fn peel_receiver(node: Node<'_>) -> Node<'_> {
}
/// `this` / `super`, a member chain rooted at either or at `window`, or
/// `new C()` still collapse to the bare method name (mirrors
/// keepsBareTsJsReceiver).
/// `new C()` still collapse to the bare method name; a subscript anywhere in
/// the chain does not (mirrors keepsBareTsJsReceiver).
fn keeps_bare_receiver(node: Node<'_>, src: &str) -> bool {
let mut cur = node;
while matches!(cur.kind(), "member_expression" | "subscript_expression") {
if cur.kind() == "subscript_expression" {
return false;
}
match cur.child_by_field_name("object") {
Some(object) => cur = peel_receiver(object),
None => return false,
+42 -2
View File
@@ -684,16 +684,42 @@ function tsJsCalleeName(func: SyntaxNode, source: string): { name: string; at: S
return /^[A-Za-z_$][\w$]*(?:\.[A-Za-z_$][\w$]*)+$/.test(chain) ? { name: chain, at: cur } : null;
}
/**
* A TS/JS receiver that subscripts the enclosing class's object: an element
* of a field, `this.<field>[k]` → `this.<field>[]`, or a field named by a
* string key, `this['svc']` → `this.svc`. Null for any other subscript.
* Mirrored in the kernel's this_subscript_of.
*/
function tsThisSubscriptReceiver(node: SyntaxNode, source: string): string | null {
if (node.type !== 'subscript_expression') return null;
const objectNode = getChildByField(node, 'object');
if (!objectNode) return null;
const object = peelTsJsReceiver(objectNode);
if (object.type === 'this') {
const index = getChildByField(node, 'index');
const key = index?.type === 'string' ? /^(['"])([A-Za-z_$][\w$]*)\1$/.exec(getNodeText(index, source))?.[2] : undefined;
return key ? `this.${key}` : null;
}
if (object.type !== 'member_expression' || getChildByField(object, 'object')?.type !== 'this') return null;
const property = getChildByField(object, 'property');
if (!property || !THIS_FIELD_PROPERTY_TYPES.has(property.type)) return null;
return `this.${getNodeText(property, source)}[]`;
}
/**
* Whether a TS/JS receiver still collapses to the bare method name: `this` /
* `super` (the resolver reads the owner off the enclosing class), a member
* chain rooted at either or at `window` (the project-global escape of
* {@link isUnresolvedTsJsChain}), and `new C()` (its class is written at the
* call).
* call). A subscript anywhere in the chain does not: the resolver cannot
* read the receiver back from it, so the bare name would exact-match any
* project method of that name. (An element of a this-field is typed instead,
* see {@link tsThisSubscriptReceiver}.)
*/
function keepsBareTsJsReceiver(node: SyntaxNode, source: string): boolean {
let cur: SyntaxNode | null = node;
while (cur && TS_JS_CHAIN_RECEIVER_TYPES.has(cur.type)) {
if (cur.type === 'subscript_expression') return false;
const object = getChildByField(cur, 'object');
cur = object ? peelTsJsReceiver(object) : null;
}
@@ -6022,6 +6048,8 @@ export class TreeSitterExtractor {
: rawReceiver && this.language === 'swift'
? peelSwiftCallWrappers(rawReceiver, this.source)
: rawReceiver;
// `this.<field>[]` / `this.<name>` of a subscript receiver, set by its branch below.
let thisSubscript: string | null = null;
// A LITERAL receiver — `", ".join(...)`, `"x".toUpperCase()`,
// `5.times`, `[].concat(...)` — calls a builtin of the literal's
// type, never a project symbol. The bare-name fallback below let
@@ -6215,6 +6243,18 @@ export class TreeSitterExtractor {
// its name keeps the `#` (#1987).
const fieldName = getNodeText(getChildByField(receiver, 'property')!, this.source);
calleeName = `this.${fieldName}.${methodName}`;
} else if (
TS_JS_CHAIN_LANGUAGES.has(this.language) &&
receiver &&
(thisSubscript = tsThisSubscriptReceiver(receiver, this.source)) !== null
) {
// An element of a field of the enclosing class —
// `this.listeners[type].push()`, `this.items[0].save()` — kept
// as `this.<field>[].<method>`: the resolver reads the element
// type off the field's declaration (`Item[]`, `Record<K, V>`)
// or leaves it unresolved. `this['svc'].start()` is the field
// itself. Mirrored in the kernel's extract_call.
calleeName = `${thisSubscript}.${methodName}`;
} else if (
(this.language === 'typescript' ||
this.language === 'javascript' ||
@@ -6276,7 +6316,7 @@ export class TreeSitterExtractor {
) {
// Any other TS/JS receiver is an expression with no static
// type here — `(a ?? b).map()`, `f().list.map()`,
// `arr[0].run()`, `(() => {}).call()`. The bare method name this
// `arr[0].run()`, `this.a.b[k].run()`, `(() => {}).call()`. The bare method name this
// used to emit exact-matched whichever project method shared the
// name (`(await list()).map()` onto an adapter class's `map`).
// Emit nothing: a silent miss, never a wrong edge. Mirrored in
+111 -10
View File
@@ -19716,6 +19716,11 @@ export function matchMethodCall(
if (ref.language === 'typescript' || ref.language === 'javascript' || ref.language === 'tsx' || ref.language === 'jsx') {
const privateField = ref.referenceName.match(/^this\.(#[\w$]+)\.(\w+)$/);
if (privateField) return matchTsThisFieldCall(privateField[1]!, privateField[2]!, ref, context);
// An element of a field — `this.listeners[type].push()`, emitted as
// `this.listeners[].push`: the element type the field's declaration
// writes, or nothing.
const elementOf = ref.referenceName.match(/^this\.(#?[\w$]+)\[\]\.([\w$]+)$/);
if (elementOf) return matchTsThisFieldCall(elementOf[1]!, elementOf[2]!, ref, context, true);
}
const match = dotMatch || colonMatch || luaColonMatch || rDollarMatch;
@@ -24952,7 +24957,13 @@ const TS_FIELD_DECL_MEMO = new WeakMap<ResolutionContext, Map<string, TsFieldDec
* names (`Mailer` of `mailer: Mailer<T>`), whether that is a value's
* (`typeof X`), and whether the field holds an array of it (`items: Item[]`).
*/
type TsFieldDecl = { valueType: boolean; type: string; array?: boolean };
type TsFieldDecl = {
valueType: boolean;
type: string;
array?: boolean;
/** What one of the field's elements holds: `Item` of `Item[]`, `Array<Item>` or `Record<string, Item>`. */
element?: string;
};
/**
* The call an initializer hands `new X(…)` to: `this.<method>(…)`, or
* `this.<holder>.<method>(…)` — vscode's `this._register(new Emitter())` and
@@ -25031,7 +25042,12 @@ function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string
// An injector's type argument is the type it returns: `inject<Params>(DIALOG_DATA)`.
const type = pattern.wrapped ? m[4] : pattern.injected ? m[2] ?? m[3] : m[1];
if (!type) return null;
const decl: TsFieldOnLine = { valueType: pattern.valueType, type, array: TS_ARRAY_SUFFIX.test(line.slice(m.index! + m[0].length)) };
const rest = line.slice(m.index! + m[0].length);
const decl: TsFieldOnLine = { valueType: pattern.valueType, type, array: TS_ARRAY_SUFFIX.test(rest) };
if (!pattern.valueType && !pattern.wrapped && !pattern.injected) {
const element = tsFieldElementType(type, rest);
if (element) decl.element = element;
}
if (pattern.wrapped) {
decl.via = { holder: m[1], method: m[2]! };
if (m[3]) decl.injector = 'createInstance';
@@ -25044,6 +25060,59 @@ function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string
return decl;
}
/**
* Whether what follows a field's type joins it with another type: `| Other[]`,
* `& Extra`. `| null` and `| undefined` do not.
*/
function tsJoinsAnotherType(tail: string): boolean {
return /^\s*[|&]/.test(tail.replace(/^(?:\s*\|\s*(?:null|undefined)\b)+/, ''));
}
/** Container types whose element type is one of their type arguments, and which. */
const TS_ELEMENT_CONTAINERS: ReadonlyMap<string, { args: number; at: number }> = new Map([
['Array', { args: 1, at: 0 }],
['ReadonlyArray', { args: 1, at: 0 }],
['Record', { args: 2, at: 1 }],
]);
/**
* The type an element of a field holds, from the field's written type (`type`)
* and what follows it on the line (`rest`): `Item` of `Item[]`, `Array<Item>`
* and `Record<string, Item>`. Undefined for any other type, an array of
* arrays, an element that is not a plain type name (a union, a generic), or
* a field typed as a union with another type (`Item[] | Other[]`); `| null`
* and `| undefined` leave the element as it is.
*/
function tsFieldElementType(type: string, rest: string): string | undefined {
const plain = (t: string | undefined): string | undefined => (t && /^[A-Za-z_$][\w.$]*$/.test(t) ? t : undefined);
const array = /^\s*\[\s*\]/.exec(rest);
if (array) {
const tail = rest.slice(array[0].length);
return /^\s*\[/.test(tail) || tsJoinsAnotherType(tail) ? undefined : type;
}
const container = TS_ELEMENT_CONTAINERS.get(type);
if (!container) return undefined;
const open = /^\s*</.exec(rest);
if (!open) return undefined;
let depth = 0;
let from = open[0].length;
const args: string[] = [];
for (let i = from; i < rest.length; i++) {
const c = rest[i]!;
if (c === '<' || c === '(' || c === '[' || c === '{') depth++;
else if ((c === '>' || c === ')' || c === ']' || c === '}') && depth > 0) depth--;
else if (c === ',' && depth === 0) {
args.push(rest.slice(from, i).trim());
from = i + 1;
} else if (c === '>' && depth === 0) {
args.push(rest.slice(from, i).trim());
if (/^\s*\[/.test(rest.slice(i + 1)) || tsJoinsAnotherType(rest.slice(i + 1))) return undefined;
return args.length === container.args ? plain(args[container.at]) : undefined;
}
}
return undefined;
}
/** The first declaration of `field` on one line, as `line.match` of its patterns in order would find it. */
function tsFieldOnLine(line: string, field: string): TsFieldOnLine | null {
let at: number[] | null = null;
@@ -25196,6 +25265,12 @@ function tsFieldDeclaration(
* before.
*/
function tsFieldRead(onLine: TsFieldOnLine | null, cls: Node, lines: readonly string[], row: number, context: ResolutionContext): TsFieldDecl | null {
// A type that goes on below with `| Other[]` holds no one element type.
if (onLine?.element) {
let below = '';
for (let r = row + 1; r < lines.length && /^\s*[|&]/.test(lines[r]!); r++) below += ' ' + lines[r];
if (tsJoinsAnotherType(below)) onLine = { ...onLine, element: undefined };
}
if (!onLine?.via && !onLine?.injector) return onLine;
if (!tsCallEndsStatement(lines, row, onLine.open!)) return null;
if (onLine.via && !tsWrapperReturnsArgument(cls, onLine.via, context)) return null;
@@ -25529,14 +25604,26 @@ function tsIndexedPropertyType(
return undefined;
}
/** Is `name` one of the type parameters the head of the TS class or interface `cls` declares (`class Emitter<T>`)? */
/** Lines of a class header the type-parameter reader looks through: decorators, then `class C<…>` over several lines. */
const TS_CLASS_HEAD_LINES = 24;
/**
* Is `name` one of the type parameters the head of the TS class, interface
* or type alias `cls` declares (`class Emitter<T>`, `type State<T> = …`)? The
* list is read from the `class` / `interface` / `type` keyword to its own
* `>`, over several lines and through constraints and defaults that hold
* braces (`<T extends { id: string }>`). A list whose `>` is not found reads
* as declaring `name`: a caller drops the type rather than take a parameter
* for a project class.
*/
function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContext): boolean {
const lines = context.getFileLines?.(cls.filePath) ?? context.readFile(cls.filePath)?.split(/\r?\n/) ?? [];
const head = lines.slice(cls.startLine - 1, cls.startLine + 2).join(' ').split('{')[0]!;
const open = new RegExp(`\\b${escapeRegExp(cls.name)}\\s*<`).exec(head);
const head = lines.slice(cls.startLine - 1, cls.startLine - 1 + TS_CLASS_HEAD_LINES).join('\n');
const open = new RegExp(`\\b(?:class|interface|type)\\s+${escapeRegExp(cls.name)}\\s*<`).exec(head);
if (!open) return false;
// The parameters between the `<` after the name and its own `>`, split
// at their top level: `T`, `K extends keyof T = never`.
const named = (param: string): boolean => param.trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name;
let depth = 0;
let from = open.index + open[0].length;
for (let i = from; i < head.length; i++) {
@@ -25544,14 +25631,14 @@ function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContex
if (c === '<' || c === '(' || c === '[' || c === '{') depth++;
else if (c === ')' || c === ']' || c === '}') depth--;
else if (c === '>' && head[i - 1] !== '=') {
if (depth === 0) return head.slice(from, i).trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name;
if (depth === 0) return named(head.slice(from, i));
depth--;
} else if (c === ',' && depth === 0) {
if (head.slice(from, i).trim().replace(/^(?:in|out|const)\s+/, '').split(/[\s=]/)[0] === name) return true;
if (named(head.slice(from, i))) return true;
from = i + 1;
}
}
return false;
return true;
}
/**
@@ -25571,6 +25658,7 @@ function matchTsThisFieldCall(
methodName: string,
ref: UnresolvedRef,
context: ResolutionContext,
element = false,
): ResolvedRef | null {
if (!field || field.includes('.')) return null;
const written = context.getNodeById?.(ref.fromNodeId);
@@ -25598,7 +25686,19 @@ function matchTsThisFieldCall(
}
return null;
});
if (found) return tsFieldCall(found.decl, found.cls, methodName, ref, context);
// `this.<field>[k].<method>()` calls the element's type: what the field's
// declaration writes for it, or nothing.
// A type parameter of the declaring class names no type (`class
// Store<Item> { items: Item[] }`), whatever project class shares its name.
const held = (decl: TsFieldRead, cls: Node): TsFieldRead | null =>
!element ? decl
: !decl.valueType && decl.element && !tsClassTypeParameter(cls, decl.element, context)
? { valueType: false, type: decl.element }
: null;
if (found) {
const decl = held(found.decl, found.cls);
return decl ? tsFieldCall(decl, found.cls, methodName, ref, context) : null;
}
// Else a same-named class elsewhere may declare it, read for a calling
// class whose own declaration isn't (a wrapper the reader can't vouch
// for), but only with a type the call's file imports or declares: another
@@ -25606,7 +25706,8 @@ function matchTsThisFieldCall(
const namesake = nmTimedT('mc-thisfield-namesakes', ref, () => {
for (const cls of owners) {
if (cls.filePath === ref.filePath) continue;
const decl = tsFieldDeclaration(cls, field, context);
const read = tsFieldDeclaration(cls, field, context);
const decl = read && held(read, cls);
if (decl && tsTypeVisibleIn(decl.type, ref, context)) return decl;
}
return null;