Skip to content
Merged
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 @@ -256,6 +256,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
- In Kotlin, a call through a property now links to the method of the type the property is declared with, like `server.enqueue(…)` on a `lateinit var server: MockWebServer` or a call on a constructor property such as `class A(private val repo: Repo)`. So does a call through a chain like `engine.pump.drain()` or `Mode.ON.next()`, through a property one of your classes inherits, through a local set from a call like `val lease = HardwareLock.tryBegin()`, and through a local of the function around an anonymous `object : …`. Before, such a call went to an interface's method or to whichever method had the name: on okhttp, the tests' calls reached the deprecated `MockWebServer` instead of the one each file imports. A call on a type from outside your project, like `Regex` or a JDK class, no longer links to a same-named method of your own, unless your project extends that type with it. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
- In Kotlin, a `fun interface` is now indexed as an interface with its methods, like okhttp's `HttpLoggingInterceptor.Logger`. Before, it was read as a broken function, and when a comment followed it, the declaration after it went missing too, its members turning into loose functions. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
- 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 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.
Expand Down
112 changes: 112 additions & 0 deletions __tests__/kotlin-extension-on-imported-type.test.ts
Original file line number Diff line number Diff line change
@@ -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<string, string> = {
'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']);
});
});
28 changes: 26 additions & 2 deletions src/resolution/name-matcher.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1966,6 +1966,29 @@ export function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: R
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<string> = new Set([
'assert', 'error', 'ipairs', 'pairs', 'next', 'type', 'tostring', 'tonumber', 'setmetatable', 'getmetatable',
Expand Down Expand Up @@ -28885,10 +28908,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
Expand Down