diff --git a/CHANGELOG.md b/CHANGELOG.md index a10c140b0..12520e27a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -48,6 +48,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- In Kotlin Multiplatform projects, a Java caller (and a Kotlin caller in a platform source set) calling an imported `expect`/`actual` declaration now links the platform `actual` — the one in the caller's own source set when there are several — instead of the commonMain `expect`, which JVM bytecode never contains (timber's Java sample calling `Timber.plant(...)` or extending `Timber.Tree` landed on the commonMain declarations). A commonMain caller still links the `expect` its compile sees. Re-index Kotlin and Java projects after upgrading. Thanks @inth3shadows for the report. (#3033) - 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-expect-actual.test.ts b/__tests__/kotlin-expect-actual.test.ts new file mode 100644 index 000000000..cc1fc20d2 --- /dev/null +++ b/__tests__/kotlin-expect-actual.test.ts @@ -0,0 +1,138 @@ +/** + * A call into a Kotlin Multiplatform object reaches the `actual` the caller + * can see, never the commonMain `expect` (#3033): an `expect` declaration + * compiles to nothing in JVM bytecode, so a Java caller — and a Kotlin caller + * in a platform source set — can only reach the platform `actual`, the one in + * the caller's own source set when there is one. A commonMain caller compiles + * against the `expect`, and keeps it. The import's declaration is whatever + * the platform artifact provides: timber's Java sample calling + * `Timber.plant(...)` reaches the `androidMain` actual, and `extends + * Timber.Tree` the actual's nested type. + */ +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-kmp-expect-actual-')); + const write = (rel: string, source: string): void => { + fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true }); + fs.writeFileSync(path.join(root, rel), source); + }; + write('lib/src/commonMain/kotlin/a/Util.kt', `package a + +expect object Util { + fun go() +} +`); + write('lib/src/androidMain/kotlin/a/Util.kt', `package a + +actual object Util { + @JvmStatic + actual fun go() { println("android") } +} +`); + write('lib/src/commonMain/kotlin/t/Timber.kt', `package t + +expect class Timber { + class Tree + companion object { + fun plant(tree: Tree) + } +} +`); + write('lib/src/androidMain/kotlin/t/Timber.kt', `package t + +actual class Timber { + actual class Tree { + fun uproot(): Int = 1 + } + actual companion object { + @JvmStatic + actual fun plant(tree: Tree) {} + } +} +`); + // A Java caller in an app module: the issue's repro, and timber's shape. + write('app/src/main/java/b/App.java', `package b; +import a.Util; +import t.Timber; +class App { + void run() { Util.go(); } + void grow() { Timber.plant(new Timber.Tree()); } +} + +class Log extends Timber.Tree { } +} +`); + // A Kotlin caller in a platform source set, another package, through the import. + write('lib/src/androidMain/kotlin/c/Caller.kt', `package c + +import a.Util + +fun callUtil() = Util.go() +`); + // A commonMain Kotlin caller: only the expect is in its compile's sight. + write('lib/src/commonMain/kotlin/c/CommonCaller.kt', `package c + +import a.Util + +fun callCommon() = Util.go() +`); + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg.close(); + fs.rmSync(root, { recursive: true, force: true }); +}); + + +/** The `go`/`plant`/`Tree` targets one caller's edges land on, by file. */ +function targetsOf(name: string, member: string): string[] { + const fn = cg.getNodesByKind('function').find((n) => n.name === name) ?? + cg.getNodesByKind('method').find((n) => n.name === name)!; + expect(fn, `${name} should be in the index`).toBeDefined(); + return cg + .getOutgoingEdges(fn.id) + .filter((e) => e.kind === 'calls' || e.kind === 'references' || e.kind === 'instantiates') + .map((e) => cg.getNode(e.target)) + .filter((n): n is NonNullable => !!n && n.name === member) + .map((n) => `${n.kind}:${n.name}@${n.filePath}`) + .sort(); +} + +describe('a Kotlin Multiplatform import reaches the actual the caller sees (#3033)', () => { + it("the issue's repro: a Java caller's `Util.go()` reaches the androidMain actual", () => { + expect(targetsOf('run', 'go')).toEqual(['method:go@lib/src/androidMain/kotlin/a/Util.kt']); + }); + + it("timber's shape: a Java caller's `Timber.plant` reaches the actual's companion method", () => { + expect(targetsOf('grow', 'plant')).toEqual(['method:plant@lib/src/androidMain/kotlin/t/Timber.kt']); + }); + + it("the issue's `extends Timber.Tree` reaches the actual's nested type", () => { + const log = cg.getNodesByKind('class').find((n) => n.name === 'Log')!; + expect(log, 'Log should be in the index').toBeDefined(); + const tree = cg + .getOutgoingEdges(log.id) + .filter((e) => e.kind === 'extends') + .map((e) => cg.getNode(e.target)) + .filter((n): n is NonNullable => !!n) + .map((n) => `${n.kind}:${n.name}@${n.filePath}`); + expect(tree).toEqual(['class:Tree@lib/src/androidMain/kotlin/t/Timber.kt']); + }); + + it('a Kotlin caller in a platform source set reaches the actual in its own source set', () => { + expect(targetsOf('callUtil', 'go')).toEqual(['method:go@lib/src/androidMain/kotlin/a/Util.kt']); + }); + + it('a commonMain caller keeps the expect its compile sees', () => { + expect(targetsOf('callCommon', 'go')).toEqual(['method:go@lib/src/commonMain/kotlin/a/Util.kt']); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index aca107fe7..859b61e3c 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -416,6 +416,7 @@ function resolveImportPathUncached( language: Language, context: ResolutionContext ): string | null { + // COBOL COPY/EXEC SQL INCLUDE names a copybook member, not a path — the // compiler searches a library, so we match against indexed file basenames. // Must run before isExternalImport: a bare member name would otherwise be @@ -2632,6 +2633,32 @@ function pickClosestJvmCandidate(candidates: Node[], fromPath: string): Node { return best; } +/** The Kotlin Multiplatform source set a path belongs to — `lib/src/androidMain/kotlin/a/Util.kt` is `androidMain` — or null. */ +function kmpSourceSet(filePath: string): string | null { + return /(?:^|\/)src\/([^/]+)\//.exec(filePath.replace(/\\/g, '/'))?.[1] ?? null; +} + +/** + * Which of an imported declaration's same-FQN classes the caller reaches when + * Kotlin Multiplatform's `expect` and `actual`s share the name: the `actual`, + * for a caller whose compilation ever holds one — Java code runs against JVM + * bytecode the `expect` never produces, and a Kotlin file outside commonMain + * compiles with its platform's `actual` (#3033). timber's Java sample calling + * `Timber.plant(...)` reaches the `androidMain` actual, not the commonMain + * `expect`. The caller's own source set decides among the actuals when there + * are several (an androidMain caller takes the androidMain one); a commonMain + * caller compiles against the `expect` alone, and keeps the proximity pick. + */ +function jvmExpectActualPick(declarations: Node[], ref: UnresolvedRef): Node { + const actuals = declarations.filter((n) => Array.isArray(n.decorators) && n.decorators.includes('actual')); + if (actuals.length === 0 || actuals.length === declarations.length) return pickClosestJvmCandidate(declarations, ref.filePath); + const callerSet = kmpSourceSet(ref.filePath); + const callerSeesActuals = ref.language === 'java' || (callerSet !== null && callerSet !== 'commonMain'); + if (!callerSeesActuals) return pickClosestJvmCandidate(declarations, ref.filePath); + const sameSet = callerSet === null ? [] : actuals.filter((n) => kmpSourceSet(n.filePath) === callerSet); + return pickClosestJvmCandidate(sameSet.length > 0 ? sameSet : actuals, ref.filePath); +} + /** * PHP scoped calls are encoded as "Alias.method" by both extractors. A use * mapping names a namespace, not a filesystem path, so resolve the receiver @@ -3919,7 +3946,7 @@ function resolveJavaImportedReference( const callOfFunction = matchesBare && ref.referenceKind === 'calls' && named.some((n) => n.kind === 'function' || n.kind === 'method'); if (declarations.length > 0 && !callOfFunction) { - const owner = declarations.length === 1 ? declarations[0]! : pickClosestJvmCandidate(declarations, ref.filePath); + const owner = declarations.length === 1 ? declarations[0]! : jvmExpectActualPick(declarations, ref); const target = jvmImportedMember(owner, memberPath, ref, context); // Of a member's overloads, the one the call's argument count fits: // `ItemStack.Hash.of(stack, registries)`, not `of(stack)`.