diff --git a/CHANGELOG.md b/CHANGELOG.md index ba63418ce..6c7cb0e9d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/__tests__/java-outside-imports.test.ts b/__tests__/java-outside-imports.test.ts index 9f2f44beb..00a53d4a5 100644 --- a/__tests__/java-outside-imports.test.ts +++ b/__tests__/java-outside-imports.test.ts @@ -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'); }); diff --git a/src/resolution/import-resolver.ts b/src/resolution/import-resolver.ts index b364d7a79..eacde7ae1 100644 --- a/src/resolution/import-resolver.ts +++ b/src/resolution/import-resolver.ts @@ -1177,8 +1177,8 @@ function extractJavaImports(content: string): ImportMapping[] { const stripped = content .replace(/\/\*[\s\S]*?\*\//g, '') .replace(/\/\/[^\n]*/g, ''); - // `import [static] [.*];` - const re = /^\s*import\s+(static\s+)?([\w.]+(?:\.\*)?)\s*;/gm; + // `import [static] [.*];` — and Kotlin's `import [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 + '.')) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 5eb90e5b8..8098681a5 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -929,6 +929,9 @@ function isLuaLocal(candidate: Node, context: ResolutionContext): boolean { return local; } +const JVM_CALLABLE_KINDS: ReadonlySet = new Set(['method', 'function']); +const JVM_TYPE_KINDS: ReadonlySet = 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>(); @@ -939,8 +942,16 @@ const JVM_PACKAGES = new WeakMap>(); * 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(); @@ -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; }