diff --git a/CHANGELOG.md b/CHANGELOG.md index f98f1a3d..07c21e8d 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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, 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. diff --git a/__tests__/kotlin-call-result-receiver.test.ts b/__tests__/kotlin-call-result-receiver.test.ts new file mode 100644 index 00000000..d74d1271 --- /dev/null +++ b/__tests__/kotlin-call-result-receiver.test.ts @@ -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 = { + // 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']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 7ab8c308..aa3c74d8 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -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(`. */ +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` → `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.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: `\0` → tsImportedTypeFile's answer. */ const TS_IMPORTED_TYPE_FILES = new WeakMap>(); @@ -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;