mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-21 08:47:04 +08:00
fix(ts): a receiver a dependency-injection call sets up has the class it is given (#2661)
Angular's `private readonly dataService = inject(DataService)` or
`inject<DialogParams>(DIALOG_DATA)`, and vscode's
`this._register(this.instantiationService.createInstance(Widget, …))` or
`const widget = this.instantiationService.createInstance(Widget)`, gave
the field or local no type, so calls through them linked nowhere or by
name alone. The field reader and the TS local patterns now read them:
`inject` only when the file imports it from `@angular/core` (Vue's
`inject(key)` returns whatever was provided under the key), its type
argument first; `createInstance` only when the project declares one that
returns an `InstanceType` of its constructor argument. A local set from a
member of what the injector returns isn't read as the class.
A type the file imports under another name, like bitwarden's
`OrganizationApiServiceAbstraction as OrganizationApiService` or vscode's
`Engine as FileSearchEngine`, is now found through that import rather
than as a class with the local name.
A/B on main 383c2cd9: vscode +594 links, 351 retargets to the created or
imported class, 677 confidence-ups, 0 removed; bitwarden +1835, 35
retargets, 3 Angular Router guesses removed; ghostfolio +277; 8 other
JS/TS repos byte-identical.
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -176,6 +176,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In TypeScript, an abstract method like `abstract send(path: string): Promise<unknown>;` is now indexed as a method of its class. Before, it was missing, so a call through a value typed as an abstract class, as Angular apps register many of their services, linked to whichever class elsewhere had a method of that name, often another app's, and the types written in an abstract method's parameters and return type were read as properties of the class. Such a call now links to the abstract method, which also leads to the classes that implement it. Re-index TypeScript projects after upgrading.
|
||||
- In TypeScript and JavaScript, a call on a field or variable whose class the file imports now goes through that class and the classes and interfaces it extends, each found the way its own file names it, before any other class of the same name. Before, when the class didn't declare the method itself, the call went to whichever same-named class had it: in vscode, `this._storage.dispose()` reached a test fixture's `Disposable`, and calls on a library's services in bitwarden reached the browser app's copies. A call like `this.logService.trace()`, whose `ILogService` gets `trace` from the `ILogger` it extends, linked nowhere and now links to `ILogger`'s method. A field's call whose class and everything it extends are known but lack the method, as when a package's base class provides it, now links to nothing rather than to an unrelated class. Re-index TypeScript and JavaScript projects after upgrading.
|
||||
- In TypeScript, a call on a value of an interface that extends one also declared as a constant, like vscode's `ITerminalLogService extends ILogService` where `ILogService` is its dependency-injection decorator too, now links to the method the extended interface declares, such as `ILogger`'s `trace`. Before, the constant was taken for the extended type, so the call linked nowhere. Re-index TypeScript projects after upgrading.
|
||||
- In TypeScript, a field or variable set from a dependency-injection call now has the class that call is given, so calls through it link to that class's methods: Angular's `private readonly dataService = inject(DataService)` or `inject<DialogParams>(DIALOG_DATA)`, and vscode's `this.instantiationService.createInstance(Widget, …)`, also inside a wrapper like `this._register(…)`. Before, such a value had no type, so calls through it, among the most common in Angular apps, linked nowhere or to a same-named method of another class. Only Angular's `inject` counts, and only a `createInstance` the project declares to return an instance of the class it is given. A call on a value whose type the file imports under another name, like `import { OrganizationApiServiceAbstraction as OrganizationApiService }`, now also reaches the imported class rather than a class with the local name. Re-index TypeScript projects after upgrading.
|
||||
- In JavaScript and TypeScript, an import written from a jsconfig or tsconfig `baseUrl`, like `import MainLayout from 'layout/MainLayout'` under `"baseUrl": "src"`, or through a catch-all `"paths": { "*": ["./src/*"] }`, now links to the file it names, read with the config of the app the importing file belongs to, and so does an import of a single name like `'config'` or `'themes'`. Create React App and Vite admin templates such as Berry, Mantis and MatX write nearly every import this way. Before, each one was taken for an npm package, so it linked nothing, its names were matched by name alone, which could reach a same-named component in another app of the same repository, and a React Router route whose page loads through `lazy(() => import('views/dashboard/Default'))` pointed at that loader instead of the page and its layout. A package the app's `package.json` declares, or a Node built-in, never links to a project module of the same name, only to type declarations the project keeps for it, and only a file the index holds counts, never a folder or something in `node_modules`. `codegraph sync` and the file watcher link such an import too when the file it names is added later or gains what it imports. Re-index JavaScript and TypeScript projects after upgrading.
|
||||
|
||||
## [1.6.2] - 2026-10-03
|
||||
|
||||
@@ -0,0 +1,172 @@
|
||||
/**
|
||||
* A TypeScript field or local set from a dependency-injection call that
|
||||
* hands back an instance of the class it is given has that class's type:
|
||||
* Angular's `private readonly dataService = inject(DataService)`, and
|
||||
* vscode's `this._register(this.instantiationService.createInstance(Widget,
|
||||
* …))` or `const widget = this.instantiationService.createInstance(Widget)`.
|
||||
*
|
||||
* Neither was read, so calls through them linked nowhere, or by name alone:
|
||||
* on bitwarden and ghostfolio thousands of `this.<service>.<method>()` calls
|
||||
* sat on `inject(X)` fields. Only Angular's `inject` counts (Vue's
|
||||
* `inject(key)` returns whatever was provided under the key), and only a
|
||||
* `createInstance` the project declares to return an `InstanceType` of its
|
||||
* argument.
|
||||
*/
|
||||
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-injected-'));
|
||||
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();
|
||||
}
|
||||
|
||||
/** The service the app injects, and a decoy of the same name nearer the component. */
|
||||
const SERVICES = {
|
||||
'src/app/data.service.ts': 'export class DataService {\n fetchInfo() {\n return 1;\n }\n}\n',
|
||||
'src/app/components/legacy/data.service.ts': 'export class DataService {\n fetchInfo() {\n return 2;\n }\n}\n',
|
||||
};
|
||||
|
||||
/** vscode's lifecycle and instantiation service, and widgets: the imported one and a nearer decoy. */
|
||||
const WORKBENCH = {
|
||||
'package.json': JSON.stringify({ name: 'app' }),
|
||||
'src/base/lifecycle.ts':
|
||||
'export interface IDisposable {\n dispose(): void;\n}\n' +
|
||||
'export abstract class Disposable implements IDisposable {\n' +
|
||||
' protected _register<T extends IDisposable>(o: T): T {\n return o;\n }\n' +
|
||||
' dispose() {}\n' +
|
||||
'}\n',
|
||||
'src/platform/instantiation.ts':
|
||||
'export interface IInstantiationService {\n' +
|
||||
' createInstance<Ctor extends new (...args: any[]) => unknown, R extends InstanceType<Ctor>>(ctor: Ctor, ...args: unknown[]): R;\n' +
|
||||
'}\n',
|
||||
'src/browser/widget.ts': 'export class Widget {\n layout() {}\n dispose() {}\n}\n',
|
||||
'src/browser/parts/legacy/widget.ts': 'export class Widget {\n layout() {}\n dispose() {}\n}\n',
|
||||
};
|
||||
|
||||
describe('a TypeScript receiver a dependency-injection call sets up', () => {
|
||||
it("has the class Angular's inject(X) is given", async () => {
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { '@angular/core': '^18.0.0' } }),
|
||||
...SERVICES,
|
||||
'src/app/components/app.component.ts':
|
||||
"import { inject } from '@angular/core';\nimport { DataService } from '../data.service';\n" +
|
||||
'export class AppComponent {\n' +
|
||||
' private readonly dataService = inject(DataService);\n' +
|
||||
' private readonly maybe = inject(DataService, { optional: true });\n' +
|
||||
' ngOnInit() {\n this.dataService.fetchInfo();\n this.maybe.fetchInfo();\n }\n' +
|
||||
'}\n' +
|
||||
'export function load() {\n const service = inject(DataService);\n return service.fetchInfo();\n}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('fetchInfo'))).toEqual([
|
||||
'src/app/components/app.component.ts: service.fetchInfo -> src/app/data.service.ts::DataService::fetchInfo',
|
||||
'src/app/components/app.component.ts: this.dataService.fetchInfo -> src/app/data.service.ts::DataService::fetchInfo',
|
||||
'src/app/components/app.component.ts: this.maybe.fetchInfo -> src/app/data.service.ts::DataService::fetchInfo',
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it("has the type inject's type argument names, for a token that isn't a class", async () => {
|
||||
// bitwarden's `protected params = inject<AdvancedUriOptionDialogParams>(DIALOG_DATA)`.
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { '@angular/core': '^18.0.0', '@angular/cdk': '^18.0.0' } }),
|
||||
'src/app/params.ts': 'export interface DialogParams {\n onContinue(): void;\n}\n',
|
||||
'src/app/legacy/params.ts': 'export interface DialogParams {\n onContinue(): void;\n}\n',
|
||||
'src/app/dialog.component.ts':
|
||||
"import { inject } from '@angular/core';\nimport { DIALOG_DATA } from '@angular/cdk/dialog';\nimport { DialogParams } from './params';\n" +
|
||||
'export class DialogComponent {\n' +
|
||||
' protected params = inject<DialogParams>(DIALOG_DATA);\n' +
|
||||
' ok() {\n this.params.onContinue();\n const local = inject<DialogParams>(DIALOG_DATA);\n local.onContinue();\n }\n' +
|
||||
'}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('onContinue'))).toEqual([
|
||||
'src/app/dialog.component.ts: local.onContinue -> src/app/params.ts::DialogParams::onContinue',
|
||||
'src/app/dialog.component.ts: this.params.onContinue -> src/app/params.ts::DialogParams::onContinue',
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it("doesn't read a field set from an inject that isn't Angular's", async () => {
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app', dependencies: { vue: '^3.0.0' } }),
|
||||
...SERVICES,
|
||||
'src/app/components/app.store.ts':
|
||||
"import { inject } from 'vue';\nimport { DataService } from '../data.service';\n" +
|
||||
'export class AppStore {\n' +
|
||||
' private readonly dataService = inject(DataService);\n' +
|
||||
' load() {\n return this.dataService.fetchInfo();\n }\n' +
|
||||
'}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('this.dataService.fetchInfo'))).toEqual([]);
|
||||
}, 60_000);
|
||||
|
||||
it('has the class an instantiation service that returns its InstanceType is given', async () => {
|
||||
const calls = await callsOf({
|
||||
...WORKBENCH,
|
||||
'src/browser/parts/view.ts':
|
||||
"import { Disposable } from '../../base/lifecycle';\n" +
|
||||
"import { IInstantiationService } from '../../platform/instantiation';\n" +
|
||||
"import { Widget } from '../widget';\n" +
|
||||
'export class View extends Disposable {\n' +
|
||||
' private readonly widget = this._register(this.instantiationService.createInstance(Widget));\n' +
|
||||
' private readonly plain = this.instantiationService.createInstance(Widget);\n' +
|
||||
' constructor(private readonly instantiationService: IInstantiationService) {\n super();\n }\n' +
|
||||
' run() {\n this.widget.layout();\n this.plain.layout();\n const local = this.instantiationService.createInstance(Widget);\n local.layout();\n }\n' +
|
||||
'}\n' +
|
||||
// A member of what it hands back is that member's type, not the class's.
|
||||
'class Accessor {\n widget!: Widget;\n}\n' +
|
||||
'export function check(instantiationService: IInstantiationService) {\n' +
|
||||
' let widget: Widget;\n' +
|
||||
' widget = instantiationService.createInstance(Accessor).widget;\n' +
|
||||
' return widget.dispose();\n' +
|
||||
'}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('layout') || c.includes('widget.dispose'))).toEqual([
|
||||
'src/browser/parts/view.ts: local.layout -> src/browser/widget.ts::Widget::layout',
|
||||
'src/browser/parts/view.ts: this.plain.layout -> src/browser/widget.ts::Widget::layout',
|
||||
'src/browser/parts/view.ts: this.widget.layout -> src/browser/widget.ts::Widget::layout',
|
||||
'src/browser/parts/view.ts: widget.dispose -> src/browser/widget.ts::Widget::dispose',
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it("doesn't read a field from a createInstance the project doesn't declare to return its argument's instance", async () => {
|
||||
const calls = await callsOf({
|
||||
...WORKBENCH,
|
||||
'src/platform/instantiation.ts': 'export interface IFactory {\n createInstance(name: unknown): unknown;\n}\n',
|
||||
'src/browser/parts/view.ts':
|
||||
"import { IFactory } from '../../platform/instantiation';\n" +
|
||||
"import { Widget } from '../widget';\n" +
|
||||
'export class View {\n' +
|
||||
' private readonly plain = this.factory.createInstance(Widget);\n' +
|
||||
' constructor(private readonly factory: IFactory) {}\n' +
|
||||
' run() {\n this.plain.layout();\n }\n' +
|
||||
'}\n',
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('this.plain.layout'))).toEqual([]);
|
||||
}, 60_000);
|
||||
});
|
||||
@@ -113,6 +113,23 @@ describe('a call on a value of a class its file names', () => {
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it('reaches the class an import renames on the way, not a namesake of the local name', async () => {
|
||||
// bitwarden's `export { MessageSender as MessagingService } from "@bitwarden/messaging"`.
|
||||
const calls = await callsOf({
|
||||
'package.json': JSON.stringify({ name: 'app' }),
|
||||
'src/messaging/sender.ts': 'export abstract class MessageSender {\n abstract send(command: string): void;\n}\n',
|
||||
'src/common/messaging.service.ts': "export { MessageSender as MessagingService } from '../messaging/sender';\n",
|
||||
'src/desktop/electron-messaging.service.ts': 'export class MessagingService {\n send(command: string) {\n return command;\n }\n}\n',
|
||||
'src/desktop/settings.component.ts':
|
||||
"import { MessagingService } from '../common/messaging.service';\n" +
|
||||
'export class SettingsComponent {\n constructor(private readonly messagingService: MessagingService) {}\n' +
|
||||
" save() {\n this.messagingService.send('saved');\n }\n}\n",
|
||||
});
|
||||
expect(calls.filter((c) => c.includes('this.messagingService.send'))).toEqual([
|
||||
'src/desktop/settings.component.ts: this.messagingService.send -> src/messaging/sender.ts::MessageSender::send',
|
||||
]);
|
||||
}, 60_000);
|
||||
|
||||
it('reaches it through an interface another file extends, whose import also names a constant', async () => {
|
||||
// vscode's `ITerminalLogService extends ILogService`, `ILogService` both
|
||||
// an interface and its `createDecorator` constant.
|
||||
|
||||
@@ -13105,6 +13105,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void {
|
||||
TS_FIELD_DECL_MEMO.delete(context);
|
||||
TS_CLASS_LINES.delete(context);
|
||||
TS_PASS_THROUGH_WRAPPERS.delete(context);
|
||||
TS_CREATE_INSTANCE_DECLARED.delete(context);
|
||||
TS_NAMED_TYPES.delete(context);
|
||||
TARGET_LANGUAGE.delete(context);
|
||||
}
|
||||
@@ -13134,6 +13135,15 @@ function buildLocalReceiverTypePatterns(language: Language, r: string): RegExp[]
|
||||
case 'arkts':
|
||||
return [
|
||||
new RegExp(`\\b${r}\\b\\s*=\\s*new\\s+([A-Za-z_$][\\w.$]*)`), // = new Logger()
|
||||
// = inject(Logger) / = this.instantiationService.createInstance(Logger, …):
|
||||
// a dependency-injection call that hands back an instance of the class
|
||||
// it is given — unless a member of it is taken (`createInstance(
|
||||
// TestServiceAccessor).filesConfigurationService`). A key that isn't a
|
||||
// class (Vue's `inject(KEY)`) names no type with the method, so it
|
||||
// links nothing.
|
||||
// A type argument is the type returned: `inject<Params>(DIALOG_DATA)`.
|
||||
new RegExp(`\\b${r}\\b\\s*=\\s*(?:inject|(?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*<\\s*([A-Z][\\w.$]*)[^;=(){}]*>\\s*\\((?![^;]*\\)\\s*(?:\\?\\.|\\.|\\[))`),
|
||||
new RegExp(`\\b${r}\\b\\s*=\\s*(?:inject|(?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*\\(\\s*([A-Z][\\w.$]*)(?![^;]*\\)\\s*(?:\\?\\.|\\.|\\[))`),
|
||||
// No keyword requirement, so this matches BOTH a local annotation
|
||||
// (`const lg: Logger`) and a typed parameter (`function use(lg: Logger)`
|
||||
// / `(lg: Logger) =>`) — the parameter case the old `const|let|var`
|
||||
@@ -15669,8 +15679,17 @@ type TsFieldDecl = { valueType: boolean; type: string; array?: boolean };
|
||||
* `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 };
|
||||
/**
|
||||
* A dependency-injection call that hands back an instance of the class it
|
||||
* is given: Angular's `inject(X)`, vscode's `instantiationService
|
||||
* .createInstance(X, …)`.
|
||||
*/
|
||||
type TsInjector = 'inject' | 'createInstance';
|
||||
/**
|
||||
* A declaration found on one line; a wrapped one names its wrapper, an
|
||||
* injected one its injector, and either where its call's `(` is.
|
||||
*/
|
||||
type TsFieldOnLine = TsFieldDecl & { via?: TsFieldWrapper; injector?: TsInjector; 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. */
|
||||
@@ -15723,18 +15742,25 @@ 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 },
|
||||
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*this\.(?:([\w$#]+)\.)?([\w$#]+)\(\s*(?:new\s+|((?:this\.)?(?:[\w$#]+\.)*createInstance)\s*\(\s*)([A-Za-z_$][\w.$]*)/y, valueType: false, wrapped: true },
|
||||
{ re: /(?<![\w$#])[\w$#]+\b\s*=\s*(inject|(?:this\.)?(?:[\w$#]+\.)*createInstance)\s*(?:<\s*([A-Z][\w.$]*)[^;=(){}]*>)?\s*\(\s*([A-Z][\w.$]*)/y, valueType: false, injected: true },
|
||||
];
|
||||
|
||||
/** A declaration `pattern` matched (`m`) on `line`: its type, and its wrapper when it has one. */
|
||||
/** A declaration `pattern` matched (`m`) on `line`: its type, and its wrapper or injector when it has one. */
|
||||
function tsFieldMatch(pattern: TsFieldPattern, m: RegExpMatchArray, line: string): TsFieldOnLine | null {
|
||||
const type = pattern.wrapped ? m[3] : m[1];
|
||||
// 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)) };
|
||||
if (pattern.wrapped) {
|
||||
decl.via = { holder: m[1], method: m[2]! };
|
||||
decl.open = m.index! + m[0].indexOf('(');
|
||||
if (m[3]) decl.injector = 'createInstance';
|
||||
} else if (pattern.injected) {
|
||||
decl.injector = m[1] === 'inject' ? 'inject' : 'createInstance';
|
||||
}
|
||||
// The first `(` after the field is the wrapper's, or the injector's: a
|
||||
// type argument list (`inject<Foo>(`) holds none.
|
||||
if (pattern.wrapped || pattern.injected) decl.open = m.index! + m[0].indexOf('(');
|
||||
return decl;
|
||||
}
|
||||
|
||||
@@ -15774,8 +15800,12 @@ function tsClassLinesWithToken(decl: TsClassDecl, token: string): readonly numbe
|
||||
return decl.linesByToken.get(token) ?? [];
|
||||
}
|
||||
|
||||
/** 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 };
|
||||
/**
|
||||
* A field declaration pattern. A wrapped one captures its wrapper's holder
|
||||
* and method, and an injector inside it, before the type; an injected one
|
||||
* its injector before the type.
|
||||
*/
|
||||
type TsFieldPattern = { re: RegExp; valueType: boolean; wrapped?: true; injected?: 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;
|
||||
@@ -15809,10 +15839,17 @@ function tsFieldPatterns(field: string): TsFieldPattern[] {
|
||||
// `_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.$]*)`),
|
||||
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*this\\.(?:([\\w$#]+)\\.)?([\\w$#]+)\\(\\s*(?:new\\s+|((?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*\\(\\s*)([A-Za-z_$][\\w.$]*)`),
|
||||
valueType: false,
|
||||
wrapped: true,
|
||||
},
|
||||
// `http = inject(HttpClient)`, `filter = this._instaService.createInstance(DocumentFilter)`:
|
||||
// an X when the injector is one that hands back an instance (tsFieldRead).
|
||||
{
|
||||
re: new RegExp(`${fieldStart}${fieldEsc}\\b\\s*=\\s*(inject|(?:this\\.)?(?:[\\w$#]+\\.)*createInstance)\\s*(?:<\\s*([A-Z][\\w.$]*)[^;=(){}]*>)?\\s*\\(\\s*([A-Z][\\w.$]*)`),
|
||||
valueType: false,
|
||||
injected: 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);
|
||||
@@ -15879,11 +15916,40 @@ function tsFieldDeclaration(
|
||||
* 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;
|
||||
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;
|
||||
if (onLine.injector && !tsInjectsInstance(cls, onLine.injector, context)) return null;
|
||||
return { valueType: onLine.valueType, type: onLine.type, array: onLine.array };
|
||||
}
|
||||
|
||||
/** Per-context memo: does the project declare a `createInstance` that returns an instance of its constructor argument? */
|
||||
const TS_CREATE_INSTANCE_DECLARED = new WeakMap<ResolutionContext, boolean>();
|
||||
|
||||
/**
|
||||
* Does `injector`, as class `cls`'s file calls it, hand back an instance of
|
||||
* the class it is given? Angular's `inject(X)` when the file imports
|
||||
* `inject` from `@angular/core` (Vue's `inject(key)` returns whatever was
|
||||
* provided under the key); `createInstance(X, …)` when the project declares
|
||||
* one that returns an `InstanceType` of its constructor argument, as
|
||||
* vscode's `IInstantiationService` does.
|
||||
*/
|
||||
function tsInjectsInstance(cls: Node, injector: TsInjector, context: ResolutionContext): boolean {
|
||||
if (injector === 'inject') {
|
||||
return context.getImportMappings?.(cls.filePath, cls.language)?.some((i) => i.localName === 'inject' && i.source === '@angular/core') === true;
|
||||
}
|
||||
let declared = TS_CREATE_INSTANCE_DECLARED.get(context);
|
||||
if (declared === undefined) {
|
||||
declared = context.getNodesByName('createInstance').some((n) => {
|
||||
if ((n.kind !== 'method' && n.kind !== 'function') || !ESM_FAMILY.has(n.language)) return false;
|
||||
const lines = context.getFileLines?.(n.filePath) ?? context.readFile(n.filePath)?.split(/\r?\n/) ?? [];
|
||||
return /\bInstanceType\s*</.test(lines.slice(n.startLine - 1, n.startLine + 2).join(' '));
|
||||
});
|
||||
TS_CREATE_INSTANCE_DECLARED.set(context, declared);
|
||||
}
|
||||
return declared;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
@@ -16023,18 +16089,24 @@ const TS_NAMED_TYPES = new WeakMap<ResolutionContext, Map<string, Node | 'outsid
|
||||
* file declares. 'outside' when the file imports it from a package outside
|
||||
* the project that no project file declares; null when it can't be found.
|
||||
*/
|
||||
function tsNamedType(written: string, from: Node, at: UnresolvedRef, context: ResolutionContext): Node | 'outside' | null {
|
||||
function tsNamedType(
|
||||
written: string,
|
||||
from: Pick<Node, 'filePath' | 'language' | 'id'>,
|
||||
at: UnresolvedRef,
|
||||
context: ResolutionContext,
|
||||
): Node | 'outside' | null {
|
||||
let memo = TS_NAMED_TYPES.get(context);
|
||||
if (!memo) {
|
||||
memo = new Map();
|
||||
TS_NAMED_TYPES.set(context, memo);
|
||||
}
|
||||
const key = from.filePath + '\0' + written;
|
||||
const mapping = context.getImportMappings?.(from.filePath, from.language)?.find((i) => i.localName === written.split('.')[0]);
|
||||
// A name the file declares is looked up past `from` itself.
|
||||
const key = from.filePath + '\0' + written + (mapping ? '' : '\0' + from.id);
|
||||
const hit = memo.get(key);
|
||||
if (hit !== undefined) return hit;
|
||||
const last = written.split('.').pop()!;
|
||||
const ref: UnresolvedRef = { ...at, filePath: from.filePath, language: from.language };
|
||||
const mapping = context.getImportMappings?.(from.filePath, from.language)?.find((i) => i.localName === written.split('.')[0]);
|
||||
let found: Node | 'outside' | null = null;
|
||||
if (mapping && context.isOutOfRepoImport?.(mapping.source, from.filePath, from.language) === true) {
|
||||
const ambient = tsOutsideTypeDeclaration(written, last, ref, context);
|
||||
@@ -16096,8 +16168,10 @@ function tsMethodUpChain(cls: Node, name: string, at: UnresolvedRef, context: Re
|
||||
* (tsMethodUpChain); null when the whole chain is known and has none, as
|
||||
* when the method is a package's: `Emitter extends lsp.Emitter` has no
|
||||
* project `fire`, whatever other `Emitter` declares one. Undefined when
|
||||
* `file` doesn't declare the type or a supertype can't be found, for the
|
||||
* caller's own fallback.
|
||||
* neither `file` nor what `at`'s file imports under the name declares the
|
||||
* type, or a supertype can't be found, for the caller's own fallback. (An
|
||||
* import may rename it: bitwarden's `MessagingService` is `export {
|
||||
* MessageSender as MessagingService }`.)
|
||||
*/
|
||||
function tsKnownTypeCall(
|
||||
typeName: string,
|
||||
@@ -16108,7 +16182,11 @@ function tsKnownTypeCall(
|
||||
context: ResolutionContext,
|
||||
confidence: number,
|
||||
): ResolvedRef | null | undefined {
|
||||
const known = tsTypeDeclaredIn(file, typeName, context);
|
||||
let known = tsTypeDeclaredIn(file, typeName, context);
|
||||
if (!known) {
|
||||
const named = tsNamedType(typeName, { filePath: at.filePath, language: at.language, id: '' }, at, context);
|
||||
if (named && named !== 'outside') known = named;
|
||||
}
|
||||
if (!known) return undefined;
|
||||
const up = nmTimedT('mc-tsknown', ref, () => tsMethodUpChain(known, methodName, at, context));
|
||||
if (up.method) return { original: ref, targetNodeId: up.method.id, confidence, resolvedBy: 'instance-method' };
|
||||
|
||||
Reference in New Issue
Block a user