diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 3eb7e7a..0b9b06e 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -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 diff --git a/understand-anything-plugin/packages/core/package.json b/understand-anything-plugin/packages/core/package.json index c4cd4f3..7ea50ae 100644 --- a/understand-anything-plugin/packages/core/package.json +++ b/understand-anything-plugin/packages/core/package.json @@ -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", diff --git a/understand-anything-plugin/packages/core/src/languages/configs/kotlin.ts b/understand-anything-plugin/packages/core/src/languages/configs/kotlin.ts index f02dc79..dd700d5 100644 --- a/understand-anything-plugin/packages/core/src/languages/configs/kotlin.ts +++ b/understand-anything-plugin/packages/core/src/languages/configs/kotlin.ts @@ -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", diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/kotlin-extractor.test.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/kotlin-extractor.test.ts new file mode 100644 index 0000000..db12d0f --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/__tests__/kotlin-extractor.test.ts @@ -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 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(); + }); + }); +}); diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts index f148c61..4f0b5a3 100644 --- a/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/index.ts @@ -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(), ]; diff --git a/understand-anything-plugin/packages/core/src/plugins/extractors/kotlin-extractor.ts b/understand-anything-plugin/packages/core/src/plugins/extractors/kotlin-extractor.ts new file mode 100644 index 0000000..2ee2818 --- /dev/null +++ b/understand-anything-plugin/packages/core/src/plugins/extractors/kotlin-extractor.ts @@ -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 `: ` 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 `: ` 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; + } +}