Skip to content
Open
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 @@ -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<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
138 changes: 138 additions & 0 deletions __tests__/kotlin-expect-actual.test.ts
Original file line number Diff line number Diff line change
@@ -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<typeof n> => !!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<typeof n> => !!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']);
});
});
29 changes: 28 additions & 1 deletion src/resolution/import-resolver.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)`.
Expand Down