mirror of
https://github.com/colbymchenry/codegraph.git
synced 2026-10-17 05:07:37 +08:00
fix(kotlin): split primary constructors keep their class; std chain names stay std (#2168)
- tree-sitter-kotlin can't read `class Name` / (comments) / `internal
constructor(…) : Super {` on separate lines — the class is dropped and its
members become loose top-level functions (okio's ByteString, Kotlin
Multiplatform's `expect` classes). The Kotlin preParse now blanks that
constructor's modifiers / annotations / keyword to spaces, so `(…) :
Super {` parses as the primary constructor; offsets and lines survive, and
preParse runs before the kernel, so both paths parse the same bytes.
- With members back on their classes, a Kotlin standard-library name on an
untyped chain link (scope functions, collection / string / conversion
methods — not names project types commonly carry) needs a receiver named
after its owner, in the exact / fuzzy filters and the unique-name guess.
A/B (edges removed / added): okio -1028/+1127, okhttp -979/+1145,
kotlinx-datetime -426/+515 (mostly re-keyed onto recovered classes),
koin -60/+4 (`msg.contains(…)` → a dependency map's `contains`).
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 classes whose primary constructor sits on the line after the class name, before a supertype list (`expect open class ByteString` / `internal constructor(…) : Comparable<ByteString>`, common in Kotlin Multiplatform code), no longer lose their members to loose functions. A call later in a chain to a Kotlin standard-library function (`apply`, `let`, `toString`, `map`, `contains`…) no longer lands on a project type's same-named method. On okhttp, `.apply { … }` used to land on `ConnectionSpec.apply`. Re-index after upgrading.
|
||||
- In Rust and Go, a call later in a chain, like `sym.filename().map(…)` or `c.Flags().String(…)`, no longer lands on the project's own method of that name when the name is a standard-library one (`unwrap`, `clone`, `map`, `iter`, Go's `String` or `Error`). A plain call like `get(handler)` or `drop(x)` never lands on a method. On tokio, `.map(…)` on an `Option` used to land on `MutexGuard::map`; on axum, the router's `get(handler)` on a cookie jar's `get`; on ripgrep, `.clone()` on an error type.
|
||||
- A call on a type from outside the project, like `Integer.valueOf(…)`, `Arrays.asList(…)`, `Object.assign(…)` or Delphi's `Exception.Create(…)`, no longer lands on the project's own method with that name. On commons-lang, `Integer.valueOf` used to land on `StringUtils.valueOf` over 800 times. Go's exported variables and Pascal's capitalized parameters and locals are not mistaken for type names.
|
||||
- Imports through a tsconfig or jsconfig path alias now use the config nearest the importing file, so each app in a monorepo resolves its own `@/…` or `~/…` alias. On bulletproof-react and trpc's examples, those imports used to fail or land in a different app.
|
||||
|
||||
@@ -0,0 +1,78 @@
|
||||
/**
|
||||
* Two Kotlin fixes that travel together:
|
||||
* - a primary constructor on the line after its class name with a supertype
|
||||
* list (`expect open class ByteString` / `internal constructor(…) :
|
||||
* Comparable<ByteString> {`, Kotlin Multiplatform's shape) dropped the class
|
||||
* and scattered its members as loose functions; the constructor's modifiers
|
||||
* and keyword are blanked (offsets kept) so the class parses;
|
||||
* - with members back on their classes, a standard-library name on an
|
||||
* untyped chain link (`builder.apply { … }`, `"x".toPath().toString()`)
|
||||
* needs a receiver named after the owner — okhttp's `.apply { }` went to
|
||||
* `ConnectionSpec.apply`.
|
||||
*/
|
||||
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 { joinKotlinSplitConstructors } from '../src/extraction/languages/kotlin';
|
||||
|
||||
let root = '';
|
||||
let cg: CodeGraph;
|
||||
|
||||
const BYTE_STRING = `package okio
|
||||
|
||||
expect open class ByteString
|
||||
// Trusted internal constructor doesn't clone data.
|
||||
internal constructor(data: ByteArray) : Comparable<ByteString> {
|
||||
fun hex(): String
|
||||
fun toByteArray(): ByteArray
|
||||
}
|
||||
`;
|
||||
|
||||
beforeAll(async () => {
|
||||
root = fs.mkdtempSync(path.join(os.tmpdir(), 'cg-kotlin-split-'));
|
||||
const files: Record<string, string> = {
|
||||
'src/commonMain/kotlin/okio/ByteString.kt': BYTE_STRING,
|
||||
'src/main/kotlin/app/ConnectionSpec.kt': `package app
|
||||
|
||||
class ConnectionSpec {
|
||||
fun apply(sslSocket: Any, isFallback: Boolean) {}
|
||||
}
|
||||
`,
|
||||
'src/main/kotlin/app/Builder.kt': `package app
|
||||
|
||||
class Builder {
|
||||
var name = ""
|
||||
fun build(): String = StringBuilder().apply { append(name) }.toString()
|
||||
}
|
||||
`,
|
||||
};
|
||||
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 });
|
||||
});
|
||||
|
||||
describe('Kotlin split primary constructors and standard chain calls', () => {
|
||||
it('keeps the class and its members', () => {
|
||||
const nodes = cg.getNodesInFile('src/commonMain/kotlin/okio/ByteString.kt');
|
||||
const hex = nodes.find((n) => n.name === 'hex')!;
|
||||
expect(hex.qualifiedName).toBe('okio::ByteString::hex');
|
||||
const out = joinKotlinSplitConstructors(BYTE_STRING);
|
||||
expect(out.length).toBe(BYTE_STRING.length);
|
||||
expect(out.split('\n')[4]).toMatch(/^\s+\(data: ByteArray\) : Comparable<ByteString> \{$/);
|
||||
});
|
||||
|
||||
it('never sends a scope function to a project type', () => {
|
||||
const build = cg.getNodesInFile('src/main/kotlin/app/Builder.kt').find((n) => n.name === 'build')!;
|
||||
const targets = cg.getOutgoingEdges(build.id).filter((e) => e.kind === 'calls').map((e) => cg.getNode(e.target)!.qualifiedName);
|
||||
expect(targets.some((t) => t.endsWith('ConnectionSpec::apply'))).toBe(false);
|
||||
});
|
||||
});
|
||||
@@ -177,8 +177,29 @@ export function blankKotlinQualifiedReceivers(source: string): string {
|
||||
return source.replace(QUALIFIED_RECEIVER, (m) => ' '.repeat(m.length));
|
||||
}
|
||||
|
||||
/**
|
||||
* A primary constructor written on the line after its class name —
|
||||
* `expect open class ByteString` / `internal constructor(data: ByteArray) :
|
||||
* Comparable<ByteString> {` (okio, and Kotlin Multiplatform code generally) —
|
||||
* with a supertype list after it: the grammar reads it as two statements and
|
||||
* drops the class, its members coming out as loose functions. Blanking the
|
||||
* constructor's modifiers and keyword (`(data: ByteArray) : …` parses) keeps
|
||||
* the class, its supertypes and every offset; only the visibility is lost.
|
||||
*/
|
||||
const SPLIT_PRIMARY_CONSTRUCTOR =
|
||||
/(\bclass\s+[A-Za-z_]\w*(?:\s*<[^<>{}]*(?:<[^<>{}]*>[^<>{}]*)*>)?[ \t]*\r?\n(?:[ \t]*\/\/[^\n]*\n)*[ \t]*)((?:(?:public|private|protected|internal|actual|expect)\s+|@[\w.]+(?:\([^)]*\))?\s+)*constructor)(?=\s*\()/g;
|
||||
|
||||
export function joinKotlinSplitConstructors(source: string): string {
|
||||
if (!source.includes('constructor')) return source;
|
||||
return source.replace(SPLIT_PRIMARY_CONSTRUCTOR, (_m, head: string, ctor: string) => head + ' '.repeat(ctor.length));
|
||||
}
|
||||
|
||||
function preParseKotlin(source: string): string {
|
||||
return joinKotlinSplitConstructors(blankKotlinQualifiedReceivers(source));
|
||||
}
|
||||
|
||||
export const kotlinExtractor: LanguageExtractor = {
|
||||
preParse: blankKotlinQualifiedReceivers,
|
||||
preParse: preParseKotlin,
|
||||
functionTypes: ['function_declaration'],
|
||||
classTypes: ['class_declaration'],
|
||||
methodTypes: ['function_declaration'], // Methods are functions inside classes
|
||||
|
||||
@@ -1572,6 +1572,50 @@ function isRustGoCallTarget(n: Node, shape: ReturnType<typeof rustGoCallShape>):
|
||||
return !std || /^(?:self|Self)$/.test(shape.receiver) || (shape.receiver !== '' && sharesReceiverWord(shape.receiver, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Kotlin's scope functions and standard collection / string / conversion
|
||||
* methods: on an untyped chain link (`builder.apply { … }`, `list.map { … }`)
|
||||
* they are the standard library's, never a project type's same-named member.
|
||||
* Names project types commonly carry (`get`, `write`, `close`, `add`) are left
|
||||
* out — okio's `sink.write(…)` is its own Buffer's.
|
||||
*/
|
||||
const KOTLIN_STD_METHODS: ReadonlySet<string> = new Set([
|
||||
'apply', 'also', 'let', 'run', 'takeIf', 'takeUnless', 'toString', 'equals', 'hashCode', 'map', 'mapNotNull',
|
||||
'mapIndexed', 'filter', 'filterNot', 'filterIsInstance', 'forEach', 'forEachIndexed', 'first', 'firstOrNull',
|
||||
'last', 'lastOrNull', 'single', 'singleOrNull', 'isEmpty', 'isNotEmpty', 'isNullOrEmpty', 'isNullOrBlank',
|
||||
'isBlank', 'isNotBlank', 'orEmpty', 'joinToString', 'toList', 'toMutableList', 'toSet', 'toMutableSet', 'toMap',
|
||||
'toTypedArray', 'any', 'all', 'none', 'count', 'sumOf', 'maxOf', 'minOf', 'maxOrNull', 'minOrNull', 'sortedBy',
|
||||
'sortedByDescending', 'sorted', 'sortedWith', 'reversed', 'drop', 'dropLast', 'take', 'takeLast', 'zip',
|
||||
'flatMap', 'flatten', 'distinct', 'groupBy', 'associate', 'associateBy', 'associateWith', 'partition',
|
||||
'contains', 'containsKey', 'getOrElse', 'getOrNull', 'getOrPut', 'getOrDefault', 'trim', 'trimEnd', 'trimStart',
|
||||
'split', 'substring', 'startsWith', 'endsWith', 'replace', 'lowercase', 'uppercase', 'toInt', 'toLong',
|
||||
'toDouble', 'toFloat', 'toIntOrNull', 'toLongOrNull', 'encodeToByteArray', 'decodeToString',
|
||||
'copyOf', 'copyOfRange', 'indexOf', 'lastIndexOf', 'withIndex', 'asSequence', 'asList', 'ifEmpty', 'ifBlank',
|
||||
'padStart', 'padEnd', 'repeat', 'lines', 'toCharArray', 'coerceAtLeast', 'coerceAtMost', 'coerceIn',
|
||||
]);
|
||||
|
||||
/** The receiver a Kotlin chain link `….name(` / `?.name {` is written on, or null for a call with none. */
|
||||
function kotlinChainReceiver(ref: UnresolvedRef, context: ResolutionContext): string | null {
|
||||
const line = context.getFileLines?.(ref.filePath)?.[ref.line - 1] ?? context.readFile(ref.filePath)?.split('\n')[ref.line - 1];
|
||||
if (line === undefined) return null;
|
||||
const name = ref.referenceName;
|
||||
let start = line.startsWith(name, ref.column) ? ref.column : -1;
|
||||
if (start < 0) {
|
||||
const m = new RegExp(`(?<![\\w$])${name}\\s*[({]`).exec(line);
|
||||
start = m ? m.index : -1;
|
||||
}
|
||||
if (start < 0) return null;
|
||||
const before = line.slice(0, start);
|
||||
if (!/\.\s*$/.test(before)) return null;
|
||||
return rustGoReceiverName(before.replace(/\?\s*\.\s*$/, '').replace(/!!\s*$/, ''));
|
||||
}
|
||||
|
||||
/** 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;
|
||||
return receiver === 'this' || (receiver !== '' && sharesReceiverWord(receiver, n));
|
||||
}
|
||||
|
||||
/**
|
||||
* Methods of Rust's Option / Result / iterators / collections / strings /
|
||||
* smart pointers — names a project type rarely carries itself. Ones it often
|
||||
@@ -2752,8 +2796,11 @@ export function matchByExactName(
|
||||
const scalaBare = ref.language === 'scala' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName);
|
||||
const rustGoShape = (ref.language === 'rust' || ref.language === 'go') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName)
|
||||
? rustGoCallShape(ref, context) : null;
|
||||
const kotlinStdChain = ref.language === 'kotlin' && ref.referenceKind === 'calls' && KOTLIN_STD_METHODS.has(ref.referenceName)
|
||||
? kotlinChainReceiver(ref, context) : null;
|
||||
const phpSelf = phpSelfReceiver(ref, context);
|
||||
const filtered = sameName.filter((n) =>
|
||||
!(kotlinStdChain !== null && !isKotlinStdChainTarget(n, kotlinStdChain)) &&
|
||||
!(rustGoShape && !isRustGoCallTarget(n, rustGoShape)) &&
|
||||
!(scalaBare && !isScalaMemberInScope(n, ref, context)) &&
|
||||
!(csharpBare && !isCsharpMemberInScope(n, ref, context)) &&
|
||||
@@ -4894,9 +4941,9 @@ export function matchMethodCall(
|
||||
!((ref.language === 'lua' || ref.language === 'luau') && isLuaLibraryCall(objectOrClass!, methodName!, ref, targetMethods[0]!)) &&
|
||||
// Rust / Go: a standard-library method name on an untyped receiver
|
||||
// (`sym.map(…)`, `w.Header().Get(…)`) is the library type's.
|
||||
!((ref.language === 'rust' || ref.language === 'go') &&
|
||||
(ref.language === 'go' ? GO_STD_METHODS : RUST_STD_METHODS).has(methodName!) &&
|
||||
!/^(?:self|Self)$/.test(objectOrClass!) && !sharesReceiverWord(objectOrClass!, targetMethods[0]!)) &&
|
||||
!((ref.language === 'rust' || ref.language === 'go' || ref.language === 'kotlin') &&
|
||||
(ref.language === 'go' ? GO_STD_METHODS : ref.language === 'kotlin' ? KOTLIN_STD_METHODS : RUST_STD_METHODS).has(methodName!) &&
|
||||
!/^(?:self|Self|this)$/.test(objectOrClass!) && !sharesReceiverWord(objectOrClass!, targetMethods[0]!)) &&
|
||||
!(UNTYPED_RECEIVER_LANGUAGES.has(ref.language) && !/^(?:self|self\.class|this|super|weak_?self|strong_?self)$/i.test(objectOrClass!) &&
|
||||
!sharesReceiverWord(objectOrClass!, targetMethods[0]!) &&
|
||||
!(ref.language === 'objc' && objcReceiverReaches(objectOrClass!, targetMethods[0]!, context)) &&
|
||||
@@ -6014,6 +6061,8 @@ export function matchFuzzy(
|
||||
const scalaBare = ref.language === 'scala' && ref.referenceKind === 'calls' && /^[A-Za-z_$][\w$]*$/.test(ref.referenceName);
|
||||
const rustGoShape = (ref.language === 'rust' || ref.language === 'go') && ref.referenceKind === 'calls' && /^[A-Za-z_]\w*$/.test(ref.referenceName)
|
||||
? rustGoCallShape(ref, context) : null;
|
||||
const kotlinStdChain = ref.language === 'kotlin' && ref.referenceKind === 'calls' && KOTLIN_STD_METHODS.has(ref.referenceName)
|
||||
? kotlinChainReceiver(ref, context) : null;
|
||||
const phpSelf = phpSelfReceiver(ref, context);
|
||||
// Names are case-sensitive in every language but a handful: Rust's
|
||||
// `Bytes` is not the method `bytes`, Python's builtin `dir(…)` not a class
|
||||
@@ -6038,6 +6087,7 @@ export function matchFuzzy(
|
||||
!(csharpBare && !isCsharpMemberInScope(n, ref, context)) &&
|
||||
!(scalaBare && !isScalaMemberInScope(n, ref, context)) &&
|
||||
!(rustGoShape && !isRustGoCallTarget(n, rustGoShape)) &&
|
||||
!(kotlinStdChain !== null && !isKotlinStdChainTarget(n, kotlinStdChain)) &&
|
||||
!(phpSelf && (n.kind !== 'method' || !isPhpMethodInScope(n, ref, phpSelf, context))))
|
||||
.filter((n) => (ref.referenceKind !== 'references' && ref.referenceKind !== 'function_ref') ||
|
||||
sameLanguageFamily(n.language, ref.language));
|
||||
|
||||
Reference in New Issue
Block a user