diff --git a/CHANGELOG.md b/CHANGELOG.md index 15a5871aa..03c6f8257 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -253,6 +253,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In C++, every name a declaration lists now has the declaration's type, like `strings_found` in google/breakpad's `ByteBuffer symbols_found, strings_found;` or `data_` in a class's `Table *index_, *data_;`, so calls on it link to that class's methods. Before, only the first name had its type read, and a call on another name linked to nothing or to the class of an earlier variable with the same name. - In C++, a call on a parameter, a local lambda or functor, a lambda's parameter or a range-`for` variable no longer links to a function or method elsewhere that only shares its name, like protocolbuffers/protobuf's code generators calling `format(…)` on their `Formatter& format` parameter, which linked to the conformance suite's `TestResult::format`, or facebook/rocksdb's tests calling a local `auto key = [](int i) {…};`. C++ finds the local first, so that is what the call calls. - In TypeScript and JavaScript, an import of a package your project declares itself with `declare module '…'` in a `.d.ts` file, like a `typings/mammoth/index.d.ts` written for an untyped package or VS Code's own `vscode.d.ts`, now links to that declaration, so `codegraph callers`, impact and `codegraph affected` show the code that uses your typings instead of nothing or a project symbol that only shares a name. Named, default and namespace imports, `export =` and renaming export lists are read the way TypeScript reads them, a function such a file declares without a body (`export function convertToHtml(…): Promise;`) is now indexed so calls to it have something to link to, and only an import of its package reaches it. A `declare module` that augments a package's own types, a wildcard like `declare module '*.svg'`, and copies of typings kept in test fixtures are left alone. Re-index TypeScript projects after upgrading. +- In TypeScript, an interface that extends other interfaces, like `interface UserRepo extends Repo, Named`, is now linked to each of them: the type hierarchy shows them above the interface and above every class that implements it, a change to an interface reaches the interfaces built on it and their implementations in impact results, and a call on a value typed by such an interface, like `hass.formatEntityName(…)` on a `HomeAssistant` that extends seven interfaces, links to the method the interface inherits instead of a same-named method elsewhere. Before, only a class's `extends` and `implements` were read, so every interface stood alone. A type from a package, like `ButtonProps` from `@mui/material`, or one of TypeScript's own, like `Omit<…>`, still links to nothing rather than to a type of the same name elsewhere in your project. Re-index TypeScript projects after upgrading. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/ts-interface-extends.test.ts b/__tests__/ts-interface-extends.test.ts new file mode 100644 index 000000000..b5398fbf3 --- /dev/null +++ b/__tests__/ts-interface-extends.test.ts @@ -0,0 +1,458 @@ +/** + * A TypeScript interface is linked to the interfaces it extends. + * + * interface Repo { find(id: string): T } + * interface UserRepo extends Repo, Named {} + * + * The grammar holds an interface's supertypes in an `extends_type_clause`, + * which neither the wasm extractor nor the native kernel read: a class's + * `extends` and `implements` became edges, an interface's `extends` nothing. + * So an interface had no place in the type hierarchy — a class implementing + * `UserRepo` showed nothing above it — and an interface that is only ever + * extended had no dependents. Now each supertype is an `extends` reference, + * resolved like a class's: a plain name, a qualified one (`shapes.Shape`), or + * a parameterized one by its name alone (`Repo`, never `Repo`). It goes + * through the file's imports first, so a package's type (`ButtonProps` from + * '@mui/material') never reaches a project namesake. + */ +import { describe, it, expect, beforeAll, beforeEach, afterEach, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; +import { extractFromSource } from '../src/extraction'; +import { initGrammars, loadGrammarsForLanguages } from '../src/extraction/grammars'; +import { tryKernelExtract, resetKernelForTests } from '../src/extraction/kernel'; +import { buildTypeHierarchy } from '../src/graph/type-hierarchy'; +import type { ExtractionResult, Language } from '../src/types'; + +const KERNEL_PATH = path.join( + __dirname, + '..', + 'codegraph-kernel', + 'prebuilds', + `${process.platform}-${process.arch}`, + 'codegraph-kernel.node' +); +const kernelAvailable = fs.existsSync(KERNEL_PATH) || process.env.CODEGRAPH_KERNEL_EXPECT === '1'; + +const SOURCE = `interface A extends B, ns.C, Repo, ns.D {} +interface E extends F {} +export default interface P extends Q {} +declare module 'pkg' { + interface N extends O {} +} +declare global { + interface Window extends Gadget {} +} +class G extends H implements L, ns.M {} +interface Commented extends B /* not a type */, C {} +`; + +const ENV_KEYS = ['CODEGRAPH_KERNEL', 'CODEGRAPH_KERNEL_LANGS'] as const; + +describe("an interface's extends clause is read", () => { + let savedEnv: Record = {}; + + beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['typescript', 'tsx']); + }); + + beforeEach(() => { + savedEnv = Object.fromEntries(ENV_KEYS.map((k) => [k, process.env[k]])); + resetKernelForTests(); + }); + + afterEach(() => { + for (const k of ENV_KEYS) { + if (savedEnv[k] === undefined) delete process.env[k]; + else process.env[k] = savedEnv[k]; + } + resetKernelForTests(); + }); + + function extract(backend: 'kernel' | 'wasm', file: string, source: string, language: Language): ExtractionResult { + if (backend === 'wasm') { + process.env.CODEGRAPH_KERNEL = '0'; + return extractFromSource(file, source, language); + } + delete process.env.CODEGRAPH_KERNEL; + process.env.CODEGRAPH_KERNEL_LANGS = 'all'; + const result = tryKernelExtract(file, source, language); + expect(result, `kernel extraction of ${file}`).not.toBeNull(); + return result!; + } + + /** ` @:` for every supertype reference. */ + const supertypeRefs = (result: ExtractionResult): string[] => { + const names = new Map(result.nodes.map((n) => [n.id, n.name])); + return result.unresolvedReferences + .filter((r) => r.referenceKind === 'extends' || r.referenceKind === 'implements') + .map((r) => `${names.get(r.fromNodeId)} ${r.referenceKind} ${r.referenceName}@${r.line}:${r.column}`); + }; + + const backends = kernelAvailable ? (['kernel', 'wasm'] as const) : (['wasm'] as const); + + for (const crlf of [false, true]) { + const eol = (s: string) => (crlf ? s.replace(/\n/g, '\r\n') : s); + const label = crlf ? ' (CRLF)' : ''; + + it.each(backends)(`each supertype is a reference, a parameterized one by its name: %s${label}`, (backend) => { + expect(supertypeRefs(extract(backend, 'src/types.ts', eol(SOURCE), 'typescript'))).toEqual([ + 'A extends B@1:20', + 'A extends ns.C@1:23', + 'A extends Repo@1:29', + 'A extends ns.D@1:38', + // A type parameter's constraint is not a supertype. + 'E extends F@2:38', + 'P extends Q@3:35', + 'N extends O@5:22', + 'Window extends Gadget@8:27', + // A class's clauses are read as before. + 'G extends H@10:16', + 'G implements L@10:29', + 'G implements ns.M@10:32', + // A comment in the clause is not a supertype. + 'Commented extends B@11:28', + 'Commented extends C@11:48', + ]); + }); + + it.each(backends)(`in a tsx file too: %s${label}`, (backend) => { + const source = eol('interface Props extends Base {}\nexport const App = (p: Props) =>
;\n'); + expect(supertypeRefs(extract(backend, 'src/App.tsx', source, 'tsx'))).toEqual(['Props extends Base@1:24']); + }); + } + + it.runIf(kernelAvailable)('the kernel and the wasm extractor agree on every node and reference', () => { + const canon = (r: ExtractionResult) => [ + ...r.nodes.map(({ updatedAt: _u, ...n }) => JSON.stringify(n, Object.keys(n).sort())), + ...r.unresolvedReferences.map((ref) => JSON.stringify(ref, Object.keys(ref).sort())), + ].sort(); + expect(canon(extract('kernel', 'src/types.ts', SOURCE, 'typescript'))).toEqual( + canon(extract('wasm', 'src/types.ts', SOURCE, 'typescript')) + ); + }); +}); + +describe('an interface links the interfaces it extends', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-interface-extends-')); + const files: Record = { + 'package.json': JSON.stringify({ name: 'app', dependencies: { '@mui/material': '^5.0.0', lib: '^1.0.0' } }), + 'src/shapes.ts': `export interface Shape { + area(): number; +} +export interface Named { + name: string; +} +`, + 'src/repo.ts': `export interface Repo { + find(id: string): T; +} +`, + // Namesakes of the package's ButtonProps and of the global Error. + 'src/legacy/button.ts': `export interface ButtonProps { + legacy: true; +} +export class Error {} +`, + // The repository declares the package 'lib' itself (#2718). + 'typings/lib.d.ts': `declare module 'lib' { + export interface Options { verbose: boolean } +} +`, + // A module's own `Omit`, a namespace's `Function`, and two globals: a + // script's top level and a module's `declare global`. + 'src/legacy/quick.ts': `export type Omit = Pick>; +`, + 'typings/fig.d.ts': `declare namespace Fig { + type Function = (param: T) => R; +} +`, + 'typings/globals.d.ts': `interface GlobalShape { + id: string; +} +`, + 'src/globals.ts': `export const appName = 'app'; +declare global { + interface Window { + appName: string; + } +} +`, + 'src/model.ts': `import { Shape, Named } from './shapes'; +import * as shapes from './shapes'; +import { Repo } from './repo'; +import { ButtonProps } from '@mui/material'; +import { Options } from 'lib'; + +interface Base { + a(): void; +} +interface Other { + b(): void; +} +export interface Sub extends Base {} +export interface Both extends Base, Other {} +export interface Circle extends Shape, Named {} +export interface Square extends shapes.Shape {} +export interface User { id: string } +export interface UserRepo extends Repo {} +export interface MyButtonProps extends ButtonProps {} +export interface MyOptions extends Options {} +export interface MyError extends Error {} +export interface Picked extends Omit {} +export interface Callable extends Function {} +export interface Located extends GlobalShape {} +export interface WindowWithApp extends Window {} +class BaseImpl {} +export class Impl extends BaseImpl implements Sub { + a(): void {} +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + const nodeOf = (file: string, name: string) => { + const node = cg.getNodesInFile(file).find((n) => n.name === name && n.kind !== 'file' && n.kind !== 'import'); + expect(node, `${file} ${name}`).toBeDefined(); + return node!; + }; + + /** ` :` of the supertype edges `name` in model.ts declares. */ + const supertypes = (name: string): string[] => + cg.getOutgoingEdges(nodeOf('src/model.ts', name).id) + .filter((e) => e.kind === 'extends' || e.kind === 'implements') + .map((e) => { + const target = cg.getNode(e.target)!; + return `${e.kind} ${target.filePath}:${target.qualifiedName}`; + }) + .sort(); + + it('a same-file supertype, and several', () => { + expect(supertypes('Sub')).toEqual(['extends src/model.ts:Base']); + expect(supertypes('Both')).toEqual(['extends src/model.ts:Base', 'extends src/model.ts:Other']); + }); + + it('an imported supertype, through a named import and a namespace import', () => { + expect(supertypes('Circle')).toEqual(['extends src/shapes.ts:Named', 'extends src/shapes.ts:Shape']); + expect(supertypes('Square')).toEqual(['extends src/shapes.ts:Shape']); + }); + + it('a parameterized supertype, by its name', () => { + expect(supertypes('UserRepo')).toEqual(['extends src/repo.ts:Repo']); + }); + + it("the repository's own declaration of a package's type", () => { + expect(supertypes('MyOptions')).toEqual(['extends typings/lib.d.ts:Options']); + }); + + it("a package's type or a global never reaches a project namesake", () => { + expect(supertypes('MyButtonProps')).toEqual([]); + expect(supertypes('MyError')).toEqual([]); + }); + + it("a name the module doesn't import is its own or a global, never another module's or a namespace's", () => { + expect(supertypes('Picked')).toEqual([]); + expect(supertypes('Callable')).toEqual([]); + expect(supertypes('Located')).toEqual(['extends typings/globals.d.ts:GlobalShape']); + expect(supertypes('WindowWithApp')).toEqual(['extends src/globals.ts:Window']); + }); + + it("a class's extends and implements are unchanged", () => { + expect(supertypes('Impl')).toEqual(['extends src/model.ts:BaseImpl', 'implements src/model.ts:Sub']); + }); + + it('the type hierarchy runs through the interface: above a class implementing it, and below its supertype', () => { + const above = buildTypeHierarchy(cg, nodeOf('src/model.ts', 'Impl'))!; + expect(above.ancestors.map((a) => `${a.depth} ${a.relation} ${a.node.name}`)).toEqual([ + '1 extends BaseImpl', + '1 implements Sub', + '2 extends Base', + ]); + const below = buildTypeHierarchy(cg, nodeOf('src/model.ts', 'Base'))!; + expect(below.descendants.map((d) => `${d.depth} ${d.relation} ${d.node.name}`).sort()).toEqual([ + '1 extends Both', + '1 extends Sub', + '2 implements Impl', + ]); + }); + + it("a change to an interface reaches the interfaces built on it, and their implementers", () => { + const impact = cg.getImpactRadius(nodeOf('src/model.ts', 'Base').id); + const reached = [...impact.nodes.values()].filter((n) => n.kind === 'interface' || n.kind === 'class').map((n) => n.name); + expect(reached.sort()).toEqual(['Base', 'Both', 'Impl', 'Sub']); + }); +}); + +/** + * A call on a value typed by an interface that only inherits the method + * reaches the declaration in the interface's own supertype: home-assistant's + * `hass.formatEntityName(…)` on a `HomeAssistant`, which extends seven + * interfaces over a head too long to read from its source text, and vscode's + * `receiver.dispose()` on an `ILocalOtlpHttpReceiver extends IDisposable` + * while another app in the repository declares an `IDisposable` of its own. + */ +describe('a call through an interface reaches the method its supertype declares', () => { + let root = ''; + let cg: CodeGraph; + + beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-interface-extends-calls-')); + const files: Record = { + 'src/types.ts': `export interface HomeAssistantRegistries { entities: string[] } +export interface HomeAssistantInternationalization { language: string } +export interface HomeAssistantApi { + callService(domain: string): void; +} +export interface HomeAssistantFormatters { + formatEntityName(entity: string): string; +} +export interface HomeAssistantConnection { connected: boolean } +export interface HomeAssistantUI { themes: string[] } +export interface HomeAssistantConfig { config: string } +export interface HomeAssistant + extends + HomeAssistantRegistries, + HomeAssistantInternationalization, + HomeAssistantApi, + HomeAssistantFormatters, + HomeAssistantConnection, + HomeAssistantUI, + HomeAssistantConfig { + user: string; +} +`, + // A namesake method in an unrelated file. + 'src/resources/completions.ts': `export interface HassArgHoverContext { + formatEntityName(entity: string): string; +} +`, + 'src/data/describe.ts': `import type { HomeAssistant } from '../types'; +export function describe(hass: HomeAssistant): string { + hass.callService('light'); + return hass.formatEntityName('light.kitchen'); +} +`, + // Another app's IDisposable, ahead of the project's own in the index. + 'extensions/copilot/src/util/lifecycle.ts': `export interface IDisposable { + dispose(): void; +} +`, + 'src/base/lifecycle.ts': `export interface IDisposable { + dispose(): void; +} +`, + 'src/otel/receiver.ts': `import { IDisposable } from '../base/lifecycle'; +export interface ILocalReceiver extends IDisposable { + readonly port: number; +} +export async function startReceiver(port: number): Promise { + return { port, dispose() {} }; +} +`, + 'src/otel/run.ts': `import { startReceiver } from './receiver'; +export async function run(): Promise { + const receiver = await startReceiver(0); + receiver.dispose(); +} +`, + // A project interface named like Node's `NodeJS.ReadableStream`. + 'src/base/stream.ts': `export interface ReadableStreamEvents { + on(event: 'data', listener: (data: T) => void): void; +} +export interface ReadableStream extends ReadableStreamEvents { + pause(): void; +} +`, + 'src/shim/runner.ts': `export function listen(stream: NodeJS.ReadableStream, listener: (chunk: string) => void): void { + stream.on('data', listener); +} +`, + // Two supertypes that both have the method, one through its own + // supertype, again over a head too long to read. + 'src/widgets.ts': `export interface IBase { + render(): void; +} +export interface IMiddle extends IBase {} +export interface IRenderable { + render(): void; +} +export interface ISized { width: number } +export interface IPositioned { x: number } +export interface IStyled { color: string } +export interface INamed { name: string } +export interface IVisible { visible: boolean } +export interface IWidget + extends + IMiddle, + ISized, + IPositioned, + IStyled, + INamed, + IVisible, + IRenderable {} +export function draw(widget: IWidget): void { + widget.render(); +} +`, + }; + for (const [rel, content] of Object.entries(files)) { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), content); + } + cg = await CodeGraph.init(root, { index: true }); + }); + + afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); + }); + + /** `:` of every call `fn` in `file` makes. */ + const callees = (file: string, fn: string): string[] => { + const node = cg.getNodesInFile(file).find((n) => n.name === fn && n.kind === 'function')!; + expect(node, `${file} ${fn}`).toBeDefined(); + return cg.getOutgoingEdges(node.id).filter((e) => e.kind === 'calls').map((e) => { + const target = cg.getNode(e.target)!; + return `${target.filePath}:${target.qualifiedName}`; + }).sort(); + }; + + it("through a composite interface, the member of the supertype that declares it, not a namesake's", () => { + expect(callees('src/data/describe.ts', 'describe')).toEqual([ + 'src/types.ts:HomeAssistantApi::callService', + 'src/types.ts:HomeAssistantFormatters::formatEntityName', + ]); + }); + + it('the supertype the interface extends, not a same-named one elsewhere', () => { + expect(callees('src/otel/run.ts', 'run')).toEqual([ + 'src/base/lifecycle.ts:IDisposable::dispose', + 'src/otel/receiver.ts:startReceiver', + ]); + }); + + it("a type from outside the project reaches none of a project namesake's supertypes", () => { + expect(callees('src/shim/runner.ts', 'listen')).toEqual([]); + }); + + it('each supertype, and then its own supertypes, in the order they are written', () => { + expect(callees('src/widgets.ts', 'draw')).toEqual(['src/widgets.ts:IBase::render']); + }); +}); diff --git a/codegraph-kernel/src/tsjs/extractors.rs b/codegraph-kernel/src/tsjs/extractors.rs index d77dd8d90..2effd5ba3 100644 --- a/codegraph-kernel/src/tsjs/extractors.rs +++ b/codegraph-kernel/src/tsjs/extractors.rs @@ -1694,6 +1694,22 @@ impl<'t> Walker<'t> { } } } + // TS `interface Repo extends Base, ns.Shape, Store` — each + // supertype is a `type` of the clause; a `generic_type` is named + // by its `name` field (`Store`, never `Store`). + "extends_type_clause" => { + let mut cursor = child.walk(); + let targets: Vec> = child.children_by_field_name("type", &mut cursor).collect(); + for target in targets { + let named = if target.kind() == "generic_type" { + target.child_by_field_name("name").unwrap_or(target) + } else { + target + }; + let name = self.text(named).to_string(); + self.push_ref(class_row, &name, extends_kind, target); + } + } // JS `class Foo extends Bar` — class_heritage holds a bare // identifier without an extends_clause wrapper. "identifier" | "type_identifier" if node.kind() == "class_heritage" => { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 4d3509419..f6657357a 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -7289,6 +7289,24 @@ export class TreeSitterExtractor { } } + // TypeScript: `interface Repo extends Base, ns.Shape, Store` — each + // supertype is a `type` of the extends_type_clause. A parameterized one + // (`generic_type`) is named by its `name` field: the index holds `Store`, + // never `Store`. + if (child.type === 'extends_type_clause') { + for (const target of child.childrenForFieldName('type')) { + if (!target) continue; + const named = target.type === 'generic_type' ? getChildByField(target, 'name') ?? target : target; + this.unresolvedReferences.push({ + fromNodeId: classId, + referenceName: getNodeText(named, this.source), + referenceKind: 'extends', + line: target.startPosition.row + 1, + column: target.startPosition.column, + }); + } + } + // C++ base classes: `class Derived : public Base, private Other` → // base_class_clause holds access specifiers + base type(s). Emit an extends // ref per base type (skip the public/private/protected keywords). A diff --git a/src/resolution/ambient-modules.ts b/src/resolution/ambient-modules.ts index bce1fcf9c..b45985e0b 100644 --- a/src/resolution/ambient-modules.ts +++ b/src/resolution/ambient-modules.ts @@ -224,6 +224,51 @@ export function scanAmbientModules(source: string): AmbientModuleScan { return { script, blocks }; } +/** Where a file declares into the global scope. */ +export interface GlobalScopeScan { + /** No top-level `import` or `export`: the file's top level is the global scope. */ + script: boolean; + /** + * `line:column` of every word at the global scope: at a script's top level, + * and directly inside `declare global { … }`. A declaration starts at one of + * these exactly when it is a global (its keyword: `interface`, `type`, `class`, + * `abstract`, `enum`, `function`, `const`, …). + */ + globalWords: Set; +} + +/** + * The global scope's share of `source`: its top level if the file is a + * script, and the bodies of its `declare global` blocks. A namespace's or a + * `declare module` block's members, and a module's own declarations, are + * not in it. + */ +export function scanGlobalScope(source: string): GlobalScopeScan { + const { script } = scanAmbientModules(source); + const globalWords = new Set(); + let declare = 0; // 1: `declare`, 2: `declare global` + let inGlobal = false; + tokenize(source, (kind, text, line, column, depth) => { + if (inGlobal) { + if (kind === Kind.Punct && text === '}' && depth === 0) inGlobal = false; + else if (kind === Kind.Word && depth === 1) globalWords.add(`${line}:${column}`); + return; + } + if (depth !== 0) { + declare = 0; + return; + } + if (kind === Kind.Word) { + if (script) globalWords.add(`${line}:${column}`); + declare = text === 'declare' ? 1 : declare === 1 && text === 'global' ? 2 : 0; + return; + } + if (kind === Kind.Punct && text === '{' && declare === 2) inGlobal = true; + declare = 0; + }); + return { script, globalWords }; +} + /** * Feed `source`'s tokens to `visit`: words, string literals (by their * contents), template literals (as one token, their `${…}` expressions read as diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index b0bd3630e..8fa0b3abd 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -21,7 +21,7 @@ import { cppClassWritten, type CppNameSite } from './cpp-supertypes'; import { cppIncludedFile, cppIncludedHeadersBeside, cppIncluders } from './cpp-includers'; import { isCFeatureVariant, isOffByDefault } from './c-feature-variants'; import { isTestPath } from '../search/query-utils'; -import { isMergedAmbientDeclaration } from './ambient-modules'; +import { isMergedAmbientDeclaration, scanGlobalScope, type GlobalScopeScan } from './ambient-modules'; import { isMinifiedContent } from '../extraction/generated-detection'; import { getCargoWorkspaceCrateMap } from './frameworks/cargo-workspace'; import { readRustScopes, rustBindingAt, rustInnerBlockAt, rustBoundsOf, rustFnAt, rustImplAt, splitRustTopLevel, type RustBinding, type RustPatternSite, type RustScopes } from './rust-scopes'; @@ -1533,6 +1533,51 @@ function isSealedModule(filePath: string, context: ResolutionContext): boolean { return sealed; } +/** A TypeScript or JavaScript source file, read as such (an SFC's markup is not). */ +const TS_JS_FILE = /\.[cm]?[jt]sx?$/i; + +/** Per-context memo: file → where it declares into the global scope (null when unreadable). */ +const TS_GLOBAL_SCOPES = new WeakMap>(); + +/** scanGlobalScope of `file`, a CommonJS file's top level counting as its own. */ +function tsGlobalScope(file: string, context: ResolutionContext): GlobalScopeScan | null { + let memo = TS_GLOBAL_SCOPES.get(context); + if (!memo) TS_GLOBAL_SCOPES.set(context, (memo = new Map())); + let scope = memo.get(file); + if (scope === undefined) { + const source = context.readFile(file); + scope = source === null ? null : scanGlobalScope(source); + if (scope?.script && HAS_CJS_EXPORT.test(source!)) scope = { script: false, globalWords: new Set() }; + memo.set(file, scope); + } + return scope; +} + +/** + * Whether a supertype written as a bare name the file doesn't import, in an + * ES module, may be `n`: a declaration of the file's own, or a global one — + * at a script's top level (a file with no import or export, a script `.d.ts`) + * or directly inside `declare global`. A module's own declaration, exported + * or not, is reached only through an import, which the import resolver has + * followed already, and a namespace's member is no global. So an interface's + * `extends Omit<…>` is TypeScript's `Omit`, never vscode's `export type Omit` + * in quickInput.ts, and `extends Function` never terminal-suggest's + * `Fig.Function`. + */ +function isTsSupertypeInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (n.filePath === ref.filePath || !TS_JS_FILE.test(n.filePath)) return true; + const scope = tsGlobalScope(n.filePath, context); + return !scope || scope.globalWords.has(`${n.startLine}:${n.startColumn}`); +} + +/** Whether `ref` is a supertype written as a bare name its ES module doesn't import (isTsSupertypeInScope). */ +function isTsBareSupertype(ref: UnresolvedRef, context: ResolutionContext): boolean { + return isInheritanceRef(ref) && JS_FAMILY.has(ref.language) && TS_JS_FILE.test(ref.filePath) && + /^[A-Za-z_$][\w$]*$/.test(ref.referenceName) && + !context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === ref.referenceName) && + tsGlobalScope(ref.filePath, context)?.script === false; +} + /** * Whether `candidate` can be named by a reference in `ref`'s file at all. * Both name-based strategies validate their chosen candidate. Removing an @@ -11356,6 +11401,9 @@ export function matchByExactName( const valueRef = ref.referenceKind === 'references' || ref.referenceKind === 'function_ref'; const importRef = ref.referenceKind === 'imports'; const inheritanceRef = isInheritanceRef(ref); + // Read once, and only for a candidate in another file: it scans the file. + let bareSupertype: boolean | undefined; + const tsBareSupertype = (): boolean => (bareSupertype ??= isTsBareSupertype(ref, context)); const sameName = context.getNodesByName(ref.referenceName); // `NAME(...)` where NAME is a function-like macro somewhere in the project is // an expansion or a call to a same-named function — never the macro itself @@ -11456,6 +11504,10 @@ export function matchByExactName( // single-match shortcut. Restricting the pool BEFORE ranking lets the // legitimate supertype win instead of merely dropping the false edge. (!inheritanceRef || isSupertypeTarget(n)) && + // And a supertype a TS/JS module names without importing it is its own or + // a global, never another module's — ahead of the ranking, so a global + // `interface Window` in a `declare global` still wins. + !(inheritanceRef && n.filePath !== ref.filePath && tsBareSupertype() && !isTsSupertypeInScope(n, ref, context)) && // Likewise for `imports`: a member that only exists inside a type is not // importable, so it is not a candidate. Without this a `path`/`id`/`url` // import resolved to some interface's same-named property. @@ -12477,6 +12529,62 @@ export function matchCollapsedObjectCall(ref: UnresolvedRef, context: Resolution return resolveObjectPathMember(host[2]!.replace(/[\s?]/g, ''), ref.referenceName, ref, context, host[1]!) ?? undefined; } +/** + * The method `methodName` a TS/JS class or interface declared in `file` gets + * from its supertypes, found through that declaration's own extends and + * implements edges: the member a supertype's own declaration has, each + * supertype and then its own supertypes in the order they are written, as + * the walk by name goes. That walk (getSupertypes) unions the supertypes of + * every type sharing the name and looks the method up by its name again, so a + * supertype declared in many files — vscode's `IDisposable`, in eleven — gave + * whichever one came first, another app's included. Undefined when `file` + * declares no such type, or none of its supertypes has the member. + */ +function esInheritedMethod(typeName: string, methodName: string, file: string, ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + const supertypeNodes = context.getSupertypeNodes; + if (!supertypeNodes) return undefined; + const decls = context.getNodesByName(typeName).filter((n) => n.filePath === file && (n.kind === 'class' || n.kind === 'interface')); + const seen = new Set(decls.map((n) => n.id)); + const inherited = (decl: Node, depth: number): Node | undefined => { + for (const base of supertypeNodes(decl.id)) { + if (seen.has(base.id)) continue; + seen.add(base.id); + const members = context.getMethodMatches?.(base.name, methodName, ref.language) ?? + context.getNodesByName(methodName).filter((m) => m.kind === 'method' && m.qualifiedName.endsWith(`${base.name}::${methodName}`)); + const own = members.find((m) => m.filePath === base.filePath && m.startLine >= base.startLine && m.endLine <= base.endLine); + if (own) return own; + const above = depth < 3 ? inherited(base, depth + 1) : undefined; + if (above) return above; + } + return undefined; + }; + for (const decl of decls) { + const found = inherited(decl, 0); + if (found) return found; + } + return undefined; +} + +/** + * The supertypes, by name, of the TS/JS classes named `typeName`: the walk by + * name without an interface's supertypes, which only esInheritedMethod reads, + * from the interface's own declaration. Through any interface of the name, a + * type from a package or a global namespace (vscode's `NodeJS.ReadableStream`) + * reached the supertypes of the project's own `ReadableStream`, and a type + * whose import doesn't resolve those of whichever app declares one. + */ +function esClassSupertypes(typeName: string, language: Language, context: ResolutionContext): string[] { + if (!context.getSupertypeNodes) return context.getSupertypes?.(typeName, language) ?? []; + const names = new Set(); + for (const n of context.getNodesByName(typeName)) { + if (n.language !== language || n.kind !== 'class') continue; + for (const s of context.getSupertypeNodes(n.id)) { + if (s.name && s.name !== typeName) names.add(s.name); + } + } + return [...names]; +} + // Exported for the precedence unit tests (#1079): they assert the // preferredFqn → same-file → matches[0] ordering directly. export function resolveMethodOnType( @@ -12544,7 +12652,16 @@ export function resolveMethodOnType( // a supertype), so a wrong inference produces no edge. if (depth < 4 && context.getSupertypes) { const viaSupers = nmTimedT('rmot-supers', ref, (): ResolvedRef | null => { - for (const supertype of context.getSupertypes!(typeName, ref.language)) { + // A TS/JS type whose declaration is known — the file it is imported + // from, or else the call's own — takes the member its own supertypes + // declare before any type of the same name elsewhere is asked. + const es = ES_IMPORT_LANGUAGES.has(ref.language); + if (depth === 0 && es) { + const inherited = esInheritedMethod(typeName, methodName, preferredFqn ?? ref.filePath, ref, context); + if (inherited) return { original: ref, targetNodeId: inherited.id, confidence, resolvedBy }; + } + const supertypes = es ? esClassSupertypes(typeName, ref.language, context) : context.getSupertypes!(typeName, ref.language); + for (const supertype of supertypes) { const via = resolveMethodOnType( supertype, methodName, ref, context, confidence, resolvedBy, preferredFqn, depth + 1, ); @@ -17086,6 +17203,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { TS_IMPORTED_TYPE_FILES.delete(context); TS_NAMED_CLASSES.delete(context); TS_AMBIENT_MODULE_FILES.delete(context); + TS_GLOBAL_SCOPES.delete(context); C_STATIC_MEMO.delete(context); CPP_TYPE_DECLS.delete(context); CPP_INCLUDE_LINES.delete(context); @@ -23372,6 +23490,7 @@ export function matchFuzzy( (TYPE_MEMBER_KINDS.has(finalCandidates[0]!.kind) || (finalCandidates[0]!.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)))) && !(JS_FAMILY.has(ref.language) && isOutOfRepoBinding(ref.referenceName, ref, context)) && + !(finalCandidates[0]!.filePath !== ref.filePath && isTsBareSupertype(ref, context) && !isTsSupertypeInScope(finalCandidates[0]!, ref, context)) && !(finalCandidates[0]!.kind === 'method' && isBareGoCall(ref, context)) && // A bare PHP call is a function call (case-insensitive, so fuzzy may find // one) — never the class `View` for `view(…)`, never a method.