diff --git a/gitnexus/package.json b/gitnexus/package.json index 6f5ae9ddd..857076678 100644 --- a/gitnexus/package.json +++ b/gitnexus/package.json @@ -69,6 +69,7 @@ "tree-sitter-go": "^0.21.0", "tree-sitter-java": "^0.21.0", "tree-sitter-javascript": "^0.21.0", + "tree-sitter-kotlin": "^0.3.8", "tree-sitter-php": "^0.23.12", "tree-sitter-python": "^0.21.0", "tree-sitter-rust": "^0.21.0", diff --git a/gitnexus/src/config/supported-languages.ts b/gitnexus/src/config/supported-languages.ts index 5df70ed83..9d67eaf05 100644 --- a/gitnexus/src/config/supported-languages.ts +++ b/gitnexus/src/config/supported-languages.ts @@ -9,6 +9,7 @@ export enum SupportedLanguages { Go = 'go', Rust = 'rust', PHP = 'php', + Kotlin = 'kotlin', // Ruby = 'ruby', Swift = 'swift', } \ No newline at end of file diff --git a/gitnexus/src/core/ingestion/call-processor.ts b/gitnexus/src/core/ingestion/call-processor.ts index e54d43dd9..7ab36ad46 100644 --- a/gitnexus/src/core/ingestion/call-processor.ts +++ b/gitnexus/src/core/ingestion/call-processor.ts @@ -37,6 +37,10 @@ const FUNCTION_NODE_TYPES = new Set([ // Rust 'function_item', 'impl_item', // Methods inside impl blocks + // Kotlin (function_declaration already included above via JS/TS) + 'anonymous_function', + 'lambda_literal', + // PHP — no additional node types needed // Swift 'init_declaration', 'deinit_declaration', @@ -324,6 +328,22 @@ const BUILT_IN_NAMES = new Set([ 'open', 'read', 'write', 'close', 'append', 'extend', 'update', 'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr', 'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs', + // Kotlin stdlib (IMPORTANT: keep in sync with parse-worker.ts BUILT_IN_NAMES) + 'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error', + 'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf', + 'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless', + 'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet', + 'repeat', 'synchronized', + // Kotlin coroutine builders & scope functions + 'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope', + 'supervisorScope', 'delay', + // Kotlin Flow operators + 'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch', + 'buffer', 'conflate', 'distinctUntilChanged', + 'flatMapLatest', 'flatMapMerge', 'combine', + 'stateIn', 'shareIn', 'launchIn', + // Kotlin infix stdlib functions + 'to', 'until', 'downTo', 'step', // C/C++ standard library and common kernel helpers 'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf', 'scanf', 'fscanf', 'sscanf', diff --git a/gitnexus/src/core/ingestion/framework-detection.ts b/gitnexus/src/core/ingestion/framework-detection.ts index 71a706aff..aecff1126 100644 --- a/gitnexus/src/core/ingestion/framework-detection.ts +++ b/gitnexus/src/core/ingestion/framework-detection.ts @@ -129,6 +129,49 @@ export function detectFrameworkFromPath(filePath: string): FrameworkHint | null return { framework: 'java-service', entryPointMultiplier: 1.8, reason: 'java-service' }; } + // ========== KOTLIN FRAMEWORKS ========== + + // Spring Boot Kotlin controllers + if ((p.includes('/controller/') || p.includes('/controllers/')) && p.endsWith('.kt')) { + return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller' }; + } + + // Spring Boot - files ending in Controller.kt + if (p.endsWith('controller.kt')) { + return { framework: 'spring-kotlin', entryPointMultiplier: 3.0, reason: 'spring-kotlin-controller-file' }; + } + + // Ktor routes + if (p.includes('/routes/') && p.endsWith('.kt')) { + return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routes' }; + } + + // Ktor plugins folder or Routing.kt files + if (p.includes('/plugins/') && p.endsWith('.kt')) { + return { framework: 'ktor', entryPointMultiplier: 2.0, reason: 'ktor-plugin' }; + } + if (p.endsWith('routing.kt') || p.endsWith('routes.kt')) { + return { framework: 'ktor', entryPointMultiplier: 2.5, reason: 'ktor-routing-file' }; + } + + // Android Activities, Fragments + if ((p.includes('/activity/') || p.includes('/ui/')) && p.endsWith('.kt')) { + return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-ui' }; + } + if (p.endsWith('activity.kt') || p.endsWith('fragment.kt')) { + return { framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-component' }; + } + + // Kotlin main entry point + if (p.endsWith('/main.kt')) { + return { framework: 'kotlin', entryPointMultiplier: 3.0, reason: 'kotlin-main' }; + } + + // Kotlin Application entry point (common naming) + if (p.endsWith('/application.kt')) { + return { framework: 'kotlin', entryPointMultiplier: 2.5, reason: 'kotlin-application' }; + } + // ========== C# / .NET FRAMEWORKS ========== // ASP.NET Controllers @@ -384,6 +427,12 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record> = + Object.fromEntries( + Object.entries(AST_FRAMEWORK_PATTERNS_BY_LANGUAGE).map(([lang, cfgs]) => [ + lang, + cfgs.map(cfg => ({ ...cfg, patterns: cfg.patterns.map(p => p.toLowerCase()) })), + ]) + ); + /** * Detect framework entry points from AST definition text (decorators/annotations/attributes). * Returns null if no known pattern is found. + * Note: callers should slice definitionText to ~300 chars since annotations appear at the start. */ export function detectFrameworkFromAST( language: string, @@ -402,14 +461,14 @@ export function detectFrameworkFromAST( ): FrameworkHint | null { if (!language || !definitionText) return null; - const configs = AST_FRAMEWORK_PATTERNS_BY_LANGUAGE[language.toLowerCase()]; + const configs = AST_PATTERNS_LOWERED[language.toLowerCase()]; if (!configs || configs.length === 0) return null; const normalized = definitionText.toLowerCase(); for (const cfg of configs) { for (const pattern of cfg.patterns) { - if (normalized.includes(pattern.toLowerCase())) { + if (normalized.includes(pattern)) { return { framework: cfg.framework, entryPointMultiplier: cfg.entryPointMultiplier, diff --git a/gitnexus/src/core/ingestion/import-processor.ts b/gitnexus/src/core/ingestion/import-processor.ts index 0ad666bba..990f968af 100644 --- a/gitnexus/src/core/ingestion/import-processor.ts +++ b/gitnexus/src/core/ingestion/import-processor.ts @@ -202,6 +202,8 @@ const EXTENSIONS = [ '.py', '/__init__.py', // Java '.java', + // Kotlin + '.kt', '.kts', // C/C++ '.c', '.h', '.cpp', '.hpp', '.cc', '.cxx', '.hxx', '.hh', // C# @@ -531,26 +533,42 @@ function tryRustModulePath(modulePath: string, allFiles: Set): string | return null; } +/** + * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. + * Pure function — returns a new string without mutating the input. + */ +const appendKotlinWildcard = (importPath: string, importNode: any): string => { + for (let i = 0; i < importNode.childCount; i++) { + if (importNode.child(i)?.type === 'wildcard_import') { + return importPath.endsWith('.*') ? importPath : `${importPath}.*`; + } + } + return importPath; +}; + // ============================================================================ -// JAVA MULTI-FILE RESOLUTION +// JVM MULTI-FILE RESOLUTION (Java + Kotlin) // ============================================================================ +/** Kotlin file extensions for JVM resolver reuse */ +const KOTLIN_EXTENSIONS: readonly string[] = ['.kt', '.kts']; + /** - * Resolve a Java wildcard import (com.example.*) to all matching .java files. - * Returns an array of file paths. + * Resolve a JVM wildcard import (com.example.*) to all matching files. + * Works for both Java (.java) and Kotlin (.kt, .kts). */ -function resolveJavaWildcard( +function resolveJvmWildcard( importPath: string, normalizedFileList: string[], allFileList: string[], + extensions: readonly string[], index?: SuffixIndex, ): string[] { // "com.example.util.*" -> "com/example/util" const packagePath = importPath.slice(0, -2).replace(/\./g, '/'); if (index) { - // Use directory index: get all .java files in this package directory - const candidates = index.getFilesInDir(packagePath, '.java'); + const candidates = extensions.flatMap(ext => index.getFilesInDir(packagePath, ext)); // Filter to only direct children (no subdirectories) const packageSuffix = '/' + packagePath + '/'; return candidates.filter(f => { @@ -567,7 +585,8 @@ function resolveJavaWildcard( const matches: string[] = []; for (let i = 0; i < normalizedFileList.length; i++) { const normalized = normalizedFileList[i]; - if (normalized.includes(packageSuffix) && normalized.endsWith('.java')) { + if (normalized.includes(packageSuffix) && + extensions.some(ext => normalized.endsWith(ext))) { const afterPackage = normalized.substring(normalized.indexOf(packageSuffix) + packageSuffix.length); if (!afterPackage.includes('/')) { matches.push(allFileList[i]); @@ -578,36 +597,39 @@ function resolveJavaWildcard( } /** - * Try to resolve a Java static import by stripping the member name. - * "com.example.Constants.VALUE" -> resolve "com.example.Constants" + * Try to resolve a JVM member/static import by stripping the member name. + * Java: "com.example.Constants.VALUE" -> resolve "com.example.Constants" + * Kotlin: "com.example.Constants.VALUE" -> resolve "com.example.Constants" */ -function resolveJavaStaticImport( +function resolveJvmMemberImport( importPath: string, normalizedFileList: string[], allFileList: string[], + extensions: readonly string[], index?: SuffixIndex, ): string | null { - // Static imports look like: com.example.Constants.VALUE or com.example.Constants.* - // The last segment is a member name (field/method) if it starts with lowercase or is ALL_CAPS + // Member imports: com.example.Constants.VALUE or com.example.Constants.* + // The last segment is a member name if it starts with lowercase, is ALL_CAPS, or is a wildcard const segments = importPath.split('.'); if (segments.length < 3) return null; const lastSeg = segments[segments.length - 1]; - // If last segment is a wildcard or ALL_CAPS constant or starts with lowercase, strip it if (lastSeg === '*' || /^[a-z]/.test(lastSeg) || /^[A-Z_]+$/.test(lastSeg)) { const classPath = segments.slice(0, -1).join('/'); - const classSuffix = classPath + '.java'; - if (index) { - return index.get(classSuffix) || index.getInsensitive(classSuffix) || null; - } - - // Fallback: linear scan - const fullSuffix = '/' + classSuffix; - for (let i = 0; i < normalizedFileList.length; i++) { - if (normalizedFileList[i].endsWith(fullSuffix) || - normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) { - return allFileList[i]; + for (const ext of extensions) { + const classSuffix = classPath + ext; + if (index) { + const result = index.get(classSuffix) || index.getInsensitive(classSuffix); + if (result) return result; + } else { + const fullSuffix = '/' + classSuffix; + for (let i = 0; i < normalizedFileList.length; i++) { + if (normalizedFileList[i].endsWith(fullSuffix) || + normalizedFileList[i].toLowerCase().endsWith(fullSuffix.toLowerCase())) { + return allFileList[i]; + } + } } } } @@ -817,26 +839,42 @@ export const processImports = async ( } // Clean path (remove quotes and angle brackets for C/C++ includes) - const rawImportPath = sourceNode.text.replace(/['"<>]/g, ''); + const rawImportPath = language === SupportedLanguages.Kotlin + ? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import']) + : sourceNode.text.replace(/['"<>]/g, ''); totalImportsFound++; - // ---- Java: handle wildcards and static imports specially ---- - if (language === SupportedLanguages.Java) { + // ---- JVM languages (Java + Kotlin): handle wildcards and member imports ---- + if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) { + const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS; + if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index); + const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) { + const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + for (const matchedFile of javaMatches) { + addImportEdge(file.path, matchedFile); + } + if (javaMatches.length > 0) return; + } for (const matchedFile of matchedFiles) { addImportEdge(file.path, matchedFile); } return; // skip single-file resolution } - // Try static import resolution (strip member name) - const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index); - if (staticResolved) { - addImportEdge(file.path, staticResolved); + // Try member/static import resolution (strip member name) + let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (!memberResolved && language === SupportedLanguages.Kotlin) { + memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + } + if (memberResolved) { + addImportEdge(file.path, memberResolved); return; } - // Fall through to normal resolution for regular Java imports + // Fall through to normal resolution for regular imports } // ---- Go: handle package-level imports ---- @@ -1000,20 +1038,34 @@ export const processImportsFromExtracted = async ( continue; } - // Java: handle wildcards and static imports - if (language === SupportedLanguages.Java) { + // JVM languages (Java + Kotlin): handle wildcards and member imports + if (language === SupportedLanguages.Java || language === SupportedLanguages.Kotlin) { + const exts = language === SupportedLanguages.Java ? ['.java'] : KOTLIN_EXTENSIONS; + if (rawImportPath.endsWith('.*')) { - const matchedFiles = resolveJavaWildcard(rawImportPath, normalizedFileList, allFileList, index); + const matchedFiles = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (matchedFiles.length === 0 && language === SupportedLanguages.Kotlin) { + const javaMatches = resolveJvmWildcard(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + for (const matchedFile of javaMatches) { + addImportEdge(filePath, matchedFile); + } + if (javaMatches.length > 0) continue; + } for (const matchedFile of matchedFiles) { addImportEdge(filePath, matchedFile); } continue; } - const staticResolved = resolveJavaStaticImport(rawImportPath, normalizedFileList, allFileList, index); - if (staticResolved) { - resolveCache.set(cacheKey, staticResolved); - addImportEdge(filePath, staticResolved); + let memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index); + // Kotlin can import Java files in mixed codebases — try .java as fallback + if (!memberResolved && language === SupportedLanguages.Kotlin) { + memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, ['.java'], index); + } + if (memberResolved) { + resolveCache.set(cacheKey, memberResolved); + addImportEdge(filePath, memberResolved); continue; } } diff --git a/gitnexus/src/core/ingestion/parsing-processor.ts b/gitnexus/src/core/ingestion/parsing-processor.ts index 0213f3a35..e5d4c4054 100644 --- a/gitnexus/src/core/ingestion/parsing-processor.ts +++ b/gitnexus/src/core/ingestion/parsing-processor.ts @@ -5,7 +5,7 @@ import { LANGUAGE_QUERIES } from './tree-sitter-queries.js'; import { generateId } from '../../lib/utils.js'; import { SymbolTable } from './symbol-table.js'; import { ASTCache } from './ast-cache.js'; -import { getLanguageFromFilename, yieldToEventLoop } from './utils.js'; +import { findSiblingChild, getLanguageFromFilename, yieldToEventLoop } from './utils.js'; import { detectFrameworkFromAST } from './framework-detection.js'; import { WorkerPool } from './workers/worker-pool.js'; import type { ParseWorkerResult, ParseWorkerInput, ExtractedImport, ExtractedCall, ExtractedHeritage } from './workers/parse-worker.js'; @@ -18,33 +18,33 @@ export interface WorkerExtractedData { heritage: ExtractedHeritage[]; } -const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { - const definitionKeys = [ - 'definition.function', - 'definition.class', - 'definition.interface', - 'definition.method', - 'definition.struct', - 'definition.enum', - 'definition.namespace', - 'definition.module', - 'definition.trait', - 'definition.impl', - 'definition.type', - 'definition.const', - 'definition.static', - 'definition.typedef', - 'definition.macro', - 'definition.union', - 'definition.property', - 'definition.record', - 'definition.delegate', - 'definition.annotation', - 'definition.constructor', - 'definition.template', - ]; +const DEFINITION_CAPTURE_KEYS = [ + 'definition.function', + 'definition.class', + 'definition.interface', + 'definition.method', + 'definition.struct', + 'definition.enum', + 'definition.namespace', + 'definition.module', + 'definition.trait', + 'definition.impl', + 'definition.type', + 'definition.const', + 'definition.static', + 'definition.typedef', + 'definition.macro', + 'definition.union', + 'definition.property', + 'definition.record', + 'definition.delegate', + 'definition.annotation', + 'definition.constructor', + 'definition.template', +] as const; - for (const key of definitionKeys) { +const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { + for (const key of DEFINITION_CAPTURE_KEYS) { if (captureMap[key]) return captureMap[key]; } return null; @@ -141,6 +141,23 @@ export const isNodeExported = (node: any, name: string, language: string): boole } return false; + // Kotlin: Default visibility is public (unlike Java) + // visibility_modifier is inside modifiers, a sibling of the name node within the declaration + case 'kotlin': + while (current) { + if (current.parent) { + const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier'); + if (visMod) { + const text = visMod.text; + if (text === 'private' || text === 'internal' || text === 'protected') return false; + if (text === 'public') return true; + } + } + current = current.parent; + } + // No visibility modifier = public (Kotlin default) + return true; + // C/C++: No native export concept at language level // Entry points will be detected via name patterns (main, etc.) case 'c': @@ -346,15 +363,15 @@ const processParsingSequential = async ( const startLine = definitionNodeForRange ? definitionNodeForRange.startPosition.row : (nameNode ? nameNode.startPosition.row : 0); const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}:${startLine}`); + const definitionNode = getDefinitionNodeFromCaptures(captureMap); + const frameworkHint = definitionNode + ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) + : null; + const node: GraphNode = { id: nodeId, label: nodeLabel as any, - properties: (() => { - const frameworkHint = definitionNodeForRange - ? detectFrameworkFromAST(language, definitionNodeForRange.text || '') - : null; - - return { + properties: { name: nodeName, filePath: file.path, startLine: definitionNodeForRange ? definitionNodeForRange.startPosition.row : startLine, @@ -365,8 +382,7 @@ const processParsingSequential = async ( astFrameworkMultiplier: frameworkHint.entryPointMultiplier, astFrameworkReason: frameworkHint.reason, } : {}), - }; - })() + }, }; graph.addNode(node); diff --git a/gitnexus/src/core/ingestion/tree-sitter-queries.ts b/gitnexus/src/core/ingestion/tree-sitter-queries.ts index a32685278..7eeeb73e0 100644 --- a/gitnexus/src/core/ingestion/tree-sitter-queries.ts +++ b/gitnexus/src/core/ingestion/tree-sitter-queries.ts @@ -396,6 +396,86 @@ export const PHP_QUERIES = ` [(name) (qualified_name)] @heritage.trait))) @heritage `; +// Kotlin queries - works with tree-sitter-kotlin (fwcd/tree-sitter-kotlin) +// Based on official tags.scm; functions use simple_identifier, classes use type_identifier +export const KOTLIN_QUERIES = ` +; ── Interfaces ───────────────────────────────────────────────────────────── +; tree-sitter-kotlin (fwcd) has no interface_declaration node type. +; Interfaces are class_declaration nodes with an anonymous "interface" keyword child. +(class_declaration + "interface" + (type_identifier) @name) @definition.interface + +; ── Classes (regular, data, sealed, enum) ──────────────────────────────── +; All have the anonymous "class" keyword child. enum class has both +; "enum" and "class" children — the "class" child still matches. +(class_declaration + "class" + (type_identifier) @name) @definition.class + +; ── Object declarations (Kotlin singletons) ────────────────────────────── +(object_declaration + (type_identifier) @name) @definition.class + +; ── Companion objects (named only) ─────────────────────────────────────── +(companion_object + (type_identifier) @name) @definition.class + +; ── Functions (top-level, member, extension) ────────────────────────────── +(function_declaration + (simple_identifier) @name) @definition.function + +; ── Properties ─────────────────────────────────────────────────────────── +(property_declaration + (variable_declaration + (simple_identifier) @name)) @definition.property + +; ── Enum entries ───────────────────────────────────────────────────────── +(enum_entry + (simple_identifier) @name) @definition.enum + +; ── Type aliases ───────────────────────────────────────────────────────── +(type_alias + (type_identifier) @name) @definition.type + +; ── Imports ────────────────────────────────────────────────────────────── +(import_header + (identifier) @import.source) @import + +; ── Function calls (direct) ────────────────────────────────────────────── +(call_expression + (simple_identifier) @call.name) @call + +; ── Method calls (via navigation: obj.method()) ────────────────────────── +(call_expression + (navigation_expression + (navigation_suffix + (simple_identifier) @call.name))) @call + +; ── Constructor invocations ────────────────────────────────────────────── +(constructor_invocation + (user_type + (type_identifier) @call.name)) @call + +; ── Infix function calls (e.g., a to b, x until y) ────────────────────── +(infix_expression + (simple_identifier) @call.name) @call + +; ── Heritage: extends / implements via delegation_specifier ────────────── +; Interface implementation (bare user_type): class Foo : Bar +(class_declaration + (type_identifier) @heritage.class + (delegation_specifier + (user_type (type_identifier) @heritage.extends))) @heritage + +; Class extension (constructor_invocation): class Foo : Bar() +(class_declaration + (type_identifier) @heritage.class + (delegation_specifier + (constructor_invocation + (user_type (type_identifier) @heritage.extends)))) @heritage +`; + // Swift queries - works with tree-sitter-swift export const SWIFT_QUERIES = ` ; Classes @@ -460,6 +540,7 @@ export const LANGUAGE_QUERIES: Record = { [SupportedLanguages.CSharp]: CSHARP_QUERIES, [SupportedLanguages.Rust]: RUST_QUERIES, [SupportedLanguages.PHP]: PHP_QUERIES, + [SupportedLanguages.Kotlin]: KOTLIN_QUERIES, [SupportedLanguages.Swift]: SWIFT_QUERIES, }; \ No newline at end of file diff --git a/gitnexus/src/core/ingestion/utils.ts b/gitnexus/src/core/ingestion/utils.ts index d975ed293..47fa8cbe4 100644 --- a/gitnexus/src/core/ingestion/utils.ts +++ b/gitnexus/src/core/ingestion/utils.ts @@ -6,6 +6,23 @@ import { SupportedLanguages } from '../../config/supported-languages.js'; */ export const yieldToEventLoop = (): Promise => new Promise(resolve => setImmediate(resolve)); +/** + * Find a child of `childType` within a sibling node of `siblingType`. + * Used for Kotlin AST traversal where visibility_modifier lives inside a modifiers sibling. + */ +export const findSiblingChild = (parent: any, siblingType: string, childType: string): any | null => { + for (let i = 0; i < parent.childCount; i++) { + const sibling = parent.child(i); + if (sibling?.type === siblingType) { + for (let j = 0; j < sibling.childCount; j++) { + const child = sibling.child(j); + if (child?.type === childType) return child; + } + } + } + return null; +}; + /** * Map file extension to SupportedLanguage enum */ @@ -31,6 +48,8 @@ export const getLanguageFromFilename = (filename: string): SupportedLanguages | if (filename.endsWith('.go')) return SupportedLanguages.Go; // Rust if (filename.endsWith('.rs')) return SupportedLanguages.Rust; + // Kotlin + if (filename.endsWith('.kt') || filename.endsWith('.kts')) return SupportedLanguages.Kotlin; // PHP (all common extensions) if (filename.endsWith('.php') || filename.endsWith('.phtml') || filename.endsWith('.php3') || filename.endsWith('.php4') || diff --git a/gitnexus/src/core/ingestion/workers/parse-worker.ts b/gitnexus/src/core/ingestion/workers/parse-worker.ts index 405545396..25836e7e9 100644 --- a/gitnexus/src/core/ingestion/workers/parse-worker.ts +++ b/gitnexus/src/core/ingestion/workers/parse-worker.ts @@ -9,16 +9,17 @@ import CPP from 'tree-sitter-cpp'; import CSharp from 'tree-sitter-c-sharp'; import Go from 'tree-sitter-go'; import Rust from 'tree-sitter-rust'; +import Kotlin from 'tree-sitter-kotlin'; import PHP from 'tree-sitter-php'; import { createRequire } from 'node:module'; import { SupportedLanguages } from '../../../config/supported-languages.js'; +import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js'; // tree-sitter-swift is an optionalDependency — may not be installed const _require = createRequire(import.meta.url); let Swift: any = null; try { Swift = _require('tree-sitter-swift'); } catch {} -import { LANGUAGE_QUERIES } from '../tree-sitter-queries.js'; -import { getLanguageFromFilename } from '../utils.js'; +import { findSiblingChild, getLanguageFromFilename } from '../utils.js'; import { detectFrameworkFromAST } from '../framework-detection.js'; import { generateId } from '../../../lib/utils.js'; @@ -111,6 +112,7 @@ const languageMap: Record = { [SupportedLanguages.CSharp]: CSharp, [SupportedLanguages.Go]: Go, [SupportedLanguages.Rust]: Rust, + [SupportedLanguages.Kotlin]: Kotlin, [SupportedLanguages.PHP]: PHP.php_only, ...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}), }; @@ -194,18 +196,25 @@ const isNodeExported = (node: any, name: string, language: string): boolean => { } return false; - case 'c': - case 'cpp': - return false; - - case 'swift': + // Kotlin: Default visibility is public (unlike Java) + // visibility_modifier is inside modifiers, a sibling of the name node within the declaration + case 'kotlin': while (current) { - if (current.type === 'modifiers' || current.type === 'visibility_modifier') { - const text = current.text || ''; - if (text.includes('public') || text.includes('open')) return true; + if (current.parent) { + const visMod = findSiblingChild(current.parent, 'modifiers', 'visibility_modifier'); + if (visMod) { + const text = visMod.text; + if (text === 'private' || text === 'internal' || text === 'protected') return false; + if (text === 'public') return true; + } } current = current.parent; } + // No visibility modifier = public (Kotlin default) + return true; + + case 'c': + case 'cpp': return false; case 'php': @@ -226,6 +235,16 @@ const isNodeExported = (node: any, name: string, language: string): boolean => { // Top-level functions (no parent class) are globally accessible return true; + case 'swift': + while (current) { + if (current.type === 'modifiers' || current.type === 'visibility_modifier') { + const text = current.text || ''; + if (text.includes('public') || text.includes('open')) return true; + } + current = current.parent; + } + return false; + default: return false; } @@ -241,8 +260,12 @@ const FUNCTION_NODE_TYPES = new Set([ 'function_definition', 'async_function_declaration', 'async_arrow_function', 'method_declaration', 'constructor_declaration', 'local_function_statement', 'function_item', 'impl_item', - 'anonymous_function_creation_expression', // PHP anonymous functions - 'init_declaration', 'deinit_declaration', // Swift initializers/deinitializers + // Kotlin (function_declaration already included above via JS/TS) + 'anonymous_function', 'lambda_literal', + // PHP + 'anonymous_function_creation_expression', + // Swift initializers/deinitializers + 'init_declaration', 'deinit_declaration', ]); /** Walk up AST to find enclosing function, return its generateId or null for top-level */ @@ -325,6 +348,22 @@ const BUILT_INS = new Set([ 'open', 'read', 'write', 'close', 'append', 'extend', 'update', 'super', 'type', 'isinstance', 'issubclass', 'getattr', 'setattr', 'hasattr', 'enumerate', 'zip', 'sorted', 'reversed', 'min', 'max', 'sum', 'abs', + // Kotlin stdlib (IMPORTANT: keep in sync with call-processor.ts BUILT_IN_NAMES) + 'println', 'print', 'readLine', 'require', 'requireNotNull', 'check', 'assert', 'lazy', 'error', + 'listOf', 'mapOf', 'setOf', 'mutableListOf', 'mutableMapOf', 'mutableSetOf', + 'arrayOf', 'sequenceOf', 'also', 'apply', 'run', 'with', 'takeIf', 'takeUnless', + 'TODO', 'buildString', 'buildList', 'buildMap', 'buildSet', + 'repeat', 'synchronized', + // Kotlin coroutine builders & scope functions + 'launch', 'async', 'runBlocking', 'withContext', 'coroutineScope', + 'supervisorScope', 'delay', + // Kotlin Flow operators + 'flow', 'flowOf', 'collect', 'emit', 'onEach', 'catch', + 'buffer', 'conflate', 'distinctUntilChanged', + 'flatMapLatest', 'flatMapMerge', 'combine', + 'stateIn', 'shareIn', 'launchIn', + // Kotlin infix stdlib functions + 'to', 'until', 'downTo', 'step', // C/C++ standard library 'printf', 'fprintf', 'sprintf', 'snprintf', 'vprintf', 'vfprintf', 'vsprintf', 'vsnprintf', 'scanf', 'fscanf', 'sscanf', @@ -430,38 +469,51 @@ const getLabelFromCaptures = (captureMap: Record): string | null => return 'CodeElement'; }; -const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { - const definitionKeys = [ - 'definition.function', - 'definition.class', - 'definition.interface', - 'definition.method', - 'definition.struct', - 'definition.enum', - 'definition.namespace', - 'definition.module', - 'definition.trait', - 'definition.impl', - 'definition.type', - 'definition.const', - 'definition.static', - 'definition.typedef', - 'definition.macro', - 'definition.union', - 'definition.property', - 'definition.record', - 'definition.delegate', - 'definition.annotation', - 'definition.constructor', - 'definition.template', - ]; +const DEFINITION_CAPTURE_KEYS = [ + 'definition.function', + 'definition.class', + 'definition.interface', + 'definition.method', + 'definition.struct', + 'definition.enum', + 'definition.namespace', + 'definition.module', + 'definition.trait', + 'definition.impl', + 'definition.type', + 'definition.const', + 'definition.static', + 'definition.typedef', + 'definition.macro', + 'definition.union', + 'definition.property', + 'definition.record', + 'definition.delegate', + 'definition.annotation', + 'definition.constructor', + 'definition.template', +] as const; - for (const key of definitionKeys) { +const getDefinitionNodeFromCaptures = (captureMap: Record): any | null => { + for (const key of DEFINITION_CAPTURE_KEYS) { if (captureMap[key]) return captureMap[key]; } return null; }; +/** + * Append .* to a Kotlin import path if the AST has a wildcard_import sibling node. + * Pure function — returns a new string without mutating the input. + */ +const appendKotlinWildcard = (importPath: string, importNode: any): string => { + for (let i = 0; i < importNode.childCount; i++) { + if (importNode.child(i)?.type === 'wildcard_import') { + return importPath.endsWith('.*') ? importPath : `${importPath}.*`; + } + } + return importPath; +}; + // ============================================================================ // Process a batch of files // ============================================================================ @@ -685,7 +737,9 @@ const processFileGroup = ( // Extract import paths before skipping if (captureMap['import'] && captureMap['import.source']) { - const rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, ''); + const rawImportPath = language === SupportedLanguages.Kotlin + ? appendKotlinWildcard(captureMap['import.source'].text.replace(/['"<>]/g, ''), captureMap['import']) + : captureMap['import.source'].text.replace(/['"<>]/g, ''); result.imports.push({ filePath: file.path, rawImportPath, @@ -761,7 +815,7 @@ const processFileGroup = ( } const frameworkHint = definitionNode - ? detectFrameworkFromAST(language, definitionNode.text || '') + ? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300)) : null; result.nodes.push({ diff --git a/gitnexus/src/core/tree-sitter/parser-loader.ts b/gitnexus/src/core/tree-sitter/parser-loader.ts index 706bb0aa1..3cc9fc025 100644 --- a/gitnexus/src/core/tree-sitter/parser-loader.ts +++ b/gitnexus/src/core/tree-sitter/parser-loader.ts @@ -8,6 +8,7 @@ import CPP from 'tree-sitter-cpp'; import CSharp from 'tree-sitter-c-sharp'; import Go from 'tree-sitter-go'; import Rust from 'tree-sitter-rust'; +import Kotlin from 'tree-sitter-kotlin'; import PHP from 'tree-sitter-php'; import { createRequire } from 'node:module'; import { SupportedLanguages } from '../../config/supported-languages.js'; @@ -30,6 +31,7 @@ const languageMap: Record = { [SupportedLanguages.CSharp]: CSharp, [SupportedLanguages.Go]: Go, [SupportedLanguages.Rust]: Rust, + [SupportedLanguages.Kotlin]: Kotlin, [SupportedLanguages.PHP]: PHP.php_only, ...(Swift ? { [SupportedLanguages.Swift]: Swift } : {}), };