feat: add full Kotlin language support

This commit is contained in:
Gary Magyar 2026-02-28 10:06:52 +00:00
parent 410ec28ff3
commit 0757ef69e2
6 changed files with 206 additions and 97 deletions

View file

@ -339,12 +339,22 @@ const BUILT_IN_NAMES = new Set([
'mutex_lock', 'mutex_unlock', 'mutex_init',
'kfree', 'kmalloc', 'kzalloc', 'kcalloc', 'krealloc', 'kvmalloc', 'kvfree',
'get', 'put',
// Kotlin stdlib
// 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',
]);
const isBuiltInOrNoise = (name: string): boolean => BUILT_IN_NAMES.has(name);

View file

@ -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
@ -332,6 +375,12 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConf
{ framework: 'spring', entryPointMultiplier: 3.2, reason: 'spring-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
],
kotlin: [
{ framework: 'spring-kotlin', entryPointMultiplier: 3.2, reason: 'spring-kotlin-annotation', patterns: FRAMEWORK_AST_PATTERNS.spring },
{ framework: 'jaxrs', entryPointMultiplier: 3.0, reason: 'jaxrs-annotation', patterns: FRAMEWORK_AST_PATTERNS.jaxrs },
{ framework: 'ktor', entryPointMultiplier: 2.8, reason: 'ktor-routing', patterns: ['routing', 'embeddedServer', 'Application.module'] },
{ framework: 'android-kotlin', entryPointMultiplier: 2.5, reason: 'android-annotation', patterns: ['@AndroidEntryPoint', 'AppCompatActivity', 'Fragment('] },
],
csharp: [
{ framework: 'aspnet', entryPointMultiplier: 3.2, reason: 'aspnet-attribute', patterns: FRAMEWORK_AST_PATTERNS.aspnet },
],
@ -340,9 +389,19 @@ const AST_FRAMEWORK_PATTERNS_BY_LANGUAGE: Record<string, AstFrameworkPatternConf
],
};
/** Pre-lowercased patterns for O(1) pattern matching at runtime */
const AST_PATTERNS_LOWERED: Record<string, Array<{ framework: string; entryPointMultiplier: number; reason: string; patterns: string[] }>> =
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,
@ -350,14 +409,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,

View file

@ -495,6 +495,19 @@ function tryRustModulePath(modulePath: string, allFiles: Set<string>): 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;
};
// ============================================================================
// JVM MULTI-FILE RESOLUTION (Java + Kotlin)
// ============================================================================
@ -787,20 +800,9 @@ export const processImports = async (
}
// Clean path (remove quotes and angle brackets for C/C++ includes)
let rawImportPath = sourceNode.text.replace(/['"<>]/g, '');
// Kotlin wildcard imports: wildcard_import is a separate AST node
// sibling to identifier, so check the import_header for it and append .*
if (language === SupportedLanguages.Kotlin) {
const importNode = captureMap['import'];
for (let ci = 0; ci < importNode.childCount; ci++) {
if (importNode.child(ci)?.type === 'wildcard_import') {
if (!rawImportPath.endsWith('.*')) {
rawImportPath += '.*';
}
break;
}
}
}
const rawImportPath = language === SupportedLanguages.Kotlin
? appendKotlinWildcard(sourceNode.text.replace(/['"<>]/g, ''), captureMap['import'])
: sourceNode.text.replace(/['"<>]/g, '');
totalImportsFound++;
// ---- JVM languages (Java + Kotlin): handle wildcards and member imports ----
@ -809,6 +811,14 @@ export const processImports = async (
if (rawImportPath.endsWith('.*')) {
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);
}
@ -816,7 +826,11 @@ export const processImports = async (
}
// Try member/static import resolution (strip member name)
const memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
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;
@ -971,13 +985,25 @@ export const processImportsFromExtracted = async (
if (rawImportPath.endsWith('.*')) {
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 memberResolved = resolveJvmMemberImport(rawImportPath, normalizedFileList, allFileList, exts, index);
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);

View file

@ -18,33 +18,33 @@ export interface WorkerExtractedData {
heritage: ExtractedHeritage[];
}
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): 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<string, any>): any | null => {
for (const key of DEFINITION_CAPTURE_KEYS) {
if (captureMap[key]) return captureMap[key];
}
return null;
@ -334,16 +334,15 @@ const processParsingSequential = async (
const nodeId = generateId(nodeLabel, `${file.path}:${nodeName}`);
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 definitionNode = getDefinitionNodeFromCaptures(captureMap);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, definitionNode.text || '')
: null;
return {
properties: {
name: nodeName,
filePath: file.path,
startLine: nameNode.startPosition.row,
@ -354,8 +353,7 @@ const processParsingSequential = async (
astFrameworkMultiplier: frameworkHint.entryPointMultiplier,
astFrameworkReason: frameworkHint.reason,
} : {}),
};
})()
},
};
graph.addNode(node);

View file

@ -432,7 +432,7 @@ export const KOTLIN_QUERIES = `
; ── Enum entries ─────────────────────────────────────────────────────────
(enum_entry
(simple_identifier) @name) @definition.property
(simple_identifier) @name) @definition.enum
; ── Type aliases ─────────────────────────────────────────────────────────
(type_alias
@ -457,6 +457,10 @@ export const KOTLIN_QUERIES = `
(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

View file

@ -360,12 +360,22 @@ const BUILT_INS = new Set([
'preg_match', 'preg_match_all', 'preg_replace', 'preg_split',
'header', 'session_start', 'session_destroy', 'ob_start', 'ob_end_clean', 'ob_get_clean',
'dd', 'dump',
// Kotlin stdlib
// 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',
]);
// ============================================================================
@ -402,38 +412,51 @@ const getLabelFromCaptures = (captureMap: Record<string, any>): string | null =>
return 'CodeElement';
};
const getDefinitionNodeFromCaptures = (captureMap: Record<string, any>): 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<string, any>): 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
// ============================================================================
@ -657,20 +680,9 @@ const processFileGroup = (
// Extract import paths before skipping
if (captureMap['import'] && captureMap['import.source']) {
let rawImportPath = captureMap['import.source'].text.replace(/['"<>]/g, '');
// Kotlin wildcard imports: wildcard_import is a separate AST node
// sibling to identifier, so check the import_header for it and append .*
if (language === SupportedLanguages.Kotlin) {
const importNode = captureMap['import'];
for (let i = 0; i < importNode.childCount; i++) {
if (importNode.child(i)?.type === 'wildcard_import') {
if (!rawImportPath.endsWith('.*')) {
rawImportPath += '.*';
}
break;
}
}
}
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,
@ -743,7 +755,7 @@ const processFileGroup = (
const definitionNode = getDefinitionNodeFromCaptures(captureMap);
const frameworkHint = definitionNode
? detectFrameworkFromAST(language, definitionNode.text || '')
? detectFrameworkFromAST(language, (definitionNode.text || '').slice(0, 300))
: null;
result.nodes.push({