From f3f7393f2f1a13955e2ea0bf2cf98ed958efb278 Mon Sep 17 00:00:00 2001 From: Colby McHenry Date: Thu, 29 Oct 2026 12:13:29 -0500 Subject: [PATCH] fix(jvm): a call through an import reaches the Java or Kotlin class it names A Java or Kotlin import was matched against a file path written with the calling file's own extension (com/example/Foo.java from Java, .kt from Kotlin), so an import across the two languages never bound: Kotlin's JUtil.make() on an imported Java class went to a same-named class in another package, JUtil.Nested.deep() and Outer.Inner() linked nothing, and Java's KUtil.INSTANCE.kmake() went to another package's object. The import is now read by qualified name in either language (the package is some prefix of the name, so import p.Outer.Inner is p::Outer::Inner), and the rest of the reference walks the imported type's nested types: a Kotlin object's INSTANCE and a class's Companion, as Java writes them, step onto the type, whose companion members the index keeps on the class. Java's UtilsKt.helper() reaches the top-level function of p/Utils.kt, and import p.UtilsKt links that file. A call picks the member's method over a same-named field or nested class (jextract's Index_h.clang_getRange() is the static method, not the holder class of that name), the overload its argument count fits (Minestom's NetworkBufferTemplate.template(a, g, b, h, ctor)), and, from Kotlin, a nested Java class's own constructor. A Kotlin chain rooted at an imported type is walked before the receiver-chain typing. Only an imported type takes this path; an imported function or property is left to the name strategies, which know a call's receiver. A Kotlin extension declared in another package (fun Modes.hidden(), indexed as Modes::hidden) is now a candidate only from a file that imports it or its package, as Kotlin has it. Validation: - jvm-cross-language-imports.test.ts: 8 tests, all red on main, green here (cases from #1948's test and the T5 triage fixtures). - A/B against main 86c85cff (the base, 8bd55fa7, gives identical graphs on jextract and nowinandroid, as 4955eac9 did there and on okhttp), sites added/removed/retargeted, with confidence raised at the same target: okhttp +172/-0/30 (167 raised), SystemUI +83/-0/25 (642), mihon +21/-26/28, nowinandroid +24/-0/5, jextract +96/-96/18 (calls that were instantiations of a same-named holder class now call the static method), spring-modulith 0/0/16, Minestom 0/0/843 (799 onto the overload their argument count fits, none against it), spring-ai +14/-12/73 (50 overloads; 22 onto the imported provider's nested class), playframework 0/0/37, kestra 0/0/302 (290 overloads); click and gin identical. Added: Java calls into Kotlin companions and objects (SystemUI's PhysicsAnimator.getInstance, okhttp's RequestBody.create), Kotlin calls into Java classes, type references across the two. Removed: guesses onto extensions a file cannot see (mihon's JsonObject.toByteArray on a String) and calls that become instantiations of a nested type. - Cost (3 interleaved fresh indexes): mihon CPU 27.7 s on main vs 27.5 s, wall 8.6 vs 8.7 s; okhttp CPU 31.5 s on both, wall 10.0 vs 9.8 s. Reimplements #1948 by @mixxer. Co-Authored-By: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/jvm-cross-language-imports.test.ts | 195 +++++++++++++++++ src/resolution/import-resolver.ts | 215 ++++++++++++++----- src/resolution/index.ts | 8 + src/resolution/name-matcher.ts | 32 ++- 5 files changed, 391 insertions(+), 60 deletions(-) create mode 100644 __tests__/jvm-cross-language-imports.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 955f1ae9e..782ff21bf 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -261,6 +261,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Kotlin, a `private val` or `private var` property, and the members of a `private` class or object, no longer take calls from other files. Properties were indexed without their visibility, so a call like `token(...)` in one file could link to another class's `private var token`, and JavaScript, Go or Python callers could land on a Kotlin test's private property; a call to `now()` could also land on a method of a test's `private class Clock`. Re-index Kotlin projects after upgrading. Thanks @danusha2345. - In Kotlin, a call on a library type you import, like Jetpack Compose's `Modifier.pad()`, now links to the extension your project declares on that type, `fun Modifier.pad()`, when the calling file can see it: in the same package, or imported by name or with `.*`. The library type's own methods, like `Modifier.fillMaxSize()`, still never link to a project method that shares the name. Re-index Kotlin projects after upgrading. Thanks @danusha2345. - In Kotlin, when two packages each declare a class with the same name, a call on a value of that type now links to the class the file imports, even when that class lives in a file named for something else, like `FooConverter` in `Converters.kt`. Before, it went to whichever of the two came first. Java calls on such a class, like okhttp's Java tests calling the `mockwebserver3.MockWebServer` they import, now link through the type their variable is declared with (#1942). Re-index Kotlin projects after upgrading. Thanks @arttttt. +- In Kotlin and Java, a call through an import of a class written in the other language now links to that class: Kotlin's `JUtil.make()`, `JUtil.Nested.deep()` or `Outer.Inner()` on an imported Java class, and Java's `KUtil.INSTANCE.kmake()` on a Kotlin object, `Factory.Companion.create()` on a companion object or `UtilsKt.helper()` on a Kotlin file's top-level function, whose import now links that file. Before, the import was only matched against files in the calling file's own language, so the call went to a same-named class in another package, or to nothing. A Kotlin extension declared in another package, like `fun Modes.hidden()`, is now reached only from a file that imports it or its package. Re-index Kotlin and Java projects after upgrading. Thanks @mixxer. - In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497) - In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499) - In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading. diff --git a/__tests__/jvm-cross-language-imports.test.ts b/__tests__/jvm-cross-language-imports.test.ts new file mode 100644 index 000000000..4b5413c50 --- /dev/null +++ b/__tests__/jvm-cross-language-imports.test.ts @@ -0,0 +1,195 @@ +/** + * A Java or Kotlin import names a declaration by its qualified name, whichever + * JVM language declares it. Kotlin calls Java classes through their imports + * (`JUtil.make()`, `JUtil.Nested.deep()`, `Outer.Inner()`), and Java calls a + * Kotlin object through `INSTANCE`, a companion through `Companion`, and a + * Kotlin file's top-level functions through the class named for the file + * (`UtilsKt.helper()`). Before, the import was matched against a file path + * with the calling file's own extension, so it never bound across languages: + * the call went to a same-named class in another package, or nowhere. A + * Kotlin extension declared in another package is in scope only through an + * import of it or of its package. + * + * Cases from @mixxer's #1948 (and the T5 triage fixtures built on it). + */ +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-jvm-cross-imports-')); + const files: Record = { + 'p/JUtil.java': `package p; +public class JUtil { + public static int make() { return 1; } + public static class Nested { public static int deep() { return 2; } } +} +`, + 'other/JUtil.java': `package other; +public class JUtil { + public static int make() { return 9; } + public static class Nested { public static int deep() { return 9; } } +} +`, + 'p/Outer.java': `package p; +public class Outer { public static class Inner { public Inner() {} } } +`, + 'p/Plain.java': `package p; +public class Plain { public static class Nest { void run() {} } } +`, + 'p/KUtil.kt': `package p + +object KUtil { + fun kmake(): Int = 1 +} + +class KCompanion { + companion object { + fun make(): Int = 1 + } +} +`, + 'other/KUtil.kt': `package other + +object KUtil { + fun kmake(): Int = 9 +} + +class KCompanion { + companion object { + fun make(): Int = 9 + } +} +`, + 'p/Utils.kt': `package p + +fun helper(): Int = 1 +`, + 'other/Utils.kt': `package other + +fun helper(): Int = 9 +`, + 'p/Modes.kt': `package p + +object Modes + +fun Modes.shared() {} +`, + 'p/Ext.kt': `package p + +fun Modes.extOther() {} +fun Modes.hidden() {} +`, + 'q/FromKotlin.kt': `package q + +import p.JUtil +import p.Outer +import p.Plain +import p.Modes +import p.extOther + +class FromKotlin { + fun staticCall() { JUtil.make() } + fun nestedStatic() { JUtil.Nested.deep() } + fun javaCtor() { Outer.Inner() } + fun javaNoCtor() { Plain.Nest() } + fun importedExtension() { Modes.extOther() } + fun unimportedExtension() { Modes.hidden() } + fun besideTypeNotImported() { Modes.shared() } +} +`, + 'q2/Shared.kt': `package q2 + +import p.Modes +import p.shared + +fun useShared() { Modes.shared() } +`, + 'q/FromJava.java': `package q; + +import p.KUtil; +import p.KCompanion; +import p.UtilsKt; + +public class FromJava { + void objectCall() { KUtil.INSTANCE.kmake(); } + void companionCall() { KCompanion.Companion.make(); } + void topLevelCall() { UtilsKt.helper(); } +} +`, + }; + 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 }); +}); + +/** `kind qualified-name` of every call or instantiation `caller` makes. */ +function callees(file: string, caller: string): string[] { + const from = cg.getNodesInFile(file).find((n) => n.name === caller)!; + return cg + .getOutgoingEdgesFrom([from.id]) + .filter((e) => e.kind === 'calls' || e.kind === 'instantiates') + .map((e) => `${e.kind} ${cg.getNode(e.target)!.qualifiedName}`) + .sort(); +} + +describe('Kotlin calls a Java class through its import', () => { + it('reaches the imported package\'s class, not a same-named one elsewhere', () => { + expect(callees('q/FromKotlin.kt', 'staticCall')).toEqual(['calls p::JUtil::make']); + }); + + it('walks a nested class', () => { + expect(callees('q/FromKotlin.kt', 'nestedStatic')).toEqual(['calls p::JUtil::Nested::deep']); + }); + + it('builds a nested class through its own constructor, or instantiates one that declares none', () => { + expect(callees('q/FromKotlin.kt', 'javaCtor')).toEqual(['calls p::Outer::Inner::Inner']); + expect(callees('q/FromKotlin.kt', 'javaNoCtor')).toEqual(['instantiates p::Plain::Nest']); + }); +}); + +describe('Java calls Kotlin through its import', () => { + it('reaches an object\'s member through INSTANCE', () => { + expect(callees('q/FromJava.java', 'objectCall')).toEqual(['calls p::KUtil::kmake']); + }); + + it('reaches a companion\'s member through Companion', () => { + expect(callees('q/FromJava.java', 'companionCall')).toEqual(['calls p::KCompanion::make']); + }); + + it('reaches a top-level function through its file\'s class, and the import links that file', () => { + expect(callees('q/FromJava.java', 'topLevelCall')).toEqual(['calls p::helper']); + const ids = cg.getNodesInFile('q/FromJava.java').map((n) => n.id); + const imported = cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'imports') + .map((e) => cg.getNode(e.target)!) + .map((n) => (n.kind === 'file' ? `file ${n.filePath}` : `${n.kind} ${n.qualifiedName}`)) + .sort(); + expect(imported).toEqual(['class p::KCompanion', 'class p::KUtil', 'file p/Utils.kt']); + }); +}); + +describe('A Kotlin extension in another package', () => { + it('is in scope through an import of it, and otherwise not', () => { + expect(callees('q/FromKotlin.kt', 'importedExtension')).toEqual(['calls Modes::extOther']); + expect(callees('q/FromKotlin.kt', 'unimportedExtension')).toEqual([]); + }); + + it('is not in scope through an import of its receiver type alone, even beside that type', () => { + expect(callees('q/FromKotlin.kt', 'besideTypeNotImported')).toEqual([]); + expect(callees('q2/Shared.kt', 'useShared')).toEqual(['calls Modes::shared']); + }); +}); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index c9a918914..eecd68068 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -34,6 +34,8 @@ import { tsGlobalScope, rustPathTarget, isRustNameFromStd, + isKotlinExtensionInScope, + retargetSelfOverload, } from './name-matcher'; import { JS_ARRAY_METHODS } from './js-builtins'; @@ -2517,7 +2519,15 @@ export function resolveJvmImport( if (sym === '*') return null; const candidates = context.getNodesByQualifiedName(`${pkg}::${sym}`); - if (candidates.length === 0) return null; + if (candidates.length === 0) { + // `import p.UtilsKt;` in Java names the class Kotlin compiles `p/Utils.kt`'s + // top-level functions into: that file, in that package. + if (ref.language !== 'java' || !/^\w+Kt$/.test(sym)) return null; + const file = context + .getNodesByName(`${sym.slice(0, -2)}.kt`) + .find((n) => n.kind === 'file' && context.getNodesInFile(n.filePath).some((d) => d.kind === 'namespace' && d.qualifiedName === pkg)); + return file ? { original: ref, targetNodeId: file.id, confidence: 0.9, resolvedBy: 'import' } : null; + } // Kotlin Multiplatform: an `expect` declaration and its `actual`s share one // FQN across source sets (commonMain / androidMain / appleMain). Taking the @@ -3691,16 +3701,112 @@ function resolveRustPathReference( return target ? { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' } : null; } +/** The languages a Java or Kotlin import can name a declaration in: either of them. */ +const JVM_IMPORT_LANGUAGES: ReadonlySet = new Set(['java', 'kotlin']); +/** Declarations a JVM import's path can step into: `p.Outer.Inner`. */ +const JVM_IMPORT_TYPE_KINDS: ReadonlySet = new Set(['class', 'interface', 'enum', 'struct', 'trait', 'type_alias']); + /** - * Resolve a Java/Kotlin reference whose receiver is the simple name of - * an imported FQN: `Foo.bar(...)` where `import com.example.Foo;`. The - * imported FQN converts to a file-path suffix (`com/example/Foo.java` - * or `.kt`) which uniquely identifies the right symbol when multiple - * classes share the same simple name. - * - * Also handles bare references to the imported class itself - * (`new Foo()` extraction emits `Foo` as a `references`/`instantiates` - * ref) and `import static .bar` style imports of a single member. + * The project declarations a Java or Kotlin import names, in either language: + * `import p.Outer.Inner` is `p::Outer::Inner`, the package being some prefix + * of the name. Several when Kotlin Multiplatform's `expect` and `actual`s + * share it; empty when no project declaration has the name. + */ +function jvmImportedDeclarations(fqn: string, context: ResolutionContext): Node[] { + const parts = fqn.split('.'); + for (let i = parts.length - 1; i > 0; i--) { + const found = context + .getNodesByQualifiedName(`${parts.slice(0, i).join('.')}::${parts.slice(i).join('::')}`) + .filter((n) => JVM_IMPORT_LANGUAGES.has(n.language)); + if (found.length > 0) return found; + } + return []; +} + +/** + * A Kotlin file's top-level functions and properties, as Java sees them: the + * members of a class named for the file, `p.UtilsKt` for `p/Utils.kt`. The + * declarations of that name in the file's package, in a file of that name. + */ +function kotlinFileFacadeMembers(fqn: string, member: string, context: ResolutionContext): Node[] { + const dot = fqn.lastIndexOf('.'); + const facade = fqn.slice(dot + 1); + if (dot <= 0 || !/^\w+Kt$/.test(facade)) return []; + const fileName = `${facade.slice(0, -2)}.kt`; + return context + .getNodesByQualifiedName(`${fqn.slice(0, dot)}::${member}`) + .filter((n) => n.language === 'kotlin' && (n.kind === 'function' || n.kind === 'variable' || n.kind === 'constant') && + path.posix.basename(n.filePath.replace(/\\/g, '/')) === fileName); +} + +/** + * The member a JVM reference names through an imported declaration: + * `make` in `JUtil.make()`, `Nested.deep` in `JUtil.Nested.deep()`. Each step + * but the last is a nested type in the same file; a Kotlin object's + * `INSTANCE` and a class's `Companion`, as Java writes them, are the + * declaration itself, whose companion members the index keeps on the class. + * A call to a nested class builds it: Kotlin's `Outer.Inner()`. From Kotlin, + * an extension declared beside the type (`fun Foo.bar()` in Foo's file) + * counts when the calling file can see it: its own package, or an import of + * it or of its package. Undefined when a step names nothing the declaration + * has, like an inherited member or an extension declared elsewhere, which the + * name strategies look up. + */ +function jvmImportedMember(owner: Node, steps: string[], ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + // A Kotlin call that builds a type — `Outer.Inner()`, or `Foo()` on an + // imported Java class — runs the constructor the type declares (Java's, + // a Kotlin secondary one), or else instantiates the type itself. Java + // builds with `new`; a Java "call" of a type's name is a record pattern + // (`case Result.Error(String m)`) or a static-import's method, kept on the type. + const built = (type: Node): Node => + (ref.language === 'kotlin' + ? context.getNodesByQualifiedName(`${type.qualifiedName}::${type.name}`) + .find((n) => n.kind === 'method' && n.filePath === type.filePath) + : undefined) ?? type; + let current = owner; + for (let i = 0; i < steps.length; i++) { + const segment = steps[i]!; + const last = i === steps.length - 1; + if (!last && (segment === 'INSTANCE' || segment === 'Companion') && current.language === 'kotlin' && + JVM_IMPORT_TYPE_KINDS.has(current.kind)) continue; + const members = context + .getNodesByQualifiedName(`${current.qualifiedName}::${segment}`) + .filter((n) => n.filePath === current.filePath); + if (!last) { + const nested = members.find((n) => JVM_IMPORT_TYPE_KINDS.has(n.kind)); + if (!nested) return undefined; + current = nested; + continue; + } + if (ref.referenceKind !== 'calls') return members[0]; + const method = members.find((n) => n.kind === 'method' || n.kind === 'function'); + if (method) return method; + const type = members.find((n) => JVM_IMPORT_TYPE_KINDS.has(n.kind)); + if (type) return built(type); + const holder = current; + const extension = ref.language === 'kotlin' && holder.language === 'kotlin' + ? context + .getNodesByQualifiedName(`${holder.name}::${segment}`) + .find((n) => n.kind === 'method' && n.language === 'kotlin' && n.filePath === holder.filePath && + isKotlinExtensionInScope(n, ref, context)) + : undefined; + // A value called like a function (`MangaCover.Book(…)`, an enum entry's + // `invoke`) is the value. + return extension ?? members[0]; + } + return ref.referenceKind === 'calls' && JVM_IMPORT_TYPE_KINDS.has(current.kind) ? built(current) : current; +} + +/** + * Resolve a Java/Kotlin reference through an import of a project declaration: + * `Foo.bar(...)` after `import com.example.Foo`, the class itself (`new Foo()` + * emits `Foo` as a `references`/`instantiates` ref), and the member an + * `import static com.example.Foo.bar` names. The import is read by qualified + * name, so it binds the right one of several same-named classes, in Java or + * Kotlin whichever language the importing file is written in: a Kotlin file + * calling a Java class, a Java file a Kotlin object through `INSTANCE`, or a + * Kotlin file's top-level function through its `FileKt` class. A declaration + * found only by its file path (`com/example/Foo.java` or `.kt`) still counts. */ function resolveJavaImportedReference( ref: UnresolvedRef, @@ -3709,61 +3815,56 @@ function resolveJavaImportedReference( ): ResolvedRef | null { if (imports.length === 0) return null; - const ext = ref.language === 'kotlin' ? '.kt' : '.java'; - for (const imp of imports) { const matchesBare = imp.localName === ref.referenceName; const matchesQualified = ref.referenceName.startsWith(imp.localName + '.'); if (!matchesBare && !matchesQualified) continue; - - // Convert FQN to a file-path suffix. `com.example.Foo` -> - // `com/example/Foo.java` (or `.kt`). The actual file may live - // under any source root (`src/main/java/`, `src/`, etc.), so match - // by suffix rather than exact path. - const fqnPath = imp.source.replace(/\./g, '/') + ext; - - // Which symbol name to look up: the class itself, or a member. - const memberName = matchesBare - ? imp.localName - : ref.referenceName.substring(imp.localName.length + 1); - - const candidates = context.getNodesByName(memberName); - for (const node of candidates) { - if (node.language !== ref.language) continue; - const fp = node.filePath.replace(/\\/g, '/'); - if (fp.endsWith(fqnPath) || fp.endsWith('/' + fqnPath)) { - return { - original: ref, - targetNodeId: node.id, - confidence: 0.9, - resolvedBy: 'import', - }; - } + const memberPath = matchesBare ? [] : ref.referenceName.substring(imp.localName.length + 1).split('.'); + + // An imported type, and what its name reaches: its members and nested + // types. An imported function or property (`import p.helper`, an + // extension among them) is left to the name strategies, which know a + // Kotlin call's receiver: an untyped chain's last call is no call of it. + const named = jvmImportedDeclarations(imp.source, context); + const declarations = named.filter((n) => JVM_IMPORT_TYPE_KINDS.has(n.kind)); + // A Kotlin name can be a function and a type at once (Compose's + // `fun AppBar(…)` beside `sealed interface AppBar`): a call of the name + // alone is the function's, which the file's own path still finds. + 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 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)`. + if (target) return retargetSelfOverload({ original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' }, ref, context); + continue; } - // `import static com.example.Foo.bar;` — the FQN's tail is the - // member name, the part before is the owner class. Look up the - // member named `` (e.g. `bar`) and prefer the - // candidate whose file matches the parent FQN's path. - if (matchesBare) { - const dot = imp.source.lastIndexOf('.'); - if (dot > 0) { - const ownerFqn = imp.source.substring(0, dot); - const ownerPath = ownerFqn.replace(/\./g, '/') + ext; - for (const node of candidates) { - if (node.language !== ref.language) continue; - const fp = node.filePath.replace(/\\/g, '/'); - if (fp.endsWith(ownerPath) || fp.endsWith('/' + ownerPath)) { - return { - original: ref, - targetNodeId: node.id, - confidence: 0.9, - resolvedBy: 'import', - }; - } - } + // `UtilsKt.helper()` from Java: a top-level Kotlin function. + if (ref.language === 'java' && memberPath.length === 1) { + const facadeMembers = kotlinFileFacadeMembers(imp.source, memberPath[0]!, context); + if (facadeMembers.length > 0) { + const target = facadeMembers.length === 1 ? facadeMembers[0]! : pickClosestJvmCandidate(facadeMembers, ref.filePath); + return retargetSelfOverload({ original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' }, ref, context); } } + + // A declaration the index holds under another name: found by the + // import's path (`com/example/Foo.java` or `.kt`, whichever the + // declaring file is), under any source root. Its member, by name. + const memberName = matchesBare ? imp.localName : memberPath.join('.'); + const fqnPath = imp.source.replace(/\./g, '/'); + const ownerPath = imp.source.includes('.') ? imp.source.substring(0, imp.source.lastIndexOf('.')).replace(/\./g, '/') : ''; + const inFile = (node: Node, file: string): boolean => { + const fp = node.filePath.replace(/\\/g, '/'); + return ['.java', '.kt'].some((ext) => fp.endsWith(file + ext) && (fp.length === file.length + ext.length || fp[fp.length - file.length - ext.length - 1] === '/')); + }; + const candidates = context.getNodesByName(memberName).filter((n) => JVM_IMPORT_LANGUAGES.has(n.language)); + const byPath = candidates.find((n) => inFile(n, fqnPath)) ?? + // `import static com.example.Foo.bar;`: the member's owner names the file. + (matchesBare && ownerPath ? candidates.find((n) => inFile(n, ownerPath)) : undefined); + if (byPath) return retargetSelfOverload({ original: ref, targetNodeId: byPath.id, confidence: 0.9, resolvedBy: 'import' }, ref, context); } return null; } diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 6bcdaf213..8bb90bdc5 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -1571,6 +1571,14 @@ export class ReferenceResolver { // library one. An untyped chain resolves as the bare method name, the ref // the extractor emitted before it kept the chain. if (ref.language === 'kotlin' && ref.referenceKind === 'calls') { + // A chain rooted at an imported declaration, `JUtil.Nested.deep()` after + // `import p.JUtil`, walks that declaration's nested types whichever JVM + // language declares them; a chain through its properties is typed below. + const root = ref.referenceName.slice(0, ref.referenceName.indexOf('.')); + if (root && this.context.getImportMappings(ref.filePath, ref.language).some((m) => m.localName === root)) { + const viaImport = this.gateLanguage(resolveViaImport(ref, this.context), ref); + if (viaImport) return viaImport; + } const chain = matchKotlinReceiverChain(ref, this.context); if (chain && 'method' in chain) { const bare = this.resolveOneInner({ ...ref, referenceName: chain.method }); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 1ca88a596..95c5c48c1 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -8083,6 +8083,20 @@ function isKotlinTopLevelVisible(n: Node, ref: UnresolvedRef, context: Resolutio return pkg === here.pkg || here.stars.has(pkg) || here.imports.has(pkg ? `${pkg}.${n.name}` : n.name) || KOTLIN_DEFAULT_IMPORTS.has(pkg); } +/** + * Whether a Kotlin method found by its type's name can be called from the + * reference's file. A member of a class in its file's package (`p::Modes::x`) + * can; an extension, `fun Modes.hidden()` indexed as `Modes::hidden`, is + * top-level, so only from its own package or through an import of it or its + * package (isKotlinTopLevelVisible). + */ +export function isKotlinExtensionInScope(n: Node, ref: UnresolvedRef, context: ResolutionContext): boolean { + if (n.language !== 'kotlin' || n.kind !== 'method' || n.filePath === ref.filePath) return true; + const pkg = kotlinFileScope(n.filePath, context).pkg; + if (pkg && n.qualifiedName.startsWith(`${pkg}::`)) return true; + return isKotlinTopLevelVisible(n, ref, context); +} + const PHP_TYPE_KINDS: ReadonlySet = new Set(['class', 'trait', 'interface', 'enum']); const PHP_SUPERS = new WeakMap>(); @@ -13944,6 +13958,12 @@ export function resolveMethodOnType( const scoped = resolveGoMethodInPackage(typeName, methodName, preferredFqn, matches, ref, context, confidence, resolvedBy, depth); if (scoped !== undefined) return scoped; } + // A Kotlin extension, `fun Modes.hidden()` (indexed as `Modes::hidden`), is + // a top-level declaration: from another package, only an import of it or of + // its package puts it in scope. A member of the type is always reachable. + if (ref.language === 'kotlin' && matches.some((m) => !isKotlinExtensionInScope(m, ref, context))) { + matches = matches.filter((m) => isKotlinExtensionInScope(m, ref, context)); + } if (matches.length === 0) { // Conformance fallback: the method may be defined on a supertype `typeName` // extends, or on a protocol / trait it conforms to (e.g. a Swift protocol- @@ -22110,7 +22130,9 @@ export function matchMethodCall( // reaches `def self.build`, not `def build`, whichever comes first. const methodNode = classStrategyMethod(classNode, methodName!, context, ref.language === 'ruby' ? 'class' : undefined); - if (methodNode) { + // A Kotlin extension beside the class (`fun Modes.shared()`) needs an + // import of its own from another package. + if (methodNode && !(ref.language === 'kotlin' && !isKotlinExtensionInScope(methodNode, ref, context))) { return { original: ref, targetNodeId: methodNode.id, @@ -22217,7 +22239,7 @@ export function matchMethodCall( // two methods of the name is preferred: the first declared is taken. const methodNode = classStrategyMethod(classNode, methodName!, context); - if (methodNode) { + if (methodNode && !(ref.language === 'kotlin' && !isKotlinExtensionInScope(methodNode, ref, context))) { if (throughCppNamespace()) return null; return { original: ref, @@ -22266,8 +22288,12 @@ export function matchMethodCall( if (methodCandidates.length > AMBIGUOUS_NAME_CEILING) { return null; } + // A Kotlin extension the calling file cannot see, `fun Modes.hidden()` in + // another package with neither it nor its package imported, is no + // candidate at all: as if no method had its name there. const methods = methodCandidates.filter( - (n) => n.kind === 'method' && n.name === methodName + (n) => n.kind === 'method' && n.name === methodName && + !(ref.language === 'kotlin' && !isKotlinExtensionInScope(n, ref, context)) ); // Filter to same-language candidates first