mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-02 01:37:32 +08:00
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<T>`, `typealias Definition<T> = 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 <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 5.5
parent
a329df883a
commit
fc73204f12
@@ -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<T> = 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.
|
||||
|
||||
@@ -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<T>` with `typealias Definition<T> = 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<string, string> = {
|
||||
'src/main/kotlin/org/koin/core/Koin.kt': `package org.koin.core
|
||||
|
||||
class Koin {
|
||||
fun <T> 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 <T> 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<T> = 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 <T> single(definition: Definition<T>) { }
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/org/koin/dsl/New.kt': `package org.koin.dsl
|
||||
|
||||
import org.koin.core.scope.Scope
|
||||
|
||||
inline fun <reified R, reified T1, reified T2> 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']);
|
||||
});
|
||||
});
|
||||
@@ -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<string> = 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<ResolutionContext, Map<string, string | null>>();
|
||||
|
||||
/**
|
||||
* 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<T>)` with `typealias
|
||||
* Definition<T> = 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<string>();
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user