Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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<User>("/me")` or `await api.get<T>(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(<App />)` or `render(<Card />)`, now counts as rendered there, so an app's entry reaches its root component and a component's tests show among its callers. (#2587)
Expand Down
76 changes: 76 additions & 0 deletions __tests__/kotlin-extension-import-alias.test.ts
Original file line number Diff line number Diff line change
@@ -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 });
}
});

});
12 changes: 8 additions & 4 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down