diff --git a/CHANGELOG.md b/CHANGELOG.md index 17022e634..52500853e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -361,6 +361,9 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - In JavaScript, TypeScript, Python, Go, Rust, Java and Kotlin, a function named in the value of a top-level constant or variable, or of a class field, like `const routes = { home }`, `HANDLERS = {"home": home}`, a Go `map[string]func()` table, a Rust `static` list of functions, `static handlers = { click: onClick }`, a Java `List.of(Registry::home)` field or a Kotlin `listOf(::home)` property, is now referenced once, by the constant, variable, field or property that holds it. The file or class around it no longer also shows up as a second reference from the same place, so a function's callers and impact list each table once. - `codegraph sync` and the file watcher now follow an edit that changes which declaration a module exports as its default, like `export default A` becoming `export default B` while `A` stays, or the module dropping its default. References through `import Y from './x'`, `{ default as Y }`, `require('./x').default`, a barrel that passes the default on or a route that renders it as a layout, and calls on a value typed by it, now link to the new default, or stay unlinked, as a fresh index has them. Before, they kept pointing at the old declaration until the importing file changed or the project was indexed again. - `codegraph sync` and the file watcher now relink a named import that a name match had linked to a declaration elsewhere while its module didn't export the name, once an edit makes the module export it, like `import { EmptyState } from './layouts/shared'` after the barrel gains `export { EmptyState } from './EmptyState'`. An import a name match had linked to a class the module exported only as its default comes unlinked once the module stops exporting it. Before, the import, the references through it and calls on values typed by it kept the old link until the importing file changed or the project was indexed again. +- Kotlin calls through a class property, a primary-constructor property, or a variable set from a function call now resolve on the declared type, and a call on a library type (such as `Regex` or a JDK class) no longer links to an unrelated project method with the same name. Chained receivers such as `engine.pump.drain()`, `this.engine.drain()` and `Mode.ON.next()` resolve the same way, as do properties inherited from a project base class and variables of the enclosing function used inside an anonymous `object : …`. Re-index Kotlin projects to pick this up. +- Kotlin `fun interface` declarations are indexed, and no longer hide the declaration that follows them. +- Java and Kotlin inheritance now resolves nested types more precisely, and anonymous implementations retain their members after re-indexing. ## [1.6.2] - 2026-10-03 diff --git a/__tests__/extraction.test.ts b/__tests__/extraction.test.ts index a481cb8a4..a11981afc 100644 --- a/__tests__/extraction.test.ts +++ b/__tests__/extraction.test.ts @@ -2035,6 +2035,46 @@ class T { expect(callersOf('target')).toEqual(['directCall', 'fieldLambda', 'run']); expect(callersOf('compute')).toEqual(['eager']); }); + + it('keeps the full qualified name on the extends reference of `new Outer.Inner() { ... }` (real AOSP AIDL shape: `new ICarPropertyEventListener.Stub() { ... }`)', () => { + // The anon class's OWN name and the enclosing `instantiates` edge are + // correctly truncated to the bare last segment ("Stub") — that matches + // how a real in-project nested class's own node is named, and how + // `instantiates` resolves (by bare class name). But the `extends` + // reference is a DIFFERENT resolution path: a real named class's + // `extends IFoo.Stub` clause is extracted verbatim (untruncated), and + // qualified lookups depend on that full dotted text surviving. + // Truncating the anonymous class's extends reference to "Stub" would + // lose the relationship to `ICarPropertyEventListener.Stub`. + const code = ` +package p; +interface ICarPropertyEventListener { + interface Stub {} +} +class CarNightService { + private final ICarPropertyEventListener mListener = + new ICarPropertyEventListener.Stub() { + public void onEvent() {} + }; +} +`; + const result = extractFromSource('CarNightService.java', code); + const anon = result.nodes.find((n) => n.kind === 'class' && /Stub\$anon@/.test(n.name)); + expect(anon, 'anonymous Stub subclass should be extracted as a class').toBeDefined(); + + const extendsRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'extends' && r.fromNodeId === anon!.id + ); + expect(extendsRef, 'anon class should carry an extends reference').toBeDefined(); + expect(extendsRef!.referenceName).toBe('ICarPropertyEventListener.Stub'); + + // The anon class's own cosmetic name and the instantiates edge stay + // truncated to the bare last segment — unaffected by this fix. + const instantiatesRef = result.unresolvedReferences.find( + (r) => r.referenceKind === 'instantiates' && r.referenceName === 'Stub' + ); + expect(instantiatesRef, 'enclosing field should still instantiate the bare Stub name').toBeDefined(); + }); }); describe('C# Extraction', () => { diff --git a/__tests__/fixtures/kernel-parity/torture.kt b/__tests__/fixtures/kernel-parity/torture.kt index 3e85d3de5..e23349a8b 100644 --- a/__tests__/fixtures/kernel-parity/torture.kt +++ b/__tests__/fixtures/kernel-parity/torture.kt @@ -291,3 +291,20 @@ class SiblingAccessorK { register(fromInit) } } + +class ReceiverChainK(private val engine: EngineK) { + fun chains() { + engine.pump.drain() + this.engine.drain() + engine?.pump?.drain() + engine + .pump + .drain() + engine.a.b.c.drain() + engine.a.b.c.d.drain() + engine!!.pump.drain() + (engine).pump.drain() + engine.pump().drain() + super.toString().length.toString() + } +} diff --git a/__tests__/kernel-kotlin-parity.test.ts b/__tests__/kernel-kotlin-parity.test.ts index 649c44455..e6b589322 100644 --- a/__tests__/kernel-kotlin-parity.test.ts +++ b/__tests__/kernel-kotlin-parity.test.ts @@ -117,16 +117,19 @@ describe.skipIf(!kernelBuilt)('kernel Kotlin extraction parity', () => { }); } - it('fun-interface files defer to the wasm extractor (grammar-inherent error)', () => { - const src = 'package p\n\nfun interface Transformer {\n fun transform(x: Int): Int\n}\n\nfun after() { work() }\n'; + it('fun-interface files parse on both paths once `fun` is blanked (preParse)', () => { + // tree-sitter-kotlin has no `fun interface`; the extractor's preParse + // blanks the `fun` (offset-preserving), so the kernel no longer defers + // and the declaration after it — here behind a doc comment, the shape + // whose error recovery used to swallow it — is extracted too. + const src = 'package p\n\nfun interface Transformer {\n fun transform(x: Int): Int\n}\n\n/** Next. */\ninterface Sink {\n fun put(x: Int)\n}\n\nfun after() { work() }\n'; + assertParity('src/FunIface.kt', src, 4); process.env.CODEGRAPH_KERNEL_LANGS = 'all'; delete process.env.CODEGRAPH_KERNEL; - expect(tryKernelExtract('src/FunIface.kt', src, 'kotlin')).toBeNull(); - process.env.CODEGRAPH_KERNEL = '0'; - const viaWasm = extractFromSource('src/FunIface.kt', src, 'kotlin'); - delete process.env.CODEGRAPH_KERNEL; - // The wasm arm's misparse-recovery hook still mints the interface node. - expect(viaWasm.nodes.some((n) => n.kind === 'interface' && n.name === 'Transformer')).toBe(true); + const result = tryKernelExtract('src/FunIface.kt', src, 'kotlin')!; + expect(result.nodes.some((n) => n.kind === 'interface' && n.name === 'Transformer')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'interface' && n.name === 'Sink')).toBe(true); + expect(result.nodes.some((n) => n.kind === 'method' && n.qualifiedName.endsWith('Sink::put'))).toBe(true); }); it('PHANTOM errors defer too — hasError with a complete, ERROR-node-free CST', () => { diff --git a/__tests__/kotlin-object-literal.test.ts b/__tests__/kotlin-object-literal.test.ts new file mode 100644 index 000000000..82f533721 --- /dev/null +++ b/__tests__/kotlin-object-literal.test.ts @@ -0,0 +1,108 @@ +/** Kotlin anonymous objects: negative shapes and traversal/ownership regressions. */ +import { describe, it, expect, beforeAll } from 'vitest'; +import { TreeSitterExtractor } from '../src/extraction/tree-sitter'; +import { initGrammars, loadGrammarsForLanguages, getParser } from '../src/extraction/grammars'; + +beforeAll(async () => { + await initGrammars(); + await loadGrammarsForLanguages(['kotlin']); +}); + +function extract(source: string) { + const tree = getParser('kotlin')!.parse(source)!; + expect(tree.rootNode.hasError, tree.rootNode.toString()).toBe(false); + tree.delete(); + const result = new TreeSitterExtractor('src/Probe.kt', source, 'kotlin').extract(); + expect(result.errors).toEqual([]); + return result; +} + +const classes = (r: ReturnType) => r.nodes.filter(n => n.kind === 'class' && n.name.includes('$anon@')); +const refs = (r: ReturnType, kind: string) => r.unresolvedReferences.filter(r => r.referenceKind === kind); + +describe('Kotlin object literals', () => { + it.each(['val value = ', 'fun create() = '])('keeps constructor argument calls in the enclosing scope: %s', prefix => { + const r = extract(`${prefix}object : IFoo.Stub(computeArg()) { override fun run() { bodyCall() }; }`); + const arg = refs(r, 'calls').filter(x => x.referenceName === 'computeArg'); + expect(arg).toHaveLength(1); + expect(arg[0]!.fromNodeId).not.toBe(classes(r)[0]!.id); + const run = r.nodes.find(n => n.name === 'run')!; + expect(refs(r, 'calls').find(x => x.referenceName === 'bodyCall')?.fromNodeId).toBe(run.id); + }); + + it('extracts delegated interface identity without treating delegate calls as supertypes', () => { + const r = extract('fun create() = object : api.IFoo by (makeDelegate()), Marker {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['api.IFoo', 'Marker']); + expect(refs(r, 'instantiates')).toEqual([]); + expect(refs(r, 'calls').filter(x => x.referenceName === 'makeDelegate')).toHaveLength(1); + }); + + it('keeps generic outer and inner type segments while excluding type arguments', () => { + const r = extract('val x = object : pkg.Outer.Inner>() {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['pkg.Outer.Inner']); + expect(refs(r, 'instantiates').map(x => x.referenceName)).toEqual(['pkg.Outer.Inner']); + }); + + it('preserves nested members and calls of objects without explicit supertypes', () => { + const r = extract(`fun create() = object { + fun outer() { outerCall() } + val child = object : IFoo.Stub() { + override fun inner() { innerCall() } + } + }`); + expect(classes(r)).toHaveLength(2); + for (const name of ['outer', 'inner']) { + const members = r.nodes.filter(n => n.name === name); + expect(members).toHaveLength(1); + expect(refs(r, 'calls').find(x => x.referenceName === `${name}Call`)?.fromNodeId).toBe(members[0]!.id); + expect(r.edges.filter(e => e.kind === 'contains' && e.target === members[0]!.id)).toHaveLength(1); + } + }); + + it('does not merge two same-supertype anonymous objects on one line', () => { + const r = extract('val x = object : Base() { val child = object : Base() {}; }'); + expect(classes(r)).toHaveLength(2); + expect(new Set(classes(r).map(n => n.id)).size).toBe(2); + expect(refs(r, 'extends').map(x => x.fromNodeId)).toEqual(classes(r).map(n => n.id)); + }); + + it('keeps same-line same-name members in distinct anonymous owners', () => { + const r = extract('val x = object : Base() { fun run() { outerCall() }; val child = object : Base() { fun run() { innerCall() }; }; }'); + const members = r.nodes.filter(n => n.name === 'run'); + expect(members).toHaveLength(2); + expect(new Set(members.map(n => n.id)).size).toBe(2); + expect(refs(r, 'calls').find(x => x.referenceName === 'outerCall')?.fromNodeId).toBe(members[0]!.id); + expect(refs(r, 'calls').find(x => x.referenceName === 'innerCall')?.fromNodeId).toBe(members[1]!.id); + }); + + it('visits an anonymous object in a constructor argument exactly once', () => { + const r = extract(`fun create() = object : Base(object : Callback { + override fun invoke() { callbackCall() } + }) { fun own() { ownCall() }; }`); + expect(classes(r)).toHaveLength(2); + expect(r.nodes.filter(n => n.name === 'invoke')).toHaveLength(1); + expect(refs(r, 'calls').filter(x => x.referenceName === 'callbackCall')).toHaveLength(1); + }); + + it('chooses the sole constructor after an interface and ignores generic argument decoys', () => { + const r = extract('val x = object : Marker, pkg.Real.Stub(), Other {}'); + expect(refs(r, 'extends').map(x => x.referenceName)).toEqual(['Marker', 'pkg.Real.Stub', 'Other']); + expect(refs(r, 'instantiates').map(x => x.referenceName)).toEqual(['pkg.Real.Stub']); + }); + + it('does not classify companion or named objects as anonymous literals', () => { + const r = extract(`class Host { + companion object : Marker {} + object Named : Marker {} + val value = object : Marker { fun run() {}; } + }`); + expect(classes(r)).toHaveLength(1); + expect(r.nodes.filter(n => n.name === 'run')).toHaveLength(1); + }); + + it('does not throw for incomplete objects or syntactically accepted multiple constructors', () => { + for (const source of ['val x = object :', 'val x = object : Base( {', 'val x = object : A(), B() {}']) { + expect(() => new TreeSitterExtractor('Bad.kt', source, 'kotlin').extract()).not.toThrow(); + } + }); +}); diff --git a/__tests__/kotlin-property-receiver.test.ts b/__tests__/kotlin-property-receiver.test.ts new file mode 100644 index 000000000..14106d4f8 --- /dev/null +++ b/__tests__/kotlin-property-receiver.test.ts @@ -0,0 +1,372 @@ +/** + * A call through a Kotlin property resolves on the property's declared type. + * + * Kotlin properties are indexed without a signature, and primary-constructor + * properties are not indexed at all, so the Java-shaped field inference found + * no type and every `prop.method()` fell through to name-only guessing: the + * interface method, or any same-named method elsewhere (a `close()` on an + * unrelated class). The declared type is now read from the declaration. + */ +import { describe, it, expect, beforeAll, afterAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import CodeGraph from '../src/index'; + +describe('Kotlin property receivers resolve on the declared type', () => { + let dir: string; + let cg: CodeGraph; + + beforeAll(async () => { + dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kt-prop-')); + const src = path.join(dir, 'src'); + fs.mkdirSync(src); + fs.writeFileSync(path.join(src, 'Probe.kt'), `package p +import java.io.Closeable +internal interface Probe : Closeable { + fun probe(host: String): Int +} +internal class HttpProbe( + private val endpoints: (String) -> List, +) : Probe { + override fun probe(host: String): Int = 1 + override fun close() {} +} +internal class OtherProbe : Closeable { + fun probe(host: String): Int = 2 + override fun close() {} +} +class Store { fun put(v: Int) = v } +class HardwareLock { + class Lease { + fun close() {} + } +} +class SessionLock { + class Lease { + fun close() {} + } +} +class AAValidator { fun validate(v: String): List = emptyList() } +object HardwareGate { + fun tryBegin(): HardwareLock.Lease? = null +} +class Checker { + fun validate(v: String): List = emptyList() + companion object { + fun load(): Checker = Checker() + } +} +internal fun interface Reply { + fun send(message: String): Boolean +} + +/** Events of one session, for the application layer. */ +internal interface SessionEvents { + fun onLost(reason: String) +} +class Pattern { fun find(text: String): Int = 0 } +class Conn { fun close() {} } +class Buffer(val size: Int) { fun drain(): Int = 0 } +class Pump { fun drain(): Int = 1 } +enum class Mode { ON, OFF; fun next(): Mode = this } +class Wheel { fun next(): Int = 0 } +class Worker { fun start() {}; fun join(ms: Long) {} } +class Vm { + fun refresh() {} + fun tick() { + activeController?.refresh() + } + companion object { + @Volatile private var activeController: Vm? = null + } +} +`); + fs.writeFileSync(path.join(src, 'Users.kt'), `package p +class LateinitUser { + private lateinit var probe: HttpProbe + fun setUp() { probe = HttpProbe(endpoints = { listOf(it) }) } + fun useIt() { probe.probe("a") } + fun tearDown() { probe.close() } +} +class CtorUser(private val store: Store, val maybe: HttpProbe?) { + fun save() { store.put(1) } + fun check() { maybe?.probe("b") } +} +class InitUser { + private val probe = HttpProbe(endpoints = { emptyList() }) + fun stop() { probe.close() } +} +class NestedUser(private val lease: HardwareLock.Lease) { + fun release() { lease.close() } +} +class CallResultUser { + private val schema = Checker.load() + fun check() { schema.validate("x") } + fun locked() { + val lease = HardwareGate.tryBegin() + lease?.close() + } +} +class Supervisor(private val events: SessionEvents) { + fun lost() { events.onLost("gone") } +} +class ValUser { + private val pending = Buffer( + size = 4, + ) + @Volatile private var current = Mode.ON + fun work() { + listOf(1).forEach { + pending.drain() + } + } + fun toggle() { current.next() } +} +class Socket2 { fun cancel() {} } +class Session { fun cancel() {} } +abstract class Listener { abstract fun onOpen(socket: java.net.Socket) } +class AliasUser { + @Volatile private var current: Session? = null + fun stop() { + val old = current ?: return + old.cancel() + } + fun listen(): Listener = object : Listener() { + override fun onOpen(socket: java.net.Socket) { + socket.close() + } + } +} +class ThreadUser { + fun run() { + val serverThread = Thread { + println("x") + } + serverThread.start() + val later = lazy { 1 } + val items = mutableListOf(1) + items.clear() + } +} +class LibraryUser { + private val regex = Regex("[0-9]+") + fun first(text: String) = regex.find(text) + fun scheduled() { + val scheduler = java.util.concurrent.Executors.newSingleThreadScheduledExecutor() + scheduler.shutdown() + } +} +`); + fs.writeFileSync(path.join(src, 'Machines.kt'), `package p +class Tank { fun flush() {} } +class Motor { + val reservoir = Tank() + val runner = Thread { } +} +class Plant(val unit: Motor) +class Crate { fun seal() {} } +object Registry { val primary = Motor() } +enum class Gear { LOW, HIGH; fun shift() {} } +fun Thread.label(): String = name +`); + // The decoy `flush` sits in the caller's own file, where a name-only + // guess would look first. + fs.writeFileSync(path.join(src, 'Chains.kt'), `package p +class Other { fun flush() {}; fun seal() {}; fun shift() {} } +class Owner(private val engine: Motor, private val site: Plant) { + private val box = Crate() + fun generic() { box.seal() } + fun viaObject() { Registry.primary.reservoir.flush() } + fun viaEnum() { Gear.LOW.shift() } + fun go() { engine.reservoir.flush() } + fun viaThis() { this.engine.reservoir.flush() } + fun deep() { site.unit.reservoir.flush() } + fun library() { engine.runner.start() } + fun extension() { engine.runner.label() } +} +`); + // A property a class inherits: from a base in another file (a body + // property and a primary-constructor one, two levels up), or in the same + // file. Variable names differ from type names so no capitalized-name + // guess can land on the right type. + fs.writeFileSync(path.join(src, 'Bases.kt'), `package p +class Ledger { fun post() {} } +class Feed { fun refresh() {} } +abstract class BaseScreen(protected val source: Feed) : java.io.Serializable { + protected lateinit var model: Ledger +} +open class MidScreen(origin: Feed) : BaseScreen(origin) +`); + fs.writeFileSync(path.join(src, 'Screens.kt'), `package p +class Cache { fun post() {}; fun refresh() {}; fun twist() {}; fun open() {} } +class Knob { fun twist() {} } +class Sink { fun drainAll() {} } +open class Panel { val widget = Knob() } +class SidePanel : Panel() { + fun turn() { widget.twist() } +} +class HomeScreen(start: Feed) : MidScreen(start), Runnable { + override fun run() {} + fun show() { model.post() } + fun pull() { source.refresh() } + fun viaThis() { this.model.post() } +} +class LibScreen : android.app.Activity() { + fun go() { helper.drainAll() } +} +abstract class Hook { abstract fun fire(code: Int) } +class Gate { fun open() {} } +class Rig { val gate = Gate() } +class Wiring { + fun wire(): Hook { + val motor = Rig() + return object : Hook() { + override fun fire(code: Int) { motor.gate.open() } + } + } + fun siblings(): Hook { + val latch = Gate() + return object : Hook() { + fun prep() { val latch = Cache() } + override fun fire(code: Int) { latch.open() } + } + } +} +`); + cg = CodeGraph.initSync(dir); + await cg.indexAll(); + }); + + afterAll(() => { + cg?.destroy(); + fs.rmSync(dir, { recursive: true, force: true }); + }); + + /** Qualified names of what `owner.method` calls. */ + function callees(owner: string, method: string): string[] { + const node = cg.searchNodes(method).map((r) => r.node) + .find((n) => n.qualifiedName.endsWith(`${owner}::${method}`)); + expect(node, `${owner}::${method}`).toBeDefined(); + return cg.getCallees(node!.id).filter((c) => c.edge.kind === 'calls').map((c) => c.node.qualifiedName); + } + + it('a lateinit property resolves on its concrete type, not the interface', () => { + expect(callees('LateinitUser', 'useIt')).toEqual(['p::HttpProbe::probe']); + expect(callees('LateinitUser', 'tearDown')).toEqual(['p::HttpProbe::close']); + }); + + it('a primary-constructor property resolves on its type, nullable included', () => { + expect(callees('CtorUser', 'save')).toEqual(['p::Store::put']); + expect(callees('CtorUser', 'check')).toEqual(['p::HttpProbe::probe']); + }); + + it('a property initialized by a constructor call resolves on that type', () => { + expect(callees('InitUser', 'stop')).toEqual(['p::HttpProbe::close']); + }); + + it('a local or property bound to a call is typed by the callee return type', () => { + expect(callees('CallResultUser', 'check')).toEqual(['p::Checker::validate']); + expect(callees('CallResultUser', 'locked').sort()).toEqual(['p::HardwareGate::tryBegin', 'p::HardwareLock::Lease::close']); + }); + + it('a fun interface does not swallow the declaration after it', () => { + const events = cg.searchNodes('SessionEvents').map((r) => r.node) + .find((n) => n.qualifiedName === 'p::SessionEvents'); + expect(events?.kind).toBe('interface'); + expect(callees('Supervisor', 'lost')).toEqual(['p::SessionEvents::onLost']); + }); + + it('a receiver of a library type gets no edge instead of a same-named project method', () => { + // `Regex.find` must not bind to the project's `Pattern.find`. + expect(callees('LibraryUser', 'first')).toEqual([]); + expect(callees('LibraryUser', 'scheduled')).toEqual([]); + }); + + it('a val property (indexed as a constant) and an enum-entry var are typed, inside a lambda too', () => { + expect(callees('ValUser', 'work')).toContain('p::Buffer::drain'); + expect(callees('ValUser', 'work')).not.toContain('p::Pump::drain'); + expect(callees('ValUser', 'toggle')).toEqual(['p::Mode::next']); + }); + + it('a companion-object property is typed', () => { + expect(callees('Vm', 'tick')).toEqual(['p::Vm::refresh']); + }); + + it('a trailing-lambda constructor or a library call types a local as a library type', () => { + // `Thread { … }.start()` must not bind to the project's `Worker.start`. + expect(callees('ThreadUser', 'run')).toEqual([]); + }); + + it('an alias takes the aliased value type; a library-typed parameter gets no edge', () => { + expect(callees('AliasUser', 'stop')).toEqual(['p::Session::cancel']); + // The anonymous object's `onOpen`, not the abstract `Listener::onOpen` + // (which may carry a synthesized override edge to it). + const listen = cg.searchNodes('listen').map((r) => r.node) + .find((n) => n.qualifiedName.endsWith('AliasUser::listen')); + expect(listen).toBeDefined(); + const onOpens = cg.searchNodes('onOpen').map((r) => r.node).filter((n) => n.filePath.endsWith('Users.kt')); + const onOpen = onOpens.find((n) => n.qualifiedName.includes('$anon')) ?? + onOpens.find((n) => n.startLine > listen!.startLine && n.startLine <= (listen!.endLine ?? listen!.startLine)); + expect(onOpen).toBeDefined(); + const calls = cg.getCallees(onOpen!.id) + .filter((c) => c.edge.kind === 'calls' && !c.edge.metadata?.synthesizedBy && !c.edge.metadata?.registeredAt); + expect(calls).toEqual([]); + }); + + it('a receiver chain is typed through each property declared type', () => { + expect(callees('Owner', 'go')).toEqual(['p::Tank::flush']); + expect(callees('Owner', 'viaThis')).toEqual(['p::Tank::flush']); + expect(callees('Owner', 'deep')).toEqual(['p::Tank::flush']); + }); + + it('a chain may start at an object or an enum entry; a generic constructor types its property', () => { + expect(callees('Owner', 'viaObject')).toEqual(['p::Tank::flush']); + expect(callees('Owner', 'viaEnum')).toEqual(['p::Gear::shift']); + expect(callees('Owner', 'generic')).toEqual(['p::Crate::seal']); + }); + + it('a receiver chain through a library type gets no edge', () => { + // `Thread.start` must not bind to the project's `Worker.start`. + expect(callees('Owner', 'library')).toEqual([]); + // The project's own extension of that library type is still reached. + expect(callees('Owner', 'extension')).toEqual(['Thread::label']); + }); + + it('a property inherited from a superclass is typed, in the same file or another', () => { + expect(callees('SidePanel', 'turn')).toEqual(['p::Knob::twist']); + // Two levels up, past a library interface in the supertype list. + expect(callees('HomeScreen', 'show')).toEqual(['p::Ledger::post']); + expect(callees('HomeScreen', 'viaThis')).toEqual(['p::Ledger::post']); + // A primary-constructor property of the base. + expect(callees('HomeScreen', 'pull')).toEqual(['p::Feed::refresh']); + }); + + it('a receiver not found up to a library base class keeps the name-only resolution', () => { + // The property may come from the library base, so it is neither typed + // nor treated as a library type. + expect(callees('LibScreen', 'go')).toEqual(['p::Sink::drainAll']); + }); + + it('a local of the outer function is typed inside an anonymous object', () => { + /** Callees of the `fire` override declared inside `Wiring::`. */ + const fire = (fn: string): string[] => { + const outer = cg.searchNodes(fn).map((r) => r.node).find((n) => n.qualifiedName.endsWith(`Wiring::${fn}`)); + expect(outer, fn).toBeDefined(); + const node = cg.searchNodes('fire').map((r) => r.node).find( + (n) => n.filePath.endsWith('Screens.kt') && n.startLine > outer!.startLine && n.startLine <= (outer!.endLine ?? outer!.startLine), + ); + expect(node, `${fn} fire`).toBeDefined(); + return cg.getCallees(node!.id) + .filter((c) => c.edge.kind === 'calls' && !c.edge.metadata?.synthesizedBy) + .map((c) => c.node.qualifiedName); + }; + expect(fire('wire')).toEqual(['p::Gate::open']); + // A sibling member's same-named local is not visible from `fire`. + expect(fire('siblings')).toEqual(['p::Gate::open']); + }); + + it('a nested type keeps its outer type', () => { + expect(callees('NestedUser', 'release')).toEqual(['p::HardwareLock::Lease::close']); + }); +}); diff --git a/__tests__/resolution.test.ts b/__tests__/resolution.test.ts index 738f13af9..4b5b6b2c6 100644 --- a/__tests__/resolution.test.ts +++ b/__tests__/resolution.test.ts @@ -81,6 +81,57 @@ describe('Resolution Module', () => { }); describe('Name Matcher', () => { + it('does not match a Kotlin local anonymous-object method from a sibling function', () => { + const node = (id: string, kind: Node['kind'], name: string, qualifiedName: string, filePath: string, startLine: number, endLine: number): Node => ({ + id, kind, name, qualifiedName, filePath, language: 'kotlin', + startLine, endLine, startColumn: 0, endColumn: 0, updatedAt: 0, + }); + const outer = node('outer', 'method', 'otherTest', 'ProbeTest::otherTest', 'ProbeTest.kt', 10, 20); + const anon = node('anon', 'class', '', 'ProbeTest::otherTest::', 'ProbeTest.kt', 12, 18); + const localMethod = node('local', 'method', 'probe', `${anon.qualifiedName}::probe`, 'ProbeTest.kt', 13, 17); + const interfaceMethod = node('interface', 'method', 'probe', 'Probe::probe', 'Probe.kt', 1, 2); + const nodes = [outer, anon, localMethod, interfaceMethod]; + const context = { + getNodesByName: (name: string) => nodes.filter((n) => n.name === name), + getNodesByLowerName: (name: string) => nodes.filter((n) => n.name.toLowerCase() === name), + getImportMappings: () => [], + getNodesByQualifiedName: (name: string) => nodes.filter((n) => n.qualifiedName === name), + getNodesInFile: (filePath: string) => nodes.filter((n) => n.filePath === filePath), + getNodesByKind: (kind: Node['kind']) => nodes.filter((n) => n.kind === kind), + fileExists: () => true, readFile: () => null, + getProjectRoot: () => tempDir, getAllFiles: () => ['ProbeTest.kt', 'Probe.kt'], + } as ResolutionContext; + const ref: UnresolvedRef = { + fromNodeId: 'testA', referenceName: 'probe.probe', referenceKind: 'calls', + filePath: 'ProbeTest.kt', language: 'kotlin', line: 5, column: 0, + }; + + expect(matchMethodCall(ref, context)?.targetNodeId).toBe(interfaceMethod.id); + expect(matchMethodCall({ ...ref, fromNodeId: outer.id, line: 15 }, context)?.targetNodeId).toBe(localMethod.id); + }); + + it('accepts every supported supertype kind in exact-name inheritance matching', () => { + for (const kind of ['component', 'namespace'] as const) { + const target: Node = { + id: `${kind}:base`, kind, name: 'Base', qualifiedName: 'Base', + filePath: 'model.ts', language: 'typescript', startLine: 1, endLine: 1, + startColumn: 0, endColumn: 0, updatedAt: 0, + }; + const context = { + getNodesByName: () => [target], getNodesInFile: () => [target], + getNodesByLowerName: () => [target], getImportMappings: () => [], + getNodesByQualifiedName: () => [], getNodesByKind: () => [], + fileExists: () => true, readFile: () => null, + getProjectRoot: () => tempDir, getAllFiles: () => ['model.ts'], + } as ResolutionContext; + const ref: UnresolvedRef = { + fromNodeId: 'class:derived', referenceName: 'Base', referenceKind: 'extends', + filePath: 'model.ts', language: 'typescript', line: 2, column: 0, + }; + expect(matchByExactName(ref, context)?.targetNodeId).toBe(target.id); + } + }); + it('should match exact name references', () => { // Create a mock context const mockNodes: Node[] = [ @@ -6078,6 +6129,54 @@ in }); }); + describe('A dotted qualified extends/implements reference resolves to a real nested type', () => { + it('resolves `extends Outer.Inner` (named class) and `new Outer.Inner() { ... }` (anonymous class) to the SAME real, indexed nested type', async () => { + // Every qualifiedName the engine builds joins scope with `::` + // (buildQualifiedName), but a Java/C# extends clause or anonymous-class + // constructor type is recorded verbatim with a dot (`Outer.Inner`). A + // real in-project nested type must still resolve — this is not an + // AOSP-specific concern (an absent AIDL Stub staying unresolved is + // correct there), it's the general case where the target genuinely + // exists in the index. + fs.writeFileSync( + path.join(tempDir, 'Host.java'), + `package p; +class Outer { static class Inner { public void run() {} } } +class Named extends Outer.Inner {} +class Host { + Object field = new Outer.Inner() { public void run() {} }; +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const innerId = db + .getDb() + .prepare("select id from nodes where kind = 'class' and name = 'Inner'") + .get() as { id: string } | undefined; + expect(innerId, 'the real Outer.Inner class should be indexed').toBeDefined(); + + const extendsTargets = db + .getDb() + .prepare( + `select src.name as sourceName, dst.id as targetId + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and dst.id = ?` + ) + .all(innerId!.id) as Array<{ sourceName: string; targetId: string }>; + + const sourceNames = extendsTargets.map((r) => r.sourceName).sort(); + // `Named` (a real named class) and the anonymous class inside `Host` + // (named ``) must BOTH resolve their extends edge to + // the real `Inner` node — neither should stay in unresolved_refs. + expect(sourceNames.some((n) => n === 'Named')).toBe(true); + expect(sourceNames.some((n) => /Inner\$anon@/.test(n))).toBe(true); + }); + }); + describe('Bindings in a module that exports nothing (#1719)', () => { it('does not treat documentation headings as package imports', () => { // Inject the planned Markdown node shape without depending on its extractor. @@ -6342,4 +6441,113 @@ bracketed() expect(reachedFrom('consumer.ts', 'HiddenFace')).toBe(false); }, 30000); }); + + describe('Inheritance references never use method-call resolution', () => { + it('does not resolve bare extends/implements names to same-named methods', async () => { + fs.writeFileSync( + path.join(tempDir, 'Hierarchy.java'), + `class Child extends Missing {} +class Implementer implements Missing {} +class Other { + void Missing() {} +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db + .getDb() + .prepare( + `select src.name as sourceName, dst.kind as targetKind + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind in ('extends', 'implements') + and src.name in ('Child', 'Implementer')` + ) + .all() as Array<{ sourceName: string; targetKind: string }>; + + expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); + }); + + it('does not resolve Java extends IBar.Stub to an unrelated Stub constructor', async () => { + // IBar intentionally has no in-project declaration: this mirrors generated + // AIDL Stub bases that are absent from a sparse source checkout. An explicit + // constructor is required because implicit Java constructors are not nodes. + fs.writeFileSync( + path.join(tempDir, 'AService.java'), + `package com.example; +class AService { + final class BinderService extends IBar.Stub {} +} +` + ); + fs.writeFileSync( + path.join(tempDir, 'UiModeManagerService.java'), + `package com.example; +class UiModeManagerService { + class Stub { Stub() {} } +} +` + ); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db + .getDb() + .prepare( + `select dst.kind as targetKind, dst.qualified_name as targetQualifiedName, + e.metadata as metadata + from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and src.name = 'BinderService'` + ) + .all() as Array<{ targetKind: string; targetQualifiedName: string; metadata: string }>; + + // An inheritance reference is a type reference, never a receiver.method() + // call. In particular, an absent IBar must not make `Stub` fall through to + // the sole same-named constructor elsewhere in the project. + expect(rows.filter((row) => row.targetKind === 'method')).toEqual([]); + }); + + it('resolves a nested Java supertype to the closest source tree', async () => { + for (const tree of ['framework', 'androidx']) { + const dir = path.join(tempDir, tree); + fs.mkdirSync(dir); + fs.writeFileSync(path.join(dir, 'RecyclerView.java'), + `package ${tree}; class RecyclerView { static class LayoutManager {} }`); + } + fs.writeFileSync(path.join(tempDir, 'androidx', 'LinearLayoutManager.java'), + 'package androidx; class LinearLayoutManager extends RecyclerView.LayoutManager {}'); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db.getDb().prepare( + `select dst.file_path as targetPath from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'extends' and src.name = 'LinearLayoutManager'` + ).all() as Array<{ targetPath: string }>; + expect(rows.map((row) => row.targetPath)).toEqual(['androidx/RecyclerView.java']); + }); + + it('does not let Spring naming conventions invent an inheritance edge', async () => { + fs.writeFileSync(path.join(tempDir, 'Service.java'), + '@Service class Service {} class Child implements View.OnClickListener {}'); + fs.writeFileSync(path.join(tempDir, 'OnClickListener.java'), + 'package unrelated; class OnClickListener {}'); + + cg = await CodeGraph.init(tempDir, { index: true }); + const db = DatabaseConnection.open(path.join(tempDir, '.codegraph', 'codegraph.db')); + const rows = db.getDb().prepare( + `select dst.qualified_name as target from edges e + join nodes src on src.id = e.source + join nodes dst on dst.id = e.target + where e.kind = 'implements' and src.name = 'Child'` + ).all() as Array<{ target: string }>; + expect(rows).toEqual([]); + }); + }); }); diff --git a/codegraph-kernel/src/java.rs b/codegraph-kernel/src/java.rs index 6a7735a20..e565ae868 100644 --- a/codegraph-kernel/src/java.rs +++ b/codegraph-kernel/src/java.rs @@ -959,13 +959,27 @@ impl<'t> Walker<'t> { .or_else(|| node.child_by_field_name("type")) .or_else(|| node.child_by_field_name("name")) .or_else(|| node.named_child(0)); - let mut type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); - type_name = strip_generic_and_qualifier(&type_name); - if type_name.is_empty() { - type_name = "Object".to_string(); + let raw_type_name = type_node.map(|t| self.text(t).to_string()).unwrap_or_else(|| "Object".to_string()); + // The `extends` reference must carry the FULL dotted name (generics + // stripped, qualifier kept) so nested-type resolution can find it. + // Only the anon class's own cosmetic name is truncated to + // the bare last segment, matching the portable extractor. + let full_type_name = { + let mut n = raw_type_name.clone(); + if let Some(lt) = n.find('<') { + if lt > 0 { + n.truncate(lt); + } + } + let trimmed = n.trim().to_string(); + if trimmed.is_empty() { "Object".to_string() } else { trimmed } + }; + let mut short_type_name = strip_generic_and_qualifier(&raw_type_name); + if short_type_name.is_empty() { + short_type_name = "Object".to_string(); } - let anon_name = format!("<{type_name}$anon@{}>", node.start_position().row + 1); + let anon_name = format!("<{short_type_name}$anon@{}>", node.start_position().row + 1); let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else { return; }; @@ -975,7 +989,7 @@ impl<'t> Walker<'t> { Some(t) => (t.start_position().row as u32, self.col_of(t)), None => (node.start_position().row as u32, self.col_of(node)), }; - self.push_ref(row, &type_name, edge_kind_index("extends").unwrap(), line, column); + self.push_ref(row, &full_type_name, edge_kind_index("extends").unwrap(), line, column); self.stack.push(Scope { row, kind: "class", name: anon_name }); for i in 0..body.named_child_count() { diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 83170723f..255611a4a 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -87,6 +87,28 @@ fn strip_js_ws(s: &str) -> String { s.chars().filter(|c| !is_js_space(*c)).collect() } +/// A receiver chain the resolver can type (TS `KOTLIN_RECEIVER_CHAIN`): +/// whitespace stripped, `?.` read as `.`, then `this` or an identifier +/// followed by one to three `.identifier` segments. +fn kotlin_receiver_chain(text: &str) -> Option { + let chain = strip_js_ws(text).replace("?.", "."); + let segments: Vec<&str> = chain.split('.').collect(); + if segments.len() < 2 || segments.len() > 4 { + return None; + } + let ident = |s: &str| { + let b = s.as_bytes(); + !b.is_empty() + && (b[0].is_ascii_alphabetic() || b[0] == b'_') + && b.iter().all(|c| c.is_ascii_alphanumeric() || *c == b'_') + }; + if segments.iter().all(|s| ident(s)) { + Some(chain) + } else { + None + } +} + /// A property's CODE children: the named child right after the `=` token, a /// `property_delegate` (`by lazy { … }`), and an accessor the grammar nested /// under the declaration (`val x: Int get() = compute()` — written on ONE line; @@ -202,6 +224,11 @@ pub struct Walker<'t> { fs_values: HashMap, fs_value_counts: HashMap, value_scopes: Vec>, + /// kotlinObjectScopeDepth (tree-sitter.ts:480) — >0 while inside an + /// `object : IFoo.Stub() { ... }` literal body, so createNode binds + /// member identity to the enclosing owner (two anon objects can share + /// both name and line). + kotlin_object_scope_depth: u32, } pub fn extract(file_path: &str, source: &str) -> Result { @@ -236,6 +263,7 @@ pub fn extract(file_path: &str, source: &str) -> Result { fs_values: HashMap::new(), fs_value_counts: HashMap::new(), value_scopes: Vec::new(), + kotlin_object_scope_depth: 0, }; let line_count = source.bytes().filter(|b| *b == b'\n').count() as u32 + 1; @@ -871,10 +899,15 @@ impl<'t> Walker<'t> { self.extract_call(node); } else if kind == "infix_expression" { self.extract_infix_call(node); + } else if kind == "object_literal" { + // Kotlin `object : IFoo.Stub() { ... }` — AIDL Stub implementation + // idiom, matching the portable extractor. + self.extract_kotlin_object_literal(node); + skip_children = true; } // companion_object, anonymous_initializer, secondary_constructor, - // getter/setter siblings, file_annotation, object_literal, if/when at - // top level: no branch — recursed (calls attribute to the stack top). + // getter/setter siblings, file_annotation, if/when at top level: no + // branch — recursed (calls attribute to the stack top). if !skip_children { for i in 0..node.named_child_count() { @@ -939,6 +972,10 @@ impl<'t> Walker<'t> { // object_declaration is NOT dispatched here — a body-local object's // `fun`s hit the function branch above and leak out as FUNCTIONS // under the enclosing fn; its properties mint nothing (quirk). + if kind == "object_literal" { + self.extract_kotlin_object_literal(node); + return; + } for i in 0..node.named_child_count() { if let Some(c) = node.named_child(i) { @@ -1264,9 +1301,17 @@ impl<'t> Walker<'t> { } else { method_name.to_string() }; + } else if let Some(chain) = receiver + .filter(|r| r.kind() == "navigation_expression") + .and_then(|r| kotlin_receiver_chain(self.text(r))) + { + // Receiver chain `a.b` / `this.a` / `a?.b`: kept for the + // resolver to type through the properties' declared types. + callee_name = format!("{chain}.{method_name}"); } else { - // this_expression / super_expression / 2-hop nav / - // postfix `!!` / parenthesized → bare method name. + // this_expression / super_expression / a longer or + // non-identifier chain / postfix `!!` / parenthesized → + // bare method name. callee_name = method_name.to_string(); } } @@ -1370,6 +1415,135 @@ impl<'t> Walker<'t> { } } + /// extractKotlinObjectLiteral (tree-sitter.ts:5330) — `object : IFoo.Stub() { ... }`, + /// the dominant AIDL Stub implementation idiom. NOT the same AST shape as + /// Java/C#'s object_creation_expression (repeatable delegation_specifier + /// supertypes, no single constructor/type field) — its own extraction. + fn extract_kotlin_object_literal(&mut self, node: Node<'t>) { + stack_guard!(); + let Some(body) = (0..node.named_child_count()) + .filter_map(|i| node.named_child(i)) + .find(|c| c.kind() == "class_body") + else { + return; + }; + + let delegation_specifiers: Vec> = (0..node.named_child_count()) + .filter_map(|i| node.named_child(i)) + .filter(|c| c.kind() == "delegation_specifier") + .collect(); + + // A specifier contains a constructor invocation, an explicit `by` + // delegation, or a bare interface type. Only the first constructs a + // supertype; a call in a delegate expression does not. Preserve all + // dotted name segments for qualified lookup (excludes type_arguments). + struct SuperType<'t> { + full_name: String, + user_type: Node<'t>, + has_call: bool, + } + let mut super_types: Vec> = Vec::new(); + for &spec in &delegation_specifiers { + let mut user_type: Option> = None; + let mut has_call = false; + for i in 0..spec.named_child_count() { + let Some(child) = spec.named_child(i) else { continue }; + if matches!(child.kind(), "constructor_invocation" | "explicit_delegation") { + has_call = child.kind() == "constructor_invocation"; + for j in 0..child.named_child_count() { + if let Some(grandchild) = child.named_child(j) { + if grandchild.kind() == "user_type" { + user_type = Some(grandchild); + break; + } + } + } + break; + } + if child.kind() == "user_type" { + user_type = Some(child); + break; + } + } + let Some(user_type) = user_type else { continue }; + // Read only direct name segments: Outer.Inner is Outer.Inner, + // and types nested in type_arguments are never supertypes themselves. + let full_name = (0..user_type.named_child_count()) + .filter_map(|i| user_type.named_child(i)) + .filter(|c| c.kind() == "type_identifier") + .map(|c| self.text(c).trim().to_string()) + .collect::>() + .join("."); + if !full_name.is_empty() { + super_types.push(SuperType { full_name, user_type, has_call }); + } + } + // Header expressions execute in the enclosing scope, not as members of + // the new class. Visit each spec once so constructor arguments, + // delegates, and objects nested in either retain their calls/structure. + for &spec in &delegation_specifiers { + self.visit_node(spec); + } + + let enclosing_row = if self.stack.is_empty() { None } else { Some(self.top_row()) }; + if let Some(enclosing_row) = enclosing_row { + if let Some(primary_ctor) = super_types.iter().find(|s| s.has_call) { + self.push_ref( + enclosing_row, + &primary_ctor.full_name, + edge_kind_index("instantiates").unwrap(), + node.start_position().row as u32 + 1, + self.col_of(node), + ); + } + } + + // The anon class's own (cosmetic) name uses the first supertype's bare + // last segment, matching extractAnonymousClass's convention. + let mut type_name = super_types.first().map(|s| s.full_name.clone()).unwrap_or_else(|| "Object".to_string()); + if let Some(last_dot) = type_name.rfind('.') { + type_name = type_name[(last_dot + 1)..].to_string(); + } + type_name = type_name.trim().to_string(); + if type_name.is_empty() { + type_name = "Object".to_string(); + } + + // createNode IDs use name + line; include the column to distinguish + // same-type literals nested or adjacent on the same source line. + let anon_name = format!( + "<{type_name}$anon@{}:{}>", + node.start_position().row + 1, + node.start_position().column + ); + let Some(row) = self.create_node("class", &anon_name, node, Extra::default()) else { + return; + }; + + // Bug-for-bug: the TS code uses `startPosition.row` (0-based) as the + // LINE here (no +1), unlike push_ref_at's usual line_of. + let extends_kind = edge_kind_index("extends").unwrap(); + for s in &super_types { + self.push_ref( + row, + &s.full_name, + extends_kind, + s.user_type.start_position().row as u32, + self.col_of(s.user_type), + ); + } + + self.stack.push(Scope { row, kind: "class", name: anon_name }); + self.kotlin_object_scope_depth += 1; + for i in 0..body.named_child_count() { + if let Some(c) = body.named_child(i) { + self.visit_node(c); + } + } + self.kotlin_object_scope_depth -= 1; + self.stack.pop(); + } + /// extractDecoratorsFor — kotlin annotations inside `modifiers`: /// `@Marker` (user_type child) → decorates ref; `@Anno(args)` /// (constructor_invocation) → NOTHING. Runs for functions/methods/classes diff --git a/docs/design/kotlin-kernel-port-checklist.md b/docs/design/kotlin-kernel-port-checklist.md index fcbdcd9c8..d895d8ba4 100644 --- a/docs/design/kotlin-kernel-port-checklist.md +++ b/docs/design/kotlin-kernel-port-checklist.md @@ -299,6 +299,12 @@ Hooks PRESENT (port each exactly): DEFER-SHIELDED in the kernel**: every `fun interface` (either pattern, probed) makes the tree `hasError=true`, so the kernel defers the whole FILE to wasm before the walker would run. **Do NOT port branches 2-3.** + *(2026-09-24: superseded for `fun interface` — the extractor's + `preParse` now blanks the `fun` of a `fun interface` declaration, + offset-preserving, so such a file parses cleanly and the kernel extracts + it; the wasm recovery branches no longer fire for it. The old defer also + lost the declaration AFTER the `fun interface` when a doc comment sat + between them.)* Walker rule: port branch 1 only; a `defer:` on has_error covers the rest. (The parity suite still needs a fun-interface fixture asserting the kernel defers and the wasm arm serves the pinned output.) diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index c84983094..3e8397c54 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -161,6 +161,24 @@ function isFunInterfaceNode(node: SyntaxNode): boolean { return hasFun && hasInterfaceType; } +/** + * tree-sitter-kotlin predates functional interfaces (Kotlin 1.4): `fun + * interface Reply { … }` parses as a broken function declaration, and its + * error recovery swallows the NEXT declaration as well, so that interface and + * its methods go missing and its members surface as top-level functions. + * Blanking the `fun` of a `fun interface` declaration (three spaces for three + * letters, so every offset holds) lets it parse as the plain interface it is. + * Only a declaration position is touched: the start of a line, after optional + * visibility / multiplatform modifiers. + */ +export function blankKotlinFunInterface(source: string): string { + if (!source.includes('interface')) return source; + return source.replace( + /^([ \t]*(?:(?:public|private|internal|protected|expect|actual)[ \t]+)*)fun([ \t]+interface\b)/gm, + (_match, before: string, after: string) => `${before} ${after}`, + ); +} + /** * The qualifier of a function type's receiver — `DatabaseConfig.` in * `configure: (DatabaseConfig.Builder.() -> Unit)?`. The grammar reads a @@ -225,7 +243,7 @@ export function rewriteNewerKotlinSyntax(source: string): string { } function preParseKotlin(source: string): string { - return rewriteNewerKotlinSyntax(joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(source))); + return rewriteNewerKotlinSyntax(joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(blankKotlinFunInterface(source)))); } export const kotlinExtractor: LanguageExtractor = { diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 1cf704d43..6c70b00ca 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -459,6 +459,8 @@ const LITERAL_RECEIVER_TYPES = new Set([ /** * Languages whose member calls go through the TS/JS grammars. */ +/** A Kotlin receiver chain the resolver can type: `a.b`, `this.a`, up to four segments. */ +const KOTLIN_RECEIVER_CHAIN = /^(?:this|[A-Za-z_]\w*)(?:\.[A-Za-z_]\w*){1,3}$/; const TS_JS_CHAIN_LANGUAGES = new Set(['typescript', 'tsx', 'javascript', 'jsx']); /** Languages whose `class` node is a class expression (#2499). */ @@ -905,6 +907,7 @@ export class TreeSitterExtractor { private sourceAsWritten: string | null = null; // Memoized reExportAsWritten verdict (per-extractor = per-file). private reExportsAsWritten: boolean | null = null; + private kotlinObjectScopeDepth = 0; constructor( filePath: string, @@ -1996,6 +1999,14 @@ export class TreeSitterExtractor { skipChildren = true; } } + // Kotlin `object : IFoo.Stub() { ... }` — structurally distinct from + // INSTANTIATION_KINDS (multiple repeatable `delegation_specifier` + // supertypes, no single constructor/type field), so it gets its own + // dedicated extraction rather than being folded into extractInstantiation. + else if (nodeType === 'object_literal') { + this.extractKotlinObjectLiteral(node); + skipChildren = true; + } // (Decorator handling lives inside the symbol-creating extractors // — extractClass / extractFunction / extractProperty — because the // decorator node sits BEFORE the symbol in the AST and the walker @@ -6323,6 +6334,21 @@ export class TreeSitterExtractor { // Go receivers resolve strictly via validated field-hop // inference (see matchGoFieldChainCall) or stay unresolved. calleeName = `${getNodeText(receiver, this.source).replace(/\s+/g, '')}.${methodName}`; + } else if ( + this.language === 'kotlin' && + receiver && + receiver.type === 'navigation_expression' && + KOTLIN_RECEIVER_CHAIN.test(getNodeText(receiver, this.source).replace(/\s+/g, '').replace(/\?\./g, '.')) + ) { + // Kotlin call through a receiver chain — `engine.pump.drain()`, + // `this.engine.drain()`, `a?.b?.c()`. Keep the chain: the + // resolver types it segment by segment through the properties' + // declared types and resolves the method on that type, leaves a + // library type unresolved, and resolves an untyped chain as the + // bare method name this used to emit. Mirrored in the kernel's + // extract_call (kotlin.rs). + const chain = getNodeText(receiver, this.source).replace(/\s+/g, '').replace(/\?\./g, '.'); + calleeName = `${chain}.${methodName}`; } else if ( TS_JS_CHAIN_LANGUAGES.has(this.language) && receiver && @@ -6953,16 +6979,22 @@ export class TreeSitterExtractor { if (!this.extractor) return; // The instantiated type sits in the same field/position that - // extractInstantiation reads from. Use the same lookup so the anon - // class's `extends` target matches the `instantiates` edge. + // extractInstantiation reads from. const typeNode = getChildByField(node, 'constructor') || getChildByField(node, 'type') || getChildByField(node, 'name') || node.namedChild(0); - let typeName = typeNode ? getNodeText(typeNode, this.source) : 'Object'; - const ltIdx = typeName.indexOf('<'); - if (ltIdx > 0) typeName = typeName.slice(0, ltIdx); + let fullTypeName = typeNode ? getNodeText(typeNode, this.source) : 'Object'; + const ltIdx = fullTypeName.indexOf('<'); + if (ltIdx > 0) fullTypeName = fullTypeName.slice(0, ltIdx); + fullTypeName = fullTypeName.trim() || 'Object'; + + // The anon class's own (cosmetic) name is deliberately the short, bare + // form — matching extractInstantiation's `instantiates` truncation, since + // that edge resolves by bare class name (a real in-project nested type's + // own node is named by its last segment too). + let typeName = fullTypeName; const lastDot = Math.max(typeName.lastIndexOf('.'), typeName.lastIndexOf('::')); if (lastDot >= 0) typeName = typeName.slice(lastDot + 1).replace(/^[:.]/, ''); typeName = typeName.trim() || 'Object'; @@ -6971,14 +7003,19 @@ export class TreeSitterExtractor { const classNode = this.createNode('class', anonName, node, {}); if (!classNode) return; - // The anonymous class implicitly extends/implements the named type. + // The anonymous class implicitly extends/implements the named type. The + // `extends` reference itself must NOT be truncated to the bare last + // segment the way the anon class's own name and the `instantiates` edge + // are: a NAMED class's real `extends IFoo.Stub` clause is extracted + // verbatim (untruncated) so qualified-name resolution can find the + // nested type. Truncating this to "Stub" loses the enclosing interface. // We can't tell at extraction time whether T is a class or an interface, // so emit `extends`. Resolution will still bind T to whatever it is, and // Phase 5.5 (which already handles both `extends` and `implements`) will // bridge T's methods to the override names found in the anon body. this.unresolvedReferences.push({ fromNodeId: classNode.id, - referenceName: typeName, + referenceName: fullTypeName, referenceKind: 'extends', line: typeNode?.startPosition.row ?? node.startPosition.row, column: typeNode?.startPosition.column ?? node.startPosition.column, @@ -6994,6 +7031,128 @@ export class TreeSitterExtractor { this.nodeStack.pop(); } + /** + * Extract a Kotlin anonymous object expression — `object : IFoo.Stub() { ... }` + * — a common AIDL Stub implementation idiom. This has a different AST shape from Java/C#'s + * `object_creation_expression`, so it cannot reuse `extractAnonymousClass`: + * + * object_literal + * delegation_specifier (one per supertype, repeatable — + * constructor_invocation Kotlin allows `object : Base(), I1, I2 { }`) + * user_type + * type_identifier ... (dotted segments, e.g. IFoo, Stub) + * -- OR, for an interface with no constructor call -- + * user_type + * type_identifier ... + * class_body + * + * Before this function existed, `object_literal` was not in + * INSTANTIATION_KINDS and had no anonymous-class handling at all, so this + * idiom produced neither an `instantiates` nor an `extends` reference — + * the interface→impl synthesizer cannot see these implementations. + */ + private extractKotlinObjectLiteral(node: SyntaxNode): void { + if (!this.extractor) return; + const body = this.findAnonymousClassBody(node); + if (!body) return; + + const delegationSpecifiers: SyntaxNode[] = []; + for (let i = 0; i < node.namedChildCount; i++) { + const child = node.namedChild(i); + if (child && child.type === 'delegation_specifier') delegationSpecifiers.push(child); + } + + // A specifier contains a constructor invocation, an explicit `by` + // delegation, or a bare interface type. Only the first constructs a + // supertype; a call in a delegate expression does not. Preserve all + // dotted name segments for qualified lookup, excluding type arguments. + const superTypes: { fullName: string; userType: SyntaxNode; hasCall: boolean }[] = []; + for (const spec of delegationSpecifiers) { + let userType: SyntaxNode | null = null; + let hasCall = false; + for (let i = 0; i < spec.namedChildCount; i++) { + const child = spec.namedChild(i); + if (!child) continue; + if (child.type === 'constructor_invocation' || child.type === 'explicit_delegation') { + hasCall = child.type === 'constructor_invocation'; + for (let j = 0; j < child.namedChildCount; j++) { + const grandchild = child.namedChild(j); + if (grandchild && grandchild.type === 'user_type') { + userType = grandchild; + break; + } + } + break; + } + if (child.type === 'user_type') { + userType = child; + break; + } + } + if (!userType) continue; + // Read only direct name segments: Outer.Inner is Outer.Inner, + // and types nested in type_arguments are never supertypes themselves. + const fullName = userType.namedChildren + .filter((child) => child.type === 'type_identifier') + .map((child) => getNodeText(child, this.source).trim()) + .join('.'); + if (fullName) superTypes.push({ fullName, userType, hasCall }); + } + // Header expressions execute in the enclosing scope, not as members of + // the new class. Visit each spec once so constructor arguments, delegates, + // and objects nested in either retain their calls and structure. + for (const spec of delegationSpecifiers) this.visitNode(spec); + + if (this.nodeStack.length > 0) { + const fromId = this.nodeStack[this.nodeStack.length - 1]; + const primaryCtor = superTypes.find((s) => s.hasCall); + if (fromId && primaryCtor) { + this.unresolvedReferences.push({ + fromNodeId: fromId, + referenceName: primaryCtor.fullName, + referenceKind: 'instantiates', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + } + + // The anon class's own (cosmetic) name uses the first supertype's bare + // last segment, matching extractAnonymousClass's convention. + let typeName = superTypes[0]?.fullName ?? 'Object'; + const lastDot = typeName.lastIndexOf('.'); + if (lastDot >= 0) typeName = typeName.slice(lastDot + 1); + typeName = typeName.trim() || 'Object'; + + // createNode IDs use name + line; include the column to distinguish + // same-type literals nested or adjacent on the same source line. + const anonName = `<${typeName}$anon@${node.startPosition.row + 1}:${node.startPosition.column}>`; + const classNode = this.createNode('class', anonName, node, {}); + if (!classNode) return; + + for (const { fullName, userType } of superTypes) { + this.unresolvedReferences.push({ + fromNodeId: classNode.id, + referenceName: fullName, + referenceKind: 'extends', + line: userType.startPosition.row, + column: userType.startPosition.column, + }); + } + + this.nodeStack.push(classNode.id); + this.kotlinObjectScopeDepth++; + try { + for (let i = 0; i < body.namedChildCount; i++) { + const child = body.namedChild(i); + if (child) this.visitNode(child); + } + } finally { + this.kotlinObjectScopeDepth--; + this.nodeStack.pop(); + } + } + /** * Scan `declNode` and its preceding siblings (within the parent's * named children) for decorator nodes, emitting a `decorates` @@ -7459,6 +7618,11 @@ export class TreeSitterExtractor { } } else if (this.language === 'kotlin' && nodeType === 'infix_expression') { this.extractKotlinInfixCall(node); + } else if (nodeType === 'object_literal') { + // Kotlin `object : IFoo.Stub() { ... }` inside a function body — + // same rationale and structure as the visitNode branch above. + this.extractKotlinObjectLiteral(node); + return; } else if (this.extractor!.extractBareCall) { const calleeName = this.extractor!.extractBareCall(node, this.source); if (calleeName && this.nodeStack.length > 0) { diff --git a/src/resolution/index.ts b/src/resolution/index.ts index 45538ccc9..8d8e262fd 100644 --- a/src/resolution/index.ts +++ b/src/resolution/index.ts @@ -21,7 +21,7 @@ import { isImportableKind, CPP_DEFINE_SIGNATURE, } from './types'; -import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE, isGuessedRecordAccessor } from './name-matcher'; +import { isPythonSelfCall, matchJsStoreBindingCall, isUnresolvedJsMemberCall, matchObjectPathCall, thisScopeCaller, isVisibleAcrossFiles, matchReference, matchByFilePath, matchFunctionRef, matchDottedCallChain, matchScopedCallChain, matchMethodCall, sameLanguageFamily, crossesCodeBoundary, gateLanguageMatch, dumpNameMatcherProfile, clearNameMatcherMemos, isRustNameInScope, isRustForeignImplCallable, isRustPathCallReachable, rustChainedCallByName, CASE_INSENSITIVE_LANGUAGES, isDartMemberRead, matchDartMemberRead, isDartChainLink, matchDartChainLink, isDartAnnotation, matchDartAnnotation, isStdMethodName, isGoUnknownQualified, isGoProjectLocalCall, GO_STDLIB_PACKAGES, isGoBareName, goTypePositionTarget, goDotImportedSupertype, GO_TYPE_KINDS, matchGoAssertedCall, matchGoIndexedCall, matchGoChainedCall, preferGoBuildTwin, isGoAssertedLocal, isGoLocallyBound, isPythonFromImportModule, pythonAttrRefResolves, matchPythonTypedLocalCall, isImportShadowedByJsBinding, isManualMockPath, isTestSuitePath, matchNewReceiverCall, newReceiverClass, NEW_RECEIVER_SHAPE, isGuessedRecordAccessor, matchKotlinReceiverChain } from './name-matcher'; import { isVisibleCppMacro, clearCppMacroVisibility } from './cpp-macro-visibility'; import { isCppConstructorRef, matchCppConstructor } from './cpp-constructor'; import { isCppSupertypeRef, matchCppSupertype, clearCppSupertypeMemos } from './cpp-supertypes'; @@ -1554,6 +1554,18 @@ export class ReferenceResolver { // or nothing: Shopify never looks in another theme (see ./shopify-themes). const themeFile = matchShopifyThemeFile(ref, this.context); if (themeFile !== undefined) return themeFile; + // A Kotlin call through a receiver chain (`engine.pump.drain()`) resolves + // on the chain's declared type, or gets no edge when that type is a + // 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') { + const chain = matchKotlinReceiverChain(ref, this.context); + if (chain && 'method' in chain) { + const bare = this.resolveOneInner({ ...ref, referenceName: chain.method }); + return bare ? { ...bare, original: ref } : null; + } + if (chain !== undefined) return this.gateLanguage(chain, ref); + } // A TS/JS `super.run(…)` runs the method of the class's parent or a // nearer ancestor, or one outside the project: never a method found by diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 6b582f6d9..399e6403e 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1287,15 +1287,15 @@ const NO_NESTED_FUNCTIONS = new Set(['c', 'cpp']); const LOCAL_TYPE_KINDS = new Set(['class', 'struct', 'enum', 'interface', 'trait', 'type_alias']); /** - * A function nested inside another FUNCTION is only callable from within its - * container — Python, JS/TS, and every closure language scope it lexically. + * A function nested inside another function, or an anonymous-class method + * inside a function, is only callable from within that container. * Resolving a bare name from elsewhere to a nested local fabricates an edge * scope already rules out: `join(...)` in one function must never bind to a * `join` defined inside a DIFFERENT function (#1230). A candidate whose * qualifiedName parent is a same-file function/method is kept only when the - * ref originates inside that parent's line range. Class members are - * unaffected (their parent resolves to a class-like node), as are top-level - * symbols and C++ namespace-prefixed names (the prefix has no node). + * ref originates inside that parent's line range. Ordinary class members and + * top-level symbols have no enclosing function; C++ namespace prefixes have + * no function node. */ export function isLexicallyReachable( candidate: Node, @@ -12063,6 +12063,26 @@ export function matchByExactName( return null; } +/** + * Whether `qualifiedName` ends with `suffix` at a real `::` scope boundary — + * NOT a plain `String.endsWith`, which false-matches across an identifier + * boundary purely by character coincidence: `"Aaa::operator+"` ends with the + * literal substring `"a::operator+"` only because `Aaa` itself happens to + * end in the letter `a`, even though `"a"` there was never meant as a scope + * qualifier at all — it was a C++ receiver *variable* named `a` in a + * `a.operator+(b)` call, decoy-matching the unrelated `Aaa::operator+` + * method instead of leaving receiver-type inference (`matchMethodCall`) to + * find the real `V::operator+`. A match only counts when the + * suffix is the WHOLE qualifiedName, or the two characters immediately + * preceding it are a real `::` separator. + */ +function endsWithQualifiedSegment(qualifiedName: string, suffix: string): boolean { + if (qualifiedName === suffix) return true; + if (!qualifiedName.endsWith(suffix)) return false; + const boundary = qualifiedName.length - suffix.length; + return boundary >= 2 && qualifiedName[boundary - 1] === ':' && qualifiedName[boundary - 2] === ':'; +} + /** * Try to resolve by qualified name */ @@ -12082,12 +12102,15 @@ export function matchByQualifiedName( // must never resolve to a yaml/properties config node — that's a wrong edge // AND it hides the real callee. Drop those from both the exact and the partial // candidate sets so resolution falls through to method resolution below (#1180). - const keepForRef = (nodes: Node[]): Node[] => - ref.referenceKind === 'calls' - ? nodes.filter( - (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), - ) - : nodes; + const keepForRef = (nodes: Node[]): Node[] => { + let kept = nodes; + if (ref.referenceKind === 'calls') { + kept = kept.filter( + (n) => !(n.kind === 'constant' && (n.language === 'yaml' || n.language === 'properties')), + ); + } + return isInheritanceRef(ref) ? kept.filter(isSupertypeTarget) : kept; + }; // A C or C++ name written from the global scope (`::hpb::CreateMessage`, // `::operator delete`) is the declaration of exactly that name. The suffix @@ -12109,7 +12132,21 @@ export function matchByQualifiedName( }; } - let candidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByQualifiedName(ref.referenceName)), ref, context), ref, context); + // The GENERIC class-hierarchy walker's qualifiedName always joins scope + // with `::` (buildQualifiedName in tree-sitter.ts) regardless of source + // language, but an extends/implements clause's own text is recorded + // verbatim from the language's own syntax — so a Java/C# dotted supertype + // (`Outer.Inner`, `IFoo.Stub`) never matched a real indexed type's + // `::`-joined qualifiedName without normalizing the separator first. + // Scoped to ONLY inheritance references: several non-generic extractors + // (MyBatis XML statements, for one) deliberately build a MIXED + // qualifiedName that keeps literal dots from an already-dotted Java + // package/namespace string and adds `::` only at one specific boundary — + // blanket-normalizing every reference's dots to `::` mangled those. + const normalizedQualifiedRef = isInheritanceRef(ref) + ? ref.referenceName.replace(/\./g, '::') + : ref.referenceName; + let candidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByQualifiedName(normalizedQualifiedRef)), ref, context), ref, context); // A C# `using X.Y;` names a namespace: one the project declares, else it is // the file's own (external) using — never another file's using of that name. // That own using ends the lookup here, and resolveOne drops it (an import @@ -12184,11 +12221,24 @@ export function matchByQualifiedName( const parts = ref.referenceName.split(/[:.]/); const lastName = parts[parts.length - 1]; if (lastName) { + // Inheritance uses normalized JVM scope names; other qualified names + // retain upstream's scope-boundary and specialization checks. const partialCandidates = cppQualifierSpecializationPool(cppSpecializationPool(keepForRef(context.getNodesByName(lastName)) - .filter((candidate) => endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); + .filter((candidate) => isInheritanceRef(ref) + ? endsWithQualifiedSegment(candidate.qualifiedName, normalizedQualifiedRef) + : endsAtScopeBoundary(candidate.qualifiedName, ref.referenceName)), ref, context), ref, context); const ownLanguage = partialCandidates.filter((candidate) => sameLanguageFamily(candidate.language, ref.language)); const pool = ownLanguage.length > 0 ? ownLanguage : partialCandidates; - const chosen = preferCallSiteFile(pool, ref.filePath)[0]; + // `RecyclerView.LayoutManager` can exist in both AndroidX and the + // framework. For inheritance, pick the uniquely closest source tree; + // an equal-distance tie has no safe target. + const closest = isInheritanceRef(ref) && pool.length > 1 + ? pool.map((node) => ({ node, distance: computePathProximity(ref.filePath, node.filePath) })) + .sort((a, b) => b.distance - a.distance) + : null; + const chosen = closest + ? (closest[0]!.distance > closest[1]!.distance ? closest[0]!.node : null) + : preferCallSiteFile(pool, ref.filePath)[0]; if (chosen) { return { original: ref, @@ -13081,6 +13131,11 @@ export function resolveMethodOnType( /** Recursion guard for the supertype/conformance walk. */ depth = 0, ): ResolvedRef | null { + // Frameworks can name an inherited handler with a qualified reference. + // Inheritance refs still name a type, not a callable receiver. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'function_ref' && + ref.referenceKind !== 'references') return null; + // Look up methods by name and match by qualifiedName ending in // `::`. This works whether the method is defined // in-class (`class Foo { int bar() { ... } }`) or out-of-line in a separate @@ -17428,6 +17483,7 @@ export function matchCppCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const looked = matchCppChainOnWrittenType(m[1], m[2], ref, context); @@ -17503,6 +17559,7 @@ export function matchScopedCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const inner = m[1]; @@ -17542,6 +17599,7 @@ export function matchDottedCallChain( ref: UnresolvedRef, context: ResolutionContext, ): ResolvedRef | null { + if (ref.referenceKind !== 'calls') return null; const m = ref.referenceName.match(/^(.+)\(\)\.(\w+)$/); if (!m || !m[1] || !m[2]) return null; const inner = m[1]; // `Foo.getInstance` @@ -17939,14 +17997,22 @@ function inferJavaFieldReceiverType( if (!enclosing) return null; const enclosingEnd = enclosing.endLine ?? enclosing.startLine; + // Kotlin indexes a `val` property as a `constant`, a `var` as a `field`, and + // a companion-object property as a `variable`. const field = inFile.find( (n) => - n.kind === 'field' && + (n.kind === 'field' || (ref.language === 'kotlin' && (n.kind === 'constant' || n.kind === 'variable'))) && n.name === receiverName && n.language === ref.language && n.startLine >= enclosing.startLine && (n.endLine ?? n.startLine) <= enclosingEnd, ); + // Kotlin keeps no signature on a property, and a primary-constructor + // property (`class A(private val repo: Repo)`) is no node at all, so the + // declared type has to be read from the declaration itself. + if (ref.language === 'kotlin') { + return inferKotlinPropertyType(receiverName, field ?? null, enclosing, inFile, ref, context); + } if (!field || !field.signature) return null; // Signature shape: " " (extractField). Pull the type, @@ -17967,6 +18033,514 @@ function inferJavaFieldReceiverType( return lastPart; } +/** + * The declared type of a Kotlin property named `name` in class `owner`: from + * the property's own declaration lines when it is a node (`lateinit var repo: + * Repo`, `val repo = Repo(…)`), otherwise from the class header, where a + * primary-constructor property (`class A(private val repo: Repo)`) lives. The + * header is the class's lines before its first member, so a same-named local + * inside a method body is never read as the property. Returns null when no + * declaration names a type; resolveMethodOnType still validates the method. + */ +function inferKotlinPropertyType( + name: string, + property: Node | null, + owner: Node, + inFile: Node[], + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const lines = kotlinFileLines(ref.filePath, context); + if (!lines) return null; + + if (property) { + const text = lines.slice(Math.max(0, property.startLine - 1), Math.min(lines.length, property.endLine ?? property.startLine)); + return kotlinDeclaredTypeIn(text.join('\n'), name, ref, context); + } + return kotlinDeclaredTypeIn(kotlinClassHeader(owner, inFile, lines), name, ref, context); +} + +/** A file's lines through the context's per-file cache; null when unreadable. */ +function kotlinFileLines(filePath: string, context: ResolutionContext): string[] | null { + const lines = context.getFileLines + ? context.getFileLines(filePath) + : (context.readFile(filePath)?.split(/\r?\n/) ?? null); + return lines && lines.length > 0 ? lines : null; +} + +/** + * A Kotlin class's lines before its first member: its annotations, name, + * primary constructor and supertype list. + */ +function kotlinClassHeader(owner: Node, inFile: Node[], lines: string[]): string { + const ownerEnd = owner.endLine ?? owner.startLine; + let firstMember = ownerEnd + 1; + for (const n of inFile) { + if (n.id === owner.id || n.kind === 'file') continue; + if (n.startLine > owner.startLine && n.startLine <= ownerEnd && n.startLine < firstMember) { + firstMember = n.startLine; + } + } + return lines.slice(Math.max(0, owner.startLine - 1), Math.min(lines.length, firstMember - 1)).join('\n'); +} + +/** + * The supertype names a Kotlin class header lists after its primary + * constructor (`class A(…) : Base(…), Iface, Api by impl {`), as + * kotlinTypeName gives them. Colons and commas inside the constructor or + * type arguments are skipped; a `where` clause or the body ends the list. + */ +function kotlinSupertypeNames(header: string, className: string): string[] { + const r = className.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const decl = new RegExp(`\\b(?:class|object|interface)\\s+${r}\\b`).exec(header); + if (!decl) return []; + const entries: string[] = []; + let paren = 0; + let angle = 0; + let start = -1; + let i = decl.index + decl[0].length; + for (; i < header.length; i++) { + const c = header[i]!; + if (c === '(') paren++; + else if (c === ')') paren--; + else if (paren > 0) continue; + else if (c === '<') angle++; + else if (c === '>') angle--; + else if (angle > 0) continue; + else if (c === '{') break; + else if (start >= 0 && c === 'w' && /^where\b/.test(header.slice(i, i + 6)) && !/\w/.test(header[i - 1] ?? '')) break; + else if (c === ':' && start < 0) start = i + 1; + else if (c === ',' && start >= 0) { + entries.push(header.slice(start, i)); + start = i + 1; + } + } + if (start >= 0) entries.push(header.slice(start, i)); + const names: string[] = []; + for (const entry of entries) { + const m = entry.match(/^\s*(?:@\w+\s+)*([A-Za-z_][\w.]*)/); + const type = m && m[1] ? kotlinTypeName(m[1]) : null; + if (type) names.push(type); + } + return names; +} + +/** + * The declared type of Kotlin property `name` if class `owner` declares it — + * as a member node or a primary-constructor `val`/`var` — read in the + * owner's own file. Undefined when the owner does not declare it; null when + * it does without a type the declaration names. + */ +function kotlinOwnPropertyType( + name: string, + owner: Node, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null | undefined { + const inFile = context.getNodesInFile(owner.filePath); + const qualified = `${owner.qualifiedName}::${name}`; + const property = inFile.find( + (n) => (n.kind === 'field' || n.kind === 'constant' || n.kind === 'variable') && n.qualifiedName === qualified, + ) ?? null; + if (!property) { + const lines = kotlinFileLines(owner.filePath, context); + const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + if (!lines || !new RegExp(`\\b(?:val|var)\\s+${r}\\b`).test(kotlinClassHeader(owner, inFile, lines))) { + return undefined; + } + } + // Read the declaration in the owner's file, as if the ref sat there. + const at: UnresolvedRef = { ...ref, filePath: owner.filePath, line: property?.startLine ?? owner.startLine }; + return inferKotlinPropertyType(name, property, owner, inFile, at, context); +} + +/** + * The declared type of Kotlin property `name` that class `owner` inherits: the + * nearest project supertype (breadth-first, at most four levels) declaring it, + * with supertypes read from each class header. A supertype the project does + * not declare, or whose name is ambiguous, is not walked — the property may + * come from it — so the result is then null (unknown), never external. + */ +function kotlinInheritedPropertyType( + name: string, + owner: Node, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const seen = new Set([owner.id]); + let level: Node[] = [owner]; + for (let depth = 0; depth < 4 && level.length > 0; depth++) { + const next: Node[] = []; + for (const cls of level) { + const lines = kotlinFileLines(cls.filePath, context); + if (!lines) continue; + const header = kotlinClassHeader(cls, context.getNodesInFile(cls.filePath), lines); + for (const superName of kotlinSupertypeNames(header, cls.name)) { + const simple = superName.split('::').pop()!; + const supers = context.getNodesByName(simple).filter( + (n) => (n.kind === 'class' || n.kind === 'interface') && n.language === 'kotlin' && + (n.qualifiedName === superName || n.qualifiedName.endsWith(`::${superName}`)), + ); + if (supers.length !== 1 || seen.has(supers[0]!.id)) continue; + const sup = supers[0]!; + seen.add(sup.id); + const type = kotlinOwnPropertyType(name, sup, ref, context); + if (type !== undefined) return type; + next.push(sup); + } + } + level = next; + } + return null; +} + + +/** Node kinds that declare a type a receiver can have. */ +const DECLARED_TYPE_KINDS = new Set([ + 'class', 'interface', 'struct', 'enum', 'type_alias', 'trait', 'protocol', 'union', +]); + +/** + * Whether the project declares `typeName` (simple, or `Outer::Inner`) in the + * ref's language family. False for library types (`Regex`, `Properties`). + */ +function isProjectType(typeName: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + const simple = typeName.split('::').pop()!; + return context.getNodesByName(simple).some( + (n) => + DECLARED_TYPE_KINDS.has(n.kind) && + sameLanguageFamily(n.language, ref.language) && + (!typeName.includes('::') || n.qualifiedName === typeName || n.qualifiedName.endsWith(`::${typeName}`)), + ); +} + +/** + * A Kotlin type written in source (`Lease?`, `HardwareLock.Lease`, + * `List`) as a name resolveMethodOnType matches: nullability and type + * arguments dropped, a nested type joined with `::`, a package prefix + * (lowercase segments) dropped. Null when it is not a type name. + */ +function kotlinTypeName(raw: string): string | null { + const cleaned = raw.replace(/<[^>]*>/g, '').replace(/\?/g, '').trim(); + const segments = cleaned.split('.').filter(Boolean); + let first = segments.length; + while (first > 0 && /^[A-Z]\w*$/.test(segments[first - 1]!)) first--; + const typeSegments = segments.slice(first); + return typeSegments.length > 0 ? typeSegments.join('::') : null; +} + +/** + * Stands for "a type the project does not declare" — a library class, or the + * result of a call on one — so the caller can refuse a name-only guess. + */ +export const KOTLIN_EXTERNAL_TYPE = '\u0000external'; + +/** + * The type a Kotlin declaration of `name` in `text` gives it: `name: Type`, a + * constructor call `name = Type(…)`, or a call `name = Owner.fn(…)` / + * `name = fn(…)`, typed by `fn`'s declared return type. KOTLIN_EXTERNAL_TYPE + * when the owner is not a project type; null when nothing names a type. + */ +function kotlinDeclaredTypeIn( + text: string, + name: string, + ref: UnresolvedRef, + context: ResolutionContext, + depth = 0, +): string | null { + const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const typed = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*:\\s*([A-Z][\\w.]*)`)); + if (typed && typed[1]) return kotlinTypeName(typed[1]); + // An alias of another value: `val old = ws`, `val s = current ?: return`, + // `val s = current!!` — typed as that value. + const alias = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([a-z_]\\w*)\\s*(?:!!|\\?:\\s*(?:return|continue|break|throw)\\b[^\\n]*)?\\s*$`, 'm')); + if (alias && alias[1] && alias[1] !== name && depth < 3) { + return kotlinReceiverDeclaredType(alias[1], ref, context, depth + 1); + } + // `(` or a trailing lambda `{`: `Thread { … }`, `thread { … }`. + // Type arguments are skipped: `WebRtcPlaneSession(plane)`. + const assigned = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Za-z_][\\w.]*)\\s*(?:<[\\w\\s.,?*<>]*>\\s*)?[({]`)); + if (!assigned || !assigned[1]) { + // An enum entry or other constant of a type: `var mode = GpsMode.ON`. + const entry = text.match(new RegExp(`\\b(?:val|var)\\s+${r}\\s*=\\s*([A-Z]\\w*(?:\\.[A-Z]\\w*)*)\\.[A-Z][A-Z0-9_]*\\b(?!\\s*[.(])`)); + if (!entry || !entry[1]) return null; + // Only an entry of a project enum names its type; `Limits.MAX` is an Int. + const type = kotlinTypeName(entry[1]); + const simple = type?.split('::').pop(); + return simple && context.getNodesByName(simple).some( + (n) => n.kind === 'enum' && sameLanguageFamily(n.language, ref.language), + ) ? type : null; + } + const segments = assigned[1].split('.'); + const last = segments.pop()!; + if (/^[A-Z]/.test(last)) return kotlinTypeName(assigned[1]); + // A call. Only an owner written as a type chain (`Lock.tryBegin`) or none at + // all (`beginOp()`) can be typed; `a.b.fn()` goes through values. + if (segments.length > 0 && !segments.every((seg) => /^[A-Z]/.test(seg))) return null; + if (segments.length === 0 && KOTLIN_ARGUMENT_PASSTHROUGH.has(last)) { + const arg = text.match(new RegExp(`\\b${last}\\s*\\(\\s*([A-Za-z_]\\w*)\\s*[,)]`)); + if (!arg || !arg[1] || arg[1] === name) return null; + // The argument is a parameter or local of the same scope. + const argType = inferLocalReceiverType(arg[1], ref, context); + return argType && /^[A-Z]/.test(argType) ? argType : null; + } + return kotlinChainedCallResultType(segments.length > 0 ? segments.join('::') : undefined, last, ref, context); +} + +/** Kotlin stdlib functions whose result is always a library type. */ +const KOTLIN_LIBRARY_FACTORIES = new Set([ + 'listOf', 'mutableListOf', 'arrayListOf', 'emptyList', 'listOfNotNull', + 'setOf', 'mutableSetOf', 'hashSetOf', 'linkedSetOf', 'sortedSetOf', 'emptySet', + 'mapOf', 'mutableMapOf', 'hashMapOf', 'linkedMapOf', 'sortedMapOf', 'emptyMap', + 'arrayOf', 'arrayOfNulls', 'emptyArray', 'intArrayOf', 'longArrayOf', 'byteArrayOf', + 'sequenceOf', 'emptySequence', 'buildList', 'buildSet', 'buildMap', 'buildString', + 'lazy', 'thread', 'mutableStateOf', 'Channel', 'MutableStateFlow', 'MutableSharedFlow', +]); + +/** Stdlib functions that return their (non-null) argument: `requireNotNull(x)`. */ +const KOTLIN_ARGUMENT_PASSTHROUGH = new Set(['requireNotNull', 'checkNotNull']); + +/** + * The declared return type of Kotlin `owner.fn` (or of an unqualified `fn`, a + * method of the ref's own class first, then one in the same file). Every + * candidate must declare the same return type. A return type nested in the + * callee's owner keeps that owner (`Lease` in `HardwareLock` → + * `HardwareLock::Lease`). KOTLIN_EXTERNAL_TYPE for a call on a type the + * project does not declare (`Executors.newSingleThreadScheduledExecutor()`). + */ +function kotlinChainedCallResultType( + owner: string | undefined, + fn: string, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + if (owner && !isProjectType(owner, ref, context)) return KOTLIN_EXTERNAL_TYPE; + let candidates = context.getNodesByName(fn).filter( + (n) => (n.kind === 'method' || n.kind === 'function') && n.language === 'kotlin' && !!n.signature, + ); + if (owner) { + candidates = candidates.filter((n) => n.qualifiedName.endsWith(`${owner}::${fn}`)); + } else { + const ownClass = context.getNodesInFile(ref.filePath) + .filter((n) => (n.kind === 'class' || n.kind === 'interface') && n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) + .sort((a, b) => b.startLine - a.startLine)[0]; + const inOwnClass = ownClass + ? candidates.filter((n) => n.qualifiedName === `${ownClass.qualifiedName}::${fn}`) + : []; + const inFile = candidates.filter((n) => n.filePath === ref.filePath); + candidates = inOwnClass.length > 0 ? inOwnClass : inFile.length > 0 ? inFile : candidates; + } + // No project function of that name. A known stdlib factory returns a + // library type; any other library function (`requireNotNull`, `let`) may + // return a project value, so its result stays unknown. + if (candidates.length === 0) return !owner && KOTLIN_LIBRARY_FACTORIES.has(fn) ? KOTLIN_EXTERNAL_TYPE : null; + + let result: string | null = null; + for (const c of candidates) { + const sig = c.signature!; + const at = sig.lastIndexOf('):'); + if (at < 0) return null; + let type = kotlinTypeName(sig.slice(at + 2)); + if (!type) return null; + if (!type.includes('::')) { + const container = c.qualifiedName.slice(0, c.qualifiedName.lastIndexOf('::')); + const containerName = container.split('::').pop(); + if (containerName && context.getNodesByQualifiedName(`${container}::${type}`).some((n) => DECLARED_TYPE_KINDS.has(n.kind))) { + type = `${containerName}::${type}`; + } + } + if (result !== null && result !== type) return null; + result = type; + } + return result; +} + +/** + * The declared type of Kotlin receiver `name` at `ref`: the nearest local + * declaration in the enclosing scope (typed, constructed, or bound to a call), + * else a property of the enclosing class, else a parameter, else a property + * the enclosing class inherits. Null when none names a type. + */ +function kotlinReceiverDeclaredType( + name: string, + ref: UnresolvedRef, + context: ResolutionContext, + depth = 0, +): string | null { + const lines = kotlinFileLines(ref.filePath, context); + let localDeclared = false; + if (lines) { + const r = name.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); + const declares = new RegExp(`\\b(?:val|var)\\s+${r}\\b`); + for (const i of kotlinLocalScanLines(ref, context, lines.length)) { + const line = lines[i]; + if (!line || line.length > 10_000 || !declares.test(line)) continue; + const local = kotlinDeclaredTypeIn(line, name, ref, context, depth); + if (local) return local; + localDeclared = true; + break; + } + } + const property = inferJavaFieldReceiverType(name, ref, context); + if (property) return property; + // A typed parameter of the enclosing function or lambda (`webSocket: + // WebSocket` in an overridden callback). + const param = inferLocalReceiverType(name, ref, context); + if (param) return /^[A-Z]/.test(param) ? param : null; + if (localDeclared) return null; + // A property of a superclass: `viewModel` declared in `BaseActivity`. + const owner = kotlinEnclosingClass(ref, context); + if (!owner || kotlinOwnPropertyType(name, owner, ref, context) !== undefined) return null; + return kotlinInheritedPropertyType(name, owner, ref, context); +} + +/** The tightest Kotlin class or interface enclosing `ref`'s line. */ +function kotlinEnclosingClass(ref: UnresolvedRef, context: ResolutionContext): Node | undefined { + return context.getNodesInFile(ref.filePath) + .filter((n) => (n.kind === 'class' || n.kind === 'interface') && n.language === 'kotlin' && + n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) + .sort((a, b) => b.startLine - a.startLine)[0]; +} + +/** + * The 0-based lines a Kotlin local visible at `ref` may be declared on, + * nearest first: the enclosing function above the call, then — when that + * function is nested in another (a member of an anonymous object, a local + * function) — each outer function above the nested one, whose locals it + * captures. Lines of a function nested in a scanned one that does not + * enclose `ref` are skipped: its locals are not visible. Outside any + * function, every line above the call. + */ +function kotlinLocalScanLines(ref: UnresolvedRef, context: ResolutionContext, lineCount: number): number[] { + const callIdx = Math.max(0, Math.min(lineCount - 1, ref.line - 1)); + const fns = context.getNodesInFile(ref.filePath).filter( + (n) => (n.kind === 'function' || n.kind === 'method') && n.language === 'kotlin', + ); + const enclosing = fns + .filter((n) => n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line) + .sort((a, b) => b.startLine - a.startLine); + const out: number[] = []; + if (enclosing.length === 0) { + for (let i = callIdx; i >= 0; i--) out.push(i); + return out; + } + let hi = callIdx; + for (const fn of enclosing) { + const fnEnd = fn.endLine ?? fn.startLine; + const hidden = fns.filter( + (n) => n.startLine > fn.startLine && (n.endLine ?? n.startLine) <= fnEnd && + !(n.startLine <= ref.line && (n.endLine ?? n.startLine) >= ref.line), + ); + for (let i = hi; i >= fn.startLine - 1; i--) { + if (!hidden.some((n) => i + 1 >= n.startLine && i + 1 <= (n.endLine ?? n.startLine))) out.push(i); + } + hi = Math.min(hi, fn.startLine - 2); + } + return out; +} + +/** + * The declared type of a Kotlin receiver chain `a.b.c` / `this.a.b`: the first + * value typed as a single receiver (`this` is the enclosing class, a type name + * its object or companion), each next segment as a property or enum entry + * declared in the class of the type before it, read from that class's own + * file. KOTLIN_EXTERNAL_TYPE as soon as a segment has a + * type the project does not declare; null when any segment's type is unknown + * (an inherited property, an ambiguous class name). + */ +function kotlinChainDeclaredType( + receiver: string, + ref: UnresolvedRef, + context: ResolutionContext, +): string | null { + const segments = receiver.split('.'); + if (segments.length < 2 || segments.length > 4) return null; + + let owner: Node | undefined; + if (segments[0] === 'this') { + owner = kotlinEnclosingClass(ref, context); + if (!owner) return null; + } + let type: string | null = owner ? null : kotlinReceiverDeclaredType(segments[0]!, ref, context); + if (!owner && !type && /^[A-Z]/.test(segments[0]!)) { + // An object or a class's companion: `Registry.current.load()`, + // `Mode.ON.next()`. + const classes = context.getNodesByName(segments[0]!).filter( + (n) => DECLARED_TYPE_KINDS.has(n.kind) && n.language === 'kotlin', + ); + if (classes.length !== 1) return null; + owner = classes[0]!; + } + for (const segment of segments.slice(1)) { + if (!owner) { + if (!type) return null; + if (type === KOTLIN_EXTERNAL_TYPE || !isProjectType(type, ref, context)) return KOTLIN_EXTERNAL_TYPE; + const t = type; + const classes = context.getNodesByName(t.split('::').pop()!).filter( + (n) => DECLARED_TYPE_KINDS.has(n.kind) && n.language === 'kotlin' && + (n.qualifiedName === t || n.qualifiedName.endsWith(`::${t}`)), + ); + if (classes.length !== 1) return null; + owner = classes[0]!; + } + const inFile = context.getNodesInFile(owner.filePath); + const qualified = `${owner.qualifiedName}::${segment}`; + if (inFile.some((n) => n.kind === 'enum_member' && n.qualifiedName === qualified)) { + // An enum entry has its enum's type (`Outer::Mode`, package dropped). + type = kotlinTypeName(owner.qualifiedName.split('::').join('.')); + owner = undefined; + continue; + } + const own = kotlinOwnPropertyType(segment, owner, ref, context); + type = own !== undefined ? own : kotlinInheritedPropertyType(segment, owner, ref, context); + owner = undefined; + } + return type; +} + +/** A Kotlin call through a receiver chain: `a.b.m`, `this.a.m`, `a.b.c.d.m`. */ +const KOTLIN_CHAIN_REF = /^((?:this|[A-Za-z_]\w*)(?:\.[A-Za-z_]\w*){1,3})\.(\w+)$/; + +/** + * A Kotlin call through a receiver chain (`engine.pump.drain()`), resolved on + * the chain's declared type. Null — no edge — when the chain ends in a type + * the project does not declare (or passes through one) and the project has + * no extension of that name on a library type. When the type is + * unknown, `{ method }` names the bare method for the caller to resolve, the + * ref the extractor emitted before it kept the chain. Undefined for a ref + * that is not a receiver chain. + */ +export function matchKotlinReceiverChain( + ref: UnresolvedRef, + context: ResolutionContext, +): ResolvedRef | null | { method: string } | undefined { + const m = ref.referenceName.match(KOTLIN_CHAIN_REF); + if (!m) return undefined; + const method = m[2]!; + const declared = kotlinChainDeclaredType(m[1]!, ref, context); + if (!declared) return { method }; + if (declared !== KOTLIN_EXTERNAL_TYPE) { + const typed = resolveMethodOnType(declared, method, ref, context, 0.9, 'instance-method'); + if (typed) return typed; + if (isProjectType(declared, ref, context)) return { method }; + } + // A library type still reaches the project's own extensions of library + // types (`view.context.dp(8)` → `fun Context.dp()`). + return kotlinDeclaresLibraryExtension(method, ref, context) ? { method } : null; +} + +/** + * Whether the project declares a Kotlin extension `method` on a type it does + * not declare itself (`fun Context.dp(…)`, indexed as `Context::dp`). + */ +function kotlinDeclaresLibraryExtension(method: string, ref: UnresolvedRef, context: ResolutionContext): boolean { + return context.getNodesByName(method).some((n) => { + if ((n.kind !== 'method' && n.kind !== 'function') || n.language !== 'kotlin') return false; + const owner = n.qualifiedName.split('::').slice(-2, -1)[0]; + return !!owner && /^[A-Z]/.test(owner) && !isProjectType(owner, ref, context); + }); +} + // ── Local-variable receiver-type inference (#1108) ────────────────────────── // // Instance calls through a local variable (`const lg = new Logger(); lg.log()`) @@ -19686,6 +20260,10 @@ export function matchMethodCall( ref: UnresolvedRef, context: ResolutionContext ): ResolvedRef | null { + // A dotted/scoped name can be a framework's handler reference, while + // inheritance refs must stay with type lookup. + if (ref.referenceKind !== 'calls' && ref.referenceKind !== 'references') return null; + // Parse method call patterns like "obj.method" or "Class::method". The method // part allows trailing `:` keywords so Objective-C selectors resolve // (`SDImageCache.storeImage:`, `obj.setX:y:`); colons never appear in other @@ -20265,6 +20843,26 @@ export function matchMethodCall( } } + // Kotlin: a receiver whose type is known — a local bound to a call + // (`val lease = HardwareLock.tryBegin()`, typed by the callee's declared + // return type), a local constructor call, or a property — resolves on that + // type. A type the project does not declare (`Regex`, `Properties`, a JDK or + // Android class) has none of the project's methods, so the call gets NO + // edge instead of the name-only guesses below, which bind it to whatever + // project class declares a method of the same name. + if (ref.language === 'kotlin' && dotMatch && !objectOrClass!.includes('.')) { + const declared = nmTimedT('mc-kt-declared', ref, () => + kotlinReceiverDeclaredType(objectOrClass!, ref, context)); + if (declared === KOTLIN_EXTERNAL_TYPE) return null; + if (declared) { + const typedMatch = nmTimedT('mc-rmot', ref, () => resolveMethodOnType( + declared, methodName!, ref, context, 0.9, 'instance-method', + )); + if (typedMatch) return typedMatch; + if (!isProjectType(declared, ref, context)) return null; + } + } + // Object-literal namespace receiver (#1573): `api.call()` where `api` is a // `const api = { call() {…}, get: () => {…} }`. Its members are functions // inside the constant's extent, found by containment — none of the @@ -20478,7 +21076,7 @@ export function matchMethodCall( return null; } const methods = methodCandidates.filter( - (n) => n.kind === 'method' && n.name === methodName + (n) => n.kind === 'method' && n.name === methodName && isLexicallyReachable(n, ref, context) ); // Filter to same-language candidates first @@ -20604,7 +21202,7 @@ export function matchMethodCall( // The owner type's own name — not its namespace (`eShop.ClientApp…` // shares `Client` with every `httpClient`) nor the method's. const cut = method.qualifiedName.lastIndexOf('::'); - const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!) : []; + const classWords = cut > 0 ? splitCamelCase(method.qualifiedName.slice(0, cut).split(/::|\./).pop()!.replace(/^<(.+)\$anon@[^>]*>$/, '$1')) : []; // An untyped python attribute chain is a guess only through its head // noun: `self.session` names a `SessionBase` and `self.client` a // `ClientMixin`, but mealie's `database.group_shopping_lists` no @@ -26943,7 +27541,7 @@ export function matchFuzzy( // lowercase index is for. const bareR = isBareRCall(ref, context); const solidityBare = isReceiverLessSolidityCall(ref, context); - const callableCandidates = candidates.filter((n) => callableKinds.has(n.kind) && !(typeRef && !canNameInTypePosition(n)) && + const callableCandidates = candidates.filter((n) => (isInheritanceRef(ref) ? isSupertypeTarget(n) : callableKinds.has(n.kind)) && !(typeRef && !canNameInTypePosition(n)) && !(bareR && n.kind === 'method') && !(solidityBare && !isSolidityMemberInScope(n, ref, context)) && // `new …MockData()` makes an instance of a type; a method is never what it names.