diff --git a/CHANGELOG.md b/CHANGELOG.md index 667fef45..4a1ce75c 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 infix calls, like `Users.id eq id1` or koin's `single { … } bind MyApi::class`, are now recorded as calls to the infix function. Before, they had no callers at all. On Exposed, `eq` now shows over a thousand callers. Bit operations on numbers (`x and 0xff`, `h shr 8`) are left to Kotlin's own operators, even where the project defines a same-named extension. Re-index after upgrading. - Kotlin code using an infix function whose name starts with `e`, like Exposed's `Users.id eq id1`, now parses cleanly. Before, the rest of such a line was a parse error, and in a file with many of them the whole class around it could be lost. On Exposed, files with a parse error dropped from 229 to 71. Re-index after upgrading. - In Kotlin, a call with no receiver now reaches a method of another class only when that class is in reach. That means a class, an anonymous `object : Base()` or an extension function's receiver around the call, what those inherit, or the receiver of a DSL lambda (koin's `single { get() }` runs on a `Scope`, Exposed's `withTables { … }` on a transaction). An object member the file imports is in reach too. Kotlin's own `error(…)`, `require(x >= 0) { … }` and `check(…)` no longer land on a project class's method of that name. On koin, `module { … }` in one test used to land on another test class's private `module` over 260 times, and inside a lambda `get()` landed on the wrong class's `get`. Re-index after upgrading. - In C#, Java and Kotlin, a call through a field, property or parameter now lands on the method of the type it is declared with. That includes a type inherited from a base class with its type argument filled in, a generic or `for`-each declaration, a C# `using` alias, and a type parameter's bound. A call no longer falls back to a guess at some project class's same-named method, and a type from outside the project gives no link. On Newtonsoft.Json, `_innerWriter.WriteValue(…)` inside a writer wrapper used to land on the wrapper's own `WriteValue`, and a `TextWriter`'s `Write(…)` on a test writer's. On commons-lang, a `DateFormat` field's `parse(…)` used to land on the project's `DateParser` 284 times. diff --git a/__tests__/kotlin-infix-calls.test.ts b/__tests__/kotlin-infix-calls.test.ts new file mode 100644 index 00000000..176b8841 --- /dev/null +++ b/__tests__/kotlin-infix-calls.test.ts @@ -0,0 +1,81 @@ +/** + * A Kotlin infix call — `Users.id eq id1`, koin's `single { … } bind I::class` + * — is a call of the infix function; nothing recorded it before, so a + * project's infix DSL had no callers (Exposed's `eq` alone is called over a + * thousand times). A standard-library infix name on an expression receiver + * (`alias(libs.x) apply false` in a build script) is not a project method's. + */ +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-infix-calls-')); + const files: Record = { + 'src/main/kotlin/app/Ops.kt': `package app + +class Column { + infix fun eq(other: Int): Boolean = true +} + +class Definition { + infix fun bind(type: Any): Definition = this +} + +fun single(block: () -> Any): Definition = Definition() + +class Ops(private val id: Column) { + fun same(id1: Int): Boolean = id eq id1 + + fun wire() { + single { Ops(Column()) } bind Ops::class + } +} + +class Spec { + fun apply(block: Spec.() -> Unit): Spec = this +} +`, + 'build.gradle.kts': `plugins { + alias(libs.plugins.kotlin.jvm) apply false +} +`, + }; + 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 }); +}); + +/** `Owner::member` of every call edge out of a file. */ +function callsFrom(file: string): string[] { + const ids = cg.getNodesInFile(file).map((n) => n.id); + return cg + .getOutgoingEdgesFrom(ids) + .filter((e) => e.kind === 'calls') + .map((e) => cg.getNode(e.target)!.qualifiedName) + .sort(); +} + +describe('Kotlin infix calls', () => { + it('call the infix function', () => { + const calls = callsFrom('src/main/kotlin/app/Ops.kt'); + expect(calls).toContain('app::Column::eq'); + expect(calls).toContain('app::Definition::bind'); + }); + + it('leave a standard infix name on an expression to the library', () => { + expect(callsFrom('build.gradle.kts')).not.toContain('app::Spec::apply'); + }); +}); diff --git a/codegraph-kernel/src/kotlin.rs b/codegraph-kernel/src/kotlin.rs index 9d4a0afe..159e478c 100644 --- a/codegraph-kernel/src/kotlin.rs +++ b/codegraph-kernel/src/kotlin.rs @@ -862,6 +862,8 @@ impl<'t> Walker<'t> { self.extract_import(node); } else if kind == "call_expression" { self.extract_call(node); + } else if kind == "infix_expression" { + self.extract_infix_call(node); } // companion_object, anonymous_initializer, secondary_constructor, // getter/setter siblings, file_annotation, object_literal, if/when at @@ -890,6 +892,8 @@ impl<'t> Walker<'t> { if kind == "call_expression" { self.extract_call(node); + } else if kind == "infix_expression" { + self.extract_infix_call(node); } // (INSTANTIATION_KINDS has no kotlin members; extractBareCall absent.) @@ -1149,6 +1153,28 @@ impl<'t> Walker<'t> { /// extractCall — the kotlin paths: navigation member branch (+ the #750 /// re-encode) and the raw-text else (paren-then-lambda / glued-invoke /// garbage preserved). + /// extractKotlinInfixCall — `Users.id eq id1` / `a to b`: a call of the + /// middle simple_identifier, `lhs.fn` when the left operand is a plain + /// name, else the bare name; literal left operands emit nothing. + fn extract_infix_call(&mut self, node: Node<'t>) { + if self.stack.is_empty() || node.named_child_count() != 3 { + return; + } + let (Some(lhs), Some(func)) = (node.named_child(0), node.named_child(1)) else { return }; + if func.kind() != "simple_identifier" || is_literal_receiver(lhs.kind()) { + return; + } + let caller = self.top_row(); + let name = self.text(func); + let receiver = if lhs.kind() == "simple_identifier" { self.text(lhs) } else { "" }; + let callee = if !receiver.is_empty() && receiver != "this" && receiver != "super" { + format!("{receiver}.{name}") + } else { + name.to_string() + }; + self.push_ref_at(caller, &callee, edge_kind_index("calls").unwrap(), node); + } + fn extract_call(&mut self, node: Node<'t>) { if self.stack.is_empty() { return; diff --git a/src/extraction/tree-sitter.ts b/src/extraction/tree-sitter.ts index 80ebd224..47432386 100644 --- a/src/extraction/tree-sitter.ts +++ b/src/extraction/tree-sitter.ts @@ -1423,6 +1423,9 @@ export class TreeSitterExtractor { else if (this.extractor.callTypes.includes(nodeType)) { this.extractCall(node); } + else if (this.language === 'kotlin' && nodeType === 'infix_expression') { + this.extractKotlinInfixCall(node); + } // `new Foo(...)` / `Foo::new(...)` / object_creation_expression — // produce an `instantiates` reference. Children still walked so // nested calls inside the constructor args (`new Foo(bar())`) get @@ -4064,6 +4067,31 @@ export class TreeSitterExtractor { return this.erlangAtomMacros.get(macroName) ?? null; } + /** + * A Kotlin infix call — `Users.id eq id1`, `a to b`, `x shouldBe y` — is a + * call of the infix function in the middle: `receiver.fn` when the left + * operand is a plain name (as `receiver.fn(arg)` would be), else the bare + * name. Nothing records it otherwise, so a project's infix DSL had no + * callers. Mirrored in the kernel's extract_infix_call (kotlin.rs). + */ + private extractKotlinInfixCall(node: SyntaxNode): void { + if (this.nodeStack.length === 0 || node.namedChildCount !== 3) return; + const lhs = node.namedChild(0); + const fn = node.namedChild(1); + if (!lhs || !fn || fn.type !== 'simple_identifier' || LITERAL_RECEIVER_TYPES.has(lhs.type)) return; + const callerId = this.nodeStack[this.nodeStack.length - 1]; + if (!callerId) return; + const name = getNodeText(fn, this.source); + const receiver = lhs.type === 'simple_identifier' ? getNodeText(lhs, this.source) : ''; + this.unresolvedReferences.push({ + fromNodeId: callerId, + referenceName: receiver && receiver !== 'this' && receiver !== 'super' ? `${receiver}.${name}` : name, + referenceKind: 'calls', + line: node.startPosition.row + 1, + column: node.startPosition.column, + }); + } + private extractCall(node: SyntaxNode): void { if (this.nodeStack.length === 0) return; @@ -5904,6 +5932,8 @@ export class TreeSitterExtractor { this.extractAnonymousClass(node, anonBody); return; } + } else if (this.language === 'kotlin' && nodeType === 'infix_expression') { + this.extractKotlinInfixCall(node); } else if (this.extractor!.extractBareCall) { const calleeName = this.extractor!.extractBareCall(node, this.source); if (calleeName && this.nodeStack.length > 0) { diff --git a/src/resolution/name-matcher.ts b/src/resolution/name-matcher.ts index 28bf86cc..5b646634 100644 --- a/src/resolution/name-matcher.ts +++ b/src/resolution/name-matcher.ts @@ -1674,12 +1674,32 @@ function kotlinChainReceiver(ref: UnresolvedRef, context: ResolutionContext): st const m = new RegExp(`(? = new Set(['and', 'or', 'xor', 'shl', 'shr', 'ushr', 'inv']); +const KOTLIN_NUMBER_TYPES: ReadonlySet = new Set(['Byte', 'Short', 'Int', 'Long', 'UByte', 'UShort', 'UInt', 'ULong', 'Char']); + +/** + * A project's bitwise extension on a number type — okio's `infix fun + * Byte.and(mask: Int)` — is indistinguishable, without the operand's type, + * from the standard library's own `Int.and` / `Long.shr` members every other + * `x and 0xff` / `h shr 8` calls; neither is a safe edge. + */ +function isKotlinNumberBitwise(n: Node, ref: UnresolvedRef): boolean { + if (ref.language !== 'kotlin' || !KOTLIN_BITWISE_INFIX.has(n.name)) return false; + const cut = n.qualifiedName.lastIndexOf('::'); + return cut > 0 && KOTLIN_NUMBER_TYPES.has(n.qualifiedName.slice(0, cut).split(/::|\./).pop()!); +} + /** Whether a bare Kotlin name is written with no receiver at its call — not a later link of a chain (`….name(`). */ function isReceiverLessKotlinCall(ref: UnresolvedRef, context: ResolutionContext): boolean { const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1]; @@ -3386,6 +3406,7 @@ export function matchByExactName( !(javaBare && n.kind === 'method' && !isJavaMethodInScope(n, ref, context)) && !(kotlinCall && !isKotlinTopLevelVisible(n, ref, context)) && !(kotlinBare && !isKotlinMemberReachable(n, ref, context)) && + !isKotlinNumberBitwise(n, ref) && !(dartBare && isDartMember(n) && !isDartMethodInScope(n, ref, context)) && !(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))) && !(pythonShape && !fitsPythonCallShape(n, pythonShape, ref, context)) && @@ -5478,6 +5499,11 @@ export function matchMethodCall( 'instance-method', importedFqn, )); + if (typedMatch && ref.language === 'kotlin') { + // `medium and 0xff` on an Int: the standard library's member, not a project `Int.and(Long)`. + const target = context.getNodeById?.(typedMatch.targetNodeId); + if (target && isKotlinNumberBitwise(target, ref)) return null; + } if (typedMatch) { if (awaited) { const target = context.getNodeById?.(typedMatch.targetNodeId); @@ -5766,6 +5792,7 @@ export function matchMethodCall( narrowed = kept.length !== targetMethods.length; targetMethods = kept; } + targetMethods = targetMethods.filter((m) => !isKotlinNumberBitwise(m, ref)); // Production code never calls into a test suite: a guess from // rest_framework/renderers.py's `view.reverse_action(…)` is not a test's // `DummyView`. The test's methods were never in the running. @@ -6943,6 +6970,7 @@ export function matchFuzzy( !(dartBare && isDartMember(n) && !isDartMethodInScope(n, ref, context)) && !(kotlinCall && !isKotlinTopLevelVisible(n, ref, context)) && !(kotlinBare && !isKotlinMemberReachable(n, ref, context)) && + !isKotlinNumberBitwise(n, ref) && !(rubyBare && n.kind === 'method' && !isRubyMethodInScope(n, ref, context)) && !(cfmlBare && n.kind === 'method' && !isCfmlMethodInScope(n, ref, context)) && !(vbReceiver !== null && !isVbMemberReachable(n, vbReceiver)) &&