diff --git a/CHANGELOG.md b/CHANGELOG.md index 8611ba779c..f376724c75 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Java, Kotlin and Scala, an interface method that is overloaded now links each overload to the implementation's overload with the same parameters, not to every implementation method of that name, so `codegraph_explore` and callers no longer follow `put(String)` into `put(List, double)`. A generic interface's method still reaches every implementation of its parameter count. Thanks @hxj1225 for the report. (#2663) - In TypeScript, an awaited call with type arguments like `await fetchApi("/me")` or `await api.get(url)` now links to the function it calls. Before, the call was dropped, so typed fetch wrappers looked like they had no callers. (#2586) - In React, a component rendered at module scope, in a top-level constant or inside a test's `it(…)` callback, like `createRoot(el).render()` or `render()`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587) - In Python, `import a.b as X` written inside a function now links its calls like `X.f()`, as the same import at module level already did. Thanks @Egrn for the report. (#2600) diff --git a/__tests__/interface-impl-overloads.test.ts b/__tests__/interface-impl-overloads.test.ts new file mode 100644 index 0000000000..16a84a798a --- /dev/null +++ b/__tests__/interface-impl-overloads.test.ts @@ -0,0 +1,150 @@ +/** + * An interface overload is implemented by the overload with its parameters + * (#2663). The interface-impl synthesizer linked every overload of an + * interface method to every same-named overload of the implementation, so + * `putTairValue(String)` reached `putTairValue(List, double)` and the + * other way round. In Java, Kotlin and Scala an override repeats the + * parameter list, so a base overload pairs with the implementation overloads + * of its parameter count, and of those with the one whose parameter types are + * written alike when there is one. A generic interface's `save(T)` still + * reaches every one-parameter `save` of its implementation. + * + * The generic case is written in Kotlin: a Java `implements Store` + * gets no `implements` edge today, generic or not of this change. + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-iface-overloads-')); + const files: Record = { + // The issue's example. + 'src/ExampleService.java': `import java.util.List; + +interface ExampleService { + Object putTairValue(String tairKey); + + Object putTairValue(List tairKey, double count); +} +`, + 'src/ExampleServiceImpl.java': `import java.util.List; + +class ExampleServiceImpl implements ExampleService { + @Override + public Object putTairValue(String tairKey) { + return null; + } + + @Override + public Object putTairValue(List tairKey, double count) { + return null; + } +} +`, + // Same parameter count, told apart by type, `final` and an annotation aside. + 'src/Store.java': `interface Store { + void put(String key); + void put(int index); +} + +class MapStore implements Store { + public void put(@Deprecated final String key) {} + public void put(int index) {} +} +`, + // A type parameter: no implementation's types are written alike, so every one of its count. + 'src/Repository.kt': `interface Repository { + fun save(entity: T) + fun save(entities: List, flush: Boolean) +} + +class UserRepository : Repository { + override fun save(entity: String) {} + fun save(entities: Map) {} + override fun save(entities: List, flush: Boolean) {} +} +`, + 'src/Cache.kt': `interface Cache { + fun put(key: String): Any? + fun put(keys: List, count: Double = 1.0): Any? +} + +class MemoryCache : Cache { + override fun put(key: String): Any? = null + override fun put(keys: List, count: Double): Any? = null +} +`, + 'src/Repo.scala': `trait Repo { + def find(id: Long): Any + def find(ids: List[Long], limit: Int): Any +} + +class SqlRepo extends Repo { + def find(id: Long): Any = null + def find(ids: List[Long], limit: Int): Any = null +} +`, + }; + 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 }); +}); + +/** `Owner::name:line -> Owner::name:line` of every interface-impl edge from methods of `owner`. */ +const implEdges = (owner: string): string[] => { + const methods = cg.getNodesByKind('method').filter((n) => n.qualifiedName.startsWith(`${owner}::`)); + return methods.flatMap((m) => cg.getOutgoingEdges(m.id) + .filter((e) => e.kind === 'calls' && e.metadata?.synthesizedBy === 'interface-impl') + .map((e) => { + const t = cg.getNode(e.target)!; + return `${m.qualifiedName}:${m.startLine} -> ${t.qualifiedName}:${t.startLine}`; + })).sort(); +}; + +describe('an interface overload is implemented by the overload with its parameters (#2663)', () => { + it('Java: by parameter count', () => { + expect(implEdges('ExampleService')).toEqual([ + 'ExampleService::putTairValue:4 -> ExampleServiceImpl::putTairValue:4', + 'ExampleService::putTairValue:6 -> ExampleServiceImpl::putTairValue:9', + ]); + }); + + it('Java: by parameter types at the same count', () => { + expect(implEdges('Store')).toEqual([ + 'Store::put:2 -> MapStore::put:7', + 'Store::put:3 -> MapStore::put:8', + ]); + }); + + it('a type parameter: every implementation overload of its count, as before', () => { + expect(implEdges('Repository')).toEqual([ + 'Repository::save:2 -> UserRepository::save:7', + 'Repository::save:2 -> UserRepository::save:8', + 'Repository::save:3 -> UserRepository::save:9', + ]); + }); + + it('Kotlin and Scala: by parameter count, a default value aside', () => { + expect(implEdges('Cache')).toEqual([ + 'Cache::put:2 -> MemoryCache::put:7', + 'Cache::put:3 -> MemoryCache::put:8', + ]); + expect(implEdges('Repo')).toEqual([ + 'Repo::find:2 -> SqlRepo::find:7', + 'Repo::find:3 -> SqlRepo::find:8', + ]); + }); +}); diff --git a/src/resolution/callback-synthesizer.ts b/src/resolution/callback-synthesizer.ts index 0dac3e1e1d..465699ccd1 100644 --- a/src/resolution/callback-synthesizer.ts +++ b/src/resolution/callback-synthesizer.ts @@ -1563,6 +1563,80 @@ function goPromotions( }; } +/** + * Languages where an override repeats the parameter list of what it + * overrides, so an interface overload is implemented by the same-named + * method with the same parameters: Java, Kotlin, C# and Scala. TypeScript is + * not one — an implementation may take fewer parameters than its interface + * declares. + */ +const PARAMETER_PAIRED_LANGS = new Set(['java', 'kotlin', 'csharp', 'scala']); + +/** + * The parameter types a stored method signature lists, as written and with + * whitespace dropped — `(String tairKey)` and `Object (List tairKey, + * double count)` (Java), `(keys: List, count: Double = 1.0): Any?` + * (Kotlin), `(keys: List[String], count: Double): Any` (Scala). Null when the + * signature is missing or takes a variable number of arguments, which a + * parameter count cannot pair. + */ +function signatureParameterTypes(signature: string | undefined): string[] | null { + if (!signature) return null; + const open = signature.indexOf('('); + if (open < 0) return null; + const params: string[] = []; + let depth = 0; + let start = open + 1; + for (let i = open; i < signature.length; i++) { + const ch = signature[i]!; + if (ch === '(' || ch === '<' || ch === '[' || ch === '{') depth++; + else if (ch === ')' || ch === '>' || ch === ']' || ch === '}') { + depth--; + if (depth === 0) { + params.push(signature.slice(start, i)); + break; + } + } else if (ch === ',' && depth === 1) { + params.push(signature.slice(start, i)); + start = i + 1; + } + } + if (depth !== 0) return null; + const types: string[] = []; + for (const raw of params) { + const param = raw.replace(/@\w+(?:\.\w+)*(?:\([^()]*\))?/g, '').trim(); + if (param === '') continue; + if (/\.\.\.|\bvararg\b|\bparams\b|\*\s*$/.test(param)) return null; + const colon = param.indexOf(':'); + const type = colon >= 0 + ? param.slice(colon + 1).replace(/=.*$/, '') + : param.replace(/\bfinal\b/g, '').replace(/[A-Za-z_$][\w$]*\s*$/, ''); + types.push(type.replace(/\s+/g, '')); + } + return types; +} + +/** + * The implementation overloads an interface method `base` pairs with, among + * `impls` of its name (#2663): in Java, Kotlin, C# and Scala the ones with as + * many parameters as `base`, and of those the ones whose parameter types are + * written alike when there are any — a generic interface's `save(T entity)` + * is implemented as `save(User entity)`, so types that differ do not rule an + * overload out. Every one, as before, when a signature can't be read, takes + * varargs, or no overload has `base`'s parameter count. + */ +function pairedOverloads(base: Node, impls: Node[]): Node[] { + if (impls.length < 2 || !PARAMETER_PAIRED_LANGS.has(base.language)) return impls; + const want = signatureParameterTypes(base.signature); + if (!want) return impls; + const typed = impls.map((m) => ({ m, types: signatureParameterTypes(m.signature) })); + if (typed.some((t) => t.types === null)) return impls; + const sameArity = typed.filter((t) => t.types!.length === want.length); + if (sameArity.length === 0) return impls; + const exact = sameArity.filter((t) => t.types!.every((type, i) => type === want[i])); + return (exact.length > 0 ? exact : sameArity).map((t) => t.m); +} + async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield): Promise { let scanned255 = 0; const edges: Edge[] = []; @@ -1644,8 +1718,10 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield const promotes = goStruct && sup.provenance === 'heuristic' && base.kind === 'interface'; // Group impl methods by name to handle OVERLOADS: an interface `list()` and // `list(params)` are distinct nodes and a call may resolve to either, so - // link every base overload → every same-name impl overload (keying by name - // alone would drop all but one and miss the resolved overload). A Go + // each base overload links the impl overloads it pairs with + // (pairedOverloads) — by parameters where the language makes an override + // repeat them, else every same-name one (keying by name alone would drop + // all but one and miss the resolved overload). A Go // interface's unexported method is matched in its own package only // (goMethodKey): a namesake the type declares elsewhere is another // method, and the one the interface asks for is promoted. @@ -1665,7 +1741,7 @@ async function interfaceOverrideEdges(queries: QueryBuilder, onYield: MaybeYield if (promotes) unmatched.push(bm); continue; } - for (const m of impls) { + for (const m of pairedOverloads(bm, impls)) { if (added >= MAX_CALLBACKS_PER_CHANNEL) break; if (bm.id === m.id) continue; const key = `${bm.id}>${m.id}`;