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
+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();
}
}