diff --git a/CHANGELOG.md b/CHANGELOG.md index 1bbf2b498..738ca4cd1 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -574,6 +574,8 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). - `codegraph sync` and the file watcher now update the files that import through a module that re-exports what it imports, like a package's `__init__.py` or an `index.ts` barrel, when that module is deleted or added, and in Python also the files that reach it through another package re-exporting it. Before, deleting `shop/__init__.py` left every `from shop import Order` linked to the class it re-exported, and deleting `components/index.ts` left every `import { Button } from './components'` linked, until each importing file changed or the project was re-indexed. When such a module came back, the imports stayed linked the way they were while it was missing, and in a large Python project an import of a module whose name is as common as `models` or `test`, like `from django.test import TestCase`, stayed unlinked. Python, JavaScript, TypeScript, Vue, Svelte and Astro. +- In Kotlin, a call on a typed receiver now links to an extension function declared in a package, like `value.shout()` or the infix `a join b` with `value: Foo` and `fun Foo.shout()` under `package demo`, including one declared in another file of the package. Before, it linked only when the file had no `package` line, so the usual Kotlin project got no edge. On JetBrains Exposed, 1,020 such calls now link (`Table.selectAll()` to `FieldSet.selectAll`, `transaction.throwUnsupportedException(…)`), and none that linked before is lost. A member of the receiver's class still wins over an extension of the same name. + #### MCP / indexing - Full indexes of large projects no longer sometimes run two to three times slower than usual. A timing clash between two background database maintenance steps could switch reference resolution from several threads to one partway through. diff --git a/README.md b/README.md index 6da976ad3..224304b82 100644 --- a/README.md +++ b/README.md @@ -148,6 +148,7 @@ Call resolution the fork adds: - 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 calls on a typed receiver link to extension functions declared in a package, infix ones included (`value.shout()`, `a join b`); on JetBrains Exposed that adds 1,020 call links and removes none. - Kotlin multi-dollar strings keep their interpolation threshold. - C++ typedef and using aliases follow their declaration scope, including nested owners and inherited aliases. Pointer, array and function members are not supertypes. Visible class-scoped aliases and declared complex receivers keep their method owners; unsubstituted template parameters and ambiguous owners stay unresolved. - C++ `->` calls on a `std::unique_ptr`, `std::shared_ptr` or `std::optional` reach the type it holds, looked up through the caller's enclosing classes and their bases, its namespaces, `using` directives and aliases. A held type the project does not declare leaves the call unresolved, as a plain pointer to one does; upstream guesses a callee from the receiver's name there. A partial class template specialization is indexed as an anonymous class, so a call to a member only it declares stays unresolved. diff --git a/__tests__/kotlin-extension-call-typed-receiver.test.ts b/__tests__/kotlin-extension-call-typed-receiver.test.ts new file mode 100644 index 000000000..2632e6ddb --- /dev/null +++ b/__tests__/kotlin-extension-call-typed-receiver.test.ts @@ -0,0 +1,51 @@ +import { expect, it } from 'vitest'; +import fs from 'node:fs'; +import os from 'node:os'; +import path from 'node:path'; +import { CodeGraph } from '../src'; + +// An extension function's qualified name is `Foo::shout` whatever its package, while the class +// it extends is `pkg::Foo`: the call on a typed receiver has to find it by the receiver's type. +it('links a call on a typed receiver to an extension function declared in a package', async () => { + const dir = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-ext-pkg-')); + let cg: CodeGraph | undefined; + try { + fs.writeFileSync(path.join(dir, 'Foo.kt'), `package demo + +class Foo +class Bar +class Baz { fun shout(): String = "member" } +`); + fs.writeFileSync(path.join(dir, 'Ext.kt'), `package demo + +fun Foo.shout(): String = "x" +infix fun Foo.join(other: Foo): Foo = this +fun Baz.shout(loud: Boolean): String = "extension" +fun Bar.whisper(): String = "y" +`); + fs.writeFileSync(path.join(dir, 'Use.kt'), `package demo + +fun byParameter(value: Foo): String = value.shout() +fun byConstructor(): String { + val f = Foo() + return f.shout() +} +fun byInfix(a: Foo, b: Foo): Foo = a join b +fun memberWins(value: Baz): String = value.shout() +fun otherType(value: Bar): String = value.shout() +fun unrelatedExtension(value: Foo): String = value.whisper() +`); + cg = await CodeGraph.init(dir, { index: true }); + const targets = (name: string) => cg!.getOutgoingEdges(cg!.getNodesInFile('Use.kt').find((n) => n.name === name)!.id) + .filter((e) => e.kind === 'calls').map((e) => cg!.getNode(e.target)!.qualifiedName + '@' + cg!.getNode(e.target)!.filePath); + expect(targets('byParameter')).toEqual(['Foo::shout@Ext.kt']); + expect(targets('byConstructor')).toEqual(['Foo::shout@Ext.kt']); + expect(targets('byInfix')).toEqual(['Foo::join@Ext.kt']); + expect(targets('memberWins')).toEqual(['demo::Baz::shout@Foo.kt']); + expect(targets('otherType')).toEqual([]); + expect(targets('unrelatedExtension')).toEqual([]); + } finally { + cg?.close(); + fs.rmSync(dir, { recursive: true, force: true }); + } +}); diff --git a/__tests__/kotlin-infix-calls.test.ts b/__tests__/kotlin-infix-calls.test.ts index e95df7d31..c78a13695 100644 --- a/__tests__/kotlin-infix-calls.test.ts +++ b/__tests__/kotlin-infix-calls.test.ts @@ -128,13 +128,13 @@ 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 class links through the typed receiver (`value and mask` gives + // the same result): the extension is named `Int::and` whatever its package. ['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', []], +`, 'custom', ['Int::and']], ['nested-comment this receiver', `package app class Spec { infix fun apply(other: Spec): Spec = this diff --git a/codegraph-kernel/src/resolve/kotlin_receiver.rs b/codegraph-kernel/src/resolve/kotlin_receiver.rs index 99596c525..bae796fc1 100644 --- a/codegraph-kernel/src/resolve/kotlin_receiver.rs +++ b/codegraph-kernel/src/resolve/kotlin_receiver.rs @@ -133,6 +133,13 @@ impl KernelResolver { fn kotlin_receiver_member(&mut self, hit: &KotlinReceiverHit, name: &str, r: &ResolveRefIn) -> Res>> { if let Some(member) = self.match_bound_type_member(&hit.ty, name, &hit.site)? { return Ok(Some(member.node)); } + self.kotlin_extension_member(hit, name, r) + } + + /// The one visible extension function `name` whose receiver type accepts `hit`'s type: its + /// qualified name carries the receiver as written (`Foo::shout`, never `pkg::Foo::shout`), so + /// it cannot be found as a member of the receiver's class by qualified name. + pub(super) fn kotlin_extension_member(&mut self, hit: &KotlinReceiverHit, name: &str, r: &ResolveRefIn) -> Res>> { let candidates: Vec<_> = self.nodes_by_name(name)?.iter().filter(|n| n.language == "kotlin" && n.kind == "method").cloned().collect(); let mut matching = Vec::new(); for candidate in candidates { diff --git a/codegraph-kernel/src/resolve/method_call.rs b/codegraph-kernel/src/resolve/method_call.rs index 10d237465..bb6cbc48c 100644 --- a/codegraph-kernel/src/resolve/method_call.rs +++ b/codegraph-kernel/src/resolve/method_call.rs @@ -773,7 +773,15 @@ if inferred.is_none() && r.language == "dart" { if let Some(b) = &binding { bsite.line = b.line; } - return self.match_bound_type_member(&t, &method_name, &bsite); + let member = self.match_bound_type_member(&t, &method_name, &bsite)?; + if member.is_none() && r.language == "kotlin" { + // An extension function is no member of the receiver's class, and in a + // package its qualified name does not say which class it extends. + let hit = super::kotlin_receiver::KotlinReceiverHit { ty: t.clone(), site: bsite, heuristic: false }; + return Ok(self.kotlin_extension_member(&hit, &method_name, r)? + .map(|node| KCand { node, confidence: 0.9, resolved_by: "instance-method" })); + } + return Ok(member); } }