diff --git a/CHANGELOG.md b/CHANGELOG.md index 773a2459f..736c93cac 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Kotlin, calls to an extension on an imported type now find it when its receiver is written with an import alias, even when the declaration and caller use different aliases; an alias for a different type is still kept apart. Re-index Kotlin projects after upgrading. - 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 JavaScript and TypeScript, a call or `new` in a parameter's default value, like `function renderWithNote(note = buildNote())`, a destructured `{ size = defaultSize() }` or `constructor(private svc = new Service())`, now counts as the function's own call. Before, it was dropped, so `buildNote` was missing that caller and impact on it came back incomplete. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and the pull request. (#2588) - 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) diff --git a/__tests__/kotlin-extension-import-alias.test.ts b/__tests__/kotlin-extension-import-alias.test.ts new file mode 100644 index 000000000..2624e0a97 --- /dev/null +++ b/__tests__/kotlin-extension-import-alias.test.ts @@ -0,0 +1,76 @@ +import { describe, expect, it } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +describe('Kotlin extension receiver import aliases', () => { + it('retains an aliased library extension without accepting the same alias for another type', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-import-alias-')); + let cg: CodeGraph | undefined; + try { + for (const [file, source] of Object.entries({ + 'p/Foreign.kt': `package p +import javax.lang.model.element.Modifier as LayoutModifier +import androidx.compose.ui.Modifier as UnusedCompose +fun LayoutModifier.keyword() {}`, + 'q/Calls.kt': `package q +import androidx.compose.ui.Modifier as LayoutModifier +import p.keyword +fun LayoutModifier.pad() {} +fun caller() { + LayoutModifier.pad() + LayoutModifier.keyword() +}`, + })) { + const target = path.join(dir, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, source); + } + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + const caller = cg.searchNodes('caller').map(r => r.node).find(n => n.name === 'caller')!; + expect(cg.getCallees(caller.id).filter(c => c.edge.kind === 'calls').map(c => c.node.qualifiedName)) + .toEqual(['LayoutModifier::pad']); + } finally { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + + it.each([ + ['Modifier', 'CallAlias'], + ['DeclAlias', 'Modifier'], + ['DeclAlias', 'CallAlias'], + ['SameAlias', 'SameAlias'], + ])('links a receiver declared as %s and called as %s', async (declared, called) => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-alias-')); + let cg: CodeGraph | undefined; + const typeImport = (alias: string) => 'import androidx.compose.ui.Modifier' + (alias === 'Modifier' ? '' : ` as ${alias}`); + try { + const files = { + 'extensions/Extensions.kt': `package extensions\n${typeImport(declared)}\nfun ${declared}.pad() {}`, + 'decoy/Extensions.kt': `package decoy\nimport javax.lang.model.element.Modifier as ${declared}\nfun ${declared}.pad() {}`, + 'caller/Calls.kt': `package caller\n${typeImport(called)}\nimport extensions.pad\nfun caller() { ${called}.pad() }`, + 'unimported/Calls.kt': `package unimported\n${typeImport(called)}\nfun hidden() { ${called}.pad() }`, + }; + for (const [file, source] of Object.entries(files)) { + const target = path.join(dir, file); + fs.mkdirSync(path.dirname(target), { recursive: true }); + fs.writeFileSync(target, source); + } + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + const calls = (name: string) => { + const caller = cg!.getNodesByName(name).find(n => n.kind === 'function')!; + return cg!.getCallees(caller.id).filter(c => c.edge.kind === 'calls').map(c => c.node.filePath); + }; + expect(calls('caller')).toEqual(['extensions/Extensions.kt']); + expect(calls('hidden')).toEqual([]); + } finally { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + } + }); + +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index e2a180ee1..371b83d98 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2367,10 +2367,14 @@ function kotlinExtensionOnImportedType(ref: UnresolvedRef, context: ResolutionCo const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === call[1]); if (!binding) return null; const pkg = binding.source.slice(0, binding.source.lastIndexOf('.')); - const extensions = context.getNodesByQualifiedName(`${binding.exportedName}::${call[2]}`).filter((n) => { - if (n.kind !== 'method' || n.language !== 'kotlin' || !isKotlinTopLevelVisible(n, ref, context)) return false; - const scope = kotlinFileScope(n.filePath, context); - return scope.imports.has(binding.source) || scope.stars.has(pkg); + const extensions = context.getNodesByName(call[2]!).filter((n) => { + if (n.kind !== 'method' || n.language !== 'kotlin') return false; + // Each file may spell the same imported receiver with a different alias. + const receiver = n.qualifiedName.slice(0, n.qualifiedName.lastIndexOf('::')); + const declared = context.getImportMappings(n.filePath, 'kotlin').find((m) => m.localName === receiver); + const sameType = declared ? declared.source === binding.source + : receiver === binding.exportedName && kotlinFileScope(n.filePath, context).stars.has(pkg); + return sameType && isKotlinTopLevelVisible(n, ref, context); }); const chosen = extensions.length > 1 ? extensions.filter((n) => n.filePath === ref.filePath) : extensions; return chosen.length === 1 ? { original: ref, targetNodeId: chosen[0]!.id, confidence: 0.9, resolvedBy: 'qualified-name' } : null;