mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-24 19:30:10 +08:00
fix(kotlin): repository().pending() links the method of the type repository() declares (#2822)
Issue #2805: in a Kotlin test class with `private fun repository():
RealRepo`, `repository().pending(1)` went to a test double's
`FakeRepo.pending` in another package, and `val repo = repository();
repo.pending(1)` to the interface `Repo.pending`. The extractor records a
call on an unqualified call's result by the outer method's bare name (a
capitalized inner, `Foo().bar()`, is kept as a chain), so the name matcher
took whichever `pending` came first; and the local's type was read only
from a constructor or an annotation, so the receiver's name guessed it.
The name matcher now reads such a call where it is written
(`repository().pending(1)`, `this.repository()…`, `nullable()?.pending()`)
and finds the inner function the way Kotlin does: a method of the calling
class, of a class it inherits from or one around it, read from their
declarations, else a top-level function the file can name. The call is
resolved on the type that function's signature declares, including what
the type inherits and extensions on it, or links nothing when the type
lacks the method: the #750 rule, as for `Foo.factory().bar()`. A local set
from such a call alone (`val repo = repository()`) takes that type for
the existing typed-local path. Edge cases the A/B surfaced:
- a nested type is the one the function names, `Request.Builder` or a
plain `Builder` written in `Request`, matched by whole names
(`MockResponse.Builder` is no `Response.Builder`);
- an extension is the one the calling file can name (Exposed declares
`Expression<T>.year()` in three modules);
- a type from outside the project, a call inside an extension on one,
and an extension the index keeps unattached (on a nullable receiver, or
`fun Dns.Call.x()` filed under `Dns`) keep the name match as before.
Validation:
- New kotlin-call-result-receiver test (9 cases): 7 fail on main, the two
that guard unchanged behavior pass on both; all pass with the fix on the
kernel and on the wasm path. Mutation checks confirm the owner-path,
scoped-nested-type, whole-segment and extension-visibility rules are
each exercised.
- A/B on main e32021f6, calls by call site, nodes identical:
okhttp c317930: 82 retargeted, 176 gained, 3 dropped, 516 confidence
only; JetBrains/Exposed 09f814c: 152 retargeted, 138 gained, 1 dropped,
1353 confidence only; nowinandroid 2982e63: 1 confidence only. Controls
gin (Go), nest (TypeScript) and mybatis-3 (Java): byte-identical.
- Triage: retargets go from name guesses to the declared type
(`request().post()` → `Request.Builder.post`, `varchar(…).index()` →
`Column.index`, `rowNumber().over()` → `RowNumber.over`,
`newReader().readInt()` → `Hpack.Reader.readInt`); gains are locals now
typed (`val connection = connect(peer)`); the 4 drops are Kotlin's
`apply` scope function, which went to a test helper's or a Gradle
plugin's `apply`. Every retarget group was checked against the source.
- Cost: whole index, median of 3 interleaved: okhttp 10.5 s -> 10.9 s,
Exposed 17.7 s -> 16.8 s, within noise.
Fixes #2805
Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
d5fc1fbb21
commit
628f69e275
@@ -201,6 +201,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html).
|
||||
- In Go, a struct that embeds another is no longer treated as overriding the methods it redeclares. gin's `Engine` embeds `RouterGroup` and has a `Use` of its own, but calling `Use` on a `RouterGroup` only ever runs `RouterGroup.Use`; before, `codegraph_explore`'s call path and impact went on from it into `Engine.Use` and the code only the engine runs. The same held for every struct or defined type embedded in another that redeclares one of its methods, like etcd's `Mutex` inside `lockerMutex`. A call through an interface still reaches each type that implements it, the embedding struct included. Re-index Go projects after upgrading.
|
||||
- In JavaScript and TypeScript, a subclass's `static` method no longer counts as overriding an instance method of the same name in its base class, or the reverse, since calling one never runs the other. In webpack, `codegraph_explore`'s call path and impact went on from `Module`'s `deserialize(context)` into the `static deserialize(context)` of `NormalModule` and other modules. A static method still overrides its base's static method and an instance method its base's instance method, and JavaScript class members are now marked static, as TypeScript ones already were. Re-index JavaScript and TypeScript projects after upgrading. Thanks @inth3shadows for the report and @yang0228 for the pull request. (#2761)
|
||||
- In Java, Kotlin, C# and Scala, each overload of an interface or base-class method now links only to the overrides with the same parameters. Before, every overload linked to every method of that name in the implementing class, so `ExampleService.putTairValue(String)` also led to `putTairValue(List<String>, double)`, and `codegraph_explore`'s call path, callers and impact went into overloads that call never runs. Where a parameter is a type parameter, like `save(T entity)` implemented as `save(String entity)`, or the class is written in another language than its interface, the number of parameters decides; a method declared once on each side links as before. Re-index Java, Kotlin, C# and Scala projects after upgrading. Thanks @hxj1225 for the report and @danusha2345 for the pull request. (#2663)
|
||||
- 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 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 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.
|
||||
- In Go, a type defined from other types, like etcd's `type WatchChan <-chan WatchResponse` or gin's `type HandlerFunc func(*Context)` and `type HandlersChain []HandlerFunc`, now links to the types it is built from, so impact on `WatchResponse` or gin's `Context` reaches these types and the code that uses them. Before, such a type linked to nothing. One written through another package, like etcd's `type PutResponse pb.PutResponse`, links to that package's type, and a recursive one like `type stateFn func(*Lexer) stateFn` doesn't link to itself. Re-index Go projects after upgrading.
|
||||
|
||||
@@ -0,0 +1,365 @@
|
||||
/**
|
||||
* A Kotlin call on what an unqualified call returns is a call on the type
|
||||
* that function declares (#2805). `repository().pending(1)`, with a private
|
||||
* `fun repository(): RealRepo` in the calling class, went to whichever
|
||||
* `pending` the name matched first — a test double's `FakeRepo.pending` in
|
||||
* another package — and `val repo = repository(); repo.pending(1)` went to
|
||||
* the interface `Repo.pending`, guessed from the local's name. Both now link
|
||||
* `RealRepo.pending`, through the helper the class declares, inherits or the
|
||||
* package declares at the top level; a type that has no such method links
|
||||
* nothing rather than a namesake elsewhere.
|
||||
*/
|
||||
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';
|
||||
|
||||
const FILES: Record<string, string> = {
|
||||
// The issue's four files, verbatim.
|
||||
'src/main/kotlin/demo/domain/Repo.kt': `package demo.domain
|
||||
|
||||
interface Repo {
|
||||
fun pending(id: Long): Int
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/demo/data/RealRepo.kt': `package demo.data
|
||||
|
||||
import demo.domain.Repo
|
||||
|
||||
class RealRepo : Repo {
|
||||
override fun pending(id: Long): Int = 1
|
||||
}
|
||||
`,
|
||||
'src/test/kotlin/demo/testutil/FakeRepo.kt': `package demo.testutil
|
||||
|
||||
import demo.domain.Repo
|
||||
|
||||
class FakeRepo : Repo {
|
||||
override fun pending(id: Long): Int = 2
|
||||
}
|
||||
`,
|
||||
'src/test/kotlin/demo/data/RealRepoTest.kt': `package demo.data
|
||||
|
||||
class RealRepoTest {
|
||||
private fun repository(): RealRepo = RealRepo()
|
||||
|
||||
fun viaConstructor() {
|
||||
val repo = RealRepo()
|
||||
repo.pending(1)
|
||||
}
|
||||
|
||||
fun viaHelperLocal() {
|
||||
val repo = repository()
|
||||
repo.pending(1)
|
||||
}
|
||||
|
||||
fun viaHelperChain() {
|
||||
repository().pending(1)
|
||||
}
|
||||
}
|
||||
`,
|
||||
// More helpers and call shapes.
|
||||
'src/test/kotlin/demo/data/MoreHelperTest.kt': `package demo.data
|
||||
|
||||
import demo.domain.Repo
|
||||
|
||||
abstract class BaseRepoTest {
|
||||
protected fun baseRepository(): RealRepo = RealRepo()
|
||||
}
|
||||
|
||||
class MoreHelperTest : BaseRepoTest() {
|
||||
private fun repository(): RealRepo = RealRepo()
|
||||
private fun asInterface(): Repo = RealRepo()
|
||||
private fun nullable(): RealRepo? = null
|
||||
|
||||
private fun runTest(block: () -> Unit) = block()
|
||||
|
||||
fun viaBaseHelper() {
|
||||
baseRepository().pending(1)
|
||||
}
|
||||
|
||||
fun viaInterfaceHelper() {
|
||||
asInterface().pending(1)
|
||||
}
|
||||
|
||||
fun viaThis() {
|
||||
this.repository().pending(1)
|
||||
}
|
||||
|
||||
fun viaSafeCall() {
|
||||
nullable()?.pending(1)
|
||||
}
|
||||
|
||||
fun viaTopLevel() {
|
||||
freshRepo().pending(1)
|
||||
}
|
||||
|
||||
fun viaLambdaLocal() {
|
||||
runTest {
|
||||
var repo = repository()
|
||||
repo.pending(1)
|
||||
}
|
||||
}
|
||||
|
||||
fun viaMissing() {
|
||||
repository().missing()
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/test/kotlin/demo/data/Fixtures.kt': `package demo.data
|
||||
|
||||
fun freshRepo(): RealRepo = RealRepo()
|
||||
|
||||
fun RealRepo.describe(): String = "real"
|
||||
|
||||
class Codec {
|
||||
class Reader {
|
||||
fun readInt(): Int = 0
|
||||
}
|
||||
}
|
||||
|
||||
// A nested type sharing its name with the one the helper names (in Request.kt); an extension on a nested type.
|
||||
class MockRequest {
|
||||
class Builder {
|
||||
fun post(): Builder = this
|
||||
}
|
||||
}
|
||||
|
||||
class Mall {
|
||||
class Builder {
|
||||
fun open(): Mall = Mall()
|
||||
}
|
||||
}
|
||||
|
||||
// Shop.newBuilder() declares a plain Builder, which is Shop.Builder.
|
||||
fun Shop.reopen(): Shop = newBuilder().open()
|
||||
|
||||
class Dns {
|
||||
class Call
|
||||
}
|
||||
|
||||
fun Dns.Call.toEvents(): Int = 1
|
||||
|
||||
class Client {
|
||||
private fun request(): Request.Builder = Request.Builder()
|
||||
private fun newCall(): Dns.Call = Dns.Call()
|
||||
|
||||
fun send() {
|
||||
request().post()
|
||||
}
|
||||
|
||||
fun events() {
|
||||
newCall().toEvents()
|
||||
}
|
||||
}
|
||||
|
||||
class InheritedHelperTest {
|
||||
private fun audited(): AuditedRepo = AuditedRepo()
|
||||
private fun repository(): RealRepo = RealRepo()
|
||||
private fun newReader(): Codec.Reader = Codec.Reader()
|
||||
private fun text(): StringBuilder = StringBuilder()
|
||||
|
||||
// A nested type the helper names through its outer class.
|
||||
fun viaNested() {
|
||||
newReader().readInt()
|
||||
}
|
||||
|
||||
// A type from outside the project: linked by the method's name, as before.
|
||||
fun viaOutside() {
|
||||
text().reverse()
|
||||
}
|
||||
|
||||
// A method the helper's type inherits; an extension function on it.
|
||||
fun viaInherited() {
|
||||
audited().audit()
|
||||
}
|
||||
|
||||
fun viaExtension() {
|
||||
repository().describe()
|
||||
}
|
||||
|
||||
fun viaVisibleExtension() {
|
||||
repository().label()
|
||||
}
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/demo/data/Shop.kt': `package demo.data
|
||||
|
||||
class Shop {
|
||||
class Builder {
|
||||
fun open(): Shop = Shop()
|
||||
}
|
||||
|
||||
fun newBuilder(): Builder = Builder()
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/demo/data/Request.kt': `package demo.data
|
||||
|
||||
class Request {
|
||||
class Builder {
|
||||
fun post(): Builder = this
|
||||
}
|
||||
}
|
||||
`,
|
||||
// The helper's type and its base, in a file indexed after the caller's.
|
||||
'src/test/kotlin/demo/data/zz/Audit.kt': `package demo.data
|
||||
|
||||
open class BaseAudit {
|
||||
fun audit() {}
|
||||
}
|
||||
|
||||
class AuditedRepo : BaseAudit()
|
||||
`,
|
||||
// Two extensions of one name: the caller's package declares one, another package the other.
|
||||
'src/test/kotlin/demo/aa/OtherLabels.kt': `package demo.aa
|
||||
|
||||
import demo.data.RealRepo
|
||||
|
||||
fun RealRepo.label(): String = "other"
|
||||
`,
|
||||
'src/test/kotlin/demo/data/zz/Labels.kt': `package demo.data
|
||||
|
||||
fun RealRepo.label(): String = "own"
|
||||
`,
|
||||
// A namesake of both methods in an unrelated package.
|
||||
'src/test/kotlin/demo/other/Decoy.kt': `package demo.other
|
||||
|
||||
class Decoy {
|
||||
fun missing() {}
|
||||
fun pending(id: Long): Int = 3
|
||||
fun audit() {}
|
||||
fun describe(): String = "decoy"
|
||||
fun readInt(): Int = 3
|
||||
fun reverse() {}
|
||||
}
|
||||
`,
|
||||
};
|
||||
|
||||
let root = '';
|
||||
let graph: CodeGraph;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-call-result-'));
|
||||
for (const [rel, content] of Object.entries(FILES)) {
|
||||
fs.mkdirSync(path.dirname(path.join(root, rel)), { recursive: true });
|
||||
fs.writeFileSync(path.join(root, rel), content);
|
||||
}
|
||||
graph = await CodeGraph.init(root, { index: true });
|
||||
}, 120_000);
|
||||
|
||||
afterAll(() => {
|
||||
graph?.close();
|
||||
if (root) fs.rmSync(root, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
/** The qualified names `owner::fn` calls, sorted. */
|
||||
function callees(owner: string, fn: string): string[] {
|
||||
const caller = graph.getNodesByKind('method').find((m) =>
|
||||
m.qualifiedName.endsWith(`::${owner}::${fn}`) || m.qualifiedName === `${owner}::${fn}`);
|
||||
expect(caller, `${owner}::${fn}`).toBeDefined();
|
||||
return graph
|
||||
.getOutgoingEdges(caller!.id)
|
||||
.filter((e) => e.kind === 'calls')
|
||||
.map((e) => graph.getNode(e.target)!.qualifiedName)
|
||||
.sort();
|
||||
}
|
||||
|
||||
describe('a Kotlin call on what an unqualified call returns (#2805)', () => {
|
||||
it("the issue's local and chained forms reach the helper's declared type", () => {
|
||||
expect(callees('RealRepoTest', 'viaConstructor')).toEqual(['demo.data::RealRepo::pending']);
|
||||
expect(callees('RealRepoTest', 'viaHelperLocal')).toEqual([
|
||||
'demo.data::RealRepo::pending',
|
||||
'demo.data::RealRepoTest::repository',
|
||||
]);
|
||||
expect(callees('RealRepoTest', 'viaHelperChain')).toEqual([
|
||||
'demo.data::RealRepo::pending',
|
||||
'demo.data::RealRepoTest::repository',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a helper the class inherits, one through `this.`, a safe call and a top-level function', () => {
|
||||
expect(callees('MoreHelperTest', 'viaBaseHelper')).toEqual([
|
||||
'demo.data::BaseRepoTest::baseRepository',
|
||||
'demo.data::RealRepo::pending',
|
||||
]);
|
||||
expect(callees('MoreHelperTest', 'viaThis')).toEqual([
|
||||
'demo.data::MoreHelperTest::repository',
|
||||
'demo.data::RealRepo::pending',
|
||||
]);
|
||||
expect(callees('MoreHelperTest', 'viaSafeCall')).toEqual([
|
||||
'demo.data::MoreHelperTest::nullable',
|
||||
'demo.data::RealRepo::pending',
|
||||
]);
|
||||
expect(callees('MoreHelperTest', 'viaTopLevel')).toEqual([
|
||||
'demo.data::RealRepo::pending',
|
||||
'demo.data::freshRepo',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a helper declared to return the interface reaches the interface method', () => {
|
||||
expect(callees('MoreHelperTest', 'viaInterfaceHelper')).toEqual([
|
||||
'demo.data::MoreHelperTest::asInterface',
|
||||
'demo.domain::Repo::pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a local set from a helper inside a lambda', () => {
|
||||
expect(callees('MoreHelperTest', 'viaLambdaLocal')).toEqual([
|
||||
'demo.data::MoreHelperTest::repository',
|
||||
'demo.data::MoreHelperTest::runTest',
|
||||
'demo.data::RealRepo::pending',
|
||||
]);
|
||||
});
|
||||
|
||||
it("a method the helper's type inherits, and an extension function on it", () => {
|
||||
expect(callees('InheritedHelperTest', 'viaInherited')).toEqual([
|
||||
'demo.data::BaseAudit::audit',
|
||||
'demo.data::InheritedHelperTest::audited',
|
||||
]);
|
||||
expect(callees('InheritedHelperTest', 'viaExtension')).toEqual([
|
||||
'RealRepo::describe',
|
||||
'demo.data::InheritedHelperTest::repository',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a nested type named through its outer class; a type from outside the project as before', () => {
|
||||
expect(callees('InheritedHelperTest', 'viaNested')).toEqual([
|
||||
'demo.data::Codec::Reader::readInt',
|
||||
'demo.data::InheritedHelperTest::newReader',
|
||||
]);
|
||||
// `StringBuilder.reverse()` is the JDK's: the project's namesake is the
|
||||
// guess it was on main, left to the strategies this change doesn't touch.
|
||||
expect(callees('InheritedHelperTest', 'viaOutside')).toEqual([
|
||||
'demo.data::InheritedHelperTest::text',
|
||||
'demo.other::Decoy::reverse',
|
||||
]);
|
||||
});
|
||||
|
||||
it('a nested type of a shared name is the one the helper names', () => {
|
||||
expect(callees('Client', 'send')).toEqual([
|
||||
'demo.data::Client::request',
|
||||
'demo.data::Request::Builder::post',
|
||||
]);
|
||||
expect(callees('Shop', 'reopen')).toEqual([
|
||||
'demo.data::Shop::Builder::open',
|
||||
'demo.data::Shop::newBuilder',
|
||||
]);
|
||||
// An extension on a nested type is kept under the outer type's name: found by name, as before.
|
||||
expect(callees('Client', 'events')).toEqual([
|
||||
'Dns::toEvents',
|
||||
'demo.data::Client::newCall',
|
||||
]);
|
||||
});
|
||||
|
||||
it("an extension the calling file can name, of two alike", () => {
|
||||
const caller = graph.getNodesByKind('method').find((m) => m.qualifiedName.endsWith('::InheritedHelperTest::viaVisibleExtension'))!;
|
||||
const targets = graph.getOutgoingEdges(caller.id).filter((e) => e.kind === 'calls').map((e) => graph.getNode(e.target)!)
|
||||
.filter((n) => n.name === 'label').map((n) => n.filePath);
|
||||
expect(targets).toEqual(['src/test/kotlin/demo/data/zz/Labels.kt']);
|
||||
});
|
||||
|
||||
it('a method the declared type lacks links nothing, not a namesake elsewhere', () => {
|
||||
expect(callees('MoreHelperTest', 'viaMissing')).toEqual(['demo.data::MoreHelperTest::repository']);
|
||||
});
|
||||
});
|
||||
@@ -17491,6 +17491,219 @@ function importedFqnOf(
|
||||
return imports.find((i) => i.localName === typeName)?.source;
|
||||
}
|
||||
|
||||
/** How many lines past its first a Kotlin call is read for its arguments. */
|
||||
const KOTLIN_CALL_LINES = 8;
|
||||
|
||||
/** An unqualified Kotlin call's head, up to its `(`: `repository(`, `this.repository(`, `make<Foo>(`. */
|
||||
const KOTLIN_UNQUALIFIED_CALL = /^(?:this\s*\.\s*)?([a-z_]\w*)\s*(?:<[^<>()]*>)?\s*\(/;
|
||||
|
||||
/** The index of the `)` closing the `(` at `open` in Kotlin source, string literals skipped; -1 when it doesn't close. */
|
||||
function kotlinClosingParen(text: string, open: number): number {
|
||||
let depth = 0;
|
||||
for (let i = open; i < text.length; i++) {
|
||||
const c = text[i]!;
|
||||
if (c === '"' || c === "'") {
|
||||
for (i++; i < text.length && text[i] !== c; i++) if (text[i] === '\\') i++;
|
||||
} else if (c === '(') depth++;
|
||||
else if (c === ')' && --depth === 0) return i;
|
||||
}
|
||||
return -1;
|
||||
}
|
||||
|
||||
/**
|
||||
* A Kotlin call written on what an unqualified call returns —
|
||||
* `repository().pending(1)`, `this.repository().pending(1)`,
|
||||
* `nullable()?.pending(1)` — which the extractor records by the outer
|
||||
* method's bare name at the inner call's column (a capitalized inner is a
|
||||
* constructor, kept as `Foo().pending`). The inner function's name, or null
|
||||
* for any other shape: anything but `!!` between the inner call and the
|
||||
* method makes another one (`repository().other().pending()`).
|
||||
*/
|
||||
function kotlinCallResultCallee(ref: UnresolvedRef, context: ResolutionContext): string | null {
|
||||
if (ref.language !== 'kotlin' || ref.referenceKind !== 'calls' || !/^[A-Za-z_]\w*$/.test(ref.referenceName)) return null;
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
|
||||
const first = lines?.[ref.line - 1];
|
||||
if (!lines || first === undefined) return null;
|
||||
const text = [first.slice(Math.max(0, ref.column)), ...lines.slice(ref.line, ref.line + KOTLIN_CALL_LINES)].join('\n');
|
||||
const head = KOTLIN_UNQUALIFIED_CALL.exec(text);
|
||||
if (!head) return null;
|
||||
const close = kotlinClosingParen(text, head[0].length - 1);
|
||||
if (close < 0) return null;
|
||||
const outer = new RegExp(`^\\s*(?:!!\\s*)?\\??\\.\\s*${escapeRegExp(ref.referenceName)}\\s*[({<]`);
|
||||
return outer.test(text.slice(close + 1)) ? head[1]! : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type an unqualified Kotlin call to `name` returns, as the function it
|
||||
* calls declares it (#2805): a method of the calling class, else of a type
|
||||
* around the call or one those inherit (kotlinHierarchyAt, read from their
|
||||
* declarations), else a top-level function the calling file can name.
|
||||
* Undefined when no such function is found, or those found don't declare one
|
||||
* type: `fun repository() = RealRepo()` declares none.
|
||||
*/
|
||||
function kotlinCallResultType(name: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined {
|
||||
const fns = context.getNodesByName(name).filter((n) => n.language === 'kotlin' && (n.kind === 'method' || n.kind === 'function'));
|
||||
if (fns.length === 0) return undefined;
|
||||
const ownerOf = (qualifiedName: string): string => qualifiedName.slice(0, Math.max(0, qualifiedName.lastIndexOf('::')));
|
||||
const caller = context.getNodeById?.(ref.fromNodeId);
|
||||
// Inside an extension function on a type from outside the project (`fun
|
||||
// URI.toHttpUrlOrNull() = toString()…`), that type's own members are the
|
||||
// first a bare call reaches, and they aren't known here.
|
||||
if (caller?.kind === 'method') {
|
||||
const owner = ownerOf(caller.qualifiedName);
|
||||
const member = context.getNodesByQualifiedName(owner).some((n) => MEMBER_CLASS_KINDS.has(n.kind) || n.kind === 'module');
|
||||
if (!member && !isKotlinProjectType(owner.split(/::|\./).pop() ?? '', context)) return undefined;
|
||||
}
|
||||
let pool = caller ? fns.filter((n) => n.kind === 'method' && ownerOf(n.qualifiedName) === ownerOf(caller.qualifiedName)) : [];
|
||||
if (pool.length === 0) {
|
||||
const hierarchy = kotlinHierarchyAt(ref, context);
|
||||
pool = fns.filter((n) => {
|
||||
if (n.kind !== 'method') return false;
|
||||
const owner = ownerOf(n.qualifiedName).split(/::|\./);
|
||||
const type = owner.pop();
|
||||
// A companion object's members are called bare inside its class.
|
||||
return hierarchy.has(type === 'Companion' ? owner.pop() ?? '' : type ?? '');
|
||||
});
|
||||
}
|
||||
if (pool.length === 0) {
|
||||
pool = fns.filter((n) => {
|
||||
if (n.kind !== 'function') return false;
|
||||
const pkg = kotlinFileScope(n.filePath, context).pkg;
|
||||
return n.qualifiedName === (pkg ? `${pkg}::${n.name}` : n.name) && isKotlinTopLevelVisible(n, ref, context);
|
||||
});
|
||||
}
|
||||
const types = new Set(pool.map((n) => kotlinScopedReturnType(n, context) ?? ''));
|
||||
return pool.length > 0 && types.size === 1 && !types.has('') ? [...types][0] : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type a Kotlin function declares it returns (kotlinDeclaredReturnType),
|
||||
* a name written alone taken as the nested type of the class it is declared
|
||||
* in, or of a class around that, that has the name: `fun newBuilder():
|
||||
* Builder` in `Request` returns `Request.Builder`, not `Headers.Builder`.
|
||||
*/
|
||||
function kotlinScopedReturnType(fn: Node, context: ResolutionContext): string | undefined {
|
||||
const written = kotlinDeclaredReturnType(fn);
|
||||
if (!written || written.includes('.')) return written;
|
||||
const isType = (qualifiedName: string): boolean =>
|
||||
context.getNodesByQualifiedName(qualifiedName).some((n) => MEMBER_CLASS_KINDS.has(n.kind) || n.kind === 'module');
|
||||
const scopes = fn.qualifiedName.split('::').slice(0, -1);
|
||||
for (let i = scopes.length; i > 0; i--) {
|
||||
const scope = scopes.slice(0, i).join('::');
|
||||
if (isType(scope) && isType(`${scope}::${written}`)) return `${scopes[i - 1]!}.${written}`;
|
||||
}
|
||||
return written;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type a Kotlin function's signature declares it returns, as its type
|
||||
* names from the outermost type on, without nullability, type arguments or a
|
||||
* package: `(source: Buffer): Hpack.Reader` → `Hpack.Reader`, `(): RealRepo?`
|
||||
* → `RealRepo`, `(): List<Foo>` → `List`. Undefined for none, or a function type.
|
||||
*/
|
||||
function kotlinDeclaredReturnType(fn: Node): string | undefined {
|
||||
const sig = fn.signature?.trim();
|
||||
if (!sig?.startsWith('(')) return undefined;
|
||||
const close = kotlinClosingParen(sig, 0);
|
||||
const written = close < 0 ? undefined : /^\s*:\s*([\s\S]+)$/.exec(sig.slice(close + 1))?.[1];
|
||||
const path = written?.trim().replace(/\?+$/, '').replace(/<[\s\S]*>$/, '').trim().split('.').map((p) => p.trim());
|
||||
if (!path || !path.every((p) => /^[A-Za-z_]\w*$/.test(p))) return undefined;
|
||||
const types = path.slice(path.findIndex((p) => /^[A-Z]/.test(p)));
|
||||
return types.length > 0 && types.every((p) => /^[A-Z]/.test(p)) ? types.join('.') : undefined;
|
||||
}
|
||||
|
||||
/** Whether a Kotlin or Java type of the project — a class, interface, enum or object — has the name `name`. */
|
||||
function isKotlinProjectType(name: string, context: ResolutionContext): boolean {
|
||||
return kotlinProjectTypes(name, context).length > 0;
|
||||
}
|
||||
|
||||
/** The project's Kotlin and Java types — classes, interfaces, enums, objects — named `name`. */
|
||||
function kotlinProjectTypes(name: string, context: ResolutionContext): Node[] {
|
||||
return context.getNodesByName(name).filter((n) =>
|
||||
(n.language === 'kotlin' || n.language === 'java') && (MEMBER_CLASS_KINDS.has(n.kind) || n.kind === 'module'));
|
||||
}
|
||||
|
||||
/**
|
||||
* A Kotlin call on what an unqualified call returns (kotlinCallResultCallee):
|
||||
* the method of the type that function declares (#2805), as a
|
||||
* `Foo.factory().bar()` chain takes it (matchDottedCallChain), or nothing —
|
||||
* never a namesake found by the method's name alone. A nested type the
|
||||
* function names through its outer one, `Request.Builder`, is that one, not
|
||||
* another type's `Builder`. Undefined when the shape or the declared type
|
||||
* can't be read, for the strategies after it.
|
||||
*/
|
||||
function matchKotlinCallResultCall(ref: UnresolvedRef, context: ResolutionContext): ResolvedRef | null | undefined {
|
||||
const callee = kotlinCallResultCallee(ref, context);
|
||||
if (!callee) return undefined;
|
||||
const written = kotlinCallResultType(callee, ref, context);
|
||||
const path = written?.split('.') ?? [];
|
||||
const type = path[path.length - 1];
|
||||
// A type from outside the project is no help: its methods, and which of the
|
||||
// project's extensions its supertypes take, aren't known here.
|
||||
if (!type || !isKotlinProjectType(type, context)) return undefined;
|
||||
const name = ref.referenceName;
|
||||
const found = resolveMethodOnType(type, name, ref, context, 0.85, 'instance-method', importedFqnOf(type, ref, context));
|
||||
// Whole names: `MockResponse::Builder::body` is no `Response::Builder::body`.
|
||||
const member = `${path.join('::')}::${name}`;
|
||||
const isPathMember = (n: Node): boolean => n.qualifiedName === member || n.qualifiedName.endsWith(`::${member}`);
|
||||
if (found) {
|
||||
const target = context.getNodeById?.(found.targetNodeId);
|
||||
// An extension function, kept under its receiver type's bare name, is
|
||||
// the one the calling file can name: Exposed declares `Expression<T>.year()`
|
||||
// in three modules, and a test of one calls its own.
|
||||
if (target && target.kind === 'method' && !target.qualifiedName.slice(0, target.qualifiedName.lastIndexOf('::')).includes('::')) {
|
||||
const visible = context.getNodesByName(name).filter((n) =>
|
||||
n.kind === 'method' && n.qualifiedName === target.qualifiedName && isKotlinTopLevelVisible(n, ref, context));
|
||||
if (visible.some((n) => n.id === target.id)) return found;
|
||||
return visible.length === 1 ? { ...found, targetNodeId: visible[0]!.id } : undefined;
|
||||
}
|
||||
if (path.length === 1 || (target && isPathMember(target))) return found;
|
||||
// Another type's `Builder`: the method of the one the path names, or the strategies after this one.
|
||||
const own = context.getNodesByName(name).filter((n) => n.kind === 'method' && isPathMember(n));
|
||||
return own.length === 1 ? { original: ref, targetNodeId: own[0]!.id, confidence: 0.85, resolvedBy: 'instance-method' } : undefined;
|
||||
}
|
||||
// An extension the index doesn't keep under the type may be it: one on a
|
||||
// nullable receiver (`fun MediaType?.charsetOrUtf8()`) is a top-level
|
||||
// function, one on a nested type (`fun Dns.Call.toEventsQueue()`) is kept
|
||||
// under its outer type's bare name (`Dns::toEventsQueue`), where a member's
|
||||
// owner carries its package.
|
||||
const extension = context.getNodesByName(name).some((n) => n.language === 'kotlin' &&
|
||||
(n.kind === 'function' || (n.kind === 'method' && !n.qualifiedName.slice(0, n.qualifiedName.lastIndexOf('::')).includes('::'))));
|
||||
return extension ? undefined : null;
|
||||
}
|
||||
|
||||
/**
|
||||
* The type a Kotlin local holds when its nearest declaration in the calling
|
||||
* function sets it from an unqualified call alone — `val repo =
|
||||
* repository()`, `var repo = this.repository()!!` — as that function declares
|
||||
* (kotlinCallResultType, #2805). Undefined for any other declaration, or none.
|
||||
*/
|
||||
function kotlinLocalCallResultType(receiver: string, ref: UnresolvedRef, context: ResolutionContext): string | undefined {
|
||||
const lines = context.getFileLines?.(ref.filePath) ?? context.readFile(ref.filePath)?.split(/\r?\n/);
|
||||
if (!lines) return undefined;
|
||||
const r = escapeRegExp(receiver);
|
||||
const declares = new RegExp(`\\b(?:val|var)\\s+${r}\\b`);
|
||||
const initializer = new RegExp(`^(?:val|var)\\s+${r}\\s*=\\s*(?:this\\s*\\.\\s*)?([a-z_]\\w*)\\s*(?:<[^<>()]*>)?\\s*\\(`);
|
||||
const start = Math.max(0, enclosingScopeStartLine(ref, context) - 1);
|
||||
for (let i = Math.min(lines.length, ref.line) - 1; i >= start; i--) {
|
||||
const line = lines[i]!;
|
||||
const at = declares.exec(i === ref.line - 1 ? line.slice(0, Math.max(0, ref.column)) : line);
|
||||
if (!at) continue;
|
||||
const text = [line.slice(at.index), ...lines.slice(i + 1, i + 1 + KOTLIN_CALL_LINES)].join('\n');
|
||||
const call = initializer.exec(text);
|
||||
if (!call) return undefined;
|
||||
const close = kotlinClosingParen(text, call[0].length - 1);
|
||||
if (close < 0 || !/^[ \t]*(?:!![ \t]*)?(?:;|\/\/|\r?\n|$)/.test(text.slice(close + 1))) return undefined;
|
||||
// Only a type of the project's (see matchKotlinCallResultCall), and a
|
||||
// nested one only when no other type shares its name: the type is looked
|
||||
// up by its last name from here on.
|
||||
const path = kotlinCallResultType(call[1]!, ref, context)?.split('.') ?? [];
|
||||
const types = path.length > 0 ? kotlinProjectTypes(path[path.length - 1]!, context) : [];
|
||||
return types.length === 1 || (types.length > 1 && path.length === 1) ? path[path.length - 1] : undefined;
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
/** Per-context memo: `<file>\0<type name as written>` → tsImportedTypeFile's answer. */
|
||||
const TS_IMPORTED_TYPE_FILES = new WeakMap<ResolutionContext, Map<string, string | null>>();
|
||||
|
||||
@@ -19517,6 +19730,12 @@ export function matchMethodCall(
|
||||
// A pytest test's parameter is what its fixture returns: flaskbb's
|
||||
// `cli_runner.invoke(…)` is click's `CliRunner`, not the project's one `invoke`.
|
||||
if (!inferredType && ref.language === 'python' && dotMatch) inferredType = pythonFixtureReturnType(objectOrClass!, ref, context);
|
||||
// A Kotlin local set from an unqualified call holds what that function
|
||||
// declares it returns: `val repo = repository()` (#2805), where the
|
||||
// receiver's name led to the interface `Repo`.
|
||||
if (!inferredType && ref.language === 'kotlin' && dotMatch && /^[a-z_]\w*$/.test(objectOrClass!)) {
|
||||
inferredType = nmTimedT('mc-kotlincall', ref, () => kotlinLocalCallResultType(objectOrClass!, ref, context)) ?? null;
|
||||
}
|
||||
// `subprocess.run(…)` after `import subprocess`: a function of a module
|
||||
// from outside the project, not a same-named project method (#2599).
|
||||
if (!inferredType && ref.language === 'python' && dotMatch && isPythonOutsideModuleReceiver(objectOrClass!, ref, context)) return null;
|
||||
@@ -26517,6 +26736,16 @@ function matchReferenceInner(
|
||||
if (lent !== undefined) return lent;
|
||||
}
|
||||
|
||||
// A Kotlin call on what an unqualified call returns, which the extractor
|
||||
// records by its bare name — `repository().pending(1)` — is a call on the
|
||||
// type that function declares, or on nothing (#2805): the strategies below
|
||||
// took any `pending`, a test double's in another package. What the type
|
||||
// inherits counts, as inheritance is resolved before any call.
|
||||
if (ref.language === 'kotlin' && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName)) {
|
||||
const onResult = nmTimedT('kotlinCallResult', ref, () => matchKotlinCallResultCall(ref, context));
|
||||
if (onResult !== undefined) return onResult;
|
||||
}
|
||||
|
||||
// Try strategies in order of confidence
|
||||
let result: ResolvedRef | null;
|
||||
|
||||
|
||||
Reference in New Issue
Block a user