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; } }