mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-11-14 16:52:09 +08:00
fix(kotlin): a call through a property or a receiver chain resolves on its declared type
Kotlin indexes a class property without a signature, and a primary-constructor property (class A(private val repo: Repo)) is no node at all, so a call through either (probe.close() on a lateinit var probe: HttpProbe) found no receiver type and fell to the name-only strategies: the interface's method, or any same-named method elsewhere. A call through a chain (engine.pump.drain()) was emitted as its bare method name, and a local bound to a qualified call (val lease = HardwareLock.tryBegin()) was untyped. tree-sitter-kotlin also predates fun interface: such a declaration parsed as a broken function and, with a doc comment after it, its error recovery swallowed the next declaration too. The declared type is now read from the declaration itself: a property's own lines, the class header for a constructor property, a callee's declared return type for a local bound to a call, an enum entry's enum, each segment of a receiver chain in turn (the chain is kept in the reference name, by the wasm extractor and the native kernel alike), a superclass's property, and an outer function's local inside an anonymous object. A receiver whose type the project does not declare (Regex, Properties, a JDK class) gets no edge instead of a name-only guess, unless the project extends that type. The extractor's preParse blanks the fun of a fun interface, offset-preserving, so the file parses cleanly in both paths. Validation: - kotlin-property-receiver.test.ts: 17 tests, 12 red on main, all green here; kernel-kotlin-parity.test.ts pins the fun interface fixture. - Kernel parity (kotlin), 0 diffs: okhttp 588/619 files byte-identical (main's kernel 578, the rest deferred to wasm on parse errors), mihon 891/940 (887), nowinandroid 340/350 (339): the fun interface files no longer defer. - A/B against main with #1942 (positions only: the extractor now keeps a chain in the reference name): okhttp +299/-257, 2,554 retargeted, 2,158 of them test calls moving from the deprecated okhttp3.mockwebserver.MockWebServer (a 0.65 name guess) to the mockwebserver3.MockWebServer each file imports; mihon +82/-118, 337 retargeted (Preference get/changes/set/register onto the declared Preference); nowinandroid -9, 23 retargeted (properties onto their concrete test doubles); SystemUI's Kotlin +1/-104, 7 retargeted; jextract, click and gin identical. The removed are name guesses on library types (okio sinks, collections' filter and clear, OkHttp bodies, MutableStateFlow.update, Kotlin's apply). 7 right edges are lost where a chain passes through a library wrapper (Lazy.value, StateFlow.value, Pair.first, a ViewBinding's view). - Cost (mihon, median of 3 interleaved indexes): CPU 25.8 s on main with #1942 vs 26.7 s, wall 8.3 vs 8.4 s. Original PR #1933 by @danusha2345. Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
committed by
Colby Mchenry
co-authored by
Claude Opus 5.5
parent
77ee3484be
commit
01e0ac3aa7
@@ -253,6 +253,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In Java, a `record` is now indexed as a class, with its methods, its constructors, a field for each component and the accessor each component gets, like `info.remoteAddress()`. Before, a record had no symbol of its own and its methods were filed under the package or the class around it, so a call on a record-typed value linked to nothing or to the wrong method, and records were missing from callers, impact and implementations. A call like `Circle.unit()` on a type that a sealed interface's `permits` list names first now links too. Re-index Java projects after upgrading. Thanks @danusha2345.
|
||||
- In Java, a call now resolves on the type its receiver is declared with in more cases: a field of an outer class read inside an inner or anonymous class, `Outer.this.field`, a chain of fields like `this.owner.repo.save()`, a local declared with type arguments, a for-each variable, a variable declared with its package, like `org.app.Message message`, and a nested class that a parent class declares. When that type comes from a library, like Android's `Parcel`, `Handler` or `Notification.Builder`, or `java.util.List`, the call no longer links to a same-named method of your own. A type the file imports from a library (`import java.util.Iterator`) or as a nested class (`import p.Http.Cookie`) is no longer mistaken for a same-named type elsewhere in the project, and a Java type wins over a same-named Scala one. Re-index Java projects after upgrading. Thanks @danusha2345.
|
||||
- In Kotlin, a call on what one of your functions returns, like `repository().pending(1)` or `val repo = repository(); repo.pending(1)` with a `private fun repository(): RealRepo` in a test class, now links to the method of the type that function declares, including one the type inherits or an extension on it. Before, the chained call went to whichever `pending` the name matched first, like a test double's in another package, and the local was guessed from its name, which reached the interface. The function is found in the calling class, a class it inherits from or one around it, or at the top level, and a nested type like `Request.Builder` is that type, not another class's `Builder`. When the declared type has no such method, the call now links to nothing rather than a method of the same name elsewhere; a type from outside your project is matched by name as before. Re-index Kotlin projects after upgrading. Thanks @mindcipherrr for the report. (#2805)
|
||||
- In Kotlin, a call through a property now links to the method of the type the property is declared with, like `server.enqueue(…)` on a `lateinit var server: MockWebServer` or a call on a constructor property such as `class A(private val repo: Repo)`. So does a call through a chain like `engine.pump.drain()` or `Mode.ON.next()`, through a property one of your classes inherits, through a local set from a call like `val lease = HardwareLock.tryBegin()`, and through a local of the function around an anonymous `object : …`. Before, such a call went to an interface's method or to whichever method had the name: on okhttp, the tests' calls reached the deprecated `MockWebServer` instead of the one each file imports. A call on a type from outside your project, like `Regex` or a JDK class, no longer links to a same-named method of your own, unless your project extends that type with it. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
|
||||
- In Kotlin, a `fun interface` is now indexed as an interface with its methods, like okhttp's `HttpLoggingInterceptor.Logger`. Before, it was read as a broken function, and when a comment followed it, the declaration after it went missing too, its members turning into loose functions. Re-index Kotlin projects after upgrading. Thanks @danusha2345.
|
||||
- In JavaScript and TypeScript, a `super.run()` call now links to the method of the class's parent, or of the nearest ancestor that has it, like three.js's `super.copy(source)` reaching `Object3D.copy`, so `codegraph_explore`'s call path and impact follow an override into the method it builds on. Before, such a call inside a method of the same name was dropped, and elsewhere it went to whatever method had the name, often the class's own or a sibling class's in the same file. A call from a static method reaches the parent's static method, and a call to a parent from outside your project, like `HTMLElement`, `Set` or `Promise`, links nothing. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2497)
|
||||
- In JavaScript and TypeScript, a named class expression, like `register(class Widget extends Base {…})`, `customElements.define('x-card', class XCard extends HTMLElement {…})` or `const Panel = class PanelImpl extends Base {…}`, is now indexed as a class with its own methods and the class it extends, so calls between its methods and its `super` calls link as a class declaration's do. Before, its methods were free functions or weren't indexed at all, and it extended nothing. Its name stays bound inside the expression alone, as JavaScript has it: `new Widget()` elsewhere links nothing, a constant, class or interface of the same name around it keeps its links, and code outside never reaches its methods by a guess. A class a CommonJS module exports, like webpack's `module.exports = class MultiCompiler {…}` or `exports.Store = class Store {…}`, is reached through `require` as a class declaration is, along with its static methods and the methods called on its instances. Re-index JavaScript and TypeScript projects after upgrading. Thanks @Puma7 for the report and the pull request. (#2499)
|
||||
- In Go, a call through a struct field that holds a function, like prometheus's `d.refreshf(ctx)`, now reaches the functions put in that field, so `codegraph_explore` follows service discovery from `Discovery.Run` into each provider's `refresh` and names the line where each provider wires its method in, like `RefreshF: d.refresh`. The field can be set in a struct literal or by an assignment, copied from another struct's field (`refreshf: opts.RefreshF`), or given a method of an interface value, and two unrelated structs with a field of the same name, like `fn` or `handler`, stay apart. Before, such a flow stopped at the field. Re-index Go projects after upgrading.
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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', () => {
|
||||
|
||||
@@ -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<String>,
|
||||
) : 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<String> = emptyList() }
|
||||
object HardwareGate {
|
||||
fun tryBegin(): HardwareLock.Lease? = null
|
||||
}
|
||||
class Checker {
|
||||
fun validate(v: String): List<String> = 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<T> { 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<Int>()
|
||||
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, { config: { include: ['**/*.kt'], exclude: [] } });
|
||||
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::<fn>`. */
|
||||
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']);
|
||||
});
|
||||
});
|
||||
@@ -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<String> {
|
||||
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;
|
||||
@@ -1264,9 +1286,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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.)
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -456,6 +456,8 @@ const LITERAL_RECEIVER_TYPES = new Set([
|
||||
'dictionary', 'dict_literal', 'object', 'tuple', 'set',
|
||||
]);
|
||||
|
||||
/** 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}$/;
|
||||
/**
|
||||
* Languages whose member calls go through the TS/JS grammars.
|
||||
*/
|
||||
@@ -6323,6 +6325,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 &&
|
||||
|
||||
+14
-1
@@ -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';
|
||||
@@ -1566,6 +1566,19 @@ export class ReferenceResolver {
|
||||
const superCall = matchJsSuperCall(ref, this.context);
|
||||
if (superCall !== undefined) return superCall;
|
||||
|
||||
// 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);
|
||||
}
|
||||
|
||||
// Skip built-in/external references — except a python call of a
|
||||
// builtin-named method through a local or a parameter its def types as a
|
||||
// project class (`reg = Registry(); reg.get(k)`, #2583): that reading,
|
||||
|
||||
@@ -18339,14 +18339,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);
|
||||
}
|
||||
return field ? fieldSignatureType(field) : null;
|
||||
}
|
||||
|
||||
@@ -18667,6 +18675,514 @@ function javaReceiverDeclaredType(
|
||||
return javaTypeRef(written, filePath, context) ?? JAVA_EXTERNAL_TYPE;
|
||||
}
|
||||
|
||||
/**
|
||||
* 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<T>, 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<string>([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<Node['kind']>([
|
||||
'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<Foo>`) 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<IceCandidate>(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 kotlinQualifiedCallResultType(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 kotlinQualifiedCallResultType(
|
||||
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()`)
|
||||
@@ -21043,6 +21559,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
|
||||
|
||||
Reference in New Issue
Block a user