fix(ts): a field set through a wrapper has the wrapped class's type (#2647)

`private readonly _onDidChange = this._register(new Emitter<void>())` and
`this._store.add(new Emitter())` gave the field no type, so its `fire()`
calls linked nowhere. The field reader now reads `new X(` handed to
`this.<method>(…)` or `this.<holder>.<method>(…)` when that method's own
declaration returns its argument (`<T>(o: T): T`, T its own type
parameter) and the call ends the statement.

The end-of-statement check trims what follows the wrapper's `)`: a CRLF
checkout's lines keep their `\r`, which a `$` anchor never matched.

The field walk and the wrapper lookup now go ten classes up a class's
`extends` chain instead of four: vscode's editors inherit `_register`
and `_store` from `Disposable` up to eight classes above them.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-10-19 07:44:00 +00:00
committed by GitHub
co-authored by Claude Opus 5.5
parent b210e0eb59
commit 6071c9e487
4 changed files with 422 additions and 28 deletions
+1
View File
@@ -167,6 +167,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In TypeScript and JavaScript, a call through a field the calling class inherits, like `this._store.add(…)` in a class whose base class declares `_store`, now links to the method of the field's class, with the base class found the way the subclass's file imports it, under its own name, an alias or a re-export. Before, such a call linked only when some other class with the calling class's name happened to declare the field, which could be another app's class: that last resort now needs a type the calling file imports or declares. A call on a field or variable whose type comes from a package outside your project, like `TelemetryReporter` from `@vscode/extension-telemetry`, no longer links to a project class that only shares the type's name, unless a file in your project declares that package (`declare module '…'`). Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript and JavaScript, a call on a variable, parameter or field whose class the file imports, like `store.add(…)` with `store: DisposableStore`, now links to the method of the class that import names when other classes in the project share its name, such as a vendored copy, a test mock or another app's class. Before, it linked to whichever of them was indexed first, or to the one in the nearest folder: in vscode, calls on classes imported from vscode's own `vs/base` and `vs/platform` reached the copies the Copilot extension vendors or its test fixtures, in bitwarden the desktop app's `initService.init()` reached the browser extension's `InitService`, and in react-native `Keyboard` and `WebSocket` reached the Jest mock of `NativeEventEmitter`. A call whose first match was a test file's class, which then linked to nothing, now reaches the imported class too. The import is followed through re-exports, `import type`, namespace imports like `new async.Delayer()` and `require(…)`. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript and JavaScript, a call on a value another file exports, like `store.add(…)` after `import { store } from './state'` with `export const store = new Store()` there, now links to the method of the class that file imports or declares when other classes in the project share its name, in Vue, Svelte and Astro components too. Before, it linked to whichever of them was indexed first: in vscode, calls on `FileAccess`, `extUri`, `errorHandler` and `MenuRegistry` reached the copies the Copilot extension vendors or one of its test fixtures, and in folo the mobile app's `settingSyncQueue.init()` reached the desktop app's `SettingSyncQueue`. Re-index TypeScript and JavaScript projects after upgrading.
- In TypeScript, a call through a field that a wrapper sets up, like `this._onDidChange.fire()` after `private readonly _onDidChange = this._register(new Emitter<void>())` or `= this._store.add(new Emitter<void>())`, now links to the method of the class handed to the wrapper, also in a class that inherits the field. Before, such a field had no type, so its calls linked nowhere, and that is one of the most common ways vscode declares its event emitters and disposables. A wrapper counts only when its own declaration hands back what it is given, like `_register<T>(o: T): T`, so a field set from a wrapper that returns something else, or from a member of what it returns (`.event`), is never taken for the class. A call through an inherited field is also found now when the field is declared further up the class hierarchy than before. Re-index TypeScript projects after upgrading.
## [1.6.2] - 2026-10-03
+20 -3
View File
@@ -97,6 +97,23 @@ describe('a this.<field> call on a field a class it extends declares', () => {
expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/sessions/part.ts: this.store.add -> src/two/store.ts::Store::add']);
}, 60_000);
it('reads it off a base many classes up, as vscode editors inherit `_store` from `Disposable`', async () => {
const files: Record<string, string> = {
...STORES,
'src/base.ts': "import { Store } from './two/store';\nexport class Base {\n protected readonly store = new Store();\n}\n",
};
let parent = ['Base', './base'];
for (const name of ['L1', 'L2', 'L3', 'L4', 'L5']) {
files[`src/${name.toLowerCase()}.ts`] = `import { ${parent[0]} } from '${parent[1]}';\nexport class ${name} extends ${parent[0]} {}\n`;
parent = [name, `./${name.toLowerCase()}`];
}
const calls = await callsOf({
...files,
'src/child.ts': "import { L5 } from './l5';\nexport class Child extends L5 {\n run() {\n return this.store.add(1);\n }\n}\n",
});
expect(calls.filter((c) => c.includes('this.store.add'))).toEqual(['src/child.ts: this.store.add -> src/two/store.ts::Store::add']);
}, 60_000);
it("doesn't read the field off an unrelated class of the same name whose type the call's file doesn't name", async () => {
const calls = await callsOf({
...STORES,
@@ -109,9 +126,9 @@ describe('a this.<field> call on a field a class it extends declares', () => {
}, 60_000);
it('still reads it off a same-named class elsewhere whose type the call\'s file imports', async () => {
// vscode's copilot `MockPromptsService` declares its emitter as
// `this._register(new Emitter<void>())`, which isn't read; the
// workbench's `MockPromptsService` declares the same field.
// The calling class's own declaration goes through a wrapper the reader
// can't vouch for (`wrap` declares no return type); the same-named
// class elsewhere declares the field.
const calls = await callsOf({
...STORES,
'src/app/mock.ts':
@@ -0,0 +1,207 @@
/**
* A TypeScript `this.<field>` call on a field initialized through a wrapper
* that hands back its argument — vscode's `this._register(new Emitter())`
* and `this._store.add(new Emitter())` — links through the wrapped class.
*
* The field reader knew `field: T`, `field: typeof X` and `field = new X()`,
* so vscode's most common declaration, `private readonly _onDidChange =
* this._register(new Emitter<void>())`, gave the field no type and its
* `fire()` calls linked nowhere. A wrapper counts only when its own
* declaration returns its argument (`_register<T extends IDisposable>(o: T):
* T`) and its call ends the statement: `this.box(value)` may return a box of
* it, and `this._register(new DomEmitter(…)).event` a member of it.
*/
import { describe, it, expect, afterEach } from 'vitest';
import * as fs from 'fs';
import * as os from 'os';
import * as path from 'path';
import CodeGraph from '../src/index';
let roots: string[] = [];
let graphs: CodeGraph[] = [];
afterEach(() => {
for (const graph of graphs) graph.close();
graphs = [];
for (const root of roots) fs.rmSync(root, { recursive: true, force: true });
roots = [];
});
/** Index `files` and return each call edge as `<file>: <refName> -> <target file>::<target qualified name>`. */
async function callsOf(files: Record<string, string>): Promise<string[]> {
const root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-ts-wrapped-fields-'));
roots.push(root);
for (const [file, text] of Object.entries(files)) {
fs.mkdirSync(path.dirname(path.join(root, file)), { recursive: true });
fs.writeFileSync(path.join(root, file), text);
}
const graph = await CodeGraph.init(root, { index: true });
graphs.push(graph);
const nodes = graph.getFiles().flatMap((f) => graph.getNodesInFile(f.path));
const byId = new Map(nodes.map((n) => [n.id, n]));
return graph.getOutgoingEdgesFrom(nodes.map((n) => n.id), ['calls'])
.map((e) => {
const target = byId.get(e.target);
return `${byId.get(e.source)?.filePath}: ${e.metadata?.refName} -> ${target?.filePath}::${target?.qualifiedName}`;
})
.sort();
}
/**
* vscode's lifecycle wrappers, the `Emitter` the app imports, and a decoy
* `Emitter` nearer the app (`src/app/legacy`) that a guess by method name or
* folder would pick.
*/
const LIFECYCLE = {
'package.json': JSON.stringify({ name: 'app' }),
'src/base/lifecycle.ts':
'export interface IDisposable {\n dispose(): void;\n}\n' +
'export class DisposableStore implements IDisposable {\n' +
' private readonly items: IDisposable[] = [];\n' +
' add<T extends IDisposable>(o: T): T {\n this.items.push(o);\n return o;\n }\n' +
' dispose() {}\n' +
'}\n' +
'export abstract class Disposable implements IDisposable {\n' +
' protected readonly _store = new DisposableStore();\n' +
' protected _register<T extends IDisposable>(o: T): T {\n return this._store.add(o);\n }\n' +
' dispose() {}\n' +
'}\n',
'src/base/event.ts': 'export class Emitter<T> {\n fire(event?: T) {\n return event;\n }\n dispose() {}\n}\n',
'src/app/legacy/event.ts': 'export class Emitter {\n fire() {\n return 0;\n }\n dispose() {}\n}\n',
};
const IMPORTS = "import { Disposable, DisposableStore } from '../base/lifecycle';\nimport { Emitter } from '../base/event';\n";
describe('a this.<field> call on a field a wrapper initializes', () => {
it('reads `this._register(new X())` as an X when the inherited `_register` returns its argument', async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/model.ts':
IMPORTS +
'export class Model extends Disposable {\n' +
' private readonly _onDidChange = this._register(new Emitter<string>());\n' +
' private readonly _onDidMove = this._register(new Emitter<number>(\n' +
' ));\n' +
' private readonly _onDidOpen;\n' +
' constructor() {\n super();\n this._onDidOpen = this._register(new Emitter<void>());\n }\n' +
" change() {\n this._onDidChange.fire('a');\n this._onDidMove.fire(1);\n this._onDidOpen.fire();\n }\n" +
'}\n',
});
expect(calls.filter((c) => c.includes('.fire'))).toEqual([
'src/app/model.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidMove.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidOpen.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);
it('reads `this.<store>.add(new X())` through the class the store field is declared as', async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/view.ts':
IMPORTS +
'export class View extends Disposable {\n' +
' private readonly _disposables = new DisposableStore();\n' +
' private readonly _onDidShow = this._store.add(new Emitter<void>());\n' +
' private readonly _onDidHide = this._disposables.add(new Emitter<void>());\n' +
' show() {\n this._onDidShow.fire();\n }\n' +
' hide() {\n this._onDidHide.fire();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('.fire'))).toEqual([
'src/app/view.ts: this._onDidHide.fire -> src/base/event.ts::Emitter::fire',
'src/app/view.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);
it('finds the wrapper and the store as many classes up as vscode editors inherit them from `Disposable`', async () => {
// `… extends EditorPane extends Composite extends Component extends Themable extends Disposable`
const chain = ['Themable', 'Component', 'Composite', 'Pane', 'EditorPane'];
const files: Record<string, string> = { ...LIFECYCLE };
chain.forEach((name, i) => {
const base = i === 0 ? ['Disposable', '../base/lifecycle'] : [chain[i - 1]!, `./${chain[i - 1]!.toLowerCase()}`];
files[`src/app/${name.toLowerCase()}.ts`] = `import { ${base[0]} } from '${base[1]}';\nexport class ${name} extends ${base[0]} {}\n`;
});
const calls = await callsOf({
...files,
'src/app/editor.ts':
"import { EditorPane } from './editorpane';\nimport { Emitter } from '../base/event';\n" +
'export class Editor extends EditorPane {\n' +
' private readonly _onDidChange = this._register(new Emitter<void>());\n' +
' private readonly _onDidShow = this._store.add(new Emitter<void>());\n' +
' run() {\n this._onDidChange.fire();\n this._onDidShow.fire();\n }\n' +
'}\n',
});
// `_onDidShow` needs `_store` found on `Disposable` too.
expect(calls.filter((c) => c.includes('.fire'))).toEqual([
'src/app/editor.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire',
'src/app/editor.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);
it('reads them in files with CRLF line endings, as a Windows checkout has', async () => {
const files: Record<string, string> = {
...LIFECYCLE,
'src/app/model.ts':
IMPORTS +
'class DomEmitter {\n readonly event = (listener: () => void) => listener;\n dispose() {}\n}\n' +
'export class Model extends Disposable {\n' +
' private readonly _onDidChange = this._register(new Emitter<string>());\n' +
' private readonly _onDidShow = this._store.add(new Emitter<void>());\n' +
' private readonly _onDidHide = this._register(new Emitter<void>())\n' +
' private readonly onKeyDown = this._register(new DomEmitter()).event;\n' +
" change() {\n this._onDidChange.fire('a');\n this._onDidShow.fire();\n this._onDidHide.fire();\n this.onKeyDown.dispose();\n }\n" +
'}\n',
};
const calls = await callsOf(Object.fromEntries(Object.entries(files).map(([file, text]) => [file, text.replace(/\n/g, '\r\n')])));
expect(calls.filter((c) => c.includes('.fire') || c.includes('.dispose'))).toEqual([
'src/app/model.ts: this._onDidChange.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidHide.fire -> src/base/event.ts::Emitter::fire',
'src/app/model.ts: this._onDidShow.fire -> src/base/event.ts::Emitter::fire',
]);
}, 60_000);
it("doesn't read a wrapper whose declaration doesn't return its argument", async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/box.ts':
IMPORTS +
'class Box<T> {\n constructor(readonly value: T) {}\n}\n' +
'class Widget {\n fire() {}\n}\n' +
'class FancyWidget extends Widget {\n fire() {}\n}\n' +
'export class Holder extends Disposable {\n' +
' private readonly _boxed = this.box(new Emitter<string>());\n' +
' private readonly _kept = this.keep(new Emitter<string>());\n' +
' private readonly _widget = this.replace(new FancyWidget());\n' +
' private readonly items = new Set<Emitter<string>>();\n' +
' private readonly _set = this.items.add(new Emitter<string>());\n' +
' private box<T>(value: T): Box<T> {\n return new Box(value);\n }\n' +
' private keep<T>(value: T) {\n return value;\n }\n' +
// Typed with a class, not a type parameter of its own: another Widget may come back.
' private replace(widget: Widget): Widget {\n return widget instanceof FancyWidget ? new Widget() : widget;\n }\n' +
" run() {\n this._boxed.fire('a');\n this._kept.fire('b');\n this._widget.fire();\n this._set.fire('c');\n }\n" +
'}\n',
'src/app/plain.ts':
"import { Emitter } from '../base/event';\n" +
'export class Plain {\n' +
' private readonly _onDidChange = this._register(new Emitter<void>());\n' +
' run() {\n this._onDidChange.fire();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('.fire'))).toEqual([]);
}, 60_000);
it("doesn't read the wrapped class when a member of what the wrapper returns is taken", async () => {
const calls = await callsOf({
...LIFECYCLE,
'src/app/input.ts':
IMPORTS +
'class DomEmitter {\n readonly event = (listener: () => void) => listener;\n dispose() {}\n}\n' +
'export class Input extends Disposable {\n' +
' private readonly onKeyDown = this._register(new DomEmitter()).event;\n' +
' private readonly onKeyUp = this._register(new DomEmitter())\n .event;\n' +
' run() {\n this.onKeyDown.dispose();\n this.onKeyUp.dispose();\n }\n' +
'}\n',
});
expect(calls.filter((c) => c.includes('.dispose'))).toEqual([]);
}, 60_000);
});
+194 -25
View File
@@ -13085,6 +13085,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
JS_FN_CODE.delete(context);
TS_FIELD_DECL_MEMO.delete(context);
TS_CLASS_LINES.delete(context);
TS_PASS_THROUGH_WRAPPERS.delete(context);
TARGET_LANGUAGE.delete(context);
}
@@ -15594,11 +15595,12 @@ function matchRustSelfFieldCall(
/**
* Per-context memo for matchTsThisFieldCall's declaration scan: `classId\0field`
* → the first line of the class that declares the field (as `: typeof X`,
* `: X`, or `= new X`, tried in that order per line) with its captured type,
* or null. The scan depends only on the class and the field, but ran for every
* `this.<field>.<method>()` call — re-splitting the file and compiling three
* patterns each time, a third of all method-call matching on vscode. Derived
* from file source: drops with clearNameMatcherMemos.
* `: X`, `= new X`, or `= this.<wrapper>(new X`, tried in that order per
* line) with its captured type, or null. The scan depends only on the class
* and the field, but ran for every `this.<field>.<method>()` call —
* re-splitting the file and compiling three patterns each time, a third of
* all method-call matching on vscode. Derived from file source: drops with
* clearNameMatcherMemos.
*/
const TS_FIELD_DECL_MEMO = new WeakMap<ResolutionContext, Map<string, TsFieldDecl | null>>();
/**
@@ -15607,6 +15609,14 @@ const TS_FIELD_DECL_MEMO = new WeakMap<ResolutionContext, Map<string, TsFieldDec
* (`typeof X`), and whether the field holds an array of it (`items: Item[]`).
*/
type TsFieldDecl = { valueType: boolean; type: string; array?: boolean };
/**
* The call an initializer hands `new X(…)` to: `this.<method>(…)`, or
* `this.<holder>.<method>(…)` — vscode's `this._register(new Emitter())` and
* `this._store.add(new Emitter())`.
*/
type TsFieldWrapper = { holder?: string; method: string };
/** A declaration found on one line; a wrapped one names its wrapper and where the wrapper's `(` is. */
type TsFieldOnLine = TsFieldDecl & { via?: TsFieldWrapper; open?: number };
/** What follows a field's type when the field holds an array of it: `[]`, after any type arguments. */
const TS_ARRAY_SUFFIX = /^\s*(?:<[^;=(){}<>]*(?:<[^;=(){}<>]*>[^;=(){}<>]*)*>)?\s*\[\s*\]/;
/** A class's comment-stripped lines, and which of them hold each `[\w$#]` token. */
@@ -15653,16 +15663,29 @@ const TS_TOKEN_CHAR = /[\w$#]/;
* token occurrence of the field. There the run consumes exactly the field (a
* shorter prefix cannot continue: every pattern needs `\s`, `?`, `!`, `:` or
* `=` next), so each matches exactly where the field's own pattern would —
* without compiling three patterns for every field of every class.
* without compiling four patterns for every field of every class.
*/
const TS_FIELD_PATTERNS_AT: readonly TsFieldPattern[] = [
{ re: /(?<![\w$#])[\w$#]+\b\s*[?!]?\s*:\s*(?:readonly\s+)?typeof\s+([A-Za-z_$][\w.$]*)/y, valueType: true },
{ re: /(?<![\w$#])[\w$#]+\b\s*[?!]?\s*:\s*(?:readonly\s+)?([A-Za-z_$][\w.$]*)/y, valueType: false },
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*new\s+([A-Za-z_$][\w.$]*)/y, valueType: false },
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*this\.(?:([\w$#]+)\.)?([\w$#]+)\(\s*new\s+([A-Za-z_$][\w.$]*)/y, valueType: false, wrapped: true },
];
/** A declaration `pattern` matched (`m`) on `line`: its type, and its wrapper when it has one. */
function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string): TsFieldOnLine | null {
const type = pattern.wrapped ? 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)) };
if (pattern.wrapped) {
decl.via = { holder: m[1], method: m[2]! };
decl.open = m.index! + m[0].indexOf('(');
}
return decl;
}
/** 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): TsFieldDecl | null {
function tsFieldOnLine(line: string, field: string): TsFieldOnLine | null {
let at: number[] | null = null;
for (let i = line.indexOf(field); i !== -1; i = line.indexOf(field, i + 1)) {
const end = i + field.length;
@@ -15670,11 +15693,12 @@ function tsFieldOnLine(line: string, field: string): TsFieldDecl | null {
(at ??= []).push(i);
}
if (!at) return null;
for (const { re, valueType } of TS_FIELD_PATTERNS_AT) {
for (const pattern of TS_FIELD_PATTERNS_AT) {
for (const i of at) {
re.lastIndex = i;
const m = re.exec(line);
if (m && m[1]) return { valueType, type: m[1], array: TS_ARRAY_SUFFIX.test(line.slice(re.lastIndex)) };
pattern.re.lastIndex = i;
const m = pattern.re.exec(line);
const decl = m && tsFieldMatch(pattern, m, line);
if (decl) return decl;
}
}
return null;
@@ -15696,7 +15720,8 @@ function tsClassLinesWithToken(decl: TsClassDecl, token: string): readonly numbe
return decl.linesByToken.get(token) ?? [];
}
type TsFieldPattern = { re: RegExp; valueType: boolean };
/** A field declaration pattern; a wrapped one captures its wrapper's holder and method before the type. */
type TsFieldPattern = { re: RegExp; valueType: boolean; wrapped?: true };
/** Compiled declaration patterns by field name — fields recur across classes. */
const TS_FIELD_PATTERNS = new Map<string, TsFieldPattern[]>();
const TS_FIELD_PATTERNS_CAP = 4096;
@@ -15727,6 +15752,13 @@ function tsFieldPatterns(field: string): TsFieldPattern[] {
},
// `mailer = new Mailer()` / `this.mailer = new Mailer()`
{ re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), valueType: false },
// `_onDidChange = this._register(new Emitter())`, `x = this._store.add(new X())`:
// an X only when the wrapper returns its argument (tsFieldRead).
{
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*this\\.(?:([\\w$#]+)\\.)?([\\w$#]+)\\(\\s*new\\s+([A-Za-z_$][\\w.$]*)`),
valueType: false,
wrapped: true,
},
];
if (TS_FIELD_PATTERNS.size >= TS_FIELD_PATTERNS_CAP) TS_FIELD_PATTERNS.delete(TS_FIELD_PATTERNS.keys().next().value!);
TS_FIELD_PATTERNS.set(field, patterns);
@@ -15746,6 +15778,8 @@ function tsFieldDeclaration(
const key = cls.id + '\0' + field;
const hit = memo.get(key);
if (hit !== undefined) return hit;
// Unread while it is being read: a wrapper's holder field can lead back here.
memo.set(key, null);
let found: TsFieldDecl | null = null;
const decl = tsClassDecl(cls, context);
if (decl && TS_FIELD_TOKEN.test(field)) {
@@ -15756,20 +15790,24 @@ function tsFieldDeclaration(
const rows = ++decl.lookups > 1 ? tsClassLinesWithToken(decl, field) : null;
const count = rows ? rows.length : decl.lines.length;
for (let k = 0; k < count && !found; k++) {
const line = decl.lines[rows ? rows[k]! : k]!;
const row = rows ? rows[k]! : k;
const line = decl.lines[row]!;
// Every pattern spells the field literally, so only a line that contains
// it can match — and most of a class's lines never mention a given field.
if (line.includes(field)) found = tsFieldOnLine(line, field);
if (line.includes(field)) found = tsFieldRead(tsFieldOnLine(line, field), cls, decl.lines, row, context);
}
} else if (decl) {
const patterns = tsFieldPatterns(field);
scan: for (const line of decl.lines) {
scan: for (let row = 0; row < decl.lines.length; row++) {
const line = decl.lines[row]!;
if (!line.includes(field)) continue;
for (const { re, valueType } of patterns) {
const m = line.match(re);
if (!m || !m[1]) continue;
found = { valueType, type: m[1], array: TS_ARRAY_SUFFIX.test(line.slice(m.index! + m[0].length)) };
break scan;
for (const pattern of patterns) {
const m = line.match(pattern.re);
const onLine = m && tsFieldMatch(pattern, m, line);
if (!onLine) continue;
found = tsFieldRead(onLine, cls, decl.lines, row, context);
if (found) break scan;
continue scan;
}
}
}
@@ -15777,6 +15815,136 @@ function tsFieldDeclaration(
return found;
}
/**
* The declaration `onLine` reads, on line `row` of class `cls`'s `lines`. A
* wrapped `new X(…)` is an X only when the wrapper's call ends the statement
* and the wrapper's declaration returns its argument: `this._register(new
* Emitter())` is an Emitter, but neither `this._register(new DomEmitter(el,
* 'keydown')).event` nor what a wrapper declared `box<T>(value: T): Box<T>`
* returns is. Otherwise the line declares nothing the reader can use, as
* before.
*/
function tsFieldRead(onLine: TsFieldOnLine | null, cls: Node, lines: readonly string[], row: number, context: ResolutionContext): TsFieldDecl | null {
if (!onLine?.via) return onLine;
if (!tsCallEndsStatement(lines, row, onLine.open!) || !tsWrapperReturnsArgument(cls, onLine.via, context)) return null;
return { valueType: onLine.valueType, type: onLine.type, array: onLine.array };
}
/**
* Does the call whose `(` is at `open` on `lines[row]` end its statement, so
* that what it returns is what the statement assigns? Nothing but `;` (and
* whatever follows it) may come after its `)`, nor start the next line —
* `.event`, `?.x` or `[0]` would take a member of what it returns. Strings
* are skipped; an unclosed one, or a call still open after 12 lines, isn't
* read.
*/
function tsCallEndsStatement(lines: readonly string[], row: number, open: number): boolean {
let depth = 0;
for (let r = row, from = open; r < lines.length && r < row + 12; r++, from = 0) {
const line = lines[r]!;
for (let i = from; i < line.length; i++) {
const c = line[i]!;
if (c === '"' || c === "'" || c === '`') {
let close = i + 1;
while (close < line.length && line[close] !== c) close += line[close] === '\\' ? 2 : 1;
if (close >= line.length) return false;
i = close;
} else if (c === '(') {
depth++;
} else if (c === ')' && --depth === 0) {
// Trimmed, not matched up to `$`: a CRLF checkout's lines keep their `\r`.
const rest = line.slice(i + 1).trim();
if (rest) return rest.startsWith(';');
const next = lines.slice(r + 1, r + 4).find((l) => l.trim());
return !next || !/^\s*(?:[.?[(!,:|&+*\/%<>=-]|as\b|satisfies\b)/.test(next);
}
}
}
return false;
}
/** Per-context memo: `<class id>\0<holder>\0<method>` → tsWrapperReturnsArgument's answer. */
const TS_PASS_THROUGH_WRAPPERS = new WeakMap<ResolutionContext, Map<string, boolean>>();
/**
* Does the wrapper a field initializer of class `cls` calls return its
* argument? `this.<method>` is the method `cls` declares or inherits, and
* `this.<holder>.<method>` that method of the class the holder field is
* declared as (its file imports or declares it). Each declaration of it that
* the nearest declaring class has must be tsReturnsItsArgument.
*/
function tsWrapperReturnsArgument(cls: Node, via: TsFieldWrapper, context: ResolutionContext): boolean {
let memo = TS_PASS_THROUGH_WRAPPERS.get(context);
if (!memo) {
memo = new Map();
TS_PASS_THROUGH_WRAPPERS.set(context, memo);
}
const key = `${cls.id}\0${via.holder ?? ''}\0${via.method}`;
const hit = memo.get(key);
if (hit !== undefined) return hit;
const at: UnresolvedRef = {
fromNodeId: cls.id, referenceName: via.method, referenceKind: 'calls',
line: cls.startLine, column: cls.startColumn, filePath: cls.filePath, language: cls.language,
};
const returns = nmTimedT('mc-thisfield-wrapper', at, () => {
let owner: Node | null = cls;
if (via.holder) {
const held = tsFieldThroughBases(cls, via.holder, at, context, 0);
owner = held && !held.decl.valueType && !held.decl.array ? tsNamedClass(held.decl.type, held.cls, at, context) : null;
}
const methods = owner ? tsClassMethods(owner, via.method, at, context, 0) : [];
return methods.length > 0 && methods.every((m) => tsReturnsItsArgument(m, context));
});
memo.set(key, returns);
return returns;
}
/**
* How many classes up its `extends` chain a TS class's fields and wrapper
* methods are looked for: vscode's editors declare emitters through
* `_register`, which `Disposable` declares up to eight classes above them
* (`… extends EditorPane extends Composite extends Component extends
* Themable extends Disposable`).
*/
const TS_BASE_WALK_DEPTH = 10;
/**
* The TS methods named `name` that class `cls` declares, or else the nearest
* class it extends that declares one does (each base the class its
* subclass's file names, as tsFieldThroughBases walks them).
*/
function tsClassMethods(cls: Node, name: string, at: UnresolvedRef, context: ResolutionContext, depth: number): Node[] {
const own = context.getNodesInFile(cls.filePath).filter((n) =>
n.kind === 'method' && n.name === name && n.startLine >= cls.startLine && n.endLine <= cls.endLine &&
n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./).pop() === cls.name);
if (own.length > 0 || depth >= TS_BASE_WALK_DEPTH) return own;
for (const sup of declaredSupertypes(cls, context)) {
const base = tsNamedClass(sup, cls, at, context);
if (!base || base.id === cls.id) continue;
const found = tsClassMethods(base, name, at, context, depth + 1);
if (found.length > 0) return found;
}
return [];
}
/** `(o: T): T` as the TS extractor writes a signature: one parameter, of the type returned. */
const TS_PASS_THROUGH_SIGNATURE = /^\(\s*[\w$]+\s*:\s*([A-Za-z_$][\w$]*)\s*\)\s*:\s*([A-Za-z_$][\w$]*)$/;
/**
* Does the TS method `fn` return its argument, as far as its declaration
* says: one parameter, typed by a type parameter of the method's own, which
* it returns — vscode's `_register<T extends IDisposable>(o: T): T` and
* `DisposableStore.add`?
*/
function tsReturnsItsArgument(fn: Node, context: ResolutionContext): boolean {
const sig = TS_PASS_THROUGH_SIGNATURE.exec(fn.signature?.trim() ?? '');
if (!sig || sig[1] !== sig[2]) return false;
const lines = context.getFileLines?.(fn.filePath) ?? context.readFile(fn.filePath)?.split(/\r?\n/) ?? [];
const head = lines.slice(fn.startLine - 1, fn.startLine + 2).join(' ');
const esc = (s: string) => s.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
return new RegExp(`(?<![\\w$#])${esc(fn.name)}\\s*<\\s*(?:const\\s+)?${esc(sig[1]!)}\\b`).test(head);
}
/**
* What a TS indexed type (`editor: IChatWidget['inputEditor']`) is: the type
* that property is declared with in the project class or interface it
@@ -15842,7 +16010,8 @@ function tsClassTypeParameter(cls: Node, name: string, context: ResolutionContex
* a class it extends (tsFieldThroughBases): a field or constructor-parameter
* property (`private mailer: Mailer`, `mailer?: Mailer`, `readonly mailer:
* Mailer`) or an initializer (`mailer = new Mailer()`, `this.mailer = new
* Mailer()`). The method is then VALIDATED on that type by
* Mailer()`, or one a wrapper hands back: `this._register(new Mailer())`,
* tsFieldRead). The method is then VALIDATED on that type by
* resolveMethodOnType. Null — never a bare-name fallback — when the field is
* not declared there or its type is external, a builtin
* (`this.items.push()`) or not spelled out.
@@ -15881,9 +16050,9 @@ function matchTsThisFieldCall(
});
if (found) return tsFieldCall(found.decl, found.cls, methodName, ref, context);
// Else a same-named class elsewhere may declare it, read for a calling
// class whose own declaration isn't (`this._register(new Emitter())`),
// but only with a type the call's file imports or declares: another app's
// class of that name says nothing about this one's field.
// 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
// app's class of that name says nothing about this one's field.
const namesake = nmTimedT('mc-thisfield-namesakes', ref, () => {
for (const cls of owners) {
if (cls.filePath === ref.filePath) continue;
@@ -15913,7 +16082,7 @@ function tsFieldThroughBases(
): { decl: TsFieldRead; cls: Node } | null {
const decl = tsFieldDeclaration(cls, field, context);
if (decl) return { decl, cls };
if (depth >= 4) return null;
if (depth >= TS_BASE_WALK_DEPTH) return null;
for (const name of declaredSupertypes(cls, context)) {
const base = tsNamedClass(name, cls, ref, context);
if (!base || base.id === cls.id) continue;