fix(kotlin): rewrite syntax newer than the grammar before parsing (#2182)

tree-sitter-kotlin 0.3.8 predates `when` guards (2.1), `..<` (1.9),
multi-dollar strings (2.1) and nullable function-type receivers; each
made error recovery drop the class around it (Exposed's dialect and
query classes came out as loose functions).

Offset-preserving preParse rewrites (so the kernel parses the same text):
`is X if cond ->` -> `is X,   cond ->` (the condition stops short of
`->`, so `is X -> if (…)` bodies are untouched), `..<` -> `.. `, the
`94189` prefix blanked, `T?.() ->` -> `T.( ) ->`. Parse-error files:
Exposed 65 -> 43, kotlinx.coroutines 50 -> 46; koin/okhttp/moshi/
nowinandroid/okio unchanged, none newly broken; kernel parity 0 diffs.

Co-authored-by: Claude Opus 5.5 <noreply@anthropic.com>
This commit is contained in:
Colby Mchenry
2026-09-30 17:51:32 +00:00
committed by GitHub
parent 5a2b5607ee
commit fcea0222f9
3 changed files with 112 additions and 1 deletions
+1
View File
@@ -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.
+80
View File
@@ -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..<n) println(i)
}
fun path(team: String) = $$"?(@.user.team == $team)"
fun adjust(body: Op<Boolean>?.() -> Op<Boolean>): Dialect = this
fun after(): Int = 1
}
class Op<T>
`;
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<Boolean>.( ) -> Op<Boolean>');
});
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',
]);
});
});
+31 -1
View File
@@ -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..<n`, becomes `1.. n`;
* - a multi-dollar string (2.1), `$$"?(@.a == $x)"`, loses its prefix;
* - a nullable receiver in a function type, `Op<Boolean>?.() -> Op<Boolean>`,
* becomes `Op<Boolean>.( ) -> …`.
*/
const WHEN_GUARD = /^([ \t]*!?(?:is|in)[ \t]+(?:(?!->)[^\n])*?[\w>?)\]'"])([ \t]+)if(?=[ \t(])/gm;
const MULTI_DOLLAR_STRING = /(?<![\w$"])\$\$+(?=")/g;
const NULLABLE_RECEIVER_FN = /([\w>])\?\.\(\)(?=\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(/\.\.</g, '.. ');
if (out.includes('$$')) out = out.replace(MULTI_DOLLAR_STRING, (m) => ' '.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 = {