mirror of
https://github.com/Egonex-AI/Understand-Anything.git
synced 2026-06-22 10:58:03 +08:00
Merge pull request #347 from tirth8205/feat/kotlin-extractor
feat(core): add Kotlin structural analysis via tree-sitter
This commit is contained in:
Generated
+24
@@ -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",
|
||||
|
||||
+364
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user