diff --git a/CHANGELOG.md b/CHANGELOG.md index 53daf8666c..5dc2548b21 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -100,6 +100,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes - **Code written without a space before a name resolves like spaced code.** Where one token ends exactly where the next begins, the resolver read the syntax at that point as the earlier token: in `x=1;local function helper()` it saw the `;`, so the Lua local read as global and calls from other files linked to it, and in `if ($x instanceof Foo)$x->bar();` it saw the `)`, so the PHP guard did not type `$x`. It now reads the token that starts there, the same way the TypeScript side already did. On json, jq and CPython the graph is unchanged. +- **A Kotlin infix call on a parenthesized receiver now reaches the right class.** `(b) put 1`, `(b)put(1)` and `((b)) put (1)` linked to whichever class had a `put`, so with `b: A` and another class `B` that also declared `put`, the call landed on `B::put`. They now follow what `b` holds, as `b put 1` already did. An extension on a project class called through a typed receiver is still not linked, as before; the parenthesized form no longer guesses it by name. - **A Vite plugin that spreads another plugin no longer turns off a React Router app's file routes.** Any `...` in a root `vite.config`, `react-router.config` or `remix.config` was taken for custom route configuration, so React Router's own RSC playground, which merges `...mdx({ … })` into a plugin entry, got no file routes. A spread of a call inside a `plugins` list is now ignored, since it only builds a plugin; a spread of anything else (`...shared`, `...makeConfig()` at the top level, `[...plugins]`, or inside a plugin call's arguments) still turns file routes off. The playground goes from 0 to 12 file routes. - **React Router's own `flatRoutes() satisfies RouteConfig` template now gets file routes, and RSC route modules link to their `ServerComponent`.** An `app/routes.ts` written as `export default flatRoutes() satisfies RouteConfig;` (or `as RouteConfig`), the form React Router's templates use, was taken for custom configuration, so the app got no file routes at all. In React Router's RSC framework mode a route module exports `ServerComponent` instead of a default component; such a page now gets its route and links to that component, both for files under `app/routes/` and for modules listed in `app/routes.ts`. A module that exports both keeps its default component. - **An inherited method no longer links to a same-named class in another package.** When a call on a `Child` reached `m()` through `Child extends Base`, the lookup past the first parent matched any class called `Base` in the project, so with an unrelated `Base` in another package the call could land on that package's `Base.m()`, or skip a grandparent's `m()` for it. The lookup now follows the parent the declaration names, for Java, Kotlin, C#, Scala and Swift, where a Java or Kotlin receiver comes from a factory call, or a C#, Scala or Swift receiver is typed. In Scala, a same-named object or a package block in the same file is told apart too; in Swift the other `Base` is one declared in another file, since modules are not modeled. Dart, TypeScript, Python and C++ are unchanged: their wrong links did not reproduce, or Dart's member lookup matches parents by name elsewhere. diff --git a/README.md b/README.md index bede623e1d..ed4c6972a1 100644 --- a/README.md +++ b/README.md @@ -145,7 +145,7 @@ Dispatch and framework coverage the fork adds, by kind: Call resolution the fork adds: -- Kotlin infix calls keep their call edge when a comment sits inside the expression. +- Kotlin infix calls keep their call edge when a comment sits inside the expression, and a parenthesized receiver (`(b) put 1`) is typed like a bare one. - Kotlin receiver inference follows bounded chains of declared returns and verified receiver-preserving methods. Properties initialized by typed factory calls keep compatible imported extensions. Explicit casts, single-type `when` branches, filtered collection elements and bound generic factory arguments also supply receiver types. - Kotlin chains on instance and extension receivers use the declared return type, including nested and multiline calls; upstream covers class and companion-factory chains. An imported return-type guess for an unknown receiver keeps confidence at most 0.7, and so does a Rust chain match without a proved receiver type. - Kotlin multi-dollar strings keep their interpolation threshold. diff --git a/__tests__/kotlin-infix-calls.test.ts b/__tests__/kotlin-infix-calls.test.ts index e602c5eb77..e95df7d31d 100644 --- a/__tests__/kotlin-infix-calls.test.ts +++ b/__tests__/kotlin-infix-calls.test.ts @@ -127,11 +127,14 @@ it.each([ infix fun Int.and(other: Int): Int = this fun numeric(value: Int, mask: Int) = (value) and mask `, 'numeric', []], + // A parenthesized receiver is typed like a bare one, and an extension on a + // project class is not linked through a typed receiver (`value and mask` + // gives the same result), so no edge is better than a guess by name. ['project numeric extension', `package project class Int infix fun Int.and(other: Int): Int = this fun custom(value: Int, mask: Int) = (value) and mask -`, 'custom', ['Int::and']], +`, 'custom', []], ['nested-comment this receiver', `package app class Spec { infix fun apply(other: Spec): Spec = this @@ -158,3 +161,26 @@ class Spec { fs.rmSync(dir, { recursive: true, force: true }); } }); + +it('types a parenthesized infix receiver by what it holds, not by the method name', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-infix-paren-')); + let graph: CodeGraph | undefined; + try { + fs.writeFileSync(path.join(dir, 'Ops.kt'), `package app +class Before { infix fun put(v: Int): Before = this } +class A { infix fun put(v: Int): A = this } +class After { infix fun put(v: Int): After = this } +fun plain(b: A) = b put 1 +fun paren(b: A) = (b) put 1 +fun parenTight(b: A) = (b)put(1) +fun parenArg(b: A) = ((b)) put (1) +`); + graph = await CodeGraph.init(dir, { index: true }); + const targets = (name: string) => graph!.getOutgoingEdges(graph!.getNodesInFile('Ops.kt').find((n) => n.name === name)!.id) + .filter((e) => e.kind === 'calls').map((e) => graph!.getNode(e.target)!.qualifiedName); + for (const name of ['plain', 'paren', 'parenTight', 'parenArg']) expect(targets(name), name).toEqual(['app::A::put']); + } finally { + graph?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/codegraph-kernel/src/kotlin/calls.rs b/codegraph-kernel/src/kotlin/calls.rs index 8d7cdb57e7..aa03dfa384 100644 --- a/codegraph-kernel/src/kotlin/calls.rs +++ b/codegraph-kernel/src/kotlin/calls.rs @@ -13,7 +13,15 @@ impl<'t> Walker<'t> { if func.kind() != "simple_identifier" || is_literal_receiver(lhs.kind()) { return; } let caller = self.top_row(); let name = self.text(func); - let receiver = if lhs.kind() == "simple_identifier" { self.text(lhs) } else { "" }; + // `(b) put 1` calls `put` on what `b` holds, as `b put 1` does. + let mut receiver_node = lhs; + while receiver_node.kind() == "parenthesized_expression" { + match named_kids(receiver_node).filter(|n| !n.is_extra()).collect::>().as_slice() { + [inner] => receiver_node = *inner, + _ => break, + } + } + let receiver = if receiver_node.kind() == "simple_identifier" { self.text(receiver_node) } else { "" }; let callee = if !receiver.is_empty() && receiver != "this" && receiver != "super" { format!("{receiver}.{name}") } else { name.to_string() };