diff --git a/CHANGELOG.md b/CHANGELOG.md index 634072fb..1f354ff0 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 files using newer syntax now keep their classes intact. That covers `when` guard conditions (`is X if cond ->`), `..<` ranges, multi-dollar strings (`$$"…"`) and nullable receivers in function types (`T?.() -> R`). Before, the class around such code could come out as a scatter of loose functions. On Exposed, files with a parse error dropped from 65 to 43, and on kotlinx.coroutines from 50 to 46. Re-index after upgrading. - A call through a class name, like `THorse.Get(…)`, `MyView.as_view()` or `ColorChoices.values()`, now reaches the class method that class inherits. The inheritance is read from the class declarations themselves, in Pascal, Python, Ruby, PHP, TypeScript/JavaScript and the Java family. Before, such a call was resolved by guessing from the name. On Horse, `THorse.Get('/ping', …)` used to land on a route-group interface's `Get` 120 times. Re-index after upgrading. - In Objective-C, `[super init]` and other messages to `super` now reach the superclass's method, never the sending class's own override. This also works where the index lost the `@implementation`'s range. On SDWebImage, `NSObject` subclasses' `[super init]` used to land on `SDDiskCache`'s `init`, and `[self class]` in tests on a proxy class's `class`. On AFNetworking, a serializer's `[super encodeWithCoder:]` now reaches its superclass. Re-index after upgrading. - In Solidity, a call with no receiver now reaches a function of the contract around it or of one it inherits. On OpenZeppelin, `_msgSender()` in contracts built on `Context` used to land on `ERC2771Context`'s override 83 times. In Erlang, a call to another module's function now follows the file's `-import(Mod, [f/N])`. On cowboy, `-import(req_SUITE, [do_get/3])` calls used to land on another suite's `do_get/3`. A bare call with no such import no longer links to some other module. Re-index after upgrading. diff --git a/__tests__/kotlin-newer-syntax.test.ts b/__tests__/kotlin-newer-syntax.test.ts new file mode 100644 index 00000000..81713ddb --- /dev/null +++ b/__tests__/kotlin-newer-syntax.test.ts @@ -0,0 +1,80 @@ +/** + * Kotlin syntax newer than the vendored grammar — `when` guards (2.1), `..<` + * ranges (1.9), multi-dollar strings (2.1), nullable function-type + * receivers — is rewritten to an older equivalent of the same length before + * the parse; otherwise error recovery dropped the class around it (Exposed's + * dialect classes came out as loose functions). + */ +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'; +import { rewriteNewerKotlinSyntax } from '../src/extraction/languages/kotlin'; + +const SOURCE = `package app + +class Dialect(private val mode: Int) { + fun sql(value: Any?): String = when (value) { + is String if mode == 2 -> "text" + is Int if ( + mode > 0 + ) -> "int" + is Long -> if (mode == 1) "long" else "big" + else -> "other" + } + + fun ranges(n: Int) { + for (i in 0..?.() -> Op): Dialect = this + + fun after(): Int = 1 +} + +class Op +`; + +let root = ''; +let cg: CodeGraph; + +beforeAll(async () => { + root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-newer-')); + fs.mkdirSync(path.join(root, 'src'), { recursive: true }); + fs.writeFileSync(path.join(root, 'src/Dialect.kt'), SOURCE); + cg = await CodeGraph.init(root, { index: true }); +}); + +afterAll(() => { + cg?.close(); + if (root) fs.rmSync(root, { recursive: true, force: true }); +}); + +describe('newer Kotlin syntax', () => { + it('is rewritten with every offset intact', () => { + const out = rewriteNewerKotlinSyntax(SOURCE); + expect(out.length).toBe(SOURCE.length); + expect(out).toContain('is String, mode == 2 ->'); + expect(out).toContain('is Long -> if (mode == 1)'); + expect(out).toContain('0.. n'); + expect(out).toContain(' "?(@.user.team == $team)"'); + expect(out).toContain('Op.( ) -> Op'); + }); + + it('keeps the class around it and every method', () => { + const methods = cg.getNodesInFile('src/Dialect.kt') + .filter((n) => n.kind === 'method') + .map((n) => n.qualifiedName) + .sort(); + expect(methods).toEqual([ + 'app::Dialect::adjust', + 'app::Dialect::after', + 'app::Dialect::path', + 'app::Dialect::ranges', + 'app::Dialect::sql', + ]); + }); +}); diff --git a/src/extraction/languages/kotlin.ts b/src/extraction/languages/kotlin.ts index d2383bf0..c41b2cd3 100644 --- a/src/extraction/languages/kotlin.ts +++ b/src/extraction/languages/kotlin.ts @@ -194,8 +194,38 @@ export function joinKotlinSplitConstructors(source: string): string { return source.replace(SPLIT_PRIMARY_CONSTRUCTOR, (_m, head: string, ctor: string) => head + ' '.repeat(ctor.length)); } +/** + * Kotlin syntax newer than the grammar (tree-sitter-kotlin 0.3.8), rewritten + * to an older equivalent of the same length so every offset survives — each + * one otherwise made error recovery drop the class around it (Exposed: 158 of + * 1,004 files): + * + * - a `when` guard (Kotlin 2.1), `is H2Dialect if dialect.mode == X ->`, + * becomes a second condition, `is H2Dialect, dialect.mode == X ->`, so + * the guard's references are kept; + * - an open-ended range (1.9), `1..?.() -> Op`, + * becomes `Op.( ) -> …`. + */ +const WHEN_GUARD = /^([ \t]*!?(?:is|in)[ \t]+(?:(?!->)[^\n])*?[\w>?)\]'"])([ \t]+)if(?=[ \t(])/gm; +const MULTI_DOLLAR_STRING = /(?])\?\.\(\)(?=\s*->)/g; + +export function rewriteNewerKotlinSyntax(source: string): string { + let out = source; + if (out.includes(' if')) { + // (the condition stops short of `->`: `is X -> if (…)` is a branch whose body is an if) + out = out.replace(WHEN_GUARD, (_m, cond: string, gap: string) => `${cond},${' '.repeat(gap.length + 1)}`); + } + if (out.includes('..<')) out = out.replace(/\.\. ' '.repeat(m.length)); + if (out.includes('?.()')) out = out.replace(NULLABLE_RECEIVER_FN, '$1.( )'); + return out; +} + function preParseKotlin(source: string): string { - return joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(source)); + return rewriteNewerKotlinSyntax(joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(source))); } export const kotlinExtractor: LanguageExtractor = {