From fc73204f125b78ed18f8ac857430a27af668b85a Mon Sep 17 00:00:00 2001 From: Colby Mchenry Date: Wed, 30 Sep 2026 21:25:06 +0000 Subject: [PATCH] fix(kotlin): a DSL lambda runs on its own receiver (#2206) #2172 made the members of every type a project function type takes as a receiver reachable from any bare call, since it could not tell which lambda a call is in. With several such types in reach, proximity chose: koin's `single { C51(get(), get()) }` and `Scope.new(...)`'s `get()` went to `Koin.get` (koin also has `Koin.() -> ...` lambdas), 2,190 times. - A lambda's receiver is read from the function it is passed to: the type before `.(` in its last parameter's function type, directly or through a typealias (`definition: Definition`, `typealias Definition = Scope.(ParametersHolder) -> T`), when every project function of that name agrees. The brace frames put it in the lambda's hierarchy. - Of the members a bare call can reach, those its lexical hierarchy reaches - its class, an extension's receiver, the lambda it is in - come before those only the project-wide receiver set reaches. koin: `get()` -> Scope (2,184), `viewModel` in `scope { }` -> ScopeDSL, `modules(...)` in `startKoin { }` -> KoinApplication. Exposed: `varchar(...)`, `integer`, `reference`, `datetime` in a table object -> Table's (were a test table's same-named property). kotlinx.coroutines: `launch { }` -> CoroutineScope.launch (was a test DSL's). Edge counts ~unchanged (targets move): koin, Exposed, okhttp, coroutines, okio; nowinandroid, moshi unchanged. Co-authored-by: Claude Opus 5.5 --- CHANGELOG.md | 1 + __tests__/kotlin-lambda-receiver.test.ts | 95 ++++++++++++++++++++++++ src/resolution/name-matcher.ts | 90 +++++++++++++++++++++- 3 files changed, 184 insertions(+), 2 deletions(-) create mode 100644 __tests__/kotlin-lambda-receiver.test.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 8bc8a415e..b31d644b0 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -14,6 +14,7 @@ and adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). ### Fixes +- Kotlin calls inside a DSL lambda now reach the lambda's own receiver. That receiver comes from the function the lambda is passed to, even through a typealias like koin's `Definition = Scope.(…) -> T`. It also wins over other types the project uses as lambda receivers elsewhere. On koin, over 2,000 `get()` calls in `single { … }` now reach `Scope.get` instead of `Koin.get`; on Exposed, `varchar(…)` inside a table object reaches `Table.varchar`; on kotlinx.coroutines, `launch { }` reaches `CoroutineScope.launch`. - A Swift call on a property declared with a type, like Kingfisher's `var cache: ImageCache!` or `self.storage.write(…)`, now reaches that type's method or one it inherits. It no longer lands on a subclass override in a test, or on another type's method of the same name. - Calls in your own JavaScript no longer link to a same-named function inside a vendored minified script, like a `*.min.js` or a bundle made of a few enormous lines. Those names are mangled. In healthchecks, every jQuery `$(…)` call had gone to a one-letter helper inside `bootstrap-native.min.js`. - A Swift call on a value built on the spot, like `JSONDecoder().decode(…)` or `Realm().create(…)`, now resolves to that type's method, or to one it inherits. It no longer lands on some other type's method of the same name: Vapor's `JSONDecoder().decode` calls had gone to a request's private container. diff --git a/__tests__/kotlin-lambda-receiver.test.ts b/__tests__/kotlin-lambda-receiver.test.ts new file mode 100644 index 000000000..46c2abf96 --- /dev/null +++ b/__tests__/kotlin-lambda-receiver.test.ts @@ -0,0 +1,95 @@ +/** + * A Kotlin lambda runs on the receiver its function's parameter type names — + * directly (`Scope.() -> T`) or through a typealias (koin's `definition: + * Definition` with `typealias Definition = Scope.(ParametersHolder) -> + * T`) — and the members the code around a bare call reaches come before + * those only some other lambda type in the project could: koin's + * `single { C51(get(), get()) }` and `Scope.new(…)`'s `get()` are Scope's, + * not Koin's (2,190 of them). + */ +import { describe, it, expect, afterAll, beforeAll } from 'vitest'; +import * as fs from 'fs'; +import * as os from 'os'; +import * as path from 'path'; +import { CodeGraph } from '../src'; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-lambda-')); + const files: Record = { + 'src/main/kotlin/org/koin/core/Koin.kt': `package org.koin.core + +class Koin { + fun get(): T = TODO() +} + +fun koinTest(block: Koin.() -> Unit) { } +`, + 'src/main/kotlin/org/koin/core/scope/Scope.kt': `package org.koin.core.scope + +class Scope { + fun get(): T = TODO() +} +`, + 'src/main/kotlin/org/koin/core/definition/Definition.kt': `package org.koin.core.definition + +import org.koin.core.scope.Scope + +typealias Definition = Scope.(Int) -> T +`, + 'src/main/kotlin/org/koin/core/module/Module.kt': `package org.koin.core.module + +import org.koin.core.definition.Definition + +class Module { + fun single(definition: Definition) { } +} +`, + 'src/main/kotlin/org/koin/dsl/New.kt': `package org.koin.dsl + +import org.koin.core.scope.Scope + +inline fun Scope.new( + constructor: (T1, T2) -> R, +): R = constructor(get(), get()) +`, + 'src/test/kotlin/app/Perfs.kt': `package app + +import org.koin.core.module.Module + +class C51(a: Int, b: Int) + +fun perfModule(m: Module) { + m.single { C51(get(), get()) } +} +`, + }; + 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); + } + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +const getTargets = (file: string) => { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return [...new Set(cg.getOutgoingEdgesFrom(ids).filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!).filter((t) => t.name === 'get').map((t) => t.qualifiedName))]; +}; + +describe('Kotlin lambda receivers', () => { + it('a lambda passed through a typealiased function type runs on its receiver', () => { + expect(getTargets('src/test/kotlin/app/Perfs.kt')).toEqual(['org.koin.core.scope::Scope::get']); + }); + + it('an extension function’s receiver beats a lambda type elsewhere', () => { + expect(getTargets('src/main/kotlin/org/koin/dsl/New.kt')).toEqual(['org.koin.core.scope::Scope::get']); + }); +}); diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index cb7e41f20..8cb250329 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -2303,7 +2303,14 @@ function kotlinBraceFrames(file: string, context: ResolutionContext): Array<{ st if (ch === '{') { // `with(x) {`, `x.apply {`, `x.run {`: a receiver of whatever type x is. const scoped = /(?:\bwith\s*\([^{}]*\)|\.\s*(?:apply|run)(?:\s*<[^<>]*>)?)\s*$/.test(pending); - stack.push({ start: line, names: scoped ? ['*'] : kotlinHeadNames(pending) }); + let names = scoped ? ['*'] : kotlinHeadNames(pending); + // `single { get() }`: a lambda runs on the receiver its function's parameter type names. + if (!scoped && (!names || names.length === 0)) { + const call = /(?:^|[^\w$])([a-z_]\w*)\s*(?:<[^<>{}]*>)?\s*(?:\([^(){}]*\))?\s*$/.exec(pending)?.[1]; + const receiver = call && !KOTLIN_BLOCK_WORDS.has(call) ? kotlinLambdaReceiver(call, context) : null; + if (receiver) names = [receiver]; + } + stack.push({ start: line, names }); pending = ''; } else if (ch === '}') { const open = stack.pop(); @@ -2317,6 +2324,63 @@ function kotlinBraceFrames(file: string, context: ResolutionContext): Array<{ st return frames; } +/** Words before a `{` that open a block, not a lambda argument. */ +const KOTLIN_BLOCK_WORDS: ReadonlySet = new Set([ + 'if', 'else', 'for', 'while', 'do', 'when', 'try', 'catch', 'finally', 'init', 'get', 'set', 'constructor', + 'fun', 'class', 'object', 'interface', 'return', 'by', 'lazy', 'apply', 'run', 'also', 'let', 'with', 'use', +]); + +const KOTLIN_LAMBDA_RECEIVERS = new WeakMap>(); + +/** + * The receiver a lambda passed to the project's `name` runs with: the type + * before `.(` in its last parameter's function type, directly or through a + * typealias — koin's `single(…, definition: Definition)` with `typealias + * Definition = Scope.(ParametersHolder) -> T` runs its lambda on a Scope. + * Null unless every `name` agrees. + */ +function kotlinLambdaReceiver(name: string, context: ResolutionContext): string | null { + let memo = KOTLIN_LAMBDA_RECEIVERS.get(context); + if (!memo) KOTLIN_LAMBDA_RECEIVERS.set(context, (memo = new Map())); + if (memo.has(name)) return memo.get(name)!; + const receiverOf = (type: string, depth: number): string | null => { + const direct = /^\s*(?:suspend\s+)?([A-Z]\w*)(?:<[^<>]*(?:<[^<>]*>[^<>]*)*>)?\s*\.\s*\(/.exec(type); + if (direct) return direct[1]!; + const alias = /^\s*([A-Z]\w*)\b/.exec(type)?.[1]; + if (!alias || depth > 2) return null; + for (const decl of context.getNodesByName(alias)) { + if (decl.language !== 'kotlin' || decl.kind !== 'type_alias') continue; + const text = (context.getFileLines?.(decl.filePath) ?? context.readFile(decl.filePath)?.split(/\r?\n/) ?? [])[decl.startLine - 1] ?? ''; + const rhs = /=\s*(.+)$/.exec(text)?.[1]; + if (rhs) return receiverOf(rhs, depth + 1); + } + return null; + }; + const found = new Set(); + for (const fn of context.getNodesByName(name)) { + if (fn.language !== 'kotlin' || (fn.kind !== 'function' && fn.kind !== 'method')) continue; + const lines = context.getFileLines?.(fn.filePath) ?? context.readFile(fn.filePath)?.split(/\r?\n/) ?? []; + const head = lines.slice(fn.startLine - 1, fn.startLine + 11).join(' '); + const open = head.search(new RegExp(`\\b${name}\\s*\\(`)); + if (open < 0) continue; + let depth = 0; + let end = -1; + for (let i = head.indexOf('(', open); i < head.length; i++) { + if (head[i] === '(') depth++; + else if (head[i] === ')' && --depth === 0) { end = i; break; } + } + if (end < 0) continue; + const params = splitCppTopLevel(head.slice(head.indexOf('(', open) + 1, end)); + const last = params[params.length - 1]; + const type = last ? /:\s*([\s\S]+?)(?:\s*=\s*[^=>][\s\S]*)?$/.exec(last.replace(/^\s*(?:noinline|crossinline)\s+/, ''))?.[1] : undefined; + const receiver = type ? receiverOf(type, 0) : null; + if (receiver) found.add(receiver); + } + const result = found.size === 1 ? [...found][0]! : null; + memo.set(name, result); + return result; +} + /** The type names a Kotlin block head introduces: a type declaration's name and supertypes, or an extension function's receiver. */ function kotlinHeadNames(head: string): string[] | null { let depth = 0; @@ -2368,6 +2432,26 @@ function isKotlinMemberReachable(n: Node, ref: UnresolvedRef, context: Resolutio return here.imports.has(`${objectPath}.${n.name}`) || here.stars.has(objectPath); } +/** + * Of the members a bare Kotlin call can reach, the ones the code around it + * reaches — its class, an extension's receiver, the lambda it is in — before + * those only a lambda type somewhere in the project could: koin's `get()` in + * `Scope.new(…)` is Scope's, not Koin's. + */ +function lexicalKotlinMembers(candidates: Node[], ref: UnresolvedRef, context: ResolutionContext): Node[] { + if (candidates.length < 2) return candidates; + const hierarchy = kotlinHierarchyAt(ref, context); + if (hierarchy.has('*')) return candidates; + const lexical = candidates.filter((n) => { + if (n.kind !== 'method') return false; + const path = n.qualifiedName.slice(0, Math.max(0, n.qualifiedName.lastIndexOf('::'))).split(/::|\./); + let owner = path.pop() ?? ''; + if (owner === 'Companion') owner = path.pop() ?? ''; + return hierarchy.has(owner); + }); + return lexical.length > 0 ? lexical : candidates; +} + /** Whether a standard-named Kotlin chain link can mean `n`: only through a receiver named after its owner. */ function isKotlinStdChainTarget(n: Node, receiver: string): boolean { if (n.kind !== 'method' && n.kind !== 'function') return true; @@ -4014,7 +4098,8 @@ export function matchByExactName( !(bareJs && n.filePath !== ref.filePath && isLocallyBoundJsName(ref.referenceName, ref.filePath, context)) ); const candidates = dartBare ? nearestDartMembers(filtered, ref, context) - : swiftShape && swiftShape.shape !== 'chained' ? nearestSwiftMembers(filtered, ref, context) : filtered; + : swiftShape && swiftShape.shape !== 'chained' ? nearestSwiftMembers(filtered, ref, context) + : kotlinBare ? lexicalKotlinMembers(filtered, ref, context) : filtered; if (candidates.length === 0) { return null; @@ -5083,6 +5168,7 @@ export function clearNameMatcherMemos(context: ResolutionContext): void { RUST_TRAIT_IMPL_MEMO.delete(context); RUST_USES.delete(context); LEXICAL_SCOPE_MEMO.delete(context); + KOTLIN_LAMBDA_RECEIVERS.delete(context); SCALA_IMPORTED_SUPERS.delete(context); SCALA_PACKAGE_OBJECTS.delete(context); LOCAL_DECL_MEMO.delete(context);