mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
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:
@@ -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.
|
||||
|
||||
@@ -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',
|
||||
]);
|
||||
});
|
||||
});
|
||||
@@ -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 = {
|
||||
|
||||
Reference in New Issue
Block a user