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:
danusha2345
2026-10-28 02:14:52 +00:00
committed by Colby Mchenry
co-authored by Claude Opus 5.5
parent 77ee3484be
commit 01e0ac3aa7
10 changed files with 1027 additions and 13 deletions
+2
View File
@@ -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()
}
}
+11 -8
View File
@@ -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', () => {
+372
View File
@@ -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']);
});
});
+32 -2
View File
@@ -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.)
+19 -1
View File
@@ -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 = {
+17
View File
@@ -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
View File
@@ -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,
+537 -1
View File
@@ -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