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