diff --git a/CHANGELOG.md b/CHANGELOG.md index db92767b1..e5b523efc 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,6 +215,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In Rust, a call like `String::from("…")`, `Vec::from([…])` or `NonNull::from(x)` no longer links to your project's own `impl From for String` (or for `Vec`, `NonNull`, …) unless what it passes is a `YourType`. A method call on a standard-library value, like `files.iter()` or `buf.extend(…)`, links to your trait's implementation for that type only where the trait is imported. Before, every `String::from("…")` call in dioxus's CLI linked to one such conversion, and tokio's `vec.iter()` calls to an internal trait's `Vec` impl. Re-index Rust projects after upgrading. - In Rust, a type alias, struct, enum or function declared without `pub` now links only from its own module and the modules inside it, and a name a `use` brings in from one of your workspace's crates, like `use tokio_test::task::spawn` or `use crate::error::Error`, links only to an item of that crate or of a crate it depends on. Before, a file's `use crate::Result` could link its `Result<…>` types to another module's private `type Result`, as in dioxus's CLI, and a call to an imported function could link to another crate's function of the same name, such as tokio's test `spawn(…)` calls to one in its examples, or to nothing because a private function of that name was in the way. Re-index Rust projects after upgrading. - In C++, every name a declaration lists now has the declaration's type, like `strings_found` in google/breakpad's `ByteBuffer symbols_found, strings_found;` or `data_` in a class's `Table *index_, *data_;`, so calls on it link to that class's methods. Before, only the first name had its type read, and a call on another name linked to nothing or to the class of an earlier variable with the same name. +- 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: 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. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/kotlin-extension-on-imported-type.test.ts b/__tests__/kotlin-extension-on-imported-type.test.ts new file mode 100644 index 000000000..70660b6b4 --- /dev/null +++ b/__tests__/kotlin-extension-on-imported-type.test.ts @@ -0,0 +1,112 @@ +/** + * A Kotlin import from outside the project owns its name (#2196): after + * `import androidx.compose.ui.Modifier`, `Modifier.fillMaxSize()` is + * Compose's, never a project method that happens to be called `fillMaxSize`. + * But Kotlin lets the project extend that type — `fun Modifier.pad()`, + * indexed as `Modifier::pad` — and `Modifier.pad()` is that extension when + * the call site can see it: in its own package, or imported by name or `.*`. + * `fun Color.Companion.fromHex()` is called the same way, `Color.fromHex()`. + */ +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-kotlin-ext-imported-')); + const files: Record = { + 'src/main/kotlin/app/ui/Ext.kt': `package app.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +fun Modifier.pad(): Modifier = this + +fun Modifier.hidden(): Modifier = this + +fun Color.Companion.fromHex(hex: String): Color = Color.Red +`, + 'src/main/kotlin/app/other/Box.kt': `package app.other + +class Box { + fun fillMaxSize(): Box = this +} +`, + 'src/main/kotlin/app/gen/Gen.kt': `package app.gen + +import javax.lang.model.element.Modifier + +fun Modifier.keyword(): String = name +`, + 'src/main/kotlin/app/ui/Screen.kt': `package app.ui + +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color + +fun screen() { + Modifier.pad() + Modifier.fillMaxSize() + Color.fromHex("#fff") +} +`, + 'src/main/kotlin/app/feature/Feed.kt': `package app.feature + +import androidx.compose.ui.Modifier +import app.gen.keyword +import app.ui.pad + +fun feed() { + Modifier.pad() + Modifier.hidden() + Modifier.keyword() +} +`, + 'src/main/kotlin/app/settings/Settings.kt': `package app.settings + +import androidx.compose.ui.Modifier +import app.ui.* + +fun settings() { + Modifier.hidden() +} +`, + }; + 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 non-structural edge out of a file. */ +function edgesFrom(file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind !== 'contains' && e.kind !== 'imports') + .map((e) => `${e.kind} ${cg.getNode(e.target)!.qualifiedName}`) + .sort(); +} + +describe('Kotlin: a project extension on a type an outside import names', () => { + it('is what a call on that type reaches from the same package; the type’s own members stay outside', () => { + expect(edgesFrom('src/main/kotlin/app/ui/Screen.kt')).toEqual([ + 'calls Color::fromHex', + 'calls Modifier::pad', + ]); + }); + + it('is reached from another package only through an import of it, by name or `.*`', () => { + expect(edgesFrom('src/main/kotlin/app/feature/Feed.kt')).toEqual(['calls Modifier::pad']); + expect(edgesFrom('src/main/kotlin/app/settings/Settings.kt')).toEqual(['calls Modifier::hidden']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index c77621fb2..61daad1ee 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1798,6 +1798,29 @@ function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: Resoluti return true; } +/** + * `Modifier.pad()` after `import androidx.compose.ui.Modifier`: the type is + * outside the project, but Kotlin lets the project extend it. The call reaches + * a project extension on that type — `fun Modifier.pad()`, indexed as + * `Modifier::pad` — that the call site can see (its file, its package, an + * import of it by name or `.*`) and whose own file imports the same type. + * Compose's own `Modifier.fillMaxSize()` matches none and stays unlinked. + */ +function kotlinExtensionOnImportedType(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null { + const call = /^(\w+)\.(\w+)$/.exec(ref.referenceName); + if (!call || ref.referenceKind !== 'calls') return null; + 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 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; +} + /** Lua's global functions, and the test runner's: `local type = type` is the standard library's `type`. */ const LUA_GLOBAL_FUNCTIONS: ReadonlySet = new Set([ 'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable', @@ -18867,10 +18890,11 @@ function matchReferenceInner( // `import java.lang.reflect.Field;` — the file's `Field` is the JDK's, never // a project class of that name (gson's production code bound it to a test's - // nested `ParameterizedTypesTest.Field`). + // nested `ParameterizedTypesTest.Field`). Only a Kotlin extension the + // project declares on that type is in the graph. if ((ref.language === 'java' || ref.language === 'kotlin') && ref.referenceKind !== 'imports' && isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) { - return null; + return ref.language === 'kotlin' ? kotlinExtensionOnImportedType(ref, context) : null; } // A symbolic name in a Scala type is a type (`F ~> G`) or a kind-projector