mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-18 22:02:49 +08:00
fix(kotlin): read Kotlin imports; an outside import owns its name (#2196)
The JVM import reader required a `;`, so a Kotlin file had no import mappings at all. It now reads `import a.b.C` and `import a.b.C as D`. - The outside-import rule from #2195 applies to Kotlin: `import java.net.Proxy`, `import org.mockito.Mockito.mock`, `import okio.buffer` own their names. A declaration the file itself makes comes first, in its own namespace (a callable for a call, a type otherwise) - Exposed's `toLocalDateTime(value)` beside `import kotlinx.datetime.toLocalDateTime` is the column type's member; gson's `new URI(...)` stays `java.net.URI` beside `TypeAdapters`' field `URI`. - The mappings pick among same-named project classes: okhttp's `MockResponse.Builder()` (642) and `Request.Builder()` reach their classes, Exposed's `TestDB`/`SchemaUtils` the module the file imports, moshi's `Moshi.Builder()` (102). - A renaming import binds its alias by FQN: kotlinx.coroutines' `import ...internal.unsafeFlow as flow` makes its 28 `flow { }` builders `unsafeFlow`, not the public `flow`. okhttp -486 +1303, Exposed -1685 +1351 (duplicate-module reroutes; losses are kotlin.test/kotlinx/okio/clikt/Gradle functions), moshi -21 +121, okio -128 +51, koin -22 +11, coroutines -68 +47, nowinandroid -44 (Gradle DSL, Compose). Java repos (gson, commons-lang, retrofit, jsoup, halo) unchanged. Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
@@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
|
||||
### Fixes
|
||||
|
||||
- Kotlin imports now decide what a name means, as Java imports already do. `import java.net.Proxy` or `import org.mockito.Mockito.mock` no longer links to a same-named project symbol, and a member the file declares itself still comes first. An `import x.Foo` picks the right one of several same-named classes: on okhttp, `MockResponse.Builder()` and `Request.Builder()` now reach their classes. A renaming import like `import …internal.unsafeFlow as flow` means the function it names.
|
||||
- In Java, a class you import from outside your project now owns its name. After `import java.lang.reflect.Field;` the file's `Field` no longer links to a project class that happens to share the name. In gson that was a test's nested `Field` class; in jsoup's W3C converter it was jsoup's own `Document` and `Element`. A static import like `import static org.mockito.Mockito.verify;` is covered the same way.
|
||||
- Java enum constants and Kotlin enum entries with a body of their own now have their methods in the graph. Examples are `PLUS { int apply(…) { … } }` and `NewBuffer { override fun pipe() … }`. Before, those methods were missing and every call inside them was dropped. jsoup's HTML tokenizer and tree builder are written this way, so over 2,000 of their calls were invisible. Re-index to pick this up.
|
||||
- Scala calls no longer land on another test's local value. A `val f` written inside one `test("…") { }` block was being picked for a lambda parameter `f` in another suite, and even for `f(…)` in cats' core library. A package import like `import cats.syntax.all._` no longer puts every class's members in scope. Members that really are in scope now resolve: those of an imported package object's parent types (`import sttp.client4._` gives `SttpApi`'s `multipart`), and of the file's own package object.
|
||||
|
||||
@@ -5,7 +5,8 @@
|
||||
* jsoup's W3C converter (`import org.w3c.dom.Document;`) to jsoup's own
|
||||
* `Document`. A static import (`import static org.junit.Assert.assertEquals;`)
|
||||
* likewise owns its name. Imports of the project's own classes — nested ones
|
||||
* included — resolve as before.
|
||||
* included — resolve as before. Kotlin imports (no `;`, optional `as`) are read
|
||||
* the same way; a member the file itself declares still comes first.
|
||||
*/
|
||||
import { describe, it, expect, afterAll, beforeAll } from 'vitest';
|
||||
import * as fs from 'fs';
|
||||
@@ -52,6 +53,22 @@ public class Reflect {
|
||||
return Inner.create();
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/app/Dates.kt': `package app
|
||||
|
||||
import java.lang.reflect.Field
|
||||
import kotlinx.datetime.toLocalDateTime
|
||||
import app.model.Outer.Inner as Made
|
||||
|
||||
class Dates {
|
||||
private fun toLocalDateTime(value: Long): Long = value
|
||||
|
||||
fun parse(value: Long, field: Field): Long {
|
||||
field.getName()
|
||||
Made.create()
|
||||
return toLocalDateTime(value)
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/test/java/app/ReflectTest.java': `package app;
|
||||
|
||||
@@ -93,6 +110,13 @@ describe('Java single-type and static imports from outside the project', () => {
|
||||
expect(edges).toContain('calls app.model::Outer::Inner::create');
|
||||
});
|
||||
|
||||
it('Kotlin: an outside import owns its name, but a member the file declares comes first; `as` aliases bind', () => {
|
||||
const edges = edgesFrom('src/main/kotlin/app/Dates.kt');
|
||||
expect(edges).not.toContain('references app.functional::TypesTest::Field');
|
||||
expect(edges).toContain('calls app::Dates::toLocalDateTime');
|
||||
expect(edges).toContain('calls app.model::Outer::Inner::create');
|
||||
});
|
||||
|
||||
it('a static import’s method is not a project method of that name', () => {
|
||||
expect(edgesFrom('src/test/java/app/ReflectTest.java')).not.toContain('calls app.functional::TypesTest::assertEquals');
|
||||
});
|
||||
|
||||
@@ -1177,8 +1177,8 @@ function extractJavaImports(content: string): ImportMapping[] {
|
||||
const stripped = content
|
||||
.replace(/\/\*[\s\S]*?\*\//g, '')
|
||||
.replace(/\/\/[^\n]*/g, '');
|
||||
// `import [static] <fqn>[.*];`
|
||||
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;/gm;
|
||||
// `import [static] <fqn>[.*];` — and Kotlin's `import <fqn> [as Alias]`, with no `;`.
|
||||
const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)(?:\s+as\s+([\w]+))?\s*(?:;|$)/gm;
|
||||
let match: RegExpExecArray | null;
|
||||
while ((match = re.exec(stripped)) !== null) {
|
||||
const fqn = match[2]!;
|
||||
@@ -1187,11 +1187,11 @@ function extractJavaImports(content: string): ImportMapping[] {
|
||||
// through the wildcard. (Future enhancement: enumerate package files.)
|
||||
if (fqn.endsWith('.*')) continue;
|
||||
const parts = fqn.split('.');
|
||||
const localName = parts[parts.length - 1];
|
||||
const localName = match[3] ?? parts[parts.length - 1];
|
||||
if (!localName) continue;
|
||||
mappings.push({
|
||||
localName,
|
||||
exportedName: localName,
|
||||
exportedName: parts[parts.length - 1]!,
|
||||
source: fqn,
|
||||
isDefault: false,
|
||||
isNamespace: false,
|
||||
@@ -1436,6 +1436,26 @@ export function resolveJvmImport(
|
||||
};
|
||||
}
|
||||
|
||||
/** `Alias` / `Alias.member` through a renaming Kotlin import, by the imported FQN. */
|
||||
function resolveJvmAlias(ref: UnresolvedRef, imports: ImportMapping[], context: ResolutionContext): ResolvedRef | null {
|
||||
const dot = ref.referenceName.indexOf('.');
|
||||
const root = dot < 0 ? ref.referenceName : ref.referenceName.slice(0, dot);
|
||||
const imp = imports.find((m) => m.localName === root && m.localName !== m.exportedName);
|
||||
if (!imp) return null;
|
||||
const parts = imp.source.split('.');
|
||||
// The package is some prefix of the FQN; the rest is the type path (`Outer::Inner`).
|
||||
for (let i = parts.length - 1; i > 0; i--) {
|
||||
const target = context.getNodesByQualifiedName(`${parts.slice(0, i).join('.')}::${parts.slice(i).join('::')}`)[0];
|
||||
if (!target) continue;
|
||||
const member = dot < 0 ? null : ref.referenceName.slice(dot + 1);
|
||||
if (member === null) return { original: ref, targetNodeId: target.id, confidence: 0.9, resolvedBy: 'import' };
|
||||
if (member.includes('.')) return null;
|
||||
const found = context.getNodesByQualifiedName(`${target.qualifiedName}::${member}`)[0];
|
||||
return found ? { original: ref, targetNodeId: found.id, confidence: 0.9, resolvedBy: 'import' } : null;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
/**
|
||||
* Pick the same-FQN candidate closest to `fromPath` by shared directory
|
||||
* prefix, preferring an `expect` declaration on a tie. Used to keep a Kotlin
|
||||
@@ -1706,6 +1726,13 @@ export function resolveViaImport(
|
||||
if (moduleFile) return moduleFile;
|
||||
}
|
||||
|
||||
// Kotlin's `import app.model.Outer.Inner as Made`: `Made.create()` is the
|
||||
// aliased class's member, found by its FQN — nothing else binds the alias.
|
||||
if (ref.language === 'kotlin' && ref.referenceKind !== 'imports') {
|
||||
const aliased = resolveJvmAlias(ref, imports, context);
|
||||
if (aliased) return aliased;
|
||||
}
|
||||
|
||||
// Check if the reference name matches any import
|
||||
for (const imp of imports) {
|
||||
if (imp.localName === ref.referenceName || ref.referenceName.startsWith(imp.localName + '.')) {
|
||||
|
||||
@@ -929,6 +929,9 @@ function isLuaLocal(candidate: Node, context: ResolutionContext): boolean {
|
||||
return local;
|
||||
}
|
||||
|
||||
const JVM_CALLABLE_KINDS: ReadonlySet<string> = new Set(['method', 'function']);
|
||||
const JVM_TYPE_KINDS: ReadonlySet<string> = new Set(['class', 'interface', 'enum', 'struct', 'trait', 'type_alias', 'annotation']);
|
||||
|
||||
/** Per context: every package the project's JVM sources declare. */
|
||||
const JVM_PACKAGES = new WeakMap<ResolutionContext, Set<string>>();
|
||||
|
||||
@@ -939,8 +942,16 @@ const JVM_PACKAGES = new WeakMap<ResolutionContext, Set<string>>();
|
||||
* type (`import com.acme.Outer.Inner;`) is under a project package, so it is not.
|
||||
*/
|
||||
function isJavaOutsideImport(name: string, ref: UnresolvedRef, context: ResolutionContext): boolean {
|
||||
const binding = context.getImportMappings(ref.filePath, 'java').find((m) => m.localName === name);
|
||||
const binding = context.getImportMappings(ref.filePath, ref.language).find((m) => m.localName === name);
|
||||
if (!binding) return false;
|
||||
// A member the file declares itself is in scope before any import —
|
||||
// Exposed's `toLocalDateTime(value)` inside the column type that defines it —
|
||||
// in the same namespace only: gson's `new URI(…)` is `java.net.URI` beside
|
||||
// `TypeAdapters`' field `URI`, as a type always is beside a value.
|
||||
const call = ref.referenceKind === 'calls' && name === ref.referenceName;
|
||||
const kinds = call ? JVM_CALLABLE_KINDS : JVM_TYPE_KINDS;
|
||||
if ((context.getNodesInFileNamed?.(ref.filePath, name) ?? context.getNodesInFile(ref.filePath).filter((n) => n.name === name))
|
||||
.some((n) => kinds.has(n.kind))) return false;
|
||||
let packages = JVM_PACKAGES.get(context);
|
||||
if (!packages) {
|
||||
packages = new Set<string>();
|
||||
@@ -7702,7 +7713,8 @@ 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`).
|
||||
if (ref.language === 'java' && ref.referenceKind !== 'imports' && isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
|
||||
if ((ref.language === 'java' || ref.language === 'kotlin') && ref.referenceKind !== 'imports' &&
|
||||
isJavaOutsideImport(ref.referenceName.split('.')[0]!, ref, context)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user