Merge pull request #347 from tirth8205/feat/kotlin-extractor

feat(core): add Kotlin structural analysis via tree-sitter
This commit is contained in:
Yuxiang Lin
2026-06-09 13:25:10 +08:00
committed by GitHub
6 changed files with 821 additions and 0 deletions
+24
View File
@@ -57,6 +57,9 @@ importers:
understand-anything-plugin/packages/core:
dependencies:
'@tree-sitter-grammars/tree-sitter-kotlin':
specifier: 1.1.0
version: 1.1.0
fuse.js:
specifier: ^7.1.0
version: 7.1.0
@@ -1120,6 +1123,14 @@ packages:
peerDependencies:
vite: ^5.2.0 || ^6 || ^7
'@tree-sitter-grammars/tree-sitter-kotlin@1.1.0':
resolution: {integrity: sha512-vlVXaxEE8t2kpJgfZpa8XVvxcnKw9AYtRTgy7KWjsDmAsadk06RxAT80IXOgGQnmM9i/orQn1nD84gPNUHu6DQ==}
peerDependencies:
tree-sitter: ^0.22.4
peerDependenciesMeta:
tree-sitter:
optional: true
'@types/babel__core@7.20.5':
resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==}
@@ -2442,6 +2453,11 @@ packages:
resolution: {integrity: sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==}
engines: {node: '>=0.10.0'}
npm-check-updates@17.1.18:
resolution: {integrity: sha512-bkUy2g4v1i+3FeUf5fXMLbxmV95eG4/sS7lYE32GrUeVgQRfQEk39gpskksFunyaxQgTIdrvYbnuNbO/pSUSqw==}
engines: {node: ^18.18.0 || >=20.0.0, npm: '>=8.12.1'}
hasBin: true
nth-check@2.1.1:
resolution: {integrity: sha512-lqjrjmaOoAnWfMmBPL+XNnynZh2+swxiX3WUE0s4yEHI6m+AwrK2UZOimIRl3X/4QctVqS8AiZjFqyOGrMXb/w==}
@@ -4048,6 +4064,12 @@ snapshots:
tailwindcss: 4.2.1
vite: 6.4.2(@types/node@25.5.0)(jiti@2.6.1)(lightningcss@1.32.0)(yaml@2.8.3)
'@tree-sitter-grammars/tree-sitter-kotlin@1.1.0':
dependencies:
node-addon-api: 8.6.0
node-gyp-build: 4.8.4
npm-check-updates: 17.1.18
'@types/babel__core@7.20.5':
dependencies:
'@babel/parser': 7.29.0
@@ -5760,6 +5782,8 @@ snapshots:
normalize-path@3.0.0: {}
npm-check-updates@17.1.18: {}
nth-check@2.1.1:
dependencies:
boolbase: 1.0.0
@@ -37,6 +37,7 @@
"vitest": "^3.1.0"
},
"dependencies": {
"@tree-sitter-grammars/tree-sitter-kotlin": "1.1.0",
"fuse.js": "^7.1.0",
"ignore": "^7.0.5",
"tree-sitter-c-sharp": "^0.23.1",
@@ -4,6 +4,10 @@ export const kotlinConfig = {
id: "kotlin",
displayName: "Kotlin",
extensions: [".kt", ".kts"],
treeSitter: {
wasmPackage: "@tree-sitter-grammars/tree-sitter-kotlin",
wasmFile: "tree-sitter-kotlin.wasm",
},
concepts: [
"coroutines",
"data classes",
@@ -0,0 +1,364 @@
import { describe, it, expect, beforeAll } from "vitest";
import { createRequire } from "node:module";
import { KotlinExtractor } from "../kotlin-extractor.js";
const require = createRequire(import.meta.url);
let Parser: any;
let Language: any;
let kotlinLang: any;
beforeAll(async () => {
const mod = await import("web-tree-sitter");
Parser = mod.Parser;
Language = mod.Language;
await Parser.init();
const wasmPath = require.resolve(
"@tree-sitter-grammars/tree-sitter-kotlin/tree-sitter-kotlin.wasm",
);
kotlinLang = await Language.load(wasmPath);
});
function parse(code: string) {
const parser = new Parser();
parser.setLanguage(kotlinLang);
const tree = parser.parse(code);
const root = tree.rootNode;
return { tree, parser, root };
}
describe("KotlinExtractor", () => {
const extractor = new KotlinExtractor();
it("has correct languageIds", () => {
expect(extractor.languageIds).toEqual(["kotlin"]);
});
describe("extractStructure - functions", () => {
it("extracts a simple top-level function with params and return type", () => {
const { tree, parser, root } = parse(`fun add(a: Int, b: Int): Int = a + b
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("add");
expect(result.functions[0].params).toEqual(["a", "b"]);
expect(result.functions[0].returnType).toBe("Int");
tree.delete();
parser.delete();
});
it("extracts function with no params and no return type", () => {
const { tree, parser, root } = parse(`fun noop() {}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("noop");
expect(result.functions[0].params).toEqual([]);
expect(result.functions[0].returnType).toBeUndefined();
tree.delete();
parser.delete();
});
it("extracts suspending and generic functions", () => {
const { tree, parser, root } = parse(`suspend fun <T> fetch(id: String): T? {
return null
}
`);
const result = extractor.extractStructure(root);
expect(result.functions).toHaveLength(1);
expect(result.functions[0].name).toBe("fetch");
expect(result.functions[0].params).toEqual(["id"]);
// Nullable type — Kotlin formats it as "T?"
expect(result.functions[0].returnType).toBe("T?");
tree.delete();
parser.delete();
});
it("extracts multiple top-level functions in declaration order", () => {
const { tree, parser, root } = parse(`fun one() {}
fun two(x: Int): Int = x
fun three(): String = ""
`);
const result = extractor.extractStructure(root);
expect(result.functions.map((f) => f.name)).toEqual([
"one",
"two",
"three",
]);
tree.delete();
parser.delete();
});
});
describe("extractStructure - classes", () => {
it("extracts a class with primary-constructor val properties + methods", () => {
const { tree, parser, root } = parse(`class Foo(val bar: Int) {
val baz: String = "hi"
fun compute(): Int = bar * 2
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Foo");
// Both the constructor val and the body val are properties
expect(result.classes[0].properties).toEqual(
expect.arrayContaining(["bar", "baz"]),
);
expect(result.classes[0].methods).toContain("compute");
tree.delete();
parser.delete();
});
it("extracts an empty class", () => {
const { tree, parser, root } = parse(`class Empty
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Empty");
expect(result.classes[0].methods).toEqual([]);
expect(result.classes[0].properties).toEqual([]);
tree.delete();
parser.delete();
});
it("extracts a data class and surfaces its constructor parameters as properties", () => {
const { tree, parser, root } = parse(`data class Point(val x: Double, val y: Double)
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Point");
expect(result.classes[0].properties).toEqual(["x", "y"]);
tree.delete();
parser.delete();
});
it("extracts class methods into functions[] as well as the class's methods[]", () => {
// Mirrors the Go/Swift extractor convention so the graph builder can
// create function nodes for class methods.
const { tree, parser, root } = parse(`class Foo {
fun bar(): Int = 1
}
`);
const result = extractor.extractStructure(root);
expect(result.functions.map((f) => f.name)).toContain("bar");
expect(result.classes[0].methods).toContain("bar");
tree.delete();
parser.delete();
});
});
describe("extractStructure - interfaces", () => {
it("extracts an interface with method requirements as a class-like entry", () => {
const { tree, parser, root } = parse(`interface Greeter {
fun greet(name: String): String
fun farewell(): String
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Greeter");
expect(result.classes[0].methods).toEqual(
expect.arrayContaining(["greet", "farewell"]),
);
tree.delete();
parser.delete();
});
});
describe("extractStructure - object declarations", () => {
it("extracts a singleton `object` with methods", () => {
const { tree, parser, root } = parse(`object Logger {
fun info(msg: String) {}
fun warn(msg: String) {}
}
`);
const result = extractor.extractStructure(root);
expect(result.classes).toHaveLength(1);
expect(result.classes[0].name).toBe("Logger");
expect(result.classes[0].methods).toEqual(
expect.arrayContaining(["info", "warn"]),
);
tree.delete();
parser.delete();
});
});
describe("extractStructure - imports", () => {
it("extracts a simple dotted import", () => {
const { tree, parser, root } = parse(`import kotlin.io.println
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("kotlin.io.println");
// The specifier is the final dotted segment
expect(result.imports[0].specifiers).toEqual(["println"]);
tree.delete();
parser.delete();
});
it("extracts a wildcard import", () => {
const { tree, parser, root } = parse(`import kotlinx.coroutines.*
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("kotlinx.coroutines");
// Wildcard is preserved as the specifier so consumers can distinguish it
// from a regular dotted import of a specific symbol.
expect(result.imports[0].specifiers).toEqual(["*"]);
tree.delete();
parser.delete();
});
it("extracts an aliased import", () => {
const { tree, parser, root } = parse(`import com.example.foo.Bar as Baz
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(1);
expect(result.imports[0].source).toBe("com.example.foo.Bar");
// The alias is the user-visible name in this file
expect(result.imports[0].specifiers).toEqual(["Baz"]);
tree.delete();
parser.delete();
});
it("extracts multiple imports in declaration order", () => {
const { tree, parser, root } = parse(`package com.example.app
import kotlinx.coroutines.flow.Flow
import kotlin.io.println
import kotlinx.coroutines.*
`);
const result = extractor.extractStructure(root);
expect(result.imports).toHaveLength(3);
expect(result.imports[0].source).toBe("kotlinx.coroutines.flow.Flow");
expect(result.imports[1].source).toBe("kotlin.io.println");
expect(result.imports[2].source).toBe("kotlinx.coroutines");
tree.delete();
parser.delete();
});
});
describe("extractStructure - exports / visibility", () => {
it("treats no-modifier declarations as exported (Kotlin default is public)", () => {
const { tree, parser, root } = parse(`fun greet() {}
class Greeter {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toEqual(expect.arrayContaining(["greet", "Greeter"]));
tree.delete();
parser.delete();
});
it("treats public/internal/protected as exported", () => {
const { tree, parser, root } = parse(`public fun a() {}
internal class B {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).toEqual(expect.arrayContaining(["a", "B"]));
tree.delete();
parser.delete();
});
it("does NOT treat private declarations as exported", () => {
const { tree, parser, root } = parse(`private fun helper() {}
private class Internal {}
`);
const result = extractor.extractStructure(root);
const exportNames = result.exports.map((e) => e.name);
expect(exportNames).not.toContain("helper");
expect(exportNames).not.toContain("Internal");
tree.delete();
parser.delete();
});
it("exports an object declaration by default", () => {
const { tree, parser, root } = parse(`object Logger {}
`);
const result = extractor.extractStructure(root);
expect(result.exports.map((e) => e.name)).toContain("Logger");
tree.delete();
parser.delete();
});
});
describe("extractCallGraph", () => {
it("extracts a call from one function to another", () => {
const { tree, parser, root } = parse(`fun helper(): Int = 1
fun caller(): Int {
return helper()
}
`);
const entries = extractor.extractCallGraph(root);
const helperCall = entries.find((e) => e.callee === "helper");
expect(helperCall).toBeDefined();
expect(helperCall!.caller).toBe("caller");
tree.delete();
parser.delete();
});
it("extracts method calls (x.foo()) and attributes them to the enclosing function", () => {
const { tree, parser, root } = parse(`fun run() {
val s = "hi".uppercase()
}
`);
const entries = extractor.extractCallGraph(root);
const callees = entries.map((e) => e.callee);
expect(callees).toContain("uppercase");
tree.delete();
parser.delete();
});
it("returns an empty array when there are no calls", () => {
const { tree, parser, root } = parse(`fun a(): Int = 1
`);
const entries = extractor.extractCallGraph(root);
expect(entries).toEqual([]);
tree.delete();
parser.delete();
});
});
});
@@ -9,6 +9,7 @@ export { RubyExtractor } from "./ruby-extractor.js";
export { PhpExtractor } from "./php-extractor.js";
export { CppExtractor } from "./cpp-extractor.js";
export { CSharpExtractor } from "./csharp-extractor.js";
export { KotlinExtractor } from "./kotlin-extractor.js";
import type { LanguageExtractor } from "./types.js";
import { TypeScriptExtractor } from "./typescript-extractor.js";
@@ -20,6 +21,7 @@ import { RubyExtractor } from "./ruby-extractor.js";
import { PhpExtractor } from "./php-extractor.js";
import { CppExtractor } from "./cpp-extractor.js";
import { CSharpExtractor } from "./csharp-extractor.js";
import { KotlinExtractor } from "./kotlin-extractor.js";
export const builtinExtractors: LanguageExtractor[] = [
new TypeScriptExtractor(),
@@ -31,4 +33,5 @@ export const builtinExtractors: LanguageExtractor[] = [
new PhpExtractor(),
new CppExtractor(),
new CSharpExtractor(),
new KotlinExtractor(),
];
@@ -0,0 +1,425 @@
import type { StructuralAnalysis, CallGraphEntry } from "../../types.js";
import type { LanguageExtractor, TreeSitterNode } from "./types.js";
import { findChild, findChildren } from "./base-extractor.js";
/**
* Extract the visibility keyword text (e.g., "public", "private") from a
* declaration's `modifiers` child, or return null when no modifier is present.
*
* Kotlin's default visibility is `public`, so a `null` result means the
* declaration IS exported — callers must treat absence as exported, not the
* other way around.
*/
function extractVisibility(declNode: TreeSitterNode): string | null {
const modifiers = findChild(declNode, "modifiers");
if (!modifiers) return null;
const visibility = findChild(modifiers, "visibility_modifier");
if (!visibility) return null;
return visibility.text;
}
/**
* Whether a Kotlin declaration is visible to other files.
*
* Default visibility in Kotlin is `public`, so a declaration with NO
* modifier counts as exported. Only an explicit `private` opts out.
* `internal` and `protected` remain exported in the project-graph sense
* because they are still resolvable from other files (within the module
* or via inheritance respectively).
*/
function isExported(declNode: TreeSitterNode): boolean {
const visibility = extractVisibility(declNode);
return visibility === null || visibility !== "private";
}
/**
* Get the identifier-text name of a Kotlin declaration. Works for
* function_declaration / class_declaration / object_declaration / interface
* — all carry the name as the first `identifier` child after the keyword.
*/
function extractDeclarationName(declNode: TreeSitterNode): string | null {
for (let i = 0; i < declNode.childCount; i++) {
const child = declNode.child(i);
if (child && child.type === "identifier") return child.text;
}
return null;
}
/**
* Extract parameter names from a `function_value_parameters` node. Each
* `parameter` child carries a leading `identifier` for the parameter name;
* the optional trailing `: <type>` annotation is ignored.
*/
function extractParams(declNode: TreeSitterNode): string[] {
const params: string[] = [];
const valueParams = findChild(declNode, "function_value_parameters");
if (!valueParams) return params;
for (const param of findChildren(valueParams, "parameter")) {
// The first `identifier` inside a parameter is its name.
const id = findChild(param, "identifier");
if (id) params.push(id.text);
}
return params;
}
/**
* Extract the return type text from a `function_declaration` by looking for
* the `:` separator and taking the next named child, which is the type node.
* Returns undefined for `Unit`-returning functions (no annotation present).
*/
function extractReturnType(declNode: TreeSitterNode): string | undefined {
// function_value_parameters comes before the optional `: <type>` block;
// walk children from after the parameters to find `:` followed by a type.
let sawParams = false;
for (let i = 0; i < declNode.childCount; i++) {
const child = declNode.child(i);
if (!child) continue;
if (child.type === "function_value_parameters") {
sawParams = true;
continue;
}
if (sawParams && child.type === ":") {
// The next named sibling is the type
for (let j = i + 1; j < declNode.childCount; j++) {
const next = declNode.child(j);
if (next && next.isNamed) return next.text;
}
}
}
return undefined;
}
/**
* Walk a `class_body` and collect functions + properties. Function entries
* are added to both the class's `methods` array and the top-level
* `functions` array (matching the GoExtractor / SwiftExtractor convention).
*/
function collectClassBody(
body: TreeSitterNode,
methods: string[],
properties: string[],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
for (let i = 0; i < body.childCount; i++) {
const member = body.child(i);
if (!member) continue;
if (member.type === "function_declaration") {
const name = extractDeclarationName(member);
if (!name) continue;
methods.push(name);
functions.push({
name,
lineRange: [member.startPosition.row + 1, member.endPosition.row + 1],
params: extractParams(member),
returnType: extractReturnType(member),
});
if (isExported(member)) {
exports.push({ name, lineNumber: member.startPosition.row + 1 });
}
} else if (member.type === "property_declaration") {
const name = extractPropertyName(member);
if (name) properties.push(name);
if (name && isExported(member)) {
exports.push({ name, lineNumber: member.startPosition.row + 1 });
}
} else if (member.type === "object_declaration") {
// Nested companion-object / object members are surfaced as a single
// synthetic property pointing at the inner object's name — enough for
// the graph builder to keep the relationship without exploding scope.
const name = extractDeclarationName(member);
if (name) properties.push(name);
}
}
}
/**
* Extract the property name from a `property_declaration`. The name lives
* inside the `variable_declaration` child as its `identifier`.
*/
function extractPropertyName(propNode: TreeSitterNode): string | null {
const varDecl = findChild(propNode, "variable_declaration");
if (!varDecl) return null;
const id = findChild(varDecl, "identifier");
return id ? id.text : null;
}
/**
* Walk a `primary_constructor`'s `class_parameters` and surface every
* `val` / `var` parameter as a class property. Plain `parameter` entries
* (no val/var keyword) are constructor-only and are NOT properties — they
* vanish after the constructor returns.
*/
function collectPrimaryConstructorProperties(
declNode: TreeSitterNode,
properties: string[],
): void {
const primary = findChild(declNode, "primary_constructor");
if (!primary) return;
const params = findChild(primary, "class_parameters");
if (!params) return;
for (const param of findChildren(params, "class_parameter")) {
// A class_parameter that starts with `val` or `var` is a property.
let isProperty = false;
for (let i = 0; i < param.childCount; i++) {
const child = param.child(i);
if (child && (child.type === "val" || child.type === "var")) {
isProperty = true;
break;
}
}
if (!isProperty) continue;
const id = findChild(param, "identifier");
if (id) properties.push(id.text);
}
}
/**
* Kotlin extractor for tree-sitter structural analysis and call graph
* extraction. Maps Kotlin's class / interface / object / data-class
* declarations to the project's shared `StructuralAnalysis.classes` array.
*/
export class KotlinExtractor implements LanguageExtractor {
readonly languageIds = ["kotlin"];
extractStructure(rootNode: TreeSitterNode): StructuralAnalysis {
const functions: StructuralAnalysis["functions"] = [];
const classes: StructuralAnalysis["classes"] = [];
const imports: StructuralAnalysis["imports"] = [];
const exports: StructuralAnalysis["exports"] = [];
for (let i = 0; i < rootNode.childCount; i++) {
const node = rootNode.child(i);
if (!node) continue;
switch (node.type) {
case "package_header":
// Package is metadata about this file, not a graph member. Skip.
break;
case "import":
this.extractImport(node, imports);
break;
case "function_declaration":
this.extractTopLevelFunction(node, functions, exports);
break;
case "class_declaration":
this.extractClassDeclaration(node, classes, functions, exports);
break;
case "object_declaration":
this.extractObjectDeclaration(node, classes, functions, exports);
break;
}
}
return { functions, classes, imports, exports };
}
extractCallGraph(rootNode: TreeSitterNode): CallGraphEntry[] {
const entries: CallGraphEntry[] = [];
const functionStack: string[] = [];
const walk = (node: TreeSitterNode) => {
let pushed = false;
if (node.type === "function_declaration") {
const name = extractDeclarationName(node);
if (name) {
functionStack.push(name);
pushed = true;
}
}
if (node.type === "call_expression" && functionStack.length > 0) {
const callee = this.extractCalleeName(node);
if (callee) {
entries.push({
caller: functionStack[functionStack.length - 1],
callee,
lineNumber: node.startPosition.row + 1,
});
}
}
for (let i = 0; i < node.childCount; i++) {
const child = node.child(i);
if (child) walk(child);
}
if (pushed) functionStack.pop();
};
walk(rootNode);
return entries;
}
// ---- Private helpers ----
private extractTopLevelFunction(
declNode: TreeSitterNode,
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const name = extractDeclarationName(declNode);
if (!name) return;
functions.push({
name,
lineRange: [declNode.startPosition.row + 1, declNode.endPosition.row + 1],
params: extractParams(declNode),
returnType: extractReturnType(declNode),
});
if (isExported(declNode)) {
exports.push({ name, lineNumber: declNode.startPosition.row + 1 });
}
}
private extractClassDeclaration(
declNode: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const name = extractDeclarationName(declNode);
if (!name) return;
const properties: string[] = [];
const methods: string[] = [];
// 1. Primary-constructor `val`/`var` parameters become properties.
collectPrimaryConstructorProperties(declNode, properties);
// 2. Body members (if any). Some Kotlin declarations have no body
// (e.g. `class Empty` or `data class Point(...)` without `{}`).
const body = findChild(declNode, "class_body");
if (body) {
collectClassBody(body, methods, properties, functions, exports);
}
classes.push({
name,
lineRange: [declNode.startPosition.row + 1, declNode.endPosition.row + 1],
methods,
properties,
});
if (isExported(declNode)) {
exports.push({ name, lineNumber: declNode.startPosition.row + 1 });
}
}
private extractObjectDeclaration(
declNode: TreeSitterNode,
classes: StructuralAnalysis["classes"],
functions: StructuralAnalysis["functions"],
exports: StructuralAnalysis["exports"],
): void {
const name = extractDeclarationName(declNode);
if (!name) return;
const properties: string[] = [];
const methods: string[] = [];
const body = findChild(declNode, "class_body");
if (body) {
collectClassBody(body, methods, properties, functions, exports);
}
classes.push({
name,
lineRange: [declNode.startPosition.row + 1, declNode.endPosition.row + 1],
methods,
properties,
});
if (isExported(declNode)) {
exports.push({ name, lineNumber: declNode.startPosition.row + 1 });
}
}
/**
* Extract a Kotlin import.
*
* The grammar gives us a single `qualified_identifier` child holding the
* dotted module path. Three trailing variants must be distinguished:
*
* - `import foo.bar.Baz` → source="foo.bar.Baz", specifier="Baz"
* - `import foo.bar.*` → source="foo.bar", specifier="*"
* - `import foo.bar.Baz as Quux` → source="foo.bar.Baz", specifier="Quux"
*
* The grammar represents the wildcard as a trailing `*` token AFTER the
* qualified_identifier (which holds the dotted prefix). The alias
* appears as `as` + a top-level `identifier` sibling.
*/
private extractImport(
declNode: TreeSitterNode,
imports: StructuralAnalysis["imports"],
): void {
const qualified = findChild(declNode, "qualified_identifier");
if (!qualified) return;
const parts: string[] = [];
for (const id of findChildren(qualified, "identifier")) {
parts.push(id.text);
}
if (parts.length === 0) return;
const source = parts.join(".");
// Look for a sibling `*` (wildcard) or an `as` keyword + identifier
// after the qualified_identifier.
let specifier = parts[parts.length - 1];
let sawWildcardStar = false;
let sawAs = false;
for (let i = 0; i < declNode.childCount; i++) {
const child = declNode.child(i);
if (!child) continue;
if (child.type === "*") sawWildcardStar = true;
if (child.type === "as") sawAs = true;
else if (sawAs && child.type === "identifier") {
specifier = child.text;
sawAs = false;
}
}
if (sawWildcardStar) specifier = "*";
imports.push({
source,
specifiers: [specifier],
lineNumber: declNode.startPosition.row + 1,
});
}
/**
* Extract the callee name from a Kotlin `call_expression`. Two shapes:
*
* foo(...) → first child is `identifier "foo"`
* target.method(...) → first child is `navigation_expression` whose
* last `navigation_suffix > identifier` is the
* method name
*/
private extractCalleeName(callNode: TreeSitterNode): string | null {
const first = callNode.child(0);
if (!first) return null;
if (first.type === "identifier") return first.text;
if (first.type === "navigation_expression") {
// The Kotlin grammar flattens navigation: `x.foo` is
// navigation_expression { x, ".", identifier "foo" }
// The method name is the LAST `identifier` child of the navigation.
let lastIdentifier: string | null = null;
for (let i = 0; i < first.childCount; i++) {
const child = first.child(i);
if (child && child.type === "identifier") {
lastIdentifier = child.text;
}
}
return lastIdentifier;
}
return null;
}
}