From 1b069cb95eb38a7a277d7023ca999ef754a5c007 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 19 Mar 2026 22:07:34 +0530 Subject: [PATCH 01/29] fix: add ability to re-write class name references in desugar plugin Signed-off-by: Akash Yadav --- .../ClassRefReplacingMethodVisitor.kt | 131 +++++++ .../desugaring/DesugarClassVisitor.kt | 119 ++++-- .../desugaring/DesugarClassVisitorFactory.kt | 109 +++--- .../androidide/desugaring/DesugarParams.kt | 78 ++-- .../dsl/DesugarReplacementsContainer.kt | 226 +++++------ .../androidide/desugaring/dsl/MethodOpcode.kt | 62 +-- .../desugaring/dsl/ReplaceClassRef.kt | 31 ++ .../desugaring/dsl/ReplaceMethodInsn.kt | 363 +++++++++--------- .../desugaring/dsl/ReplaceMethodInsnKey.kt | 10 +- 9 files changed, 670 insertions(+), 459 deletions(-) create mode 100644 composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/ClassRefReplacingMethodVisitor.kt create mode 100644 composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceClassRef.kt diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/ClassRefReplacingMethodVisitor.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/ClassRefReplacingMethodVisitor.kt new file mode 100644 index 0000000000..6eab4658bb --- /dev/null +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/ClassRefReplacingMethodVisitor.kt @@ -0,0 +1,131 @@ +package com.itsaky.androidide.desugaring + +import org.objectweb.asm.Label +import org.objectweb.asm.MethodVisitor +import org.objectweb.asm.Type + +/** + * Replaces all bytecode references to one or more classes within a method body. + * + * Covered visit sites: + * - [visitMethodInsn] — owner and embedded descriptor + * - [visitFieldInsn] — owner and field descriptor + * - [visitTypeInsn] — NEW / CHECKCAST / INSTANCEOF / ANEWARRAY operand + * - [visitLdcInsn] — class-literal Type constants + * - [visitLocalVariable] — local variable descriptor and generic signature + * - [visitMultiANewArrayInsn]— array descriptor + * - [visitTryCatchBlock] — caught exception type + * + * @param classReplacements Mapping from source internal name (slash-notation) + * to target internal name (slash-notation). An empty map is a no-op. + * + * @author Akash Yadav + */ +class ClassRefReplacingMethodVisitor( + api: Int, + mv: MethodVisitor?, + private val classReplacements: Map, +) : MethodVisitor(api, mv) { + + override fun visitMethodInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + isInterface: Boolean, + ) { + super.visitMethodInsn( + opcode, + replace(owner), + name, + replaceInDescriptor(descriptor), + isInterface, + ) + } + + override fun visitFieldInsn( + opcode: Int, + owner: String, + name: String, + descriptor: String, + ) { + super.visitFieldInsn( + opcode, + replace(owner), + name, + replaceInDescriptor(descriptor), + ) + } + + override fun visitTypeInsn(opcode: Int, type: String) { + super.visitTypeInsn(opcode, replace(type)) + } + + override fun visitLdcInsn(value: Any?) { + // Replace class-literal constants: Foo.class → Bar.class + if (value is Type && value.sort == Type.OBJECT) { + val replaced = replace(value.internalName) + if (replaced !== value.internalName) { + super.visitLdcInsn(Type.getObjectType(replaced)) + return + } + } + super.visitLdcInsn(value) + } + + override fun visitLocalVariable( + name: String, + descriptor: String, + signature: String?, + start: Label, + end: Label, + index: Int, + ) { + super.visitLocalVariable( + name, + replaceInDescriptor(descriptor), + replaceInSignature(signature), + start, + end, + index, + ) + } + + override fun visitMultiANewArrayInsn(descriptor: String, numDimensions: Int) { + super.visitMultiANewArrayInsn(replaceInDescriptor(descriptor), numDimensions) + } + + override fun visitTryCatchBlock( + start: Label, + end: Label, + handler: Label, + type: String?, + ) { + super.visitTryCatchBlock(start, end, handler, type?.let { replace(it) }) + } + + // ------------------------------------------------------------------------- + // Helpers + // ------------------------------------------------------------------------- + + /** Replaces a bare internal class name (slash-notation). */ + private fun replace(internalName: String): String = + classReplacements[internalName] ?: internalName + + /** + * Substitutes every `L;` token in a JVM descriptor or generic + * signature with `L;`. + */ + private fun replaceInDescriptor(descriptor: String): String { + if (classReplacements.isEmpty()) return descriptor + var result = descriptor + for ((from, to) in classReplacements) { + result = result.replace("L$from;", "L$to;") + } + return result + } + + /** Delegates to [replaceInDescriptor]; returns `null` for `null` input. */ + private fun replaceInSignature(signature: String?): String? = + signature?.let { replaceInDescriptor(it) } +} \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitor.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitor.kt index 0a0e7b10f2..977aa8e7bc 100644 --- a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitor.kt +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitor.kt @@ -1,41 +1,106 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - package com.itsaky.androidide.desugaring import com.android.build.api.instrumentation.ClassContext import org.objectweb.asm.ClassVisitor +import org.objectweb.asm.FieldVisitor import org.objectweb.asm.MethodVisitor /** * [ClassVisitor] implementation for desugaring. * + * Applies two transformations to every method body, in priority order: + * + * 1. **[DesugarMethodVisitor]** (outermost / highest priority) — fine-grained + * per-method-call replacement defined via [DesugarReplacementsContainer.replaceMethod]. + * Its output flows into the next layer. + * + * 2. **[ClassRefReplacingMethodVisitor]** (innermost) — bulk class-reference + * replacement defined via [DesugarReplacementsContainer.replaceClass]. + * Handles every site where a class name can appear in a method body. + * + * Class references that appear in field and method *declarations* (descriptors + * and generic signatures at the class-structure level) are also rewritten here. + * * @author Akash Yadav */ -class DesugarClassVisitor(private val params: DesugarParams, - private val classContext: ClassContext, api: Int, - classVisitor: ClassVisitor +class DesugarClassVisitor( + private val params: DesugarParams, + private val classContext: ClassContext, + api: Int, + classVisitor: ClassVisitor, ) : ClassVisitor(api, classVisitor) { - override fun visitMethod(access: Int, name: String?, descriptor: String?, - signature: String?, exceptions: Array? - ): MethodVisitor { - return DesugarMethodVisitor(params, classContext, api, - super.visitMethod(access, name, descriptor, signature, exceptions)) - } -} + /** + * Class replacement map in ASM internal (slash) notation. + * Derived lazily from the dot-notation map stored in [params]. + */ + private val slashClassReplacements: Map by lazy { + params.classReplacements.get() + .entries.associate { (from, to) -> + from.replace('.', '/') to to.replace('.', '/') + } + } + + // ------------------------------------------------------------------------- + // Class-structure level: rewrite descriptors in field / method declarations + // ------------------------------------------------------------------------- + + override fun visitField( + access: Int, + name: String, + descriptor: String, + signature: String?, + value: Any?, + ): FieldVisitor? = super.visitField( + access, + name, + replaceInDescriptor(descriptor), + replaceInSignature(signature), + value, + ) + + override fun visitMethod( + access: Int, + name: String?, + descriptor: String?, + signature: String?, + exceptions: Array?, + ): MethodVisitor { + // Rewrite the method's own descriptor/signature at the class-structure level. + val base = super.visitMethod( + access, + name, + descriptor?.let { replaceInDescriptor(it) }, + replaceInSignature(signature), + exceptions, + ) + + // Layer 1 — class-reference replacement inside the method body. + // Skip instantiation entirely when there are no class replacements. + val withClassRefs: MethodVisitor = when { + slashClassReplacements.isNotEmpty() -> + ClassRefReplacingMethodVisitor(api, base, slashClassReplacements) + else -> base + } + + // Layer 2 — fine-grained method-call replacement. + // Runs first; any instruction it emits flows through withClassRefs. + return DesugarMethodVisitor(params, classContext, api, withClassRefs) + } + + // ------------------------------------------------------------------------- + // Descriptor / signature helpers + // ------------------------------------------------------------------------- + + private fun replaceInDescriptor(descriptor: String): String { + if (slashClassReplacements.isEmpty()) return descriptor + var result = descriptor + for ((from, to) in slashClassReplacements) { + result = result.replace("L$from;", "L$to;") + } + return result + } + private fun replaceInSignature(signature: String?): String? = + signature?.let { replaceInDescriptor(it) } +} \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitorFactory.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitorFactory.kt index 069a5ca142..98f6bde68d 100644 --- a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitorFactory.kt +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarClassVisitorFactory.kt @@ -1,20 +1,3 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - package com.itsaky.androidide.desugaring import com.android.build.api.instrumentation.AsmClassVisitorFactory @@ -28,51 +11,49 @@ import org.slf4j.LoggerFactory * * @author Akash Yadav */ -abstract class DesugarClassVisitorFactory : - AsmClassVisitorFactory { - - companion object { - - private val log = - LoggerFactory.getLogger(DesugarClassVisitorFactory::class.java) - } - - override fun createClassVisitor(classContext: ClassContext, - nextClassVisitor: ClassVisitor - ): ClassVisitor { - val params = parameters.orNull - if (params == null) { - log.warn("Could not find desugaring parameters. Disabling desugaring.") - return nextClassVisitor - } - - return DesugarClassVisitor(params, classContext, - instrumentationContext.apiVersion.get(), nextClassVisitor) - } - - override fun isInstrumentable(classData: ClassData): Boolean { - val params = parameters.orNull - if (params == null) { - log.warn("Could not find desugaring parameters. Disabling desugaring.") - return false - } - - val isEnabled = params.enabled.get().also { isEnabled -> - log.debug("Is desugaring enabled: $isEnabled") - } - - if (!isEnabled) { - return false - } - - val includedPackages = params.includedPackages.get() - if (includedPackages.isNotEmpty()) { - val className = classData.className - if (!includedPackages.any { className.startsWith(it) }) { - return false - } - } - - return true - } +abstract class DesugarClassVisitorFactory : AsmClassVisitorFactory { + + companion object { + private val log = + LoggerFactory.getLogger(DesugarClassVisitorFactory::class.java) + } + + private val desugarParams: DesugarParams? + get() = parameters.orNull ?: run { + log.warn("Could not find desugaring parameters. Disabling desugaring.") + null + } + + override fun createClassVisitor( + classContext: ClassContext, + nextClassVisitor: ClassVisitor, + ): ClassVisitor { + val params = desugarParams ?: return nextClassVisitor + return DesugarClassVisitor( + params = params, + classContext = classContext, + api = instrumentationContext.apiVersion.get(), + classVisitor = nextClassVisitor, + ) + } + + override fun isInstrumentable(classData: ClassData): Boolean { + val params = desugarParams ?: return false + + val isEnabled = params.enabled.get().also { log.debug("Is desugaring enabled: $it") } + if (!isEnabled) return false + + // Class-reference replacement must scan every class — any class may + // contain a reference to the one being replaced, regardless of package. + if (params.classReplacements.get().isNotEmpty()) return true + + val includedPackages = params.includedPackages.get() + if (includedPackages.isNotEmpty()) { + if (!includedPackages.any { classData.className.startsWith(it) }) { + return false + } + } + + return true + } } \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarParams.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarParams.kt index 1e5905b45c..315288e458 100644 --- a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarParams.kt +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/DesugarParams.kt @@ -1,20 +1,3 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - package com.itsaky.androidide.desugaring import com.android.build.api.instrumentation.InstrumentationParameters @@ -32,33 +15,36 @@ import org.gradle.api.tasks.Input */ interface DesugarParams : InstrumentationParameters { - /** - * Whether the desugaring is enabled. - */ - @get:Input - val enabled: Property - - /** - * The replacement instructions. - */ - @get:Input - val replacements: MapProperty - - @get:Input - val includedPackages: SetProperty - - companion object { - - /** - * Sets [DesugarParams] properties from [DesugarExtension]. - */ - fun DesugarParams.setFrom(extension: DesugarExtension) { - replacements.convention(emptyMap()) - includedPackages.convention(emptySet()) - - enabled.set(extension.enabled) - replacements.set(extension.replacements.instructions) - includedPackages.set(extension.replacements.includePackages) - } - } + /** Whether desugaring is enabled. */ + @get:Input + val enabled: Property + + /** Fine-grained method-call replacement instructions. */ + @get:Input + val replacements: MapProperty + + /** Packages to scan for method-level replacements (empty = all packages). */ + @get:Input + val includedPackages: SetProperty + + /** + * Class-level replacement map: dot-notation source class → dot-notation + * target class. Any class may be instrumented when this is non-empty. + */ + @get:Input + val classReplacements: MapProperty + + companion object { + + fun DesugarParams.setFrom(extension: DesugarExtension) { + replacements.convention(emptyMap()) + includedPackages.convention(emptySet()) + classReplacements.convention(emptyMap()) + + enabled.set(extension.enabled) + replacements.set(extension.replacements.instructions) + includedPackages.set(extension.replacements.includePackages) + classReplacements.set(extension.replacements.classReplacements) + } + } } \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/DesugarReplacementsContainer.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/DesugarReplacementsContainer.kt index 057fcc1cb9..1ad5f89f32 100644 --- a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/DesugarReplacementsContainer.kt +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/DesugarReplacementsContainer.kt @@ -1,20 +1,3 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - package com.itsaky.androidide.desugaring.dsl import com.itsaky.androidide.desugaring.internal.parsing.InsnLexer @@ -30,101 +13,126 @@ import javax.inject.Inject /** * Defines replacements for desugaring. * + * Two replacement strategies are supported and can be combined freely: + * + * - **Method-level** ([replaceMethod]): replaces a specific method call with + * another, with full control over opcodes and descriptors. + * - **Class-level** ([replaceClass]): rewrites every bytecode reference to a + * given class (owners, descriptors, type instructions, LDC constants, etc.) + * with a replacement class. This is a broader, structural operation. + * + * When both apply to the same instruction, method-level replacement wins + * because it runs first in the visitor chain. + * * @author Akash Yadav */ abstract class DesugarReplacementsContainer @Inject constructor( - private val objects: ObjectFactory + private val objects: ObjectFactory, ) { - internal val includePackages = TreeSet() - - internal val instructions = - mutableMapOf() - - companion object { - - private val PACKAGE_NAME_REGEX = - Regex("""^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*${'$'}""") - } - - /** - * Adds the given packages to the list of packages that will be scanned for - * the desugaring process. By default, the list of packages is empty. An empty - * list will include all packages. - */ - fun includePackage(vararg packages: String) { - for (pck in packages) { - if (!PACKAGE_NAME_REGEX.matches(pck)) { - throw IllegalArgumentException("Invalid package name: $pck") - } - - includePackages.add(pck) - } - } - - /** - * Removes the given packages from the list of included packages. - */ - fun removePackage(vararg packages: String) { - includePackages.removeAll(packages.toSet()) - } - - /** - * Adds an instruction to replace the given method. - */ - fun replaceMethod(configure: Action) { - val instruction = objects.newInstance(ReplaceMethodInsn::class.java) - configure.execute(instruction) - addReplaceInsns(instruction) - } - - /** - * Replace usage of [sourceMethod] with the [targetMethod]. - */ - @JvmOverloads - fun replaceMethod( - sourceMethod: Method, - targetMethod: Method, - configure: Action = Action {} - ) { - val instruction = ReplaceMethodInsn.forMethods(sourceMethod, targetMethod).build() - configure.execute(instruction) - if (instruction.requireOpcode == MethodOpcode.INVOKEVIRTUAL - && instruction.toOpcode == MethodOpcode.INVOKESTATIC - ) { - ReflectionUtils.validateVirtualToStaticReplacement(sourceMethod, targetMethod) - } - addReplaceInsns(instruction) - } - - /** - * Load instructions from the given file. - */ - fun loadFromFile(file: File) { - val lexer = InsnLexer(file.readText()) - val parser = InsnParser(lexer) - val insns = parser.parse() - addReplaceInsns(insns) - } - - private fun addReplaceInsns(vararg insns: ReplaceMethodInsn - ) { - addReplaceInsns(insns.asIterable()) - } - - private fun addReplaceInsns(insns: Iterable - ) { - for (insn in insns) { - val className = insn.fromClass.replace('/', '.') - val methodName = insn.methodName - val methodDescriptor = insn.methodDescriptor - - insn.requireOpcode ?: run { - insn.requireOpcode = MethodOpcode.ANY - } - - val key = ReplaceMethodInsnKey(className, methodName, methodDescriptor) - this.instructions[key] = insn - } - } + internal val includePackages = TreeSet() + + internal val instructions = + mutableMapOf() + + /** Class-level replacements: dot-notation source → dot-notation target. */ + internal val classReplacements = mutableMapOf() + + companion object { + private val PACKAGE_NAME_REGEX = + Regex("""^[a-z][a-z0-9_]*(\.[a-z][a-z0-9_]*)*${'$'}""") + } + + fun includePackage(vararg packages: String) { + for (pck in packages) { + if (!PACKAGE_NAME_REGEX.matches(pck)) { + throw IllegalArgumentException("Invalid package name: $pck") + } + includePackages.add(pck) + } + } + + fun removePackage(vararg packages: String) { + includePackages.removeAll(packages.toSet()) + } + + fun replaceMethod(configure: Action) { + val instruction = objects.newInstance(ReplaceMethodInsn::class.java) + configure.execute(instruction) + addReplaceInsns(instruction) + } + + @JvmOverloads + fun replaceMethod( + sourceMethod: Method, + targetMethod: Method, + configure: Action = Action {}, + ) { + val instruction = ReplaceMethodInsn.forMethods(sourceMethod, targetMethod).build() + configure.execute(instruction) + if (instruction.requireOpcode == MethodOpcode.INVOKEVIRTUAL + && instruction.toOpcode == MethodOpcode.INVOKESTATIC + ) { + ReflectionUtils.validateVirtualToStaticReplacement(sourceMethod, targetMethod) + } + addReplaceInsns(instruction) + } + + /** + * Replaces every bytecode reference to [fromClass] with [toClass]. + * + * This rewrites: + * - Instruction owners (`INVOKEVIRTUAL`, `GETFIELD`, `NEW`, `CHECKCAST`, …) + * - Type descriptors and generic signatures in method bodies + * - Class-literal LDC constants (`Foo.class`) + * - Field and method *declaration* descriptors in the instrumented class + * + * Class names can be provided in dot-notation (`com.example.Foo`) or + * slash-notation (`com/example/Foo`). + * + * Note: unlike [replaceMethod], class-level replacement is applied to + * **all** instrumented classes regardless of [includePackage] filters, + * because any class may contain a reference to the replaced one. + */ + fun replaceClass(fromClass: String, toClass: String) { + require(fromClass.isNotBlank()) { "fromClass must not be blank." } + require(toClass.isNotBlank()) { "toClass must not be blank." } + val from = fromClass.replace('/', '.') + val to = toClass.replace('/', '.') + classReplacements[from] = to + } + + /** + * Replaces every bytecode reference to [fromClass] with [toClass]. + * + * @throws UnsupportedOperationException for array or primitive types. + */ + fun replaceClass(fromClass: Class<*>, toClass: Class<*>) { + require(!fromClass.isArray && !fromClass.isPrimitive) { + "Array and primitive types are not supported for class replacement." + } + require(!toClass.isArray && !toClass.isPrimitive) { + "Array and primitive types are not supported for class replacement." + } + replaceClass(fromClass.name, toClass.name) + } + + fun loadFromFile(file: File) { + val lexer = InsnLexer(file.readText()) + val parser = InsnParser(lexer) + val insns = parser.parse() + addReplaceInsns(insns) + } + + private fun addReplaceInsns(vararg insns: ReplaceMethodInsn) = + addReplaceInsns(insns.asIterable()) + + private fun addReplaceInsns(insns: Iterable) { + for (insn in insns) { + val className = insn.fromClass.replace('/', '.') + insn.requireOpcode = insn.requireOpcode ?: MethodOpcode.ANY + val key = ReplaceMethodInsnKey(className, insn.methodName, insn.methodDescriptor) + instructions[key] = insn + } + } } \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/MethodOpcode.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/MethodOpcode.kt index 8317865c2d..bed859bc6d 100644 --- a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/MethodOpcode.kt +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/MethodOpcode.kt @@ -24,42 +24,44 @@ import org.objectweb.asm.Opcodes * * @author Akash Yadav */ -enum class MethodOpcode(val insnName: String, val opcode: Int +enum class MethodOpcode( + val insnName: String, + val opcode: Int, ) { - /** - * The opcode for `invokestatic`. - */ - INVOKESTATIC("invoke-static", Opcodes.INVOKESTATIC), + /** + * The opcode for `invokestatic`. + */ + INVOKESTATIC("invoke-static", Opcodes.INVOKESTATIC), - /** - * The opcode for `invokespecial`. - */ - INVOKESPECIAL("invoke-special", Opcodes.INVOKESPECIAL), + /** + * The opcode for `invokespecial`. + */ + INVOKESPECIAL("invoke-special", Opcodes.INVOKESPECIAL), - /** - * The opcode for `invokevirtual`. - */ - INVOKEVIRTUAL("invoke-virtual", Opcodes.INVOKEVIRTUAL), + /** + * The opcode for `invokevirtual`. + */ + INVOKEVIRTUAL("invoke-virtual", Opcodes.INVOKEVIRTUAL), - /** - * The opcode for `invokeinterface`. - */ - INVOKEINTERFACE("invoke-interface", Opcodes.INVOKEINTERFACE), + /** + * The opcode for `invokeinterface`. + */ + INVOKEINTERFACE("invoke-interface", Opcodes.INVOKEINTERFACE), - /** - * Any opcode. This is for internal use only. - */ - ANY("invoke-any", 0); + /** + * Any opcode. This is for internal use only. + */ + ANY("invoke-any", 0); - companion object { + companion object { - /** - * Finds the [MethodOpcode] with the given instruction name. - */ - @JvmStatic - fun find(insn: String): MethodOpcode? { - return MethodOpcode.values().find { it.insnName == insn } - } - } + /** + * Finds the [MethodOpcode] with the given instruction name. + */ + @JvmStatic + fun find(insn: String): MethodOpcode? { + return MethodOpcode.values().find { it.insnName == insn } + } + } } \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceClassRef.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceClassRef.kt new file mode 100644 index 0000000000..224ab00ebe --- /dev/null +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceClassRef.kt @@ -0,0 +1,31 @@ +package com.itsaky.androidide.desugaring.dsl + +import java.io.Serializable + +/** + * Describes a full class-reference replacement: every bytecode reference to + * [fromClass] in any instrumented class will be rewritten to [toClass]. + * + * Class names may be given in dot-notation (`com.example.Foo`) or + * slash-notation (`com/example/Foo`); both are normalised internally. + * + * @author Akash Yadav + */ +data class ReplaceClassRef( + /** The class whose references should be replaced (dot-notation). */ + val fromClass: String, + /** The class that should replace all [fromClass] references (dot-notation). */ + val toClass: String, +) : Serializable { + + companion object { + @JvmField + val serialVersionUID = 1L + } + + /** ASM internal name (slash-notation) for [fromClass]. */ + val fromInternal: String get() = fromClass.replace('.', '/') + + /** ASM internal name (slash-notation) for [toClass]. */ + val toInternal: String get() = toClass.replace('.', '/') +} \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsn.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsn.kt index 09c113f283..da5e59255c 100644 --- a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsn.kt +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsn.kt @@ -29,182 +29,189 @@ import java.lang.reflect.Modifier */ interface ReplaceMethodInsn { - /** - * The owner class name for the method to be replaced. The class name must be - * in the form of a fully qualified name or in the binary name format. - */ - var fromClass: String - - /** - * The name of the method to be replaced. - */ - var methodName: String - - /** - * The descriptor of the method to be replaced. This is the method signature - * as it appears in the bytecode. - */ - var methodDescriptor: String - - /** - * The opcode for the method to be replaced. If this is specified, then the - * opcode for the invoked method will be checked against this and the invocation - * will only be replaced of the opcode matches. - * - * This is optional. By default, the invocation will always be replaced. - */ - var requireOpcode: MethodOpcode? - - /** - * The owner class name for the method which will replace the [methodName]. - * The class name must be in the form of a fully qualified name or in the - * binary name format. - */ - var toClass: String - - /** - * The name of the method in [toClass] which will replace the [methodName]. - */ - var toMethod: String - - /** - * The descriptor of the method in [toClass] which will replace the [methodName]. - */ - var toMethodDescriptor: String - - /** - * The opcode for invoking [toMethod] in [toClass]. - */ - var toOpcode: MethodOpcode - - class Builder { - - @JvmField - var fromClass: String = "" - - @JvmField - var methodName: String = "" - - @JvmField - var methodDescriptor: String = "" - - @JvmField - var requireOpcode: MethodOpcode? = null - - @JvmField - var toClass: String = "" - - @JvmField - var toMethod: String = "" - - @JvmField - var toMethodDescriptor: String = "" - - @JvmField - var toOpcode: MethodOpcode = MethodOpcode.ANY - - fun fromMethod(method: Method) = apply { - fromClass(method.declaringClass) - methodName(method.name) - methodDescriptor(ReflectionUtils.describe(method)) - - if (Modifier.isStatic(method.modifiers)) { - requireOpcode(MethodOpcode.INVOKESTATIC) - } else { - requireOpcode(MethodOpcode.INVOKEVIRTUAL) - } - } - - fun fromClass(fromClass: String) = apply { - this.fromClass = fromClass - } - - fun fromClass(klass: Class<*>): Builder { - if (klass.isArray || klass.isPrimitive) { - throw UnsupportedOperationException( - "Array and primitive types are not supported for desugaring") - } - - return fromClass(klass.name) - } - - fun methodName(methodName: String) = apply { - this.methodName = methodName - } - - fun methodDescriptor(methodDescriptor: String) = apply { - this.methodDescriptor = methodDescriptor - } - - fun requireOpcode(requireOpcode: MethodOpcode) = apply { - this.requireOpcode = requireOpcode - } - - fun toClass(toClass: String) = apply { - this.toClass = toClass - } - - fun toClass(klass: Class<*>): Builder { - if (klass.isArray || klass.isPrimitive) { - throw UnsupportedOperationException( - "Array and primitive types are not supported for desugaring") - } - - return toClass(klass.name) - } - - fun toMethod(toMethod: String) = apply { - this.toMethod = toMethod - } - - fun toMethodDescriptor(toMethodDescriptor: String) = apply { - this.toMethodDescriptor = toMethodDescriptor - } - - fun toMethod(method: Method) = apply { - toClass(method.declaringClass) - toMethod(method.name) - toMethodDescriptor(ReflectionUtils.describe(method)) - - if (Modifier.isStatic(method.modifiers)) { - toOpcode(MethodOpcode.INVOKESTATIC) - } else { - toOpcode(MethodOpcode.INVOKEVIRTUAL) - } - } - - fun toOpcode(toOpcode: MethodOpcode) = apply { - this.toOpcode = toOpcode - } - - fun build(): DefaultReplaceMethodInsn { - require(fromClass.isNotBlank()) { "fromClass cannot be blank." } - require(methodName.isNotBlank()) { "methodName cannot be blank." } - require( - methodDescriptor.isNotBlank()) { "methodDescriptor cannot be blank." } - require(toClass.isNotBlank()) { "toClass cannot be blank." } - require(toMethod.isNotBlank()) { "toMethod cannot be blank." } - require( - toMethodDescriptor.isNotBlank()) { "toMethodDescriptor cannot be blank." } - require(toOpcode != MethodOpcode.ANY) { "toOpcode cannot be ANY." } - - return DefaultReplaceMethodInsn(fromClass, methodName, methodDescriptor, - requireOpcode, toClass, toMethod, toMethodDescriptor, toOpcode) - } - } - - companion object { - - @JvmStatic - fun builder(): Builder = Builder() - - /** - * Creates a [Builder] for the given source and target method. - */ - @JvmStatic - fun forMethods(fromMethod: Method, toMethod: Method - ): Builder { - return builder().fromMethod(fromMethod).toMethod(toMethod) - } - } + /** + * The owner class name for the method to be replaced. The class name must be + * in the form of a fully qualified name or in the binary name format. + */ + var fromClass: String + + /** + * The name of the method to be replaced. + */ + var methodName: String + + /** + * The descriptor of the method to be replaced. This is the method signature + * as it appears in the bytecode. + */ + var methodDescriptor: String + + /** + * The opcode for the method to be replaced. If this is specified, then the + * opcode for the invoked method will be checked against this and the invocation + * will only be replaced of the opcode matches. + * + * This is optional. By default, the invocation will always be replaced. + */ + var requireOpcode: MethodOpcode? + + /** + * The owner class name for the method which will replace the [methodName]. + * The class name must be in the form of a fully qualified name or in the + * binary name format. + */ + var toClass: String + + /** + * The name of the method in [toClass] which will replace the [methodName]. + */ + var toMethod: String + + /** + * The descriptor of the method in [toClass] which will replace the [methodName]. + */ + var toMethodDescriptor: String + + /** + * The opcode for invoking [toMethod] in [toClass]. + */ + var toOpcode: MethodOpcode + + class Builder { + + @JvmField + var fromClass: String = "" + + @JvmField + var methodName: String = "" + + @JvmField + var methodDescriptor: String = "" + + @JvmField + var requireOpcode: MethodOpcode? = null + + @JvmField + var toClass: String = "" + + @JvmField + var toMethod: String = "" + + @JvmField + var toMethodDescriptor: String = "" + + @JvmField + var toOpcode: MethodOpcode = MethodOpcode.ANY + + fun fromMethod(method: Method) = apply { + fromClass(method.declaringClass) + methodName(method.name) + methodDescriptor(ReflectionUtils.describe(method)) + + if (Modifier.isStatic(method.modifiers)) { + requireOpcode(MethodOpcode.INVOKESTATIC) + } else { + requireOpcode(MethodOpcode.INVOKEVIRTUAL) + } + } + + fun fromClass(fromClass: String) = apply { + this.fromClass = fromClass + } + + fun fromClass(klass: Class<*>): Builder { + if (klass.isArray || klass.isPrimitive) { + throw UnsupportedOperationException( + "Array and primitive types are not supported for desugaring" + ) + } + + return fromClass(klass.name) + } + + fun methodName(methodName: String) = apply { + this.methodName = methodName + } + + fun methodDescriptor(methodDescriptor: String) = apply { + this.methodDescriptor = methodDescriptor + } + + fun requireOpcode(requireOpcode: MethodOpcode) = apply { + this.requireOpcode = requireOpcode + } + + fun toClass(toClass: String) = apply { + this.toClass = toClass + } + + fun toClass(klass: Class<*>): Builder { + if (klass.isArray || klass.isPrimitive) { + throw UnsupportedOperationException( + "Array and primitive types are not supported for desugaring" + ) + } + + return toClass(klass.name) + } + + fun toMethod(toMethod: String) = apply { + this.toMethod = toMethod + } + + fun toMethodDescriptor(toMethodDescriptor: String) = apply { + this.toMethodDescriptor = toMethodDescriptor + } + + fun toMethod(method: Method) = apply { + toClass(method.declaringClass) + toMethod(method.name) + toMethodDescriptor(ReflectionUtils.describe(method)) + + if (Modifier.isStatic(method.modifiers)) { + toOpcode(MethodOpcode.INVOKESTATIC) + } else { + toOpcode(MethodOpcode.INVOKEVIRTUAL) + } + } + + fun toOpcode(toOpcode: MethodOpcode) = apply { + this.toOpcode = toOpcode + } + + fun build(): DefaultReplaceMethodInsn { + require(fromClass.isNotBlank()) { "fromClass cannot be blank." } + require(methodName.isNotBlank()) { "methodName cannot be blank." } + require( + methodDescriptor.isNotBlank() + ) { "methodDescriptor cannot be blank." } + require(toClass.isNotBlank()) { "toClass cannot be blank." } + require(toMethod.isNotBlank()) { "toMethod cannot be blank." } + require( + toMethodDescriptor.isNotBlank() + ) { "toMethodDescriptor cannot be blank." } + require(toOpcode != MethodOpcode.ANY) { "toOpcode cannot be ANY." } + + return DefaultReplaceMethodInsn( + fromClass, methodName, methodDescriptor, + requireOpcode, toClass, toMethod, toMethodDescriptor, toOpcode + ) + } + } + + companion object { + + @JvmStatic + fun builder(): Builder = Builder() + + /** + * Creates a [Builder] for the given source and target method. + */ + @JvmStatic + fun forMethods( + fromMethod: Method, toMethod: Method + ): Builder { + return builder().fromMethod(fromMethod).toMethod(toMethod) + } + } } \ No newline at end of file diff --git a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsnKey.kt b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsnKey.kt index b3d41fbbc9..b25b487ce9 100644 --- a/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsnKey.kt +++ b/composite-builds/build-logic/desugaring/src/main/java/com/itsaky/androidide/desugaring/dsl/ReplaceMethodInsnKey.kt @@ -25,10 +25,10 @@ import java.io.Serializable * @author Akash Yadav */ data class ReplaceMethodInsnKey( - val className: String, - val methodName: String, - val methodDescriptor: String + val className: String, + val methodName: String, + val methodDescriptor: String ) : Serializable { - @JvmField - val serialVersionUID = 1L + @JvmField + val serialVersionUID = 1L } From a70fd6627a5bd26c3b4c900223fd193a4551f11f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 19 Mar 2026 22:36:15 +0530 Subject: [PATCH 02/29] feat: integrate Kotlin analysis API Signed-off-by: Akash Yadav --- app/build.gradle.kts | 23 ++++++++- lsp/kotlin/build.gradle.kts | 1 + settings.gradle.kts | 51 ++++++++++--------- subprojects/kotlin-analysis-api/.gitignore | 1 + .../kotlin-analysis-api/build.gradle.kts | 27 ++++++++++ .../kotlin-analysis-api/consumer-rules.pro | 0 .../kotlin-analysis-api/proguard-rules.pro | 21 ++++++++ 7 files changed, 97 insertions(+), 27 deletions(-) create mode 100644 subprojects/kotlin-analysis-api/.gitignore create mode 100644 subprojects/kotlin-analysis-api/build.gradle.kts create mode 100644 subprojects/kotlin-analysis-api/consumer-rules.pro create mode 100644 subprojects/kotlin-analysis-api/proguard-rules.pro diff --git a/app/build.gradle.kts b/app/build.gradle.kts index b99b2e1bbc..071151d551 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -153,14 +153,29 @@ android { packaging { resources { - excludes.add("META-INF/DEPENDENCIES") - excludes.add("META-INF/gradle/incremental.annotation.processors") + excludes += "META-INF/DEPENDENCIES" + excludes += "META-INF/gradle/incremental.annotation.processors" + + pickFirsts += "kotlin/internal/internal.kotlin_builtins" + pickFirsts += "kotlin/reflect/reflect.kotlin_builtins" + pickFirsts += "kotlin/kotlin.kotlin_builtins" + pickFirsts += "kotlin/coroutines/coroutines.kotlin_builtins" + pickFirsts += "kotlin/ranges/ranges.kotlin_builtins" + pickFirsts += "kotlin/concurrent/atomics/atomics.kotlin_builtins" + pickFirsts += "kotlin/collections/collections.kotlin_builtins" + pickFirsts += "kotlin/annotation/annotation.kotlin_builtins" + + pickFirsts += "META-INF/FastDoubleParser-LICENSE" + pickFirsts += "META-INF/thirdparty-LICENSE" + pickFirsts += "META-INF/FastDoubleParser-NOTICE" + pickFirsts += "META-INF/thirdparty-NOTICE" } jniLibs { useLegacyPackaging = false } } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -193,6 +208,10 @@ configurations.matching { it.name.contains("AndroidTest") }.configureEach { exclude(group = "com.google.protobuf", module = "protobuf-lite") } +configurations.configureEach { + exclude(group = "org.jetbrains.kotlin", module = "kotlin-android-extensions-runtime") +} + dependencies { debugImplementation(libs.common.leakcanary) diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 9de97e6c31..dbb8e664bc 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -56,6 +56,7 @@ dependencies { implementation(projects.lsp.models) implementation(projects.eventbusEvents) implementation(projects.shared) + implementation(projects.subprojects.kotlinAnalysisApi) implementation(projects.subprojects.projects) implementation(projects.subprojects.projectModels) diff --git a/settings.gradle.kts b/settings.gradle.kts index 247903307d..dfb9c6f997 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -38,25 +38,25 @@ dependencyResolutionManagement { val dependencySubstitutions = mapOf( "build-deps" to - arrayOf( - "appintro", - "fuzzysearch", - "google-java-format", - "java-compiler", - "javac", - "javapoet", - "jaxp", - "jdk-compiler", - "jdk-jdeps", - "jdt", - "layoutlib-api", - "treeview", - ), + arrayOf( + "appintro", + "fuzzysearch", + "google-java-format", + "java-compiler", + "javac", + "javapoet", + "jaxp", + "jdk-compiler", + "jdk-jdeps", + "jdt", + "layoutlib-api", + "treeview", + ), "build-deps-common" to - arrayOf( - "constants", - "desugaring-core", - ), + arrayOf( + "constants", + "desugaring-core", + ), ) for ((build, modules) in dependencySubstitutions) { @@ -123,7 +123,7 @@ include( ":eventbus", ":eventbus-android", ":eventbus-events", - ":git-core", + ":git-core", ":gradle-plugin", ":gradle-plugin-config", ":idetooltips", @@ -155,6 +155,7 @@ include( ":subprojects:flashbar", ":subprojects:framework-stubs", ":subprojects:javac-services", + ":subprojects:kotlin-analysis-api", ":subprojects:libjdwp", ":subprojects:projects", ":subprojects:project-models", @@ -185,12 +186,12 @@ include( ":plugin-api", ":plugin-api:plugin-builder", ":plugin-manager", - ":llama-api", - ":llama-impl", - ":cv-image-to-xml", - ":llama-api", - ":llama-impl", - ":compose-preview" + ":llama-api", + ":llama-impl", + ":cv-image-to-xml", + ":llama-api", + ":llama-impl", + ":compose-preview" ) object FDroidConfig { diff --git a/subprojects/kotlin-analysis-api/.gitignore b/subprojects/kotlin-analysis-api/.gitignore new file mode 100644 index 0000000000..42afabfd2a --- /dev/null +++ b/subprojects/kotlin-analysis-api/.gitignore @@ -0,0 +1 @@ +/build \ No newline at end of file diff --git a/subprojects/kotlin-analysis-api/build.gradle.kts b/subprojects/kotlin-analysis-api/build.gradle.kts new file mode 100644 index 0000000000..4238540dea --- /dev/null +++ b/subprojects/kotlin-analysis-api/build.gradle.kts @@ -0,0 +1,27 @@ +import com.itsaky.androidide.build.config.BuildConfig +import com.itsaky.androidide.plugins.extension.AssetSource + +plugins { + alias(libs.plugins.android.library) + id("com.itsaky.androidide.build.external-assets") +} + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.kt.analysis" +} + +val ktAndroidRepo = "https://github.com/appdevforall/kotlin-android" +val ktAndroidVersion = "2.3.255" +val ktAndroidTag = "v${ktAndroidVersion}-073dc78" +val ktAndroidJarName = "analysis-api-standalone-embeddable-for-ide-${ktAndroidVersion}-SNAPSHOT.jar" + +externalAssets { + jarDependency("kt-android") { + configuration = "api" + source = + AssetSource.External( + url = uri("$ktAndroidRepo/releases/download/$ktAndroidTag/$ktAndroidJarName"), + sha256Checksum = "56918aee41a9a1f6bb4df11cdd3b78ff7bcaadbfb6f939f1dd4a645dbfe03cdd", + ) + } +} diff --git a/subprojects/kotlin-analysis-api/consumer-rules.pro b/subprojects/kotlin-analysis-api/consumer-rules.pro new file mode 100644 index 0000000000..e69de29bb2 diff --git a/subprojects/kotlin-analysis-api/proguard-rules.pro b/subprojects/kotlin-analysis-api/proguard-rules.pro new file mode 100644 index 0000000000..481bb43481 --- /dev/null +++ b/subprojects/kotlin-analysis-api/proguard-rules.pro @@ -0,0 +1,21 @@ +# Add project specific ProGuard rules here. +# You can control the set of applied configuration files using the +# proguardFiles setting in build.gradle. +# +# For more details, see +# http://developer.android.com/guide/developing/tools/proguard.html + +# If your project uses WebView with JS, uncomment the following +# and specify the fully qualified class name to the JavaScript interface +# class: +#-keepclassmembers class fqcn.of.javascript.interface.for.webview { +# public *; +#} + +# Uncomment this to preserve the line number information for +# debugging stack traces. +#-keepattributes SourceFile,LineNumberTable + +# If you keep the line number information, uncomment this to +# hide the original source file name. +#-renamesourcefileattribute SourceFile \ No newline at end of file From 43286a629f99665d26dc8b0bd5d105f1b5a1b005 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 23 Mar 2026 17:12:08 +0530 Subject: [PATCH 03/29] fix: update kotlin-android to latest version fixes duplicate class errors for org.antrl.v4.* classes Signed-off-by: Akash Yadav --- subprojects/kotlin-analysis-api/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/subprojects/kotlin-analysis-api/build.gradle.kts b/subprojects/kotlin-analysis-api/build.gradle.kts index 4238540dea..6e11cc1ce0 100644 --- a/subprojects/kotlin-analysis-api/build.gradle.kts +++ b/subprojects/kotlin-analysis-api/build.gradle.kts @@ -12,7 +12,7 @@ android { val ktAndroidRepo = "https://github.com/appdevforall/kotlin-android" val ktAndroidVersion = "2.3.255" -val ktAndroidTag = "v${ktAndroidVersion}-073dc78" +val ktAndroidTag = "v${ktAndroidVersion}-f1ac8b3" val ktAndroidJarName = "analysis-api-standalone-embeddable-for-ide-${ktAndroidVersion}-SNAPSHOT.jar" externalAssets { @@ -21,7 +21,7 @@ externalAssets { source = AssetSource.External( url = uri("$ktAndroidRepo/releases/download/$ktAndroidTag/$ktAndroidJarName"), - sha256Checksum = "56918aee41a9a1f6bb4df11cdd3b78ff7bcaadbfb6f939f1dd4a645dbfe03cdd", + sha256Checksum = "8c7cad7e0905a861048cce000c3ef22d9ad05572b4f9a0830e0c0e0060ddd3c9", ) } } From f1fe62f36a7c70be3c1bfdd8024706bdc0a3979e Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 25 Mar 2026 15:36:00 +0530 Subject: [PATCH 04/29] fix: remove UnsafeImpl It is now included in the embeddable JAR (named UnsafeAndroid) with proper relocations. Signed-off-by: Akash Yadav --- .../intellij/util/containers/UnsafeImpl.java | 129 ------------------ 1 file changed, 129 deletions(-) delete mode 100644 app/src/main/java/org/jetbrains/kotlin/com/intellij/util/containers/UnsafeImpl.java diff --git a/app/src/main/java/org/jetbrains/kotlin/com/intellij/util/containers/UnsafeImpl.java b/app/src/main/java/org/jetbrains/kotlin/com/intellij/util/containers/UnsafeImpl.java deleted file mode 100644 index b38c4f2439..0000000000 --- a/app/src/main/java/org/jetbrains/kotlin/com/intellij/util/containers/UnsafeImpl.java +++ /dev/null @@ -1,129 +0,0 @@ -package org.jetbrains.kotlin.com.intellij.util.containers; - -import android.util.Log; -import java.lang.reflect.Field; -import java.lang.reflect.Method; -import java.util.Arrays; -import org.jetbrains.annotations.NotNull; -import org.jetbrains.kotlin.com.intellij.util.ReflectionUtil; -import org.lsposed.hiddenapibypass.HiddenApiBypass; - -@SuppressWarnings("ALL") -public class UnsafeImpl { - - private static final Object unsafe; - - private static final Method putObjectVolatile; - private static final Method getObjectVolatile; - private static final Method compareAndSwapObject; - private static final Method compareAndSwapInt; - private static final Method compareAndSwapLong; - private static final Method getAndAddInt; - private static final Method objectFieldOffset; - private static final Method arrayIndexScale; - private static final Method arrayBaseOffset; - // private static final Method copyMemory; - - private static final String TAG = "UnsafeImpl"; - - static { - try { - unsafe = ReflectionUtil.getUnsafe(); - putObjectVolatile = find("putObjectVolatile", Object.class, long.class, Object.class); - getObjectVolatile = find("getObjectVolatile", Object.class, long.class); - compareAndSwapObject = find("compareAndSwapObject", Object.class, long.class, Object.class, Object.class); - compareAndSwapInt = find("compareAndSwapInt", Object.class, long.class, int.class, int.class); - compareAndSwapLong = find("compareAndSwapLong", Object.class, long.class, long.class, long.class); - getAndAddInt = find("getAndAddInt", Object.class, long.class, int.class); - objectFieldOffset = find("objectFieldOffset", Field.class); - arrayBaseOffset = find("arrayBaseOffset", Class.class); - arrayIndexScale = find("arrayIndexScale", Class.class); - // copyMemory = find("copyMemory", Object.class, long.class, Object.class, long.class, long.class); - } catch (Throwable t) { - throw new Error(t); - } - } - - public static int arrayBaseOffset(Class arrayClass) { - try { - return (int) arrayBaseOffset.invoke(unsafe, arrayClass); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static int arrayIndexScale(Class arrayClass) { - try { - return (int) arrayIndexScale.invoke(unsafe, arrayClass); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static boolean compareAndSwapInt(Object object, long offset, int expected, int value) { - try { - return (boolean) compareAndSwapInt.invoke(unsafe, object, offset, expected, value); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static boolean compareAndSwapLong(@NotNull Object object, long offset, long expected, long value) { - try { - return (boolean) compareAndSwapLong.invoke(unsafe, object, offset, expected, value); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static boolean compareAndSwapObject(Object o, long offset, Object expected, Object x) { - try { - return (boolean) compareAndSwapObject.invoke(unsafe, o, offset, expected, x); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static void copyMemory(Object srcBase, long srcOffset, Object destBase, long destOffset, long bytes) { - throw new UnsupportedOperationException("Not supported on Android!"); - } - - public static int getAndAddInt(Object object, long offset, int v) { - try { - return (int) getAndAddInt.invoke(unsafe, object, offset, v); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static Object getObjectVolatile(Object object, long offset) { - try { - return getObjectVolatile.invoke(unsafe, object, offset); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static long objectFieldOffset(Field f) { - try { - return (long) objectFieldOffset.invoke(unsafe, f); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - public static void putObjectVolatile(Object o, long offset, Object x) { - try { - putObjectVolatile.invoke(unsafe, o, offset, x); - } catch (Throwable t) { - throw new RuntimeException(t); - } - } - - private static @NotNull Method find(String name, Class... params) throws Exception { - Log.d(TAG, "find: name=" + name + ", params=" + Arrays.toString(params)); - Method m = HiddenApiBypass.getDeclaredMethod(unsafe.getClass(), name, params); - m.setAccessible(true); - return m; - } -} From 6e6d8b3eeeb77765f75207962695192fee4abbca Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 25 Mar 2026 15:42:11 +0530 Subject: [PATCH 05/29] fix: update kotlin-android to latest version Signed-off-by: Akash Yadav --- subprojects/kotlin-analysis-api/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/subprojects/kotlin-analysis-api/build.gradle.kts b/subprojects/kotlin-analysis-api/build.gradle.kts index 6e11cc1ce0..57fe554dae 100644 --- a/subprojects/kotlin-analysis-api/build.gradle.kts +++ b/subprojects/kotlin-analysis-api/build.gradle.kts @@ -12,7 +12,7 @@ android { val ktAndroidRepo = "https://github.com/appdevforall/kotlin-android" val ktAndroidVersion = "2.3.255" -val ktAndroidTag = "v${ktAndroidVersion}-f1ac8b3" +val ktAndroidTag = "v${ktAndroidVersion}-a98fda0" val ktAndroidJarName = "analysis-api-standalone-embeddable-for-ide-${ktAndroidVersion}-SNAPSHOT.jar" externalAssets { @@ -21,7 +21,7 @@ externalAssets { source = AssetSource.External( url = uri("$ktAndroidRepo/releases/download/$ktAndroidTag/$ktAndroidJarName"), - sha256Checksum = "8c7cad7e0905a861048cce000c3ef22d9ad05572b4f9a0830e0c0e0060ddd3c9", + sha256Checksum = "804781ae6c6cdbc5af1ca9a08959af9552395d48704a6c5fcb43b5516cb3e378", ) } } From b208658bc1ea471871096818a723f3a7bca76f1b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 24 Mar 2026 21:38:58 +0530 Subject: [PATCH 06/29] fix: replace usages of Unsafe with UnsafeImpl Signed-off-by: Akash Yadav --- app/build.gradle.kts | 12 ++++++++++++ 1 file changed, 12 insertions(+) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d9dcb121a5..554718fb66 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -205,6 +205,18 @@ desugaring { EnvUtil::logbackVersion.javaMethod!!, DesugarEnvUtil::logbackVersion.javaMethod!!, ) + + // Replace usages of Unsafe class (from com.intellij.util.containers) + // with our own implementation + // The original implementation uses MethodHandle instances to access APIs + // from sun.misc.Unsafe which are not directly accessible on Android + // As a result, we have our implementatio of that class which makes use + // of HiddenApiBypass to access the same methods, and provides a drop-in + // replacement of the original class + replaceClass( + "org.jetbrains.kotlin.com.intellij.util.containers.Unsafe", + "org.jetbrains.kotlin.com.intellij.util.containers.UnsafeImpl", + ) } } From c844ad29236081068288393223ff1684bceaacd6 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 23 Mar 2026 20:18:36 +0530 Subject: [PATCH 07/29] fix: make Kotlin LSP no-op Signed-off-by: Akash Yadav --- lsp/kotlin/build.gradle.kts | 17 - .../lsp/kotlin/KotlinLanguageClientBridge.kt | 184 ---------- .../lsp/kotlin/KotlinLanguageServer.kt | 314 +----------------- .../lsp/kotlin/KotlinServerSettings.kt | 12 +- .../lsp/kotlin/adapters/ModelConverters.kt | 196 ----------- .../androidide/lsp/models/Definitions.kt | 6 +- .../androidide/lsp/models/References.kt | 6 +- .../androidide/lsp/models/Signatures.kt | 12 +- 8 files changed, 40 insertions(+), 707 deletions(-) delete mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageClientBridge.kt delete mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/adapters/ModelConverters.kt diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index d111677334..8af6820538 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -25,21 +25,6 @@ plugins { android { namespace = "${BuildConfig.PACKAGE_NAME}.lsp.kotlin" - - sourceSets { - named("main") { - resources.srcDir( - project(":lsp:kotlin-stdlib-generator") - .layout.buildDirectory.dir("generated-resources/stdlib") - ) - } - } -} - -afterEvaluate { - tasks.matching { it.name.startsWith("process") && it.name.endsWith("JavaRes") }.configureEach { - dependsOn(":lsp:kotlin-stdlib-generator:generateStdlibIndex") - } } kapt { @@ -51,7 +36,6 @@ kapt { dependencies { kapt(projects.annotationProcessors) - implementation(projects.lsp.kotlinCore) implementation(projects.lsp.api) implementation(projects.lsp.models) implementation(projects.eventbusEvents) @@ -60,7 +44,6 @@ dependencies { implementation(projects.subprojects.projects) implementation(projects.subprojects.projectModels) - implementation(libs.common.lsp4j) implementation(libs.common.jsonrpc) implementation(libs.common.kotlin) implementation(libs.common.kotlin.coroutines.core) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageClientBridge.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageClientBridge.kt deleted file mode 100644 index 44ceca27cc..0000000000 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageClientBridge.kt +++ /dev/null @@ -1,184 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.lsp.kotlin - -import com.itsaky.androidide.lsp.api.ILanguageClient -import com.itsaky.androidide.lsp.models.DiagnosticResult -import org.eclipse.lsp4j.MessageActionItem -import org.eclipse.lsp4j.MessageParams -import org.eclipse.lsp4j.PublishDiagnosticsParams -import org.eclipse.lsp4j.ShowMessageRequestParams -import org.eclipse.lsp4j.services.LanguageClient -import org.slf4j.LoggerFactory -import java.net.URI -import java.nio.file.Paths -import java.util.concurrent.CompletableFuture - -typealias PositionToOffsetResolver = (uri: String) -> ((line: Int, column: Int) -> Int)? - -class KotlinLanguageClientBridge( - private val ideClient: ILanguageClient, - private val positionResolver: PositionToOffsetResolver -) : LanguageClient { - - companion object { - private val log = LoggerFactory.getLogger(KotlinLanguageClientBridge::class.java) - } - - override fun telemetryEvent(obj: Any?) { - } - - override fun publishDiagnostics(diagnostics: PublishDiagnosticsParams) { - log.info("[DIAG-DEBUG] publishDiagnostics: uri={}, count={}", diagnostics.uri, diagnostics.diagnostics.size) - - val path = try { - Paths.get(URI(diagnostics.uri)) - } catch (e: Exception) { - Paths.get(diagnostics.uri) - } - - val positionToOffset = positionResolver(diagnostics.uri) ?: run { - log.warn("[DIAG-DEBUG] Position resolver NULL for: {}, using fallback", diagnostics.uri) - createFallbackPositionCalculator(path) - } - - if (positionToOffset == null) { - log.error("[DIAG-DEBUG] No resolver, dropping {} diagnostics for: {}", diagnostics.diagnostics.size, diagnostics.uri) - return - } - - val diagnosticItems = diagnostics.diagnostics.mapNotNull { diag -> - try { - val startIndex = positionToOffset(diag.range.start.line, diag.range.start.character) - val endIndex = positionToOffset(diag.range.end.line, diag.range.end.character) - - val expectedColSpan = if (diag.range.start.line == diag.range.end.line) { - diag.range.end.character - diag.range.start.character - } else { - -1 - } - val actualIndexSpan = endIndex - startIndex - - log.info("[DIAG-DEBUG] range={}:{}-{}:{} -> idx={}-{} (colSpan={}, idxSpan={}) '{}'", - diag.range.start.line, diag.range.start.character, - diag.range.end.line, diag.range.end.character, - startIndex, endIndex, - expectedColSpan, actualIndexSpan, - diag.message.take(50) - ) - - if (expectedColSpan >= 0 && actualIndexSpan != expectedColSpan) { - log.warn("[DIAG-DEBUG] MISMATCH! idxSpan={} != colSpan={}", actualIndexSpan, expectedColSpan) - } - - val startPos = com.itsaky.androidide.models.Position( - diag.range.start.line, - diag.range.start.character, - startIndex - ) - val endPos = com.itsaky.androidide.models.Position( - diag.range.end.line, - diag.range.end.character, - endIndex - ) - - com.itsaky.androidide.lsp.models.DiagnosticItem( - diag.message, - diag.code?.left ?: diag.code?.right?.toString() ?: "", - com.itsaky.androidide.models.Range(startPos, endPos), - diag.source ?: "ktlsp", - when (diag.severity) { - org.eclipse.lsp4j.DiagnosticSeverity.Error -> - com.itsaky.androidide.lsp.models.DiagnosticSeverity.ERROR - org.eclipse.lsp4j.DiagnosticSeverity.Warning -> - com.itsaky.androidide.lsp.models.DiagnosticSeverity.WARNING - org.eclipse.lsp4j.DiagnosticSeverity.Information -> - com.itsaky.androidide.lsp.models.DiagnosticSeverity.INFO - org.eclipse.lsp4j.DiagnosticSeverity.Hint -> - com.itsaky.androidide.lsp.models.DiagnosticSeverity.HINT - null -> com.itsaky.androidide.lsp.models.DiagnosticSeverity.INFO - } - ) - } catch (e: Exception) { - log.error("Error converting diagnostic: ${diag.message}", e) - null - } - } - - val result = DiagnosticResult(path, diagnosticItems) - log.info("[DIAG-DEBUG] Publishing {} diagnostics to IDE", diagnosticItems.size) - ideClient.publishDiagnostics(result) - } - - private fun createFallbackPositionCalculator(path: java.nio.file.Path): ((Int, Int) -> Int)? { - return try { - val file = path.toFile() - if (!file.exists() || !file.isFile) { - log.warn("File does not exist for fallback position calculation: {}", path) - return null - } - - val content = file.readText() - val lineOffsets = mutableListOf() - lineOffsets.add(0) - - var offset = 0 - for (char in content) { - offset++ - if (char == '\n') { - lineOffsets.add(offset) - } - } - - log.info("Created fallback position calculator for {} with {} lines", path, lineOffsets.size) - - val calculator: (Int, Int) -> Int = { line, column -> - if (line < lineOffsets.size) { - lineOffsets[line] + column - } else { - content.length - } - } - calculator - } catch (e: Exception) { - log.error("Error creating fallback position calculator for {}: {}", path, e.message) - null - } - } - - override fun showMessage(messageParams: MessageParams) { - log.info("Kotlin LSP: ${messageParams.message}") - } - - override fun showMessageRequest( - requestParams: ShowMessageRequestParams - ): CompletableFuture { - log.info("Kotlin LSP request: ${requestParams.message}") - return CompletableFuture.completedFuture(null) - } - - override fun logMessage(message: MessageParams) { - when (message.type) { - org.eclipse.lsp4j.MessageType.Error -> log.error("Kotlin LSP: ${message.message}") - org.eclipse.lsp4j.MessageType.Warning -> log.warn("Kotlin LSP: ${message.message}") - org.eclipse.lsp4j.MessageType.Info -> log.info("Kotlin LSP: ${message.message}") - org.eclipse.lsp4j.MessageType.Log -> log.debug("Kotlin LSP: ${message.message}") - null -> log.debug("Kotlin LSP: ${message.message}") - } - } -} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index fc5cf4e92a..dba45c6bf3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -17,8 +17,6 @@ package com.itsaky.androidide.lsp.kotlin -import androidx.core.net.toUri -import com.itsaky.androidide.eventbus.events.editor.ChangeType import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent @@ -26,8 +24,6 @@ import com.itsaky.androidide.eventbus.events.editor.DocumentSelectedEvent import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.IServerSettings -import com.itsaky.androidide.lsp.kotlin.adapters.toIde -import com.itsaky.androidide.lsp.kotlin.adapters.toLsp4j import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult import com.itsaky.androidide.lsp.models.DefinitionParams @@ -39,26 +35,8 @@ import com.itsaky.androidide.lsp.models.ReferenceResult import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.models.Range -import com.itsaky.androidide.projects.api.AndroidModule -import com.itsaky.androidide.projects.api.ModuleProject import com.itsaky.androidide.projects.api.Workspace -import com.itsaky.androidide.projects.models.bootClassPaths -import com.itsaky.androidide.projects.models.projectDir import com.itsaky.androidide.utils.DocumentUtils -import java.io.File -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import org.appdevforall.codeonthego.lsp.kotlin.server.KotlinLanguageServer as KtLspServer -import org.eclipse.lsp4j.DidChangeTextDocumentParams -import org.eclipse.lsp4j.DidCloseTextDocumentParams -import org.eclipse.lsp4j.DidOpenTextDocumentParams -import org.eclipse.lsp4j.InitializeParams -import org.eclipse.lsp4j.TextDocumentContentChangeEvent -import org.eclipse.lsp4j.TextDocumentIdentifier -import org.eclipse.lsp4j.TextDocumentItem -import org.eclipse.lsp4j.VersionedTextDocumentIdentifier import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -67,9 +45,7 @@ import java.nio.file.Path class KotlinLanguageServer : ILanguageServer { - private val ktLspServer = KtLspServer() - private var clientBridge: KotlinLanguageClientBridge? = null - private var _client: ILanguageClient? = null + private var _client: ILanguageClient? = null private var _settings: IServerSettings? = null private var selectedFile: Path? = null private var initialized = false @@ -96,203 +72,52 @@ class KotlinLanguageServer : ILanguageServer { } override fun shutdown() { - ktLspServer.shutdown().get() EventBus.getDefault().unregister(this) initialized = false } override fun connectClient(client: ILanguageClient?) { this._client = client - if (client != null) { - val positionResolver: PositionToOffsetResolver = { uri -> - val normalizedUri = normalizeUri(uri) - val state = ktLspServer.getDocumentManager().get(normalizedUri) - if (state == null) { - log.debug("positionResolver: no document state for URI: {} (normalized: {})", uri, normalizedUri) - } - state?.let { it::positionToOffset } - } - clientBridge = KotlinLanguageClientBridge(client, positionResolver) - ktLspServer.connect(clientBridge!!) - } - } - - private fun normalizeUri(uri: String): String { - return try { - java.net.URI(uri).normalize().toString() - } catch (e: Exception) { - uri - } } - override fun applySettings(settings: IServerSettings?) { + override fun applySettings(settings: IServerSettings?) { this._settings = settings } override fun setupWithProject(workspace: Workspace) { log.info("setupWithProject called, initialized={}", initialized) if (!initialized) { - loadStdlibIndex() - - val initParams = InitializeParams().apply { - rootUri = workspace.rootProject.projectDir.toUri().toString() - } - ktLspServer.initialize(initParams).get() - ktLspServer.initialized(null) - log.info("Kotlin LSP initialized with stdlib index") initialized = true } - - indexClasspaths(workspace) } - private fun loadStdlibIndex() { - try { - val startTime = System.currentTimeMillis() - val stdlibStream = javaClass.getResourceAsStream("/stdlib-index.json") - if (stdlibStream != null) { - stdlibStream.use { inputStream -> - val stdlibIndex = org.appdevforall.codeonthego.lsp.kotlin.index.StdlibIndexLoader.loadFromStream(inputStream) - ktLspServer.loadStdlibIndex(stdlibIndex) - val elapsed = System.currentTimeMillis() - startTime - log.info("Loaded stdlib index: {} symbols in {}ms", stdlibIndex.size, elapsed) - } - } else { - log.warn("stdlib-index.json not found in resources, using minimal index") - } - } catch (e: Exception) { - log.error("Failed to load stdlib-index.json, using minimal index", e) - - } - } - - private fun indexClasspaths(workspace: Workspace) { - log.info("indexClasspaths called, subProjects count={}", workspace.subProjects.size) - CoroutineScope(Dispatchers.IO).launch { - try { - val classpaths = mutableSetOf() - val bootClasspaths = mutableSetOf() - - for (project in workspace.subProjects) { - log.debug("Checking project: {} (type={})", project.name, project::class.simpleName) - if (project is ModuleProject) { - val projectClasspaths = project.getCompileClasspaths() - log.debug("Project {} has {} classpath entries", project.name, projectClasspaths.size) - classpaths.addAll(projectClasspaths) - - if (project is AndroidModule) { - val projectBootClasspaths = project.bootClassPaths - log.debug("Project {} has {} boot classpath entries", project.name, projectBootClasspaths.size) - bootClasspaths.addAll(projectBootClasspaths) - } - } - } - - classpaths.addAll(bootClasspaths.filter { it.exists() }) - - log.info("Total classpath entries found: {} (including {} boot classpaths)", classpaths.size, bootClasspaths.size) - if (classpaths.isNotEmpty()) { - val files = classpaths.filter { it.exists() } - log.info("Indexing {} existing classpath entries for Kotlin LSP", files.size) - ktLspServer.setClasspathAsync(files).thenAccept { index -> - log.info("Kotlin LSP classpath indexed: {} symbols from {} jars", index.size, index.jarCount) - }.exceptionally { e -> - log.error("Error in classpath indexing async", e) - null - } - } else { - log.warn("No classpath entries found for Kotlin LSP") - } - } catch (e: Exception) { - log.error("Error indexing classpaths for Kotlin LSP", e) - } - } - } override fun complete(params: CompletionParams?): CompletionResult { - log.debug("complete() called, params={}", params != null) - if (params == null || !settings.completionsEnabled()) { - log.debug("complete() returning EMPTY: params={}, completionsEnabled={}", params != null, settings.completionsEnabled()) - return CompletionResult.EMPTY - } - - if (!DocumentUtils.isKotlinFile(params.file)) { - log.debug("complete() returning EMPTY: not a Kotlin file") - return CompletionResult.EMPTY - } - - val uri = params.file.toUri().toString() - - ktLspServer.getAnalysisScheduler().analyzeSync(uri) - - log.debug("complete() uri={}, position={}:{}, prefix={}", uri, params.position.line, params.position.column, params.prefix) - val lspParams = org.eclipse.lsp4j.CompletionParams().apply { - textDocument = TextDocumentIdentifier(uri) - position = params.position.toLsp4j() - } - - return try { - val future = ktLspServer.textDocumentService.completion(lspParams) - val result = future.get() - val items = result?.right?.items ?: result?.left ?: emptyList() - log.debug("complete() got {} items from ktlsp", items.size) - CompletionResult(items.map { it.toIde(params.prefix ?: "") }) - } catch (e: Exception) { - log.error("Error during completion", e) - CompletionResult.EMPTY - } + return CompletionResult.EMPTY } override suspend fun findReferences(params: ReferenceParams): ReferenceResult { if (!settings.referencesEnabled()) { - return ReferenceResult(emptyList()) + return ReferenceResult.empty() } if (!DocumentUtils.isKotlinFile(params.file)) { - return ReferenceResult(emptyList()) - } - - val uri = params.file.toUri().toString() - val lspParams = org.eclipse.lsp4j.ReferenceParams().apply { - textDocument = TextDocumentIdentifier(uri) - position = params.position.toLsp4j() - context = org.eclipse.lsp4j.ReferenceContext(params.includeDeclaration) + return ReferenceResult.empty() } - return try { - val future = ktLspServer.textDocumentService.references(lspParams) - val locations = future.get() ?: emptyList() - ReferenceResult(locations.map { it.toIde() }) - } catch (e: Exception) { - log.error("Error finding references", e) - ReferenceResult(emptyList()) - } + return ReferenceResult.empty() } override suspend fun findDefinition(params: DefinitionParams): DefinitionResult { if (!settings.definitionsEnabled()) { - return DefinitionResult(emptyList()) + return DefinitionResult.empty() } if (!DocumentUtils.isKotlinFile(params.file)) { - return DefinitionResult(emptyList()) - } - - val uri = params.file.toUri().toString() - val lspParams = org.eclipse.lsp4j.DefinitionParams().apply { - textDocument = TextDocumentIdentifier(uri) - position = params.position.toLsp4j() + return DefinitionResult.empty() } - return try { - val future = ktLspServer.textDocumentService.definition(lspParams) - val result = future.get() - val locations = result?.left ?: emptyList() - DefinitionResult(locations.map { it.toIde() }) - } catch (e: Exception) { - log.error("Error finding definition", e) - DefinitionResult(emptyList()) - } + return DefinitionResult.empty() } override suspend fun expandSelection(params: ExpandSelectionParams): Range { @@ -301,31 +126,18 @@ class KotlinLanguageServer : ILanguageServer { override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp { if (!settings.signatureHelpEnabled()) { - return SignatureHelp(emptyList(), -1, -1) + return SignatureHelp.empty() } if (!DocumentUtils.isKotlinFile(params.file)) { - return SignatureHelp(emptyList(), -1, -1) - } - - val uri = params.file.toUri().toString() - val lspParams = org.eclipse.lsp4j.SignatureHelpParams().apply { - textDocument = TextDocumentIdentifier(uri) - position = params.position.toLsp4j() + return SignatureHelp.empty() } - return try { - val future = ktLspServer.textDocumentService.signatureHelp(lspParams) - val result = future.get() - result?.toIde() ?: SignatureHelp(emptyList(), -1, -1) - } catch (e: Exception) { - log.error("Error getting signature help", e) - SignatureHelp(emptyList(), -1, -1) - } + return SignatureHelp.empty() } override suspend fun analyze(file: Path): DiagnosticResult { - log.debug("analyze() called for file: {}", file) + log.debug("analyze(file={})", file) if (!settings.diagnosticsEnabled() || !settings.codeAnalysisEnabled()) { log.debug("analyze() skipped: diagnosticsEnabled={}, codeAnalysisEnabled={}", @@ -338,19 +150,7 @@ class KotlinLanguageServer : ILanguageServer { return DiagnosticResult.NO_UPDATE } - val uri = file.toUri().toString() - val state = ktLspServer.getDocumentManager().get(uri) - if (state == null) { - log.warn("analyze() skipped: document state not found for URI: {}", uri) - return DiagnosticResult.NO_UPDATE - } - - ktLspServer.getAnalysisScheduler().analyzeSync(uri) - - val diagnostics = state.diagnostics - log.info("analyze() completed: {} diagnostics found for {}", diagnostics.size, file.fileName) - - return DiagnosticResult(file, diagnostics.map { it.toIde(state::positionToOffset) }) + return DiagnosticResult.NO_UPDATE } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -361,17 +161,7 @@ class KotlinLanguageServer : ILanguageServer { } selectedFile = event.openedFile - val uri = event.openedFile.toUri().toString() - - log.debug("onDocumentOpen: uri={}, version={}, textLen={}", uri, event.version, event.text.length) - - val params = DidOpenTextDocumentParams().apply { - textDocument = TextDocumentItem(uri, "kotlin", event.version, event.text) - } - ktLspServer.textDocumentService.didOpen(params) - - analyzeCurrentFileAsync() - } + } @Subscribe(threadMode = ThreadMode.ASYNC) @Suppress("unused") @@ -379,35 +169,6 @@ class KotlinLanguageServer : ILanguageServer { if (!DocumentUtils.isKotlinFile(event.changedFile)) { return } - - val uri = event.changedFile.toUri().toString() - - log.debug("onDocumentChange: uri={}, version={}, changeType={}", uri, event.version, event.changeType) - log.debug(" changeRange={}, changedText='{}', newText.len={}", - event.changeRange, event.changedText, event.newText?.length ?: -1) - - val changeText = when (event.changeType) { - ChangeType.DELETE -> "" - else -> event.changedText - } - - val startIndex = event.changeRange.start.index - val endIndex = if (event.changeType == ChangeType.INSERT) { - startIndex - } else { - event.changeRange.end.index - } - - log.debug(" using index-based sync: indices=$startIndex-$endIndex (adjusted for {}), text='{}' ({} chars)", - event.changeType, changeText, changeText.length) - - ktLspServer.didChangeByIndex( - uri = uri, - startIndex = startIndex, - endIndex = endIndex, - newText = changeText, - version = event.version - ) } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -416,16 +177,6 @@ class KotlinLanguageServer : ILanguageServer { if (!DocumentUtils.isKotlinFile(event.closedFile)) { return } - - val uri = event.closedFile.toUri().toString() - val params = DidCloseTextDocumentParams().apply { - textDocument = TextDocumentIdentifier(uri) - } - ktLspServer.textDocumentService.didClose(params) - - if (selectedFile == event.closedFile) { - selectedFile = null - } } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -439,40 +190,5 @@ class KotlinLanguageServer : ILanguageServer { val uri = event.selectedFile.toUri().toString() log.debug("onDocumentSelected: uri={}", uri) - - val existingState = ktLspServer.getDocumentManager().get(uri) - if (existingState == null) { - log.info("onDocumentSelected: document not open in KtLsp, opening it first: {}", uri) - log.debug(" available uris: {}", ktLspServer.getDocumentManager().openUris.take(5)) - try { - val content = event.selectedFile.toFile().readText() - log.debug(" read {} chars from disk", content.length) - val params = DidOpenTextDocumentParams().apply { - textDocument = TextDocumentItem(uri, "kotlin", 0, content) - } - ktLspServer.textDocumentService.didOpen(params) - } catch (e: Exception) { - log.error("Failed to open document in KtLsp: {}", uri, e) - } - } else { - log.debug("onDocumentSelected: document already open, version={}, contentLen={}", - existingState.version, existingState.content.length) - } - - analyzeCurrentFileAsync() - } - - private fun analyzeCurrentFileAsync() { - val file = selectedFile ?: return - val client = _client ?: return - - CoroutineScope(Dispatchers.Default).launch { - val result = analyze(file) - if (result != DiagnosticResult.NO_UPDATE) { - withContext(Dispatchers.Main) { - client.publishDiagnostics(result) - } - } - } } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinServerSettings.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinServerSettings.kt index 6d1019d3cc..fa51c56c7f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinServerSettings.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinServerSettings.kt @@ -24,14 +24,12 @@ class KotlinServerSettings private constructor() : PrefBasedServerSettings() { override fun diagnosticsEnabled(): Boolean = true companion object { - private var instance: KotlinServerSettings? = null + private val _instance by lazy(mode = LazyThreadSafetyMode.SYNCHRONIZED) { + KotlinServerSettings() + } @JvmStatic - fun getInstance(): KotlinServerSettings { - if (instance == null) { - instance = KotlinServerSettings() - } - return instance!! - } + fun getInstance(): KotlinServerSettings = + _instance } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/adapters/ModelConverters.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/adapters/ModelConverters.kt deleted file mode 100644 index 498821dc75..0000000000 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/adapters/ModelConverters.kt +++ /dev/null @@ -1,196 +0,0 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.lsp.kotlin.adapters - -import android.util.Log -import com.itsaky.androidide.lsp.models.CompletionItem -import com.itsaky.androidide.lsp.models.CompletionItemKind -import com.itsaky.androidide.lsp.models.DiagnosticItem -import com.itsaky.androidide.lsp.models.DiagnosticSeverity -import com.itsaky.androidide.lsp.models.InsertTextFormat -import com.itsaky.androidide.lsp.models.MarkupContent -import com.itsaky.androidide.lsp.models.MarkupKind -import com.itsaky.androidide.lsp.models.MatchLevel -import com.itsaky.androidide.lsp.models.ParameterInformation -import com.itsaky.androidide.lsp.models.SignatureHelp -import com.itsaky.androidide.lsp.models.SignatureInformation -import com.itsaky.androidide.models.Location -import com.itsaky.androidide.models.Position -import com.itsaky.androidide.models.Range -import org.appdevforall.codeonthego.lsp.kotlin.semantic.Diagnostic -import org.appdevforall.codeonthego.lsp.kotlin.semantic.DiagnosticSeverity as KtDiagnosticSeverity -import java.net.URI -import java.nio.file.Paths -import org.eclipse.lsp4j.CompletionItemKind as Lsp4jCompletionItemKind -import org.eclipse.lsp4j.DiagnosticSeverity as Lsp4jDiagnosticSeverity -import org.eclipse.lsp4j.InsertTextFormat as Lsp4jInsertTextFormat -import org.eclipse.lsp4j.Location as Lsp4jLocation -import org.eclipse.lsp4j.MarkupKind as Lsp4jMarkupKind -import org.eclipse.lsp4j.Position as Lsp4jPosition -import org.eclipse.lsp4j.Range as Lsp4jRange -import org.eclipse.lsp4j.SignatureHelp as Lsp4jSignatureHelp -import org.eclipse.lsp4j.CompletionItem as Lsp4jCompletionItem - -fun Position.toLsp4j(): Lsp4jPosition = Lsp4jPosition(line, column) - -fun Lsp4jPosition.toIde(): Position = Position(line, character) - -fun Range.toLsp4j(): Lsp4jRange = Lsp4jRange(start.toLsp4j(), end.toLsp4j()) - -fun Lsp4jRange.toIde(): Range = Range(start.toIde(), end.toIde()) - -fun Lsp4jLocation.toIde(): Location { - val path = try { - Paths.get(URI(uri)) - } catch (e: Exception) { - Paths.get(uri) - } - return Location(path, range.toIde()) -} - -fun Lsp4jCompletionItem.toIde(prefix: String): CompletionItem { - val matchLevel = CompletionItem.matchLevel(label, prefix) - - return CompletionItem( - label, - detail ?: "", - insertText, - insertTextFormat?.toIde(), - sortText, - null, - kind?.toIde() ?: CompletionItemKind.NONE, - matchLevel, - null, - null - ) -} - -fun Lsp4jCompletionItemKind.toIde(): CompletionItemKind { - return when (this) { - Lsp4jCompletionItemKind.Text -> CompletionItemKind.NONE - Lsp4jCompletionItemKind.Method -> CompletionItemKind.METHOD - Lsp4jCompletionItemKind.Function -> CompletionItemKind.FUNCTION - Lsp4jCompletionItemKind.Constructor -> CompletionItemKind.CONSTRUCTOR - Lsp4jCompletionItemKind.Field -> CompletionItemKind.FIELD - Lsp4jCompletionItemKind.Variable -> CompletionItemKind.VARIABLE - Lsp4jCompletionItemKind.Class -> CompletionItemKind.CLASS - Lsp4jCompletionItemKind.Interface -> CompletionItemKind.INTERFACE - Lsp4jCompletionItemKind.Module -> CompletionItemKind.MODULE - Lsp4jCompletionItemKind.Property -> CompletionItemKind.PROPERTY - Lsp4jCompletionItemKind.Keyword -> CompletionItemKind.KEYWORD - Lsp4jCompletionItemKind.Snippet -> CompletionItemKind.SNIPPET - Lsp4jCompletionItemKind.Value -> CompletionItemKind.VALUE - Lsp4jCompletionItemKind.EnumMember -> CompletionItemKind.ENUM_MEMBER - Lsp4jCompletionItemKind.Enum -> CompletionItemKind.ENUM - Lsp4jCompletionItemKind.TypeParameter -> CompletionItemKind.TYPE_PARAMETER - else -> CompletionItemKind.NONE - } -} - -fun Lsp4jInsertTextFormat.toIde(): InsertTextFormat { - return when (this) { - Lsp4jInsertTextFormat.PlainText -> InsertTextFormat.PLAIN_TEXT - Lsp4jInsertTextFormat.Snippet -> InsertTextFormat.SNIPPET - } -} - -fun Diagnostic.toIde(positionToOffset: (line: Int, column: Int) -> Int): DiagnosticItem { - val startIndex = if (range.hasOffsets) { - range.startOffset - } else { - positionToOffset(range.startLine, range.startColumn) - } - val endIndex = if (range.hasOffsets) { - range.endOffset - } else { - positionToOffset(range.endLine, range.endColumn) - } - - Log.i("DIAG-DEBUG", "range=${range.startLine}:${range.startColumn}-${range.endLine}:${range.endColumn} -> idx=$startIndex-$endIndex (hasOffsets=${range.hasOffsets}) '${message.take(50)}'") - - return DiagnosticItem( - message, - code.name, - Range( - Position(range.startLine, range.startColumn, startIndex), - Position(range.endLine, range.endColumn, endIndex) - ), - "ktlsp", - severity.toIde() - ) -} - -fun KtDiagnosticSeverity.toIde(): DiagnosticSeverity { - return when (this) { - KtDiagnosticSeverity.ERROR -> DiagnosticSeverity.ERROR - KtDiagnosticSeverity.WARNING -> DiagnosticSeverity.WARNING - KtDiagnosticSeverity.INFO -> DiagnosticSeverity.INFO - KtDiagnosticSeverity.HINT -> DiagnosticSeverity.HINT - } -} - -fun Lsp4jSignatureHelp.toIde(): SignatureHelp { - return SignatureHelp( - signatures.map { it.toIde() }, - activeSignature ?: -1, - activeParameter ?: -1 - ) -} - -fun org.eclipse.lsp4j.SignatureInformation.toIde(): SignatureInformation { - val doc = when { - documentation?.isRight == true -> { - val markup = documentation.right - MarkupContent( - markup.value, - when (markup.kind) { - Lsp4jMarkupKind.MARKDOWN -> MarkupKind.MARKDOWN - else -> MarkupKind.PLAIN - } - ) - } - documentation?.isLeft == true -> MarkupContent(documentation.left, MarkupKind.PLAIN) - else -> MarkupContent() - } - - return SignatureInformation( - label, - doc, - parameters?.map { it.toIde() } ?: emptyList() - ) -} - -fun org.eclipse.lsp4j.ParameterInformation.toIde(): ParameterInformation { - val labelStr = if (label.isLeft) label.left else "${label.right.first}-${label.right.second}" - val doc = when { - documentation?.isRight == true -> { - val markup = documentation.right - MarkupContent( - markup.value, - when (markup.kind) { - Lsp4jMarkupKind.MARKDOWN -> MarkupKind.MARKDOWN - else -> MarkupKind.PLAIN - } - ) - } - documentation?.isLeft == true -> MarkupContent(documentation.left, MarkupKind.PLAIN) - else -> MarkupContent() - } - - return ParameterInformation(labelStr, doc) -} diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Definitions.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Definitions.kt index 7e473d63f8..51dcd66689 100644 --- a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Definitions.kt +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Definitions.kt @@ -30,4 +30,8 @@ data class DefinitionParams( override val cancelChecker: ICancelChecker ) : CancellableRequestParams -data class DefinitionResult(var locations: List) +data class DefinitionResult(var locations: List) { + companion object { + fun empty() = DefinitionResult(locations = emptyList()) + } +} diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/References.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/References.kt index d73505af23..e1320ec5d2 100644 --- a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/References.kt +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/References.kt @@ -31,4 +31,8 @@ data class ReferenceParams( override val cancelChecker: ICancelChecker ) : CancellableRequestParams -data class ReferenceResult(var locations: List) +data class ReferenceResult(var locations: List) { + companion object { + fun empty() = ReferenceResult(locations = emptyList()) + } +} diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Signatures.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Signatures.kt index deadc55d64..c5219e211e 100644 --- a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Signatures.kt +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/Signatures.kt @@ -21,7 +21,7 @@ import com.itsaky.androidide.lsp.CancellableRequestParams import com.itsaky.androidide.models.Position import com.itsaky.androidide.progress.ICancelChecker import java.nio.file.Path -import java.util.* +import java.util.Collections data class ParameterInformation(var label: String, var documentation: MarkupContent) { constructor() : this("", MarkupContent()) @@ -39,7 +39,15 @@ data class SignatureHelp( var signatures: List, var activeSignature: Int, var activeParameter: Int -) +) { + companion object { + fun empty() = SignatureHelp( + signatures = emptyList(), + activeSignature = -1, + activeParameter = -1, + ) + } +} data class SignatureHelpParams( var file: Path, From 58db2cbecb1adb6b4a0d93b076fa462e020651d1 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 24 Mar 2026 18:31:29 +0530 Subject: [PATCH 08/29] feat: configure K2 standalone session when setting up LSP Signed-off-by: Akash Yadav --- .../configuration/IJdkDistributionProvider.kt | 91 ++--- .../lsp/kotlin/KotlinLanguageServer.kt | 338 +++++++++++------- .../kotlin/compiler/CompilationEnvironment.kt | 75 ++++ .../lsp/kotlin/compiler/CompilationKind.kt | 16 + .../lsp/kotlin/compiler/Compiler.kt | 79 ++++ .../lsp/kotlin/compiler/CompilerExts.kt | 10 + .../androidide/projects/api/AndroidModule.kt | 43 ++- .../androidide/projects/api/JavaModule.kt | 21 +- .../androidide/projects/api/ModuleProject.kt | 5 +- 9 files changed, 499 insertions(+), 179 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationKind.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilerExts.kt diff --git a/common/src/main/java/com/itsaky/androidide/app/configuration/IJdkDistributionProvider.kt b/common/src/main/java/com/itsaky/androidide/app/configuration/IJdkDistributionProvider.kt index 5aa03ff595..d754dce9d4 100644 --- a/common/src/main/java/com/itsaky/androidide/app/configuration/IJdkDistributionProvider.kt +++ b/common/src/main/java/com/itsaky/androidide/app/configuration/IJdkDistributionProvider.kt @@ -28,55 +28,60 @@ import com.itsaky.androidide.utils.ServiceLoader */ interface IJdkDistributionProvider { - /** - * The list of JDK distributions installed on the device. - */ - val installedDistributions: List + /** + * The list of JDK distributions installed on the device. + */ + val installedDistributions: List - /** - * Reloads the installed JDK distributions. This function is synchronous and should not be called - * on the UI thread. - */ - @WorkerThread - fun loadDistributions() + /** + * Reloads the installed JDK distributions. This function is synchronous and should not be called + * on the UI thread. + */ + @WorkerThread + fun loadDistributions() - /** - * Get the [JdkDistribution] instance for the given java version. - * - * @return The [JdkDistribution] instance for the given java version, or `null` if no such - * distribution is found. - */ - fun forVersion(javaVersion: String) : JdkDistribution? = - installedDistributions.firstOrNull { it.javaVersion == javaVersion } + /** + * Get the [JdkDistribution] instance for the given java version. + * + * @return The [JdkDistribution] instance for the given java version, or `null` if no such + * distribution is found. + */ + fun forVersion(javaVersion: String): JdkDistribution? = + installedDistributions.firstOrNull { it.javaVersion == javaVersion } - /** - * Get the [JdkDistribution] instance for the given java home. - * - * @return The [JdkDistribution] instance for the given java home, or `null` if no such - * distribution is found. - */ - fun forJavaHome(javaHome: String) : JdkDistribution? = - installedDistributions.firstOrNull { it.javaHome == javaHome } + /** + * Get the [JdkDistribution] instance for the given java home. + * + * @return The [JdkDistribution] instance for the given java home, or `null` if no such + * distribution is found. + */ + fun forJavaHome(javaHome: String): JdkDistribution? = + installedDistributions.firstOrNull { it.javaHome == javaHome } - companion object { + companion object { - /** - * The default java version. - */ - const val DEFAULT_JAVA_VERSION = "17" + /** + * The default Java version. + */ + const val DEFAULT_JAVA_RELEASE = 21 - private val _instance by lazy { - ServiceLoader.load( - IJdkDistributionProvider::class.java, - IJdkDistributionProvider::class.java.classLoader - ).findFirstOrThrow() - } + /** + * The default java version. + */ + const val DEFAULT_JAVA_VERSION = DEFAULT_JAVA_RELEASE.toString() - /** - * Get instance of [IJdkDistributionProvider]. - */ - @JvmStatic - fun getInstance(): IJdkDistributionProvider = _instance - } + private val _instance by lazy { + ServiceLoader.load( + IJdkDistributionProvider::class.java, + IJdkDistributionProvider::class.java.classLoader + ).findFirstOrThrow() + } + + /** + * Get instance of [IJdkDistributionProvider]. + */ + @JvmStatic + fun getInstance(): IJdkDistributionProvider = _instance + } } \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index dba45c6bf3..bf9275787f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -17,6 +17,8 @@ package com.itsaky.androidide.lsp.kotlin +import com.itsaky.androidide.app.BaseApplication +import com.itsaky.androidide.app.configuration.IJdkDistributionProvider import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent @@ -24,6 +26,7 @@ import com.itsaky.androidide.eventbus.events.editor.DocumentSelectedEvent import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.IServerSettings +import com.itsaky.androidide.lsp.kotlin.compiler.Compiler import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult import com.itsaky.androidide.lsp.models.DefinitionParams @@ -35,160 +38,255 @@ import com.itsaky.androidide.lsp.models.ReferenceResult import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.models.Range +import com.itsaky.androidide.projects.api.ModuleProject import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.utils.DocumentUtils +import com.itsaky.androidide.utils.Environment import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +import org.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule +import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule +import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule +import org.jetbrains.kotlin.config.JvmTarget +import org.jetbrains.kotlin.config.LanguageVersion +import org.jetbrains.kotlin.platform.jvm.JdkPlatform +import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.slf4j.LoggerFactory import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.pathString class KotlinLanguageServer : ILanguageServer { private var _client: ILanguageClient? = null - private var _settings: IServerSettings? = null - private var selectedFile: Path? = null - private var initialized = false + private var _settings: IServerSettings? = null + private var selectedFile: Path? = null + private var initialized = false - override val serverId: String = SERVER_ID + private var compiler: Compiler? = null - override val client: ILanguageClient? - get() = _client + override val serverId: String = SERVER_ID - val settings: IServerSettings - get() = _settings ?: KotlinServerSettings.getInstance().also { _settings = it } + override val client: ILanguageClient? + get() = _client - companion object { - const val SERVER_ID = "ide.lsp.kotlin" - private val log = LoggerFactory.getLogger(KotlinLanguageServer::class.java) - } + val settings: IServerSettings + get() = _settings ?: KotlinServerSettings.getInstance().also { _settings = it } - init { - applySettings(KotlinServerSettings.getInstance()) + companion object { + const val SERVER_ID = "ide.lsp.kotlin" + private val log = LoggerFactory.getLogger(KotlinLanguageServer::class.java) + } + + init { + applySettings(KotlinServerSettings.getInstance()) - if (!EventBus.getDefault().isRegistered(this)) { - EventBus.getDefault().register(this) - } - } + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } + } - override fun shutdown() { - EventBus.getDefault().unregister(this) - initialized = false - } + override fun shutdown() { + EventBus.getDefault().unregister(this) + compiler?.close() + initialized = false + } - override fun connectClient(client: ILanguageClient?) { - this._client = client - } + override fun connectClient(client: ILanguageClient?) { + this._client = client + } override fun applySettings(settings: IServerSettings?) { - this._settings = settings - } + this._settings = settings + } - override fun setupWithProject(workspace: Workspace) { - log.info("setupWithProject called, initialized={}", initialized) - if (!initialized) { - initialized = true - } - } + override fun setupWithProject(workspace: Workspace) { + log.info("setupWithProject called, initialized={}", initialized) + if (!initialized) { + recreateSession(workspace) + initialized = true + } + } + private fun recreateSession(workspace: Workspace) { + compiler?.close() + + val jdkHome = Environment.JAVA_HOME.toPath() + val jdkRelease = IJdkDistributionProvider.DEFAULT_JAVA_RELEASE + val intellijPluginRoot = Paths.get( + BaseApplication + .baseInstance.applicationInfo.sourceDir + ) + + val jdkPlatform = JvmPlatforms.jvmPlatformByTargetVersion( + JvmTarget.supportedValues().first { it.majorVersion == jdkRelease }) + + compiler = Compiler( + intellijPluginRoot = intellijPluginRoot, + jdkHome = jdkHome, + jdkRelease = jdkRelease, + languageVersion = LanguageVersion.LATEST_STABLE + ) { + buildKtModuleProvider { + platform = jdkPlatform + + val moduleProjects = + workspace.subProjects + .filterIsInstance() + .filter { it.path != workspace.rootProject.path } + + val libraryDependencies = + moduleProjects + .flatMap { it.getCompileClasspaths() } + .associateWith { library -> + addModule(buildKtLibraryModule { + addBinaryRoot(library.toPath()) + }) + } + + val subprojectsAsModules = mutableMapOf() + + fun getOrCreateModule(project: ModuleProject): KaSourceModule { + subprojectsAsModules[project]?.also { module -> + // a source module already exists for this project + return module + } + + val module = buildKtSourceModule { + addSourceRoots( + project.getSourceDirectories().map { it.toPath() }) + + project.getCompileClasspaths(excludeSourceGeneratedClassPath = true) + .forEach { classpath -> + val libDependency = libraryDependencies[classpath] + if (libDependency == null) { + log.error( + "Unable to locate library module for classpath: {}", + libDependency + ) + return@forEach + } + + addRegularDependency(libDependency) + } + + project.getCompileModuleProjects() + .forEach { dependencyModule -> + addRegularDependency(getOrCreateModule(dependencyModule)) + } + } + + subprojectsAsModules[project] = module + return module + } + + moduleProjects.forEach { project -> + addModule(getOrCreateModule(project)) + } + } + } + } - override fun complete(params: CompletionParams?): CompletionResult { - return CompletionResult.EMPTY - } + override fun complete(params: CompletionParams?): CompletionResult { + return CompletionResult.EMPTY + } - override suspend fun findReferences(params: ReferenceParams): ReferenceResult { - if (!settings.referencesEnabled()) { - return ReferenceResult.empty() - } + override suspend fun findReferences(params: ReferenceParams): ReferenceResult { + if (!settings.referencesEnabled()) { + return ReferenceResult.empty() + } - if (!DocumentUtils.isKotlinFile(params.file)) { - return ReferenceResult.empty() - } + if (!DocumentUtils.isKotlinFile(params.file)) { + return ReferenceResult.empty() + } - return ReferenceResult.empty() - } + return ReferenceResult.empty() + } - override suspend fun findDefinition(params: DefinitionParams): DefinitionResult { - if (!settings.definitionsEnabled()) { - return DefinitionResult.empty() - } + override suspend fun findDefinition(params: DefinitionParams): DefinitionResult { + if (!settings.definitionsEnabled()) { + return DefinitionResult.empty() + } - if (!DocumentUtils.isKotlinFile(params.file)) { - return DefinitionResult.empty() - } + if (!DocumentUtils.isKotlinFile(params.file)) { + return DefinitionResult.empty() + } return DefinitionResult.empty() - } + } - override suspend fun expandSelection(params: ExpandSelectionParams): Range { - return params.selection - } + override suspend fun expandSelection(params: ExpandSelectionParams): Range { + return params.selection + } - override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp { - if (!settings.signatureHelpEnabled()) { - return SignatureHelp.empty() - } + override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp { + if (!settings.signatureHelpEnabled()) { + return SignatureHelp.empty() + } - if (!DocumentUtils.isKotlinFile(params.file)) { - return SignatureHelp.empty() - } + if (!DocumentUtils.isKotlinFile(params.file)) { + return SignatureHelp.empty() + } return SignatureHelp.empty() - } - - override suspend fun analyze(file: Path): DiagnosticResult { - log.debug("analyze(file={})", file) - - if (!settings.diagnosticsEnabled() || !settings.codeAnalysisEnabled()) { - log.debug("analyze() skipped: diagnosticsEnabled={}, codeAnalysisEnabled={}", - settings.diagnosticsEnabled(), settings.codeAnalysisEnabled()) - return DiagnosticResult.NO_UPDATE - } - - if (!DocumentUtils.isKotlinFile(file)) { - log.debug("analyze() skipped: not a Kotlin file") - return DiagnosticResult.NO_UPDATE - } - - return DiagnosticResult.NO_UPDATE - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("unused") - fun onDocumentOpen(event: DocumentOpenEvent) { - if (!DocumentUtils.isKotlinFile(event.openedFile)) { - return - } - - selectedFile = event.openedFile - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("unused") - fun onDocumentChange(event: DocumentChangeEvent) { - if (!DocumentUtils.isKotlinFile(event.changedFile)) { - return - } - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("unused") - fun onDocumentClose(event: DocumentCloseEvent) { - if (!DocumentUtils.isKotlinFile(event.closedFile)) { - return - } - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("unused") - fun onDocumentSelected(event: DocumentSelectedEvent) { - if (!DocumentUtils.isKotlinFile(event.selectedFile)) { - return - } - - selectedFile = event.selectedFile - val uri = event.selectedFile.toUri().toString() - - log.debug("onDocumentSelected: uri={}", uri) - } + } + + override suspend fun analyze(file: Path): DiagnosticResult { + log.debug("analyze(file={})", file) + + if (!settings.diagnosticsEnabled() || !settings.codeAnalysisEnabled()) { + log.debug( + "analyze() skipped: diagnosticsEnabled={}, codeAnalysisEnabled={}", + settings.diagnosticsEnabled(), settings.codeAnalysisEnabled() + ) + return DiagnosticResult.NO_UPDATE + } + + if (!DocumentUtils.isKotlinFile(file)) { + log.debug("analyze() skipped: not a Kotlin file") + return DiagnosticResult.NO_UPDATE + } + + return DiagnosticResult.NO_UPDATE + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("unused") + fun onDocumentOpen(event: DocumentOpenEvent) { + if (!DocumentUtils.isKotlinFile(event.openedFile)) { + return + } + + selectedFile = event.openedFile + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("unused") + fun onDocumentChange(event: DocumentChangeEvent) { + if (!DocumentUtils.isKotlinFile(event.changedFile)) { + return + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("unused") + fun onDocumentClose(event: DocumentCloseEvent) { + if (!DocumentUtils.isKotlinFile(event.closedFile)) { + return + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("unused") + fun onDocumentSelected(event: DocumentSelectedEvent) { + if (!DocumentUtils.isKotlinFile(event.selectedFile)) { + return + } + + selectedFile = event.selectedFile + val uri = event.selectedFile.toUri().toString() + + log.debug("onDocumentSelected: uri={}", uri) + } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt new file mode 100644 index 0000000000..8414b2dc73 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -0,0 +1,75 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISession +import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISessionBuilder +import org.jetbrains.kotlin.analysis.api.standalone.buildStandaloneAnalysisAPISession +import org.jetbrains.kotlin.cli.common.intellijPluginRoot +import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.config.ApiVersion +import org.jetbrains.kotlin.config.CompilerConfiguration +import org.jetbrains.kotlin.config.LanguageFeature +import org.jetbrains.kotlin.config.LanguageVersion +import org.jetbrains.kotlin.config.LanguageVersionSettingsImpl +import org.jetbrains.kotlin.config.jdkHome +import org.jetbrains.kotlin.config.jdkRelease +import org.jetbrains.kotlin.config.languageVersionSettings +import org.jetbrains.kotlin.config.moduleName +import org.jetbrains.kotlin.config.useFir +import org.jetbrains.kotlin.metadata.jvm.deserialization.JvmProtoBufUtil +import org.jetbrains.kotlin.psi.KtPsiFactory +import java.nio.file.Path +import kotlin.io.path.pathString + +/** + * A compilation environment for compiling Kotlin sources. + * + * @param intellijPluginRoot The IntelliJ plugin root. This is usually the location of the embeddable JAR file. Required. + * @param languageVersion The language version that this environment should be compatible with. + * @param jdkHome Path to the JDK installation directory. + * @param jdkRelease The JDK release version at [jdkHome]. + */ +class CompilationEnvironment( + intellijPluginRoot: Path, + jdkHome: Path, + jdkRelease: Int, + languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, + configureSession: StandaloneAnalysisAPISessionBuilder.() -> Unit = {} +) : AutoCloseable { + private val disposable = Disposer.newDisposable() + + val session: StandaloneAnalysisAPISession + val parser: KtPsiFactory + + init { + val configuration = CompilerConfiguration().apply { + this.moduleName = JvmProtoBufUtil.DEFAULT_MODULE_NAME + this.useFir = true + this.intellijPluginRoot = intellijPluginRoot.pathString + this.languageVersionSettings = LanguageVersionSettingsImpl( + languageVersion = languageVersion, + apiVersion = ApiVersion.createByLanguageVersion(languageVersion), + analysisFlags = emptyMap(), + specificFeatures = buildMap { + // enable all features + LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED } + } + ) + + this.jdkHome = jdkHome.toFile() + this.jdkRelease = jdkRelease + } + + session = buildStandaloneAnalysisAPISession( + projectDisposable = disposable, + unitTestMode = false, + compilerConfiguration = configuration, + init = configureSession + ) + + parser = KtPsiFactory(session.project) + } + + override fun close() { + disposable.dispose() + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationKind.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationKind.kt new file mode 100644 index 0000000000..a15305ffba --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationKind.kt @@ -0,0 +1,16 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +/** + * The kind of compilation being performed in a [Compiler]. + */ +enum class CompilationKind { + /** + * The default compilation kind. Mostly used for normal Kotlin source files. + */ + Default, + + /** + * Compilation kind for compiling Kotlin scripts. + */ + Script, +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt new file mode 100644 index 0000000000..5baf9f6614 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -0,0 +1,79 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISessionBuilder +import org.jetbrains.kotlin.com.intellij.lang.Language +import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.com.intellij.psi.PsiFile +import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory +import org.jetbrains.kotlin.config.JvmTarget +import org.jetbrains.kotlin.config.LanguageVersion +import org.jetbrains.kotlin.idea.KotlinLanguage +import org.jetbrains.kotlin.platform.jvm.JvmPlatforms +import org.jetbrains.kotlin.psi.KtFile +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.pathString + +class Compiler( + intellijPluginRoot: Path, + jdkHome: Path, + jdkRelease: Int, + languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, + configureSession: StandaloneAnalysisAPISessionBuilder.() -> Unit = {}, +) : AutoCloseable { + private val logger = LoggerFactory.getLogger(Compiler::class.java) + private val fileSystem = + VirtualFileManager.getInstance().getFileSystem(StandardFileSystems.FILE_PROTOCOL) + + private val defaultCompilationEnv = CompilationEnvironment( + intellijPluginRoot, + jdkHome, + jdkRelease, + languageVersion, + configureSession, + ) + + fun compilationEnvironmentFor(compilationKind: CompilationKind): CompilationEnvironment = + when (compilationKind) { + CompilationKind.Default -> defaultCompilationEnv + CompilationKind.Script -> throw UnsupportedOperationException("Not supported yet") + } + + fun psiFileFactoryFor(compilationKind: CompilationKind): PsiFileFactory = + PsiFileFactory.getInstance(compilationEnvironmentFor(compilationKind).session.project) + + fun createPsiFileFor( + content: String, + file: Path = Paths.get("dummy.virtual.kt"), + language: Language = KotlinLanguage.INSTANCE, + compilationKind: CompilationKind = CompilationKind.Default + ): PsiFile { + require(!content.contains('\r')) + + val psiFile = psiFileFactoryFor(compilationKind).createFileFromText( + file.pathString, + language, + content, + true, + false + ) + check(psiFile.virtualFile != null) { + "No virtual-file associated with newly created psiFile" + } + + return psiFile + } + + fun createKtFile( + content: String, + file: Path = Paths.get("dummy.virtual.kt"), + compilationKind: CompilationKind = CompilationKind.Default + ): KtFile = + createPsiFileFor(content, file, KotlinLanguage.INSTANCE, compilationKind) as KtFile + + override fun close() { + defaultCompilationEnv.close() + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilerExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilerExts.kt new file mode 100644 index 0000000000..28e8dcdf9b --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilerExts.kt @@ -0,0 +1,10 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import org.jetbrains.kotlin.config.JvmTarget +import org.jetbrains.kotlin.config.LanguageVersion + +internal val DEFAULT_LANGUAGE_VERSION = + LanguageVersion.LATEST_STABLE + +internal val DEFAULT_JVM_TARGET = + JvmTarget.JVM_11 diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/AndroidModule.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/AndroidModule.kt index a91c1ab5e7..39a70b9cc6 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/AndroidModule.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/AndroidModule.kt @@ -83,7 +83,8 @@ open class AndroidModule( override fun getClassPaths(): Set = getModuleClasspaths() - fun getVariant(name: String): AndroidModels.AndroidVariant? = variantList.firstOrNull { it.name == name } + fun getVariant(name: String): AndroidModels.AndroidVariant? = + variantList.firstOrNull { it.name == name } fun getResourceDirectories(): Set { if (mainSourceSet == null) { @@ -140,14 +141,24 @@ open class AndroidModule( addAll(getSelectedVariant()?.mainArtifact?.classJars ?: emptyList()) } - override fun getCompileClasspaths(): Set { + override fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set { val project = IProjectManager.getInstance().workspace ?: return emptySet() val result = mutableSetOf() - result.addAll(getModuleClasspaths()) + if (excludeSourceGeneratedClassPath) { + // TODO: The mainArtifact.classJars are technically generated from source files + // But they're also kind-of not and are required for resolving R.* symbols + // Should we instead split this API into more fine-tuned getters? + result.addAll( + getSelectedVariant()?.mainArtifact?.classJars ?: emptyList() + ) + } else { + result.addAll(getModuleClasspaths()) + } collectLibraries( root = project, libraries = variantDependencies.mainArtifact?.compileDependencyList ?: emptyList(), result = result, + excludeSourceGeneratedClassPath = excludeSourceGeneratedClassPath, ) return result } @@ -169,7 +180,10 @@ open class AndroidModule( .forEach { result.add(it) } } - val rClassDir = File(buildDirectory, "intermediates/compile_and_runtime_not_namespaced_r_class_jar/$variant") + val rClassDir = File( + buildDirectory, + "intermediates/compile_and_runtime_not_namespaced_r_class_jar/$variant" + ) if (rClassDir.exists()) { rClassDir.walkTopDown() .filter { it.name == "R.jar" && it.isFile } @@ -184,7 +198,11 @@ open class AndroidModule( val variant = getSelectedVariant()?.name ?: "debug" val buildDirectory = delegate.buildDir - log.info("getRuntimeDexFiles: buildDir={}, variant={}", buildDirectory.absolutePath, variant) + log.info( + "getRuntimeDexFiles: buildDir={}, variant={}", + buildDirectory.absolutePath, + variant + ) val dexDir = File(buildDirectory, "intermediates/dex/$variant") log.info(" Checking dexDir: {} (exists: {})", dexDir.absolutePath, dexDir.exists()) @@ -198,7 +216,11 @@ open class AndroidModule( } val mergeProjectDexDir = File(buildDirectory, "intermediates/project_dex_archive/$variant") - log.info(" Checking project_dex_archive: {} (exists: {})", mergeProjectDexDir.absolutePath, mergeProjectDexDir.exists()) + log.info( + " Checking project_dex_archive: {} (exists: {})", + mergeProjectDexDir.absolutePath, + mergeProjectDexDir.exists() + ) if (mergeProjectDexDir.exists()) { mergeProjectDexDir.walkTopDown() .filter { it.name.endsWith(".dex") && it.isFile } @@ -216,6 +238,7 @@ open class AndroidModule( root: Workspace, libraries: List, result: MutableSet, + excludeSourceGeneratedClassPath: Boolean = false, ) { val libraryMap = variantDependencies.librariesMap for (library in libraries) { @@ -226,7 +249,7 @@ open class AndroidModule( continue } - result.addAll(module.getCompileClasspaths()) + result.addAll(module.getCompileClasspaths(excludeSourceGeneratedClassPath)) } else if (lib.type == AndroidModels.LibraryType.ExternalAndroidLibrary && lib.hasAndroidLibraryData()) { result.addAll(lib.androidLibraryData.compileJarFiles) } else if (lib.type == AndroidModels.LibraryType.ExternalJavaLibrary && lib.hasArtifactPath()) { @@ -386,7 +409,8 @@ open class AndroidModule( var deps: Int val androidLibraries = variantDependencies.librariesMap.values.mapNotNull { library -> - val packageName = library.androidLibraryData?.findPackageName() ?: UNKNOWN_PACKAGE + val packageName = + library.androidLibraryData?.findPackageName() ?: UNKNOWN_PACKAGE if (library.type != AndroidModels.LibraryType.ExternalAndroidLibrary || !library.hasAndroidLibraryData() || !library.androidLibraryData!!.resFolder.exists() || @@ -563,5 +587,6 @@ open class AndroidModule( return variant } - private fun getPlatformDir() = bootClassPaths.firstOrNull { it.name == "android.jar" }?.parentFile + private fun getPlatformDir() = + bootClassPaths.firstOrNull { it.name == "android.jar" }?.parentFile } diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/JavaModule.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/JavaModule.kt index 95cca3a7a7..e24dc9079a 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/JavaModule.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/JavaModule.kt @@ -86,9 +86,18 @@ class JavaModule( override fun getModuleClasspaths(): Set = mutableSetOf(classesJar) - override fun getCompileClasspaths(): Set { - val classpaths = getModuleClasspaths().toMutableSet() - getCompileModuleProjects().forEach { classpaths.addAll(it.getCompileClasspaths()) } + override fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set { + val classpaths = + if (excludeSourceGeneratedClassPath) mutableSetOf() else getModuleClasspaths().toMutableSet() + + getCompileModuleProjects().forEach { + classpaths.addAll( + it.getCompileClasspaths( + excludeSourceGeneratedClassPath + ) + ) + } + classpaths.addAll(getDependencyClassPaths()) return classpaths } @@ -126,9 +135,9 @@ class JavaModule( ): Boolean = this.dependencyList.any { dependency -> dependency.hasExternalLibrary() && - dependency.externalLibrary.libraryInfo?.let { artifact -> - artifact.group == group && artifact.name == name - } ?: false + dependency.externalLibrary.libraryInfo?.let { artifact -> + artifact.group == group && artifact.name == name + } ?: false } fun getDependencyClassPaths(): Set = diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt index de2b35e503..5f45acc408 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/api/ModuleProject.kt @@ -98,9 +98,12 @@ abstract class ModuleProject( * Get the classpaths with compile scope. This must include classpaths of transitive project * dependencies as well. This includes classpaths for this module as well. * + * @param excludeSourceGeneratedClassPath Whether to exclude classpath that's generated from + * source files of this module or its dependencies. Defaults to `false`. * @return The source directories. */ - abstract fun getCompileClasspaths(): Set + abstract fun getCompileClasspaths(excludeSourceGeneratedClassPath: Boolean): Set + fun getCompileClasspaths() = getCompileClasspaths(false) /** * Get the intermediate build output classpaths for this module. From bf7acd1032ea5e65013751788355d5ce2c75a133 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 24 Mar 2026 21:31:46 +0530 Subject: [PATCH 09/29] fix: JvmTarget resolution fails for input "21" Signed-off-by: Akash Yadav --- .../java/providers/JavaDiagnosticProvider.kt | 232 +++++++++--------- .../lsp/kotlin/KotlinLanguageServer.kt | 8 +- .../projects/models/ActiveDocument.kt | 24 +- 3 files changed, 133 insertions(+), 131 deletions(-) diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/JavaDiagnosticProvider.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/JavaDiagnosticProvider.kt index 8448d4401e..78671f4d67 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/JavaDiagnosticProvider.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/providers/JavaDiagnosticProvider.kt @@ -37,120 +37,120 @@ import java.util.concurrent.atomic.AtomicBoolean */ class JavaDiagnosticProvider { - private val analyzeTimestamps = mutableMapOf() - private var cachedDiagnostics = DiagnosticResult.NO_UPDATE - private var analyzing = AtomicBoolean(false) - private var analyzingThread: AnalyzingThread? = null - - companion object { - - private val log = LoggerFactory.getLogger(JavaDiagnosticProvider::class.java) - } - - fun analyze(file: Path): DiagnosticResult { - - val module = IProjectManager.getInstance().findModuleForFile(file, false) - ?: return DiagnosticResult.NO_UPDATE - val compiler = JavaCompilerService(module) - - abortIfCancelled() - - log.debug("Analyzing: {}", file) - - val modifiedAt = FileManager.getLastModified(file) - val analyzedAt = analyzeTimestamps[file] - - if (analyzedAt?.isAfter(modifiedAt) == true) { - log.debug("Using cached analyze results...") - return cachedDiagnostics - } - - analyzingThread?.let { analyzingThread -> - if (analyzing.get()) { - log.debug("Cancelling currently analyzing thread...") - ProgressManager.instance.cancel(analyzingThread) - this.analyzingThread = null - } - } - - analyzing.set(true) - - val analyzingThread = AnalyzingThread(compiler, file).also { - analyzingThread = it - it.start() - it.join() - } - - return analyzingThread.result.also { - this.analyzingThread = null - } - } - - fun isAnalyzing(): Boolean { - return this.analyzing.get() - } - - fun cancel() { - this.analyzingThread?.cancel() - } - - fun clearTimestamp(file: Path) { - analyzeTimestamps.remove(file) - } - - private fun doAnalyze(file: Path, task: CompileTask): DiagnosticResult { - val result = - if (!isTaskValid(task)) { - // Do not use Collections.emptyList () - // The returned list is accessed and the list returned by Collections.emptyList() - // throws exception when trying to access. - log.info("Using cached diagnostics") - cachedDiagnostics - } else - DiagnosticResult( - file, - findDiagnostics(task, file).sortedBy { - it.range - } - ) - return result.also { - log.info("Analyze file completed. Found {} diagnostic items", result.diagnostics.size) - } - } - - private fun isTaskValid(task: CompileTask?): Boolean { - abortIfCancelled() - return task?.task != null && task.roots != null && task.roots.size > 0 - } - - inner class AnalyzingThread(val compiler: JavaCompilerService, val file: Path) : - Thread("JavaAnalyzerThread") { - - var result: DiagnosticResult = DiagnosticResult.NO_UPDATE - - fun cancel() { - ProgressManager.instance.cancel(this) - } - - override fun run() { - result = - try { - compiler.compile(file).get { task -> doAnalyze(file, task) } - } catch (err: Throwable) { - if (CancelChecker.isCancelled(err)) { - log.error("Analyze request cancelled") - } else { - log.warn("Unable to analyze file", err) - } - DiagnosticResult.NO_UPDATE - } finally { - compiler.destroy() - analyzing.set(false) - } - .also { - cachedDiagnostics = it - analyzeTimestamps[file] = Instant.now() - } - } - } + private val analyzeTimestamps = mutableMapOf() + private var cachedDiagnostics = DiagnosticResult.NO_UPDATE + private var analyzing = AtomicBoolean(false) + private var analyzingThread: AnalyzingThread? = null + + companion object { + + private val log = LoggerFactory.getLogger(JavaDiagnosticProvider::class.java) + } + + fun analyze(file: Path): DiagnosticResult { + + val module = IProjectManager.getInstance().findModuleForFile(file, false) + ?: return DiagnosticResult.NO_UPDATE + val compiler = JavaCompilerService(module) + + abortIfCancelled() + + log.debug("Analyzing: {}", file) + + val modifiedAt = FileManager.getLastModified(file) + val analyzedAt = analyzeTimestamps[file] + + if (analyzedAt?.isAfter(modifiedAt) == true) { + log.debug("Using cached analyze results...") + return cachedDiagnostics + } + + analyzingThread?.let { analyzingThread -> + if (analyzing.get()) { + log.debug("Cancelling currently analyzing thread...") + ProgressManager.instance.cancel(analyzingThread) + this.analyzingThread = null + } + } + + analyzing.set(true) + + val analyzingThread = AnalyzingThread(compiler, file).also { + analyzingThread = it + it.start() + it.join() + } + + return analyzingThread.result.also { + this.analyzingThread = null + } + } + + fun isAnalyzing(): Boolean { + return this.analyzing.get() + } + + fun cancel() { + this.analyzingThread?.cancel() + } + + fun clearTimestamp(file: Path) { + analyzeTimestamps.remove(file) + } + + private fun doAnalyze(file: Path, task: CompileTask): DiagnosticResult { + val result = + if (!isTaskValid(task)) { + // Do not use Collections.emptyList () + // The returned list is accessed and the list returned by Collections.emptyList() + // throws exception when trying to access. + log.info("Using cached diagnostics") + cachedDiagnostics + } else + DiagnosticResult( + file, + findDiagnostics(task, file).sortedBy { + it.range + } + ) + return result.also { + log.info("Analyze file completed. Found {} diagnostic items", result.diagnostics.size) + } + } + + private fun isTaskValid(task: CompileTask?): Boolean { + abortIfCancelled() + return task?.task != null && task.roots != null && task.roots.size > 0 + } + + inner class AnalyzingThread(val compiler: JavaCompilerService, val file: Path) : + Thread("JavaAnalyzerThread") { + + var result: DiagnosticResult = DiagnosticResult.NO_UPDATE + + fun cancel() { + ProgressManager.instance.cancel(this) + } + + override fun run() { + result = + try { + compiler.compile(file).get { task -> doAnalyze(file, task) } + } catch (err: Throwable) { + if (CancelChecker.isCancelled(err)) { + log.error("Analyze request cancelled") + } else { + log.warn("Unable to analyze file", err) + } + DiagnosticResult.NO_UPDATE + } finally { + compiler.destroy() + analyzing.set(false) + } + .also { + cachedDiagnostics = it + analyzeTimestamps[file] = Instant.now() + } + } + } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index bf9275787f..149e00fc09 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -119,8 +119,10 @@ class KotlinLanguageServer : ILanguageServer { .baseInstance.applicationInfo.sourceDir ) - val jdkPlatform = JvmPlatforms.jvmPlatformByTargetVersion( - JvmTarget.supportedValues().first { it.majorVersion == jdkRelease }) + val jvmTarget = JvmTarget.fromString(IJdkDistributionProvider.DEFAULT_JAVA_VERSION) + ?: JvmTarget.JVM_21 + + val jvmPlatform = JvmPlatforms.jvmPlatformByTargetVersion(jvmTarget) compiler = Compiler( intellijPluginRoot = intellijPluginRoot, @@ -129,7 +131,7 @@ class KotlinLanguageServer : ILanguageServer { languageVersion = LanguageVersion.LATEST_STABLE ) { buildKtModuleProvider { - platform = jdkPlatform + platform = jvmPlatform val moduleProjects = workspace.subProjects diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/models/ActiveDocument.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/models/ActiveDocument.kt index 5f23eeb68a..42b0b7e6cc 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/models/ActiveDocument.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/models/ActiveDocument.kt @@ -28,20 +28,20 @@ import java.time.Instant * @author Akash Yadav */ open class ActiveDocument( - val file: Path, - var version: Int, - var modified: Instant, - content: String = "" + val file: Path, + var version: Int, + var modified: Instant, + content: String = "" ) { - var content: String = content - internal set + var content: String = content + internal set - fun inputStream(): BufferedInputStream { - return content.byteInputStream().buffered() - } + fun inputStream(): BufferedInputStream { + return content.byteInputStream().buffered() + } - fun reader(): BufferedReader { - return content.reader().buffered() - } + fun reader(): BufferedReader { + return content.reader().buffered() + } } From dc62a51d5feb9625e0676dd99d6c506db8bf2fbb Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 24 Mar 2026 21:32:28 +0530 Subject: [PATCH 10/29] fix: do not early-init VirtualFileSystem Signed-off-by: Akash Yadav --- .../androidide/lsp/kotlin/compiler/Compiler.kt | 16 +++++----------- 1 file changed, 5 insertions(+), 11 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index 5baf9f6614..2bbaf4bbee 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -2,14 +2,10 @@ package com.itsaky.androidide.lsp.kotlin.compiler import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISessionBuilder import org.jetbrains.kotlin.com.intellij.lang.Language -import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems -import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.com.intellij.psi.PsiFile import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory -import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.config.LanguageVersion import org.jetbrains.kotlin.idea.KotlinLanguage -import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory import java.nio.file.Path @@ -24,15 +20,13 @@ class Compiler( configureSession: StandaloneAnalysisAPISessionBuilder.() -> Unit = {}, ) : AutoCloseable { private val logger = LoggerFactory.getLogger(Compiler::class.java) - private val fileSystem = - VirtualFileManager.getInstance().getFileSystem(StandardFileSystems.FILE_PROTOCOL) private val defaultCompilationEnv = CompilationEnvironment( - intellijPluginRoot, - jdkHome, - jdkRelease, - languageVersion, - configureSession, + intellijPluginRoot = intellijPluginRoot, + jdkHome = jdkHome, + jdkRelease = jdkRelease, + languageVersion = languageVersion, + configureSession = configureSession, ) fun compilationEnvironmentFor(compilationKind: CompilationKind): CompilationEnvironment = From 4b1c8e4e0d2769bb4116ed9ca98c9346512fa8f3 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 25 Mar 2026 16:28:41 +0530 Subject: [PATCH 11/29] fix: remove replaceClass desugar instruction for Unsafe Signed-off-by: Akash Yadav --- app/build.gradle.kts | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 554718fb66..d9dcb121a5 100755 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -205,18 +205,6 @@ desugaring { EnvUtil::logbackVersion.javaMethod!!, DesugarEnvUtil::logbackVersion.javaMethod!!, ) - - // Replace usages of Unsafe class (from com.intellij.util.containers) - // with our own implementation - // The original implementation uses MethodHandle instances to access APIs - // from sun.misc.Unsafe which are not directly accessible on Android - // As a result, we have our implementatio of that class which makes use - // of HiddenApiBypass to access the same methods, and provides a drop-in - // replacement of the original class - replaceClass( - "org.jetbrains.kotlin.com.intellij.util.containers.Unsafe", - "org.jetbrains.kotlin.com.intellij.util.containers.UnsafeImpl", - ) } } From b14f6ee0aa096dbf5b495c1f603d9f4acc7c73d9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 26 Mar 2026 16:35:23 +0530 Subject: [PATCH 12/29] fix: ensure boot class path is added as dependency to Android modules Signed-off-by: Akash Yadav --- .../lsp/kotlin/KotlinLanguageServer.kt | 27 +++++++++++++- .../kotlin/compiler/CompilationEnvironment.kt | 37 ++++++++++++++++++- .../lsp/kotlin/compiler/Compiler.kt | 26 +++++++++---- 3 files changed, 80 insertions(+), 10 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index 149e00fc09..40c52ba404 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -38,8 +38,10 @@ import com.itsaky.androidide.lsp.models.ReferenceResult import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.models.Range +import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.api.ModuleProject import com.itsaky.androidide.projects.api.Workspace +import com.itsaky.androidide.projects.models.bootClassPaths import com.itsaky.androidide.utils.DocumentUtils import com.itsaky.androidide.utils.Environment import org.greenrobot.eventbus.EventBus @@ -50,12 +52,10 @@ import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryMod import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.config.LanguageVersion -import org.jetbrains.kotlin.platform.jvm.JdkPlatform import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.slf4j.LoggerFactory import java.nio.file.Path import java.nio.file.Paths -import kotlin.io.path.pathString class KotlinLanguageServer : ILanguageServer { @@ -138,11 +138,27 @@ class KotlinLanguageServer : ILanguageServer { .filterIsInstance() .filter { it.path != workspace.rootProject.path } + val bootClassPaths = + moduleProjects + .filterIsInstance() + .flatMap { project -> + project.bootClassPaths + .map { bootClassPath -> + addModule(buildKtLibraryModule { + this.platform = jvmPlatform + this.libraryName = bootClassPath.nameWithoutExtension + addBinaryRoot(bootClassPath.toPath()) + }) + } + } + val libraryDependencies = moduleProjects .flatMap { it.getCompileClasspaths() } .associateWith { library -> addModule(buildKtLibraryModule { + this.platform = jvmPlatform + this.libraryName = library.nameWithoutExtension addBinaryRoot(library.toPath()) }) } @@ -156,9 +172,16 @@ class KotlinLanguageServer : ILanguageServer { } val module = buildKtSourceModule { + this.platform = jvmPlatform + this.moduleName = project.name addSourceRoots( project.getSourceDirectories().map { it.toPath() }) + // always dependent on boot class paths, if any + bootClassPaths.forEach { bootClassPathModule -> + addRegularDependency(bootClassPathModule) + } + project.getCompileClasspaths(excludeSourceGeneratedClassPath = true) .forEach { classpath -> val libDependency = libraryDependencies[classpath] diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index 8414b2dc73..4c8a84d2f5 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -4,7 +4,12 @@ import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISession import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISessionBuilder import org.jetbrains.kotlin.analysis.api.standalone.buildStandaloneAnalysisAPISession import org.jetbrains.kotlin.cli.common.intellijPluginRoot +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity +import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation +import org.jetbrains.kotlin.cli.common.messages.MessageCollector import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager +import org.jetbrains.kotlin.com.intellij.psi.PsiManager import org.jetbrains.kotlin.config.ApiVersion import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.config.LanguageFeature @@ -13,10 +18,12 @@ import org.jetbrains.kotlin.config.LanguageVersionSettingsImpl import org.jetbrains.kotlin.config.jdkHome import org.jetbrains.kotlin.config.jdkRelease import org.jetbrains.kotlin.config.languageVersionSettings +import org.jetbrains.kotlin.config.messageCollector import org.jetbrains.kotlin.config.moduleName import org.jetbrains.kotlin.config.useFir import org.jetbrains.kotlin.metadata.jvm.deserialization.JvmProtoBufUtil import org.jetbrains.kotlin.psi.KtPsiFactory +import org.slf4j.LoggerFactory import java.nio.file.Path import kotlin.io.path.pathString @@ -39,6 +46,30 @@ class CompilationEnvironment( val session: StandaloneAnalysisAPISession val parser: KtPsiFactory + val psiManager: PsiManager + val psiDocumentManager: PsiDocumentManager + + private val envMessageCollector = object: MessageCollector { + override fun clear() { + } + + override fun report( + severity: CompilerMessageSeverity, + message: String, + location: CompilerMessageSourceLocation? + ) { + logger.info("[{}] {} ({})", severity.name, message, location) + } + + override fun hasErrors(): Boolean { + return false + } + + } + + companion object { + private val logger = LoggerFactory.getLogger(CompilationEnvironment::class.java) + } init { val configuration = CompilerConfiguration().apply { @@ -51,12 +82,14 @@ class CompilationEnvironment( analysisFlags = emptyMap(), specificFeatures = buildMap { // enable all features - LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED } + putAll(LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED }) } ) this.jdkHome = jdkHome.toFile() this.jdkRelease = jdkRelease + + this.messageCollector = envMessageCollector } session = buildStandaloneAnalysisAPISession( @@ -67,6 +100,8 @@ class CompilationEnvironment( ) parser = KtPsiFactory(session.project) + psiManager = PsiManager.getInstance(session.project) + psiDocumentManager = PsiDocumentManager.getInstance(session.project) } override fun close() { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index 2bbaf4bbee..9dc0ff24b5 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -2,6 +2,9 @@ package com.itsaky.androidide.lsp.kotlin.compiler import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISessionBuilder import org.jetbrains.kotlin.com.intellij.lang.Language +import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileSystem import org.jetbrains.kotlin.com.intellij.psi.PsiFile import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory import org.jetbrains.kotlin.config.LanguageVersion @@ -20,14 +23,23 @@ class Compiler( configureSession: StandaloneAnalysisAPISessionBuilder.() -> Unit = {}, ) : AutoCloseable { private val logger = LoggerFactory.getLogger(Compiler::class.java) + private val defaultCompilationEnv: CompilationEnvironment - private val defaultCompilationEnv = CompilationEnvironment( - intellijPluginRoot = intellijPluginRoot, - jdkHome = jdkHome, - jdkRelease = jdkRelease, - languageVersion = languageVersion, - configureSession = configureSession, - ) + val fileSystem: VirtualFileSystem + + init { + defaultCompilationEnv = CompilationEnvironment( + intellijPluginRoot = intellijPluginRoot, + jdkHome = jdkHome, + jdkRelease = jdkRelease, + languageVersion = languageVersion, + configureSession = configureSession, + ) + + // must be initialized AFTER the compilation env has been initialized + fileSystem = VirtualFileManager.getInstance() + .getFileSystem(StandardFileSystems.FILE_PROTOCOL) + } fun compilationEnvironmentFor(compilationKind: CompilationKind): CompilationEnvironment = when (compilationKind) { From 54ca7a94d5a79cac780530d5a10598c2e57b31f7 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 26 Mar 2026 16:35:53 +0530 Subject: [PATCH 13/29] feat: add diagnostic provider for Kotlin Signed-off-by: Akash Yadav --- .../lsp/kotlin/KotlinLanguageServer.kt | 58 ++++++- .../diagnostic/KotlinDiagnosticProvider.kt | 154 ++++++++++++++++++ 2 files changed, 211 insertions(+), 1 deletion(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index 40c52ba404..eb0546f19d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.IServerSettings import com.itsaky.androidide.lsp.kotlin.compiler.Compiler +import com.itsaky.androidide.lsp.kotlin.diagnostic.KotlinDiagnosticProvider import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult import com.itsaky.androidide.lsp.models.DefinitionParams @@ -38,12 +39,22 @@ import com.itsaky.androidide.lsp.models.ReferenceResult import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.models.Range +import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.api.ModuleProject import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.models.bootClassPaths import com.itsaky.androidide.utils.DocumentUtils import com.itsaky.androidide.utils.Environment +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancel +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -54,8 +65,10 @@ import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.config.LanguageVersion import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.slf4j.LoggerFactory +import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths +import kotlin.time.Duration.Companion.milliseconds class KotlinLanguageServer : ILanguageServer { @@ -64,7 +77,11 @@ class KotlinLanguageServer : ILanguageServer { private var selectedFile: Path? = null private var initialized = false + private val scope = + CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!)) private var compiler: Compiler? = null + private var diagnosticProvider: KotlinDiagnosticProvider? = null + private var analyzeJob: Job? = null override val serverId: String = SERVER_ID @@ -75,6 +92,9 @@ class KotlinLanguageServer : ILanguageServer { get() = _settings ?: KotlinServerSettings.getInstance().also { _settings = it } companion object { + + private val ANALYZE_DEBOUNCE_DELAY = 400.milliseconds + const val SERVER_ID = "ide.lsp.kotlin" private val log = LoggerFactory.getLogger(KotlinLanguageServer::class.java) } @@ -89,6 +109,7 @@ class KotlinLanguageServer : ILanguageServer { override fun shutdown() { EventBus.getDefault().unregister(this) + scope.cancel("LSP is being shut down") compiler?.close() initialized = false } @@ -110,6 +131,7 @@ class KotlinLanguageServer : ILanguageServer { } private fun recreateSession(workspace: Workspace) { + diagnosticProvider?.close() compiler?.close() val jdkHome = Environment.JAVA_HOME.toPath() @@ -211,6 +233,11 @@ class KotlinLanguageServer : ILanguageServer { } } } + + diagnosticProvider = KotlinDiagnosticProvider( + compiler = compiler!!, + scope = scope, + ) } override fun complete(params: CompletionParams?): CompletionResult { @@ -273,7 +300,8 @@ class KotlinLanguageServer : ILanguageServer { return DiagnosticResult.NO_UPDATE } - return DiagnosticResult.NO_UPDATE + return diagnosticProvider?.analyze(file) + ?: DiagnosticResult.NO_UPDATE } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -284,6 +312,27 @@ class KotlinLanguageServer : ILanguageServer { } selectedFile = event.openedFile + debouncingAnalyze() + } + + private fun debouncingAnalyze() { + analyzeJob?.cancel() + analyzeJob = scope.launch(Dispatchers.Default) { + delay(ANALYZE_DEBOUNCE_DELAY) + analyzeSelected() + } + } + + private suspend fun analyzeSelected() { + val file = selectedFile ?: return + val client = _client ?: return + + if (!Files.exists(file)) return + + val result = analyze(file) + withContext(Dispatchers.Main) { + client.publishDiagnostics(result) + } } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -292,6 +341,7 @@ class KotlinLanguageServer : ILanguageServer { if (!DocumentUtils.isKotlinFile(event.changedFile)) { return } + debouncingAnalyze() } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -300,6 +350,12 @@ class KotlinLanguageServer : ILanguageServer { if (!DocumentUtils.isKotlinFile(event.closedFile)) { return } + + diagnosticProvider?.clearTimestamp(event.closedFile) + if (FileManager.getActiveDocumentCount() == 0) { + selectedFile = null + analyzeJob?.cancel("No active files") + } } @Subscribe(threadMode = ThreadMode.ASYNC) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt new file mode 100644 index 0000000000..37e34d03af --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -0,0 +1,154 @@ +package com.itsaky.androidide.lsp.kotlin.diagnostic + +import com.itsaky.androidide.lsp.kotlin.compiler.CompilationKind +import com.itsaky.androidide.lsp.kotlin.compiler.Compiler +import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.lsp.models.DiagnosticResult +import com.itsaky.androidide.lsp.models.DiagnosticSeverity +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.projects.FileManager +import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter +import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi +import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity +import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager +import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange +import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager +import org.jetbrains.kotlin.com.intellij.psi.PsiFile +import org.jetbrains.kotlin.com.intellij.testFramework.LightVirtualFile +import org.jetbrains.kotlin.psi.KtFile +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.time.Instant +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.pathString +import org.jetbrains.kotlin.analysis.api.analyze as ktAnalyze + +class KotlinDiagnosticProvider( + private val compiler: Compiler, + private val scope: CoroutineScope +) : AutoCloseable { + + companion object { + private val logger = LoggerFactory.getLogger(KotlinDiagnosticProvider::class.java) + } + + private val analyzeTimestamps = ConcurrentHashMap() + + fun analyze(file: Path): DiagnosticResult = + try { + logger.info("Analyzing file: {}", file) + return doAnalyze(file) + } catch (err: Throwable) { + if (err is CancellationException) { + logger.debug("analysis cancelled") + throw err + } + logger.error("An error occurred analyzing file: {}", file, err) + return DiagnosticResult.NO_UPDATE + } + + @OptIn(KaExperimentalApi::class) + private fun doAnalyze(file: Path): DiagnosticResult { + val modifiedAt = FileManager.getLastModified(file) + val analyzedAt = analyzeTimestamps[file] + if (analyzedAt?.isAfter(modifiedAt) == true) { + logger.debug("Skipping analysis. File unmodified.") + return DiagnosticResult.NO_UPDATE + } + + logger.info("fetch document contents") + val fileContents = FileManager.getDocumentContents(file) + .replace("\r", "") + + val env = compiler.compilationEnvironmentFor(CompilationKind.Default) + val virtualFile = compiler.fileSystem.refreshAndFindFileByPath(file.pathString) + if (virtualFile == null) { + logger.warn("Unable to find virtual file for path: {}", file.pathString) + return DiagnosticResult.NO_UPDATE + } + + val ktFile = env.psiManager.findFile(virtualFile) + if (ktFile == null) { + logger.warn("Unable to find KtFile for path: {}", file.pathString) + return DiagnosticResult.NO_UPDATE + } + + if (ktFile !is KtFile) { + logger.warn("Expected KtFile, but found {} for path:{}", ktFile.javaClass, file.pathString) + return DiagnosticResult.NO_UPDATE + } + + val inMemoryPsi = compiler.createKtFile(fileContents, file, CompilationKind.Default) + inMemoryPsi.originalFile = ktFile + + val rawDiagnostics = ktAnalyze(inMemoryPsi) { + logger.info("ktFile.text={}", inMemoryPsi.text) + ktFile.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) + } + + logger.info("Found {} diagnostics", rawDiagnostics.size) + + return DiagnosticResult( + file = file, + diagnostics = rawDiagnostics.map { rawDiagnostic -> + rawDiagnostic.toDiagnosticItem() + } + ).also { + analyzeTimestamps[file] = Instant.now() + } + } + + internal fun clearTimestamp(file: Path) { + analyzeTimestamps.remove(file) + } + + override fun close() { + scope.cancelIfActive("diagnostic provider is being destroyed") + } +} + +private fun KaDiagnosticWithPsi<*>.toDiagnosticItem(): DiagnosticItem { + val range = psi.textRange.toRange(psi.containingFile) + val severity = severity.toDiagnosticSeverity() + return DiagnosticItem( + message = defaultMessage, + code = "", + range = range, + source = "Kotlin", + severity = severity, + ) +} + +private fun KaSeverity.toDiagnosticSeverity(): DiagnosticSeverity { + return when (this) { + KaSeverity.ERROR -> DiagnosticSeverity.ERROR + KaSeverity.WARNING -> DiagnosticSeverity.WARNING + KaSeverity.INFO -> DiagnosticSeverity.INFO + } +} + +private fun TextRange.toRange(containingFile: PsiFile): Range { + val doc = PsiDocumentManager.getInstance(containingFile.project) + .getDocument(containingFile) ?: return Range.NONE + val startLine = doc.getLineNumber(startOffset) + val startCol = startOffset - doc.getLineStartOffset(startLine) + val endLine = doc.getLineNumber(endOffset) + val endCol = endOffset - doc.getLineStartOffset(endLine) + return Range( + start = Position( + line = startLine, + column = startCol, + index = startOffset, + ), + end = Position( + line = endLine, + column = endCol, + index = endOffset, + ) + ) +} From 6e4d4581e7c5fda8998ca628239de2c7ccebf16f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 26 Mar 2026 16:42:08 +0530 Subject: [PATCH 14/29] fix: remove unnecessary log statement Signed-off-by: Akash Yadav --- .../androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt | 1 - 1 file changed, 1 deletion(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt index 37e34d03af..73be5b1484 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -87,7 +87,6 @@ class KotlinDiagnosticProvider( inMemoryPsi.originalFile = ktFile val rawDiagnostics = ktAnalyze(inMemoryPsi) { - logger.info("ktFile.text={}", inMemoryPsi.text) ktFile.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) } From 5d841a321fc186bf5bad453fd16c15ef8ecaf809 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 26 Mar 2026 19:46:21 +0530 Subject: [PATCH 15/29] fix: update to latest kotlin-android release Signed-off-by: Akash Yadav --- subprojects/kotlin-analysis-api/build.gradle.kts | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/subprojects/kotlin-analysis-api/build.gradle.kts b/subprojects/kotlin-analysis-api/build.gradle.kts index 57fe554dae..2e4f08710f 100644 --- a/subprojects/kotlin-analysis-api/build.gradle.kts +++ b/subprojects/kotlin-analysis-api/build.gradle.kts @@ -12,7 +12,7 @@ android { val ktAndroidRepo = "https://github.com/appdevforall/kotlin-android" val ktAndroidVersion = "2.3.255" -val ktAndroidTag = "v${ktAndroidVersion}-a98fda0" +val ktAndroidTag = "v${ktAndroidVersion}-f047b07" val ktAndroidJarName = "analysis-api-standalone-embeddable-for-ide-${ktAndroidVersion}-SNAPSHOT.jar" externalAssets { @@ -21,7 +21,7 @@ externalAssets { source = AssetSource.External( url = uri("$ktAndroidRepo/releases/download/$ktAndroidTag/$ktAndroidJarName"), - sha256Checksum = "804781ae6c6cdbc5af1ca9a08959af9552395d48704a6c5fcb43b5516cb3e378", + sha256Checksum = "c9897c94ae1431fadeb4fa5b05dd4d478a60c4589f38f801e07c72405a7b34b1", ) } } From 4b7b0f2824a896f33a2aa6134cc6cd2f73510f07 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 26 Mar 2026 19:47:42 +0530 Subject: [PATCH 16/29] fix: always re-initialize K2 session on setupWithProject Signed-off-by: Akash Yadav --- .../itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt | 6 ++---- 1 file changed, 2 insertions(+), 4 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index eb0546f19d..d8f45ab761 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -124,10 +124,8 @@ class KotlinLanguageServer : ILanguageServer { override fun setupWithProject(workspace: Workspace) { log.info("setupWithProject called, initialized={}", initialized) - if (!initialized) { - recreateSession(workspace) - initialized = true - } + recreateSession(workspace) + initialized = true } private fun recreateSession(workspace: Workspace) { From e2f137abc603cf112466b02283b0dd0f7db40a3b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 26 Mar 2026 19:48:10 +0530 Subject: [PATCH 17/29] fix: diagnostics are always collected from the on-disk file Signed-off-by: Akash Yadav --- .../kotlin/compiler/CompilationEnvironment.kt | 3 ++- .../lsp/kotlin/compiler/Compiler.kt | 5 +++++ .../diagnostic/KotlinDiagnosticProvider.kt | 21 +++++++++++++++---- 3 files changed, 24 insertions(+), 5 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index 4c8a84d2f5..f49753a39f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -40,6 +40,7 @@ class CompilationEnvironment( jdkHome: Path, jdkRelease: Int, languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, + enableParserEventSystem: Boolean = true, configureSession: StandaloneAnalysisAPISessionBuilder.() -> Unit = {} ) : AutoCloseable { private val disposable = Disposer.newDisposable() @@ -99,7 +100,7 @@ class CompilationEnvironment( init = configureSession ) - parser = KtPsiFactory(session.project) + parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) psiManager = PsiManager.getInstance(session.project) psiDocumentManager = PsiDocumentManager.getInstance(session.project) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index 9dc0ff24b5..263f554e02 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -10,6 +10,7 @@ import org.jetbrains.kotlin.com.intellij.psi.PsiFileFactory import org.jetbrains.kotlin.config.LanguageVersion import org.jetbrains.kotlin.idea.KotlinLanguage import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path import java.nio.file.Paths @@ -27,12 +28,16 @@ class Compiler( val fileSystem: VirtualFileSystem + val defaultKotlinParser: KtPsiFactory + get() = defaultCompilationEnv.parser + init { defaultCompilationEnv = CompilationEnvironment( intellijPluginRoot = intellijPluginRoot, jdkHome = jdkHome, jdkRelease = jdkRelease, languageVersion = languageVersion, + enableParserEventSystem = true, configureSession = configureSession, ) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt index 73be5b1484..9b984adaaa 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -12,9 +12,12 @@ import com.itsaky.androidide.tasks.cancelIfActive import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.analyzeCopy import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity +import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileModule +import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager @@ -25,6 +28,7 @@ import org.slf4j.LoggerFactory import java.nio.file.Path import java.time.Instant import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.name import kotlin.io.path.pathString import org.jetbrains.kotlin.analysis.api.analyze as ktAnalyze @@ -79,15 +83,24 @@ class KotlinDiagnosticProvider( } if (ktFile !is KtFile) { - logger.warn("Expected KtFile, but found {} for path:{}", ktFile.javaClass, file.pathString) + logger.warn( + "Expected KtFile, but found {} for path:{}", + ktFile.javaClass, + file.pathString + ) return DiagnosticResult.NO_UPDATE } - val inMemoryPsi = compiler.createKtFile(fileContents, file, CompilationKind.Default) + val inMemoryPsi = compiler.defaultKotlinParser + .createFile(file.name, fileContents) inMemoryPsi.originalFile = ktFile - val rawDiagnostics = ktAnalyze(inMemoryPsi) { - ktFile.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) + val rawDiagnostics = analyzeCopy( + useSiteElement = inMemoryPsi, + resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, + ) { + logger.info("ktFile.text={}", inMemoryPsi.text) + inMemoryPsi.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) } logger.info("Found {} diagnostics", rawDiagnostics.size) From 09fc9ea123fcfe7f168b128c08979dfe02accdcb Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 30 Mar 2026 19:35:07 +0530 Subject: [PATCH 18/29] feat: add the ability to incrementally invalidate source roots on project re-sync Signed-off-by: Akash Yadav --- .../lsp/kotlin/KotlinLanguageServer.kt | 141 +++---------- .../kotlin/compiler/CompilationEnvironment.kt | 196 +++++++++++++++--- .../lsp/kotlin/compiler/Compiler.kt | 6 +- .../IncrementalModificationTracker.kt | 22 ++ .../lsp/kotlin/compiler/KotlinProjectModel.kt | 166 +++++++++++++++ .../diagnostic/KotlinDiagnosticProvider.kt | 5 + 6 files changed, 392 insertions(+), 144 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/IncrementalModificationTracker.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index d8f45ab761..da9a96664a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.IServerSettings import com.itsaky.androidide.lsp.kotlin.compiler.Compiler +import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel import com.itsaky.androidide.lsp.kotlin.diagnostic.KotlinDiagnosticProvider import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult @@ -40,10 +41,7 @@ import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.FileManager -import com.itsaky.androidide.projects.api.AndroidModule -import com.itsaky.androidide.projects.api.ModuleProject import com.itsaky.androidide.projects.api.Workspace -import com.itsaky.androidide.projects.models.bootClassPaths import com.itsaky.androidide.utils.DocumentUtils import com.itsaky.androidide.utils.Environment import kotlinx.coroutines.CoroutineName @@ -58,9 +56,6 @@ import kotlinx.coroutines.withContext import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode -import org.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule -import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule -import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.config.LanguageVersion import org.jetbrains.kotlin.platform.jvm.JvmPlatforms @@ -79,6 +74,7 @@ class KotlinLanguageServer : ILanguageServer { private val scope = CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!)) + private var projectModel: KotlinProjectModel? = null private var compiler: Compiler? = null private var diagnosticProvider: KotlinDiagnosticProvider? = null private var analyzeJob: Job? = null @@ -96,7 +92,7 @@ class KotlinLanguageServer : ILanguageServer { private val ANALYZE_DEBOUNCE_DELAY = 400.milliseconds const val SERVER_ID = "ide.lsp.kotlin" - private val log = LoggerFactory.getLogger(KotlinLanguageServer::class.java) + private val logger = LoggerFactory.getLogger(KotlinLanguageServer::class.java) } init { @@ -110,6 +106,7 @@ class KotlinLanguageServer : ILanguageServer { override fun shutdown() { EventBus.getDefault().unregister(this) scope.cancel("LSP is being shut down") + diagnosticProvider?.close() compiler?.close() initialized = false } @@ -123,14 +120,7 @@ class KotlinLanguageServer : ILanguageServer { } override fun setupWithProject(workspace: Workspace) { - log.info("setupWithProject called, initialized={}", initialized) - recreateSession(workspace) - initialized = true - } - - private fun recreateSession(workspace: Workspace) { - diagnosticProvider?.close() - compiler?.close() + logger.info("setupWithProject called, initialized={}", initialized) val jdkHome = Environment.JAVA_HOME.toPath() val jdkRelease = IJdkDistributionProvider.DEFAULT_JAVA_RELEASE @@ -144,98 +134,31 @@ class KotlinLanguageServer : ILanguageServer { val jvmPlatform = JvmPlatforms.jvmPlatformByTargetVersion(jvmTarget) - compiler = Compiler( - intellijPluginRoot = intellijPluginRoot, - jdkHome = jdkHome, - jdkRelease = jdkRelease, - languageVersion = LanguageVersion.LATEST_STABLE - ) { - buildKtModuleProvider { - platform = jvmPlatform - - val moduleProjects = - workspace.subProjects - .filterIsInstance() - .filter { it.path != workspace.rootProject.path } - - val bootClassPaths = - moduleProjects - .filterIsInstance() - .flatMap { project -> - project.bootClassPaths - .map { bootClassPath -> - addModule(buildKtLibraryModule { - this.platform = jvmPlatform - this.libraryName = bootClassPath.nameWithoutExtension - addBinaryRoot(bootClassPath.toPath()) - }) - } - } - - val libraryDependencies = - moduleProjects - .flatMap { it.getCompileClasspaths() } - .associateWith { library -> - addModule(buildKtLibraryModule { - this.platform = jvmPlatform - this.libraryName = library.nameWithoutExtension - addBinaryRoot(library.toPath()) - }) - } - - val subprojectsAsModules = mutableMapOf() - - fun getOrCreateModule(project: ModuleProject): KaSourceModule { - subprojectsAsModules[project]?.also { module -> - // a source module already exists for this project - return module - } - - val module = buildKtSourceModule { - this.platform = jvmPlatform - this.moduleName = project.name - addSourceRoots( - project.getSourceDirectories().map { it.toPath() }) - - // always dependent on boot class paths, if any - bootClassPaths.forEach { bootClassPathModule -> - addRegularDependency(bootClassPathModule) - } - - project.getCompileClasspaths(excludeSourceGeneratedClassPath = true) - .forEach { classpath -> - val libDependency = libraryDependencies[classpath] - if (libDependency == null) { - log.error( - "Unable to locate library module for classpath: {}", - libDependency - ) - return@forEach - } - - addRegularDependency(libDependency) - } - - project.getCompileModuleProjects() - .forEach { dependencyModule -> - addRegularDependency(getOrCreateModule(dependencyModule)) - } - } - - subprojectsAsModules[project] = module - return module - } - - moduleProjects.forEach { project -> - addModule(getOrCreateModule(project)) - } - } + if (!initialized) { + logger.info("Creating initial analysis session") + + val model = KotlinProjectModel() + model.update(workspace, jvmPlatform) + this.projectModel = model + + val compiler = Compiler( + projectModel = model, + intellijPluginRoot = intellijPluginRoot, + jdkHome = jdkHome, + jdkRelease = jdkRelease, + languageVersion = LanguageVersion.LATEST_STABLE, + ) + + this.compiler = compiler + this.diagnosticProvider = KotlinDiagnosticProvider(compiler, scope) + } else { + logger.info("Updating project model") + + projectModel?.update(workspace, jvmPlatform) } - diagnosticProvider = KotlinDiagnosticProvider( - compiler = compiler!!, - scope = scope, - ) + initialized = true + logger.info("Kotlin project initialized") } override fun complete(params: CompletionParams?): CompletionResult { @@ -283,10 +206,10 @@ class KotlinLanguageServer : ILanguageServer { } override suspend fun analyze(file: Path): DiagnosticResult { - log.debug("analyze(file={})", file) + logger.debug("analyze(file={})", file) if (!settings.diagnosticsEnabled() || !settings.codeAnalysisEnabled()) { - log.debug( + logger.debug( "analyze() skipped: diagnosticsEnabled={}, codeAnalysisEnabled={}", settings.diagnosticsEnabled(), settings.codeAnalysisEnabled() ) @@ -294,7 +217,7 @@ class KotlinLanguageServer : ILanguageServer { } if (!DocumentUtils.isKotlinFile(file)) { - log.debug("analyze() skipped: not a Kotlin file") + logger.debug("analyze() skipped: not a Kotlin file") return DiagnosticResult.NO_UPDATE } @@ -366,6 +289,6 @@ class KotlinLanguageServer : ILanguageServer { selectedFile = event.selectedFile val uri = event.selectedFile.toUri().toString() - log.debug("onDocumentSelected: uri={}", uri) + logger.debug("onDocumentSelected: uri={}", uri) } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index f49753a39f..1b5cc52b0c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -1,13 +1,26 @@ package com.itsaky.androidide.lsp.kotlin.compiler +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolverFactory +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProviderFactory +import org.jetbrains.kotlin.analysis.api.platform.modification.KotlinModificationTrackerFactory +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderFactory import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISession -import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISessionBuilder +import org.jetbrains.kotlin.analysis.api.standalone.base.declarations.KotlinStandaloneAnnotationsResolverFactory +import org.jetbrains.kotlin.analysis.api.standalone.base.declarations.KotlinStandaloneDeclarationProviderFactory +import org.jetbrains.kotlin.analysis.api.standalone.base.modification.KotlinStandaloneModificationTrackerFactory +import org.jetbrains.kotlin.analysis.api.standalone.base.packages.KotlinStandalonePackageProviderFactory import org.jetbrains.kotlin.analysis.api.standalone.buildStandaloneAnalysisAPISession import org.jetbrains.kotlin.cli.common.intellijPluginRoot import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSeverity import org.jetbrains.kotlin.cli.common.messages.CompilerMessageSourceLocation import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.com.intellij.core.CoreApplicationEnvironment +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager import org.jetbrains.kotlin.com.intellij.openapi.util.Disposer +import org.jetbrains.kotlin.com.intellij.openapi.util.SimpleModificationTracker +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager import org.jetbrains.kotlin.com.intellij.psi.PsiManager import org.jetbrains.kotlin.config.ApiVersion @@ -22,6 +35,7 @@ import org.jetbrains.kotlin.config.messageCollector import org.jetbrains.kotlin.config.moduleName import org.jetbrains.kotlin.config.useFir import org.jetbrains.kotlin.metadata.jvm.deserialization.JvmProtoBufUtil +import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path @@ -36,21 +50,33 @@ import kotlin.io.path.pathString * @param jdkRelease The JDK release version at [jdkHome]. */ class CompilationEnvironment( - intellijPluginRoot: Path, - jdkHome: Path, - jdkRelease: Int, - languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, - enableParserEventSystem: Boolean = true, - configureSession: StandaloneAnalysisAPISessionBuilder.() -> Unit = {} -) : AutoCloseable { - private val disposable = Disposer.newDisposable() - - val session: StandaloneAnalysisAPISession - val parser: KtPsiFactory + val projectModel: KotlinProjectModel, + val intellijPluginRoot: Path, + val jdkHome: Path, + val jdkRelease: Int, + val languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, + val enableParserEventSystem: Boolean = true +) : KotlinProjectModel.ProjectModelListener, AutoCloseable { + private var disposable = Disposer.newDisposable() + + var session: StandaloneAnalysisAPISession + private set + var parser: KtPsiFactory + private set + val psiManager: PsiManager + get() = PsiManager.getInstance(session.project) + val psiDocumentManager: PsiDocumentManager + get() = PsiDocumentManager.getInstance(session.project) - private val envMessageCollector = object: MessageCollector { + val modificationTrackerFactory: KotlinModificationTrackerFactory + get() = session.project.getService(KotlinModificationTrackerFactory::class.java) + + val coreApplicationEnvironment: CoreApplicationEnvironment + get() = session.coreApplicationEnvironment + + private val envMessageCollector = object : MessageCollector { override fun clear() { } @@ -73,39 +99,143 @@ class CompilationEnvironment( } init { - val configuration = CompilerConfiguration().apply { + session = buildSession() + parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) + + projectModel.addListener(this) + } + + private fun buildSession(): StandaloneAnalysisAPISession { + val configuration = createCompilerConfiguration() + + val session = buildStandaloneAnalysisAPISession( + projectDisposable = disposable, + unitTestMode = false, + compilerConfiguration = configuration, + ) { + buildKtModuleProvider { + projectModel.configureModules(this) + } + } + + return session + } + + private fun rebuildSession() { + logger.info("Rebuilding analysis session") + + disposable.dispose() + disposable = Disposer.newDisposable() + + session = buildSession() + parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) + + logger.info("Analysis session rebuilt") + } + + private fun createCompilerConfiguration(): CompilerConfiguration { + return CompilerConfiguration().apply { this.moduleName = JvmProtoBufUtil.DEFAULT_MODULE_NAME this.useFir = true - this.intellijPluginRoot = intellijPluginRoot.pathString + this.intellijPluginRoot = this@CompilationEnvironment.intellijPluginRoot.pathString this.languageVersionSettings = LanguageVersionSettingsImpl( - languageVersion = languageVersion, - apiVersion = ApiVersion.createByLanguageVersion(languageVersion), + languageVersion = this@CompilationEnvironment.languageVersion, + apiVersion = ApiVersion.createByLanguageVersion(this@CompilationEnvironment.languageVersion), analysisFlags = emptyMap(), - specificFeatures = buildMap { - // enable all features - putAll(LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED }) - } + specificFeatures = LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED } ) - this.jdkHome = jdkHome.toFile() - this.jdkRelease = jdkRelease + this.jdkHome = this@CompilationEnvironment.jdkHome.toFile() + this.jdkRelease = this@CompilationEnvironment.jdkRelease - this.messageCollector = envMessageCollector + this.messageCollector = this@CompilationEnvironment.envMessageCollector } + } - session = buildStandaloneAnalysisAPISession( - projectDisposable = disposable, - unitTestMode = false, - compilerConfiguration = configuration, - init = configureSession - ) + private fun refreshSourceFiles() { + logger.info("Refreshing source files") - parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) - psiManager = PsiManager.getInstance(session.project) - psiDocumentManager = PsiDocumentManager.getInstance(session.project) + val project = session.project + val sourceKtFiles = collectSourceKtFiles() + + ApplicationManager.getApplication().runWriteAction { + (project as MockProject).apply { + registerService( + KotlinAnnotationsResolverFactory::class.java, + KotlinStandaloneAnnotationsResolverFactory(this, sourceKtFiles) + ) + + val decProviderFactory = KotlinStandaloneDeclarationProviderFactory( + this, + session.coreApplicationEnvironment, + sourceKtFiles + ) + registerService( + KotlinDeclarationProviderFactory::class.java, + decProviderFactory + ) + + registerService( + KotlinPackageProviderFactory::class.java, + KotlinStandalonePackageProviderFactory( + project, + sourceKtFiles + decProviderFactory.getAdditionalCreatedKtFiles() + ) + ) + } + + val modificationTrackerFactory = + project.getService(KotlinModificationTrackerFactory::class.java) as? KotlinStandaloneModificationTrackerFactory? + val sourceModificationTracker = + modificationTrackerFactory?.createProjectWideSourceModificationTracker() as? SimpleModificationTracker? + sourceModificationTracker?.incModificationCount() + } + + logger.info("Refreshed: {} source KtFiles", sourceKtFiles.size) + } + + @OptIn(KaExperimentalApi::class) + private fun collectSourceKtFiles(): List = buildList { + session.modulesWithFiles.keys.forEach { module -> + module.psiRoots.forEach { psiRoot -> + val rootFile = psiRoot.virtualFile ?: return@forEach + rootFile.refresh(false, false) + collectKtFilesRecursively(rootFile, this) + } + } + } + + private fun collectKtFilesRecursively( + dir: VirtualFile, + files: MutableList + ) { + dir.children.orEmpty().forEach { child -> + if (child.isDirectory) { + collectKtFilesRecursively(child, files) + return@forEach + } + + if (child.extension == "kt" || child.extension == "kts") { + val psiFile = psiManager.findFile(child) + if (psiFile is KtFile) { + files.add(psiFile) + } + } + } } override fun close() { + projectModel.removeListener(this) disposable.dispose() } + + override fun onProjectModelChanged( + model: KotlinProjectModel, + changeKind: KotlinProjectModel.ChangeKind + ) { + when (changeKind) { + KotlinProjectModel.ChangeKind.STRUCTURE -> rebuildSession() + KotlinProjectModel.ChangeKind.SOURCES -> refreshSourceFiles() + } + } } \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index 263f554e02..30cf39d8fb 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -17,13 +17,15 @@ import java.nio.file.Paths import kotlin.io.path.pathString class Compiler( + projectModel: KotlinProjectModel, intellijPluginRoot: Path, jdkHome: Path, jdkRelease: Int, languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, - configureSession: StandaloneAnalysisAPISessionBuilder.() -> Unit = {}, ) : AutoCloseable { private val logger = LoggerFactory.getLogger(Compiler::class.java) + + @Suppress("JoinDeclarationAndAssignment") private val defaultCompilationEnv: CompilationEnvironment val fileSystem: VirtualFileSystem @@ -33,12 +35,12 @@ class Compiler( init { defaultCompilationEnv = CompilationEnvironment( + projectModel = projectModel, intellijPluginRoot = intellijPluginRoot, jdkHome = jdkHome, jdkRelease = jdkRelease, languageVersion = languageVersion, enableParserEventSystem = true, - configureSession = configureSession, ) // must be initialized AFTER the compilation env has been initialized diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/IncrementalModificationTracker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/IncrementalModificationTracker.kt new file mode 100644 index 0000000000..032341591d --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/IncrementalModificationTracker.kt @@ -0,0 +1,22 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import org.jetbrains.kotlin.com.intellij.openapi.util.ModificationTracker +import java.util.concurrent.atomic.AtomicLong + +class IncrementalModificationTracker : ModificationTracker { + + private val myCounter = AtomicLong(0) + + /** + * Increment the modification count. + */ + fun incModificationCount() = apply { + myCounter.incrementAndGet() + } + + operator fun inc() = incModificationCount() + + override fun getModificationCount(): Long { + return myCounter.get() + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt new file mode 100644 index 0000000000..e78b8646c1 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt @@ -0,0 +1,166 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.projects.api.ModuleProject +import com.itsaky.androidide.projects.api.Workspace +import com.itsaky.androidide.projects.models.bootClassPaths +import org.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule +import org.jetbrains.kotlin.analysis.project.structure.builder.KtModuleProviderBuilder +import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule +import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule +import org.jetbrains.kotlin.config.LanguageVersion +import org.jetbrains.kotlin.platform.TargetPlatform +import org.jetbrains.kotlin.platform.jvm.JvmPlatforms +import org.slf4j.LoggerFactory + +/** + * Holds the project structure derived from a [Workspace]. + * + * This is the single source of truth for module layout, dependencies, + * and source roots. It knows nothing about analysis sessions — it just + * describes *what* the project looks like. + * + * When the project structure changes (re-sync) or source files change + * (build complete), it notifies registered listeners so they can + * refresh their sessions. + */ +class KotlinProjectModel { + + private val logger = LoggerFactory.getLogger(KotlinProjectModel::class.java) + + private var workspace: Workspace? = null + private var platform: TargetPlatform = JvmPlatforms.defaultJvmPlatform + + private val listeners = mutableListOf() + + /** + * The kind of change that occurred. + */ + enum class ChangeKind { + /** Module structure, dependencies, or platform changed. Full rebuild needed. */ + STRUCTURE, + + /** Only source files within existing roots changed. Incremental refresh possible. */ + SOURCES, + } + + fun interface ProjectModelListener { + fun onProjectModelChanged(model: KotlinProjectModel, changeKind: ChangeKind) + } + + fun addListener(listener: ProjectModelListener) { + listeners.add(listener) + } + + fun removeListener(listener: ProjectModelListener) { + listeners.remove(listener) + } + + /** + * Called when the project is synced (setupWithProject). + * This replaces the entire project structure. + */ + fun update(workspace: Workspace, platform: TargetPlatform) { + this.workspace = workspace + this.platform = platform + notifyListeners(ChangeKind.STRUCTURE) + } + + /** + * Called when a build completes and source files may have changed + * (generated sources added/removed), but the module structure is the same. + */ + fun onSourcesChanged() { + if (workspace == null) { + logger.warn("onSourcesChanged called before project model was initialized") + return + } + notifyListeners(ChangeKind.SOURCES) + } + + /** + * Configures a [KtModuleProviderBuilder] with the current project structure. + * + * Called by [CompilationEnvironment] during session creation or rebuild. + * This is where the module/dependency graph is constructed — the same logic + * currently in [KotlinLanguageServer.recreateSession], but centralized here. + */ + fun configureModules(builder: KtModuleProviderBuilder) { + val workspace = this.workspace + ?: throw IllegalStateException("Project model not initialized") + + builder.apply { + this.platform = this@KotlinProjectModel.platform + + val moduleProjects = workspace.subProjects + .filterIsInstance() + .filter { it.path != workspace.rootProject.path } + + val bootClassPaths = moduleProjects + .filterIsInstance() + .flatMap { project -> + project.bootClassPaths + .filter { it.exists() } + .map { bootClassPath -> + addModule(buildKtLibraryModule { + this.platform = this@KotlinProjectModel.platform + this.libraryName = bootClassPath.nameWithoutExtension + addBinaryRoot(bootClassPath.toPath()) + }) + } + } + + val libraryDependencies = moduleProjects + .flatMap { it.getCompileClasspaths() } + .filter { it.exists() } + .associateWith { library -> + addModule(buildKtLibraryModule { + this.platform = this@KotlinProjectModel.platform + this.libraryName = library.nameWithoutExtension + addBinaryRoot(library.toPath()) + }) + } + + val subprojectsAsModules = mutableMapOf() + + fun getOrCreateModule(project: ModuleProject): KaSourceModule { + subprojectsAsModules[project]?.let { return it } + + val module = buildKtSourceModule { + this.platform = this@KotlinProjectModel.platform + this.moduleName = project.name + addSourceRoots(project.getSourceDirectories().map { it.toPath() }) + + bootClassPaths.forEach { addRegularDependency(it) } + + project.getCompileClasspaths(excludeSourceGeneratedClassPath = true) + .forEach { classpath -> + val libDep = libraryDependencies[classpath] + if (libDep == null) { + logger.error( + "Skipping non-existent classpath classpath: {}", + classpath + ) + return@forEach + } + addRegularDependency(libDep) + } + + project.getCompileModuleProjects().forEach { dep -> + addRegularDependency(getOrCreateModule(dep)) + } + } + + subprojectsAsModules[project] = module + return module + } + + moduleProjects.forEach { addModule(getOrCreateModule(it)) } + } + } + + private fun notifyListeners(changeKind: ChangeKind) { + logger.info("Notifying project listeners for change: {}", changeKind) + listeners.forEach { it.onProjectModelChanged(this, changeKind) } + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt index 9b984adaaa..fa8a60ffc2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -115,11 +115,16 @@ class KotlinDiagnosticProvider( } } + internal fun clearTimestamps() { + analyzeTimestamps.clear() + } + internal fun clearTimestamp(file: Path) { analyzeTimestamps.remove(file) } override fun close() { + clearTimestamps() scope.cancelIfActive("diagnostic provider is being destroyed") } } From dd3d519e6b4258f8a89200f1dda97d8387fae960 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 31 Mar 2026 21:03:29 +0530 Subject: [PATCH 19/29] fix: dispatch build-related events from GradleBuildService Signed-off-by: Akash Yadav --- .../editor/ProjectHandlerActivity.kt | 3 +- .../analytics/gradle/BuildMetric.kt | 1 + .../services/builder/GradleBuildService.kt | 52 ++++++++++++------- eventbus-events/build.gradle.kts | 1 + .../androidide/eventbus/events/BuildEvent.kt | 32 ++++++++++++ .../tooling/api/messages/BuildId.kt | 21 +++++++- .../testing/tooling/ToolingApiTestLauncher.kt | 2 + 7 files changed, 92 insertions(+), 20 deletions(-) create mode 100644 eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/BuildEvent.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt index 017ca90126..78d6cbc64d 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/ProjectHandlerActivity.kt @@ -66,6 +66,7 @@ import com.itsaky.androidide.services.builder.GradleBuildServiceConnnection import com.itsaky.androidide.services.builder.gradleDistributionParams import com.itsaky.androidide.tooling.api.messages.AndroidInitializationParams import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams import com.itsaky.androidide.tooling.api.messages.result.InitializeResult import com.itsaky.androidide.tooling.api.messages.result.TaskExecutionResult @@ -565,7 +566,7 @@ abstract class ProjectHandlerActivity : BaseEditorActivity() { projectDir = projectDir, buildVariants = buildVariants, needsGradleSync = needsSync, - buildId = buildService.nextBuildId(), + buildId = buildService.nextBuildId(BuildRunType.ProjectSync), ), ) diff --git a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildMetric.kt b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildMetric.kt index bc4be3ee79..06a6b43372 100644 --- a/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildMetric.kt +++ b/app/src/main/java/com/itsaky/androidide/analytics/gradle/BuildMetric.kt @@ -19,5 +19,6 @@ abstract class BuildMetric : Metric { Bundle().apply { putString("build_session_id", buildId.buildSessionId) putLong("build_id", buildId.buildId) + putString("run_type", buildId.runType.typeName) } } diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 2a2732e68b..85f95e5324 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -33,6 +33,8 @@ import com.itsaky.androidide.analytics.gradle.BuildCompletedMetric import com.itsaky.androidide.analytics.gradle.BuildStartedMetric import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.app.IDEApplication +import com.itsaky.androidide.eventbus.events.BuildCompletedEvent +import com.itsaky.androidide.eventbus.events.BuildStartedEvent import com.itsaky.androidide.lookup.Lookup import com.itsaky.androidide.lsp.java.debug.JdwpOptions import com.itsaky.androidide.managers.ToolsManager @@ -52,6 +54,7 @@ import com.itsaky.androidide.tooling.api.GradlePluginConfig.PROPERTY_LOGSENDER_E import com.itsaky.androidide.tooling.api.IToolingApiClient import com.itsaky.androidide.tooling.api.IToolingApiServer import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.ClientGradleBuildConfig import com.itsaky.androidide.tooling.api.messages.GradleBuildParams import com.itsaky.androidide.tooling.api.messages.InitializeProjectParams @@ -77,6 +80,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.future.await import kotlinx.coroutines.isActive import kotlinx.coroutines.launch +import org.greenrobot.eventbus.EventBus import org.koin.android.ext.android.inject import org.slf4j.LoggerFactory import java.io.File @@ -162,10 +166,11 @@ class GradleBuildService : } } ?: "unknown" - internal fun nextBuildId(): BuildId = + internal fun nextBuildId(runType: BuildRunType): BuildId = BuildId( buildSessionId = buildSessionId, buildId = buildId.incrementAndGet(), + runType = runType, ) companion object { @@ -360,7 +365,8 @@ class GradleBuildService : var newTuningConfig: GradleTuningConfig? = null @Suppress("SimplifyBooleanWithConstants") - val extraArgs = getGradleExtraArgs(enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild) + val extraArgs = + getGradleExtraArgs(enableJdwp = JdwpOptions.JDWP_ENABLED && isDebugBuild) var buildParams = if (FeatureFlags.isExperimentsEnabled) { @@ -402,6 +408,11 @@ class GradleBuildService : ), ) + EventBus.getDefault() + .post( + BuildStartedEvent(buildInfo) + ) + eventListener?.prepareBuild(buildInfo) return@supplyAsync ClientGradleBuildConfig( @@ -412,33 +423,38 @@ class GradleBuildService : override fun onBuildSuccessful(result: BuildResult) { updateNotification(getString(R.string.build_status_sucess), false) - val buildType = getBuildType(result.tasks) - analyticsManager.trackBuildCompleted( - metric = - BuildCompletedMetric( - buildId = result.buildId, - isSuccess = true, - buildType = buildType, - buildResult = result, - ), - ) + dispatchBuildResult(result, true) eventListener?.onBuildSuccessful(result.tasks) } override fun onBuildFailed(result: BuildResult) { updateNotification(getString(R.string.build_status_failed), false) + dispatchBuildResult(result, false) + eventListener?.onBuildFailed(result.tasks) + } + + private fun dispatchBuildResult( + result: BuildResult, + isSuccess: Boolean, + ) { val buildType = getBuildType(result.tasks) analyticsManager.trackBuildCompleted( metric = BuildCompletedMetric( buildId = result.buildId, - isSuccess = false, + isSuccess = isSuccess, buildType = buildType, buildResult = result, ), ) - eventListener?.onBuildFailed(result.tasks) + + EventBus.getDefault() + .post( + BuildCompletedEvent( + result = result, + ) + ) } override fun onProgressEvent(event: ProgressEvent) { @@ -574,7 +590,7 @@ class GradleBuildService : message = TaskExecutionMessage( tasks = tasks, - buildId = nextBuildId(), + buildId = nextBuildId(BuildRunType.TaskRun), ), ) @@ -610,9 +626,9 @@ class GradleBuildService : } catch (e: Throwable) { if (BuildPreferences.isScanEnabled && ( - e.toString().contains(ERROR_GRADLE_ENTERPRISE_PLUGIN) || - e.toString().contains(ERROR_COULD_NOT_FIND_GRADLE) - ) + e.toString().contains(ERROR_GRADLE_ENTERPRISE_PLUGIN) || + e.toString().contains(ERROR_COULD_NOT_FIND_GRADLE) + ) ) { BuildPreferences.isScanEnabled = false diff --git a/eventbus-events/build.gradle.kts b/eventbus-events/build.gradle.kts index 8a48563076..ab62bdeea3 100644 --- a/eventbus-events/build.gradle.kts +++ b/eventbus-events/build.gradle.kts @@ -29,6 +29,7 @@ android { dependencies { implementation(libs.common.kotlin) implementation(projects.shared) + implementation(projects.subprojects.toolingApi) implementation(projects.logger) api(projects.eventbus) diff --git a/eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/BuildEvent.kt b/eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/BuildEvent.kt new file mode 100644 index 0000000000..916a0954e6 --- /dev/null +++ b/eventbus-events/src/main/java/com/itsaky/androidide/eventbus/events/BuildEvent.kt @@ -0,0 +1,32 @@ +package com.itsaky.androidide.eventbus.events + +import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.result.BuildInfo +import com.itsaky.androidide.tooling.api.messages.result.BuildResult + +/** + * Events dispatched from the IDE's build service. + * + * @property buildId The build identifier. + */ +abstract class BuildEvent( + val buildId: BuildId, +) : Event() + +/** + * Event dispatched when a Gradle build is started in the IDE. + * + * @property buildInfo Info about the build. + */ +class BuildStartedEvent( + val buildInfo: BuildInfo, +): BuildEvent(buildInfo.buildId) + +/** + * Event dispatched when a Gradle build is completed in the IDE. + * + * @property result The result of the Gradle build. + */ +class BuildCompletedEvent( + val result: BuildResult, +): BuildEvent(result.buildId) diff --git a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/BuildId.kt b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/BuildId.kt index fb5dadfda9..2984af70ea 100644 --- a/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/BuildId.kt +++ b/subprojects/tooling-api/src/main/java/com/itsaky/androidide/tooling/api/messages/BuildId.kt @@ -10,8 +10,27 @@ import java.io.Serializable data class BuildId( val buildSessionId: String, val buildId: Long, + val runType: BuildRunType, ) : Serializable { companion object { - val Unknown = BuildId("unknown", -1) + val Unknown = BuildId("unknown", -1, BuildRunType.TaskRun) } } + +/** + * The type of Gradle build run. + */ +enum class BuildRunType( + val typeName: String, +) { + + /** + * Gradle build for project synchronization. + */ + ProjectSync("sync"), + + /** + * Gradle build for running one or more tasks. + */ + TaskRun("taskRun"), +} diff --git a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt index f32bcee758..e9e72ff7bf 100644 --- a/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt +++ b/testing/tooling/src/main/java/com/itsaky/androidide/testing/tooling/ToolingApiTestLauncher.kt @@ -23,6 +23,7 @@ import com.itsaky.androidide.testing.tooling.models.ToolingApiTestScope import com.itsaky.androidide.tooling.api.IToolingApiClient import com.itsaky.androidide.tooling.api.IToolingApiServer import com.itsaky.androidide.tooling.api.messages.BuildId +import com.itsaky.androidide.tooling.api.messages.BuildRunType import com.itsaky.androidide.tooling.api.messages.ClientGradleBuildConfig import com.itsaky.androidide.tooling.api.messages.GradleBuildParams import com.itsaky.androidide.tooling.api.messages.GradleDistributionParams @@ -93,6 +94,7 @@ object ToolingApiTestLauncher { BuildId( buildSessionId = UUID.randomUUID().toString(), buildId = Random.nextLong(), + runType = BuildRunType.ProjectSync, ), ), log: Logger = LoggerFactory.getLogger("BuildOutputLogger"), From d6defa68862cd65e73839331d53ee9bb1ff887d5 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 31 Mar 2026 20:39:50 +0530 Subject: [PATCH 20/29] feat: introduct KtFileManager Handles document events to manage instances of in-memory KtFile that can be used by various Kt LSP components (like diagnostics provider, code completions) to re-use already parsed KtFile instances Signed-off-by: Akash Yadav --- .../lsp/kotlin/FileEventConsumer.kt | 12 ++ .../lsp/kotlin/KotlinLanguageServer.kt | 38 +++- .../androidide/lsp/kotlin/KtFileManager.kt | 193 ++++++++++++++++++ .../kotlin/compiler/CompilationEnvironment.kt | 8 + .../lsp/kotlin/compiler/Compiler.kt | 28 ++- .../diagnostic/KotlinDiagnosticProvider.kt | 146 ++++--------- 6 files changed, 303 insertions(+), 122 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/FileEventConsumer.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KtFileManager.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/FileEventConsumer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/FileEventConsumer.kt new file mode 100644 index 0000000000..0fc2feab55 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/FileEventConsumer.kt @@ -0,0 +1,12 @@ +package com.itsaky.androidide.lsp.kotlin + +import java.nio.file.Path + +interface FileEventConsumer { + + fun onFileOpened(path: Path, content: String) + fun onFileClosed(path: Path) + + fun onFileContentChanged(path: Path, content: String) + fun onFileSaved(path: Path) +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index da9a96664a..5085d10fb3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -22,13 +22,14 @@ import com.itsaky.androidide.app.configuration.IJdkDistributionProvider import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentSaveEvent import com.itsaky.androidide.eventbus.events.editor.DocumentSelectedEvent import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.IServerSettings import com.itsaky.androidide.lsp.kotlin.compiler.Compiler import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel -import com.itsaky.androidide.lsp.kotlin.diagnostic.KotlinDiagnosticProvider +import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult import com.itsaky.androidide.lsp.models.DefinitionParams @@ -76,7 +77,6 @@ class KotlinLanguageServer : ILanguageServer { CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!)) private var projectModel: KotlinProjectModel? = null private var compiler: Compiler? = null - private var diagnosticProvider: KotlinDiagnosticProvider? = null private var analyzeJob: Job? = null override val serverId: String = SERVER_ID @@ -106,7 +106,6 @@ class KotlinLanguageServer : ILanguageServer { override fun shutdown() { EventBus.getDefault().unregister(this) scope.cancel("LSP is being shut down") - diagnosticProvider?.close() compiler?.close() initialized = false } @@ -150,7 +149,6 @@ class KotlinLanguageServer : ILanguageServer { ) this.compiler = compiler - this.diagnosticProvider = KotlinDiagnosticProvider(compiler, scope) } else { logger.info("Updating project model") @@ -221,7 +219,7 @@ class KotlinLanguageServer : ILanguageServer { return DiagnosticResult.NO_UPDATE } - return diagnosticProvider?.analyze(file) + return compiler?.compilationEnvironmentFor(file)?.collectDiagnosticsFor(file) ?: DiagnosticResult.NO_UPDATE } @@ -232,6 +230,11 @@ class KotlinLanguageServer : ILanguageServer { return } + compiler?.compilationEnvironmentFor(event.openedFile)?.apply { + val content = FileManager.getDocumentContents(event.openedFile) + fileManager.onFileOpened(event.openedFile, content) + } + selectedFile = event.openedFile debouncingAnalyze() } @@ -262,6 +265,13 @@ class KotlinLanguageServer : ILanguageServer { if (!DocumentUtils.isKotlinFile(event.changedFile)) { return } + + compiler?.compilationEnvironmentFor(event.changedFile)?.apply { + val content = FileManager.getDocumentContents(event.changedFile) + logger.info("Notifying KtFileManager for file {} with contents {}", event.changedFile, content) + fileManager.onFileContentChanged(event.changedFile, content) + } + debouncingAnalyze() } @@ -272,13 +282,29 @@ class KotlinLanguageServer : ILanguageServer { return } - diagnosticProvider?.clearTimestamp(event.closedFile) + compiler?.compilationEnvironmentFor(event.closedFile)?.apply { + fileManager.onFileClosed(event.closedFile) + fileManager.clearAnalyzeTimestampOf(event.closedFile) + } + if (FileManager.getActiveDocumentCount() == 0) { selectedFile = null analyzeJob?.cancel("No active files") } } + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("unused") + fun onDocumentSaved(event: DocumentSaveEvent) { + if (!DocumentUtils.isKotlinFile(event.savedFile)) { + return + } + + compiler?.compilationEnvironmentFor(event.savedFile)?.apply { + fileManager.onFileSaved(event.savedFile) + } + } + @Subscribe(threadMode = ThreadMode.ASYNC) @Suppress("unused") fun onDocumentSelected(event: DocumentSelectedEvent) { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KtFileManager.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KtFileManager.kt new file mode 100644 index 0000000000..2941cfb2c1 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KtFileManager.kt @@ -0,0 +1,193 @@ +package com.itsaky.androidide.lsp.kotlin + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.analyzeCopy +import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode +import org.jetbrains.kotlin.com.intellij.openapi.editor.Document +import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager +import org.jetbrains.kotlin.com.intellij.psi.PsiManager +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtPsiFactory +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.name +import kotlin.io.path.pathString +import kotlin.time.Clock +import kotlin.time.Instant + +/** + * Manages [KtFile] instances for all open files. + */ +class KtFileManager( + private val psiFactory: KtPsiFactory, + private val psiManager: PsiManager, + private val psiDocumentManager: PsiDocumentManager, +) : FileEventConsumer, AutoCloseable { + + companion object { + private val logger = LoggerFactory.getLogger(KtFileManager::class.java) + } + + private val entries = ConcurrentHashMap() + + @ConsistentCopyVisibility + data class ManagedFile @Deprecated("Use ManagedFile.create instead") internal constructor( + val file: Path, + val diskKtFile: KtFile, + @Volatile var inMemoryKtFile: KtFile, + val document: Document, + @Volatile var lastModified: Instant, + @Volatile var isDirty: Boolean, + @Volatile var analyzeTimestamp: Instant, + ) { + + /** + * Analyze this [ManagedFile] contents. + * + * @param action The analysis action. + */ + fun analyze(action: KaSession.(file: KtFile) -> R): R { + if (diskKtFile === inMemoryKtFile) { + return analyze(useSiteElement = inMemoryKtFile) { action(inMemoryKtFile) } + } + + return analyzeCopy( + useSiteElement = inMemoryKtFile, + resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF + ) { + action(inMemoryKtFile) + } + } + + fun createInMemoryFileWithContent(psiFactory: KtPsiFactory, content: String): KtFile { + val inMemoryFile = psiFactory.createFile(file.name, content) + inMemoryFile.originalFile = diskKtFile + return inMemoryFile + } + + companion object { + @Suppress("DEPRECATION") + fun create( + file: Path, + ktFile: KtFile, + document: Document, + inMemoryKtFile: KtFile = ktFile, + lastModified: Instant = Clock.System.now(), + isDirty: Boolean = false, + analyzeTimestamp: Instant = Instant.DISTANT_PAST, + ) = + ManagedFile( + file = file, + diskKtFile = ktFile, + inMemoryKtFile = inMemoryKtFile, + document = document, + lastModified = lastModified, + isDirty = isDirty, + analyzeTimestamp = analyzeTimestamp, + ) + } + } + + override fun onFileOpened(path: Path, content: String) { + logger.debug("onFileOpened: {}", path) + + entries[path]?.let { existing -> + logger.info("File is already opened, updating content") + updateDocumentContent(existing, content) + return + } + + val ktFile = resolveKtFile(path) + + if (ktFile == null) { + logger.warn("Cannot resolve KtFile for: {}", path) + return + } + + val document = getOrCreateDocument(ktFile) + if (document == null) { + logger.warn("Cannot obtain Document for: {}", path) + return + } + + logger.info("Creating managed file entry") + val entry = ManagedFile.create( + file = path, + ktFile = ktFile, + document = document, + ) + + entries[path] = entry + + updateDocumentContent(entry, content) + logger.debug("File opened and managed: {}", path) + return + } + + override fun onFileContentChanged(path: Path, content: String) { + logger.debug("onFileContentChanged: {}", path) + val entry = entries[path] ?: run { + logger.debug("Content changed for unmanaged file: {}. Ignoring.", path) + return + } + + updateDocumentContent(entry, content) + } + + override fun onFileSaved(path: Path) { + val entry = entries[path] ?: return + entry.isDirty = false + + logger.debug("File saved: {}", path) + } + + override fun onFileClosed(path: Path) { + entries.remove(path) ?: return + logger.debug("File closed: {}", path) + } + + fun getOpenFile(path: Path): ManagedFile? = entries[path] + + fun allOpenFiles(): Collection = + entries.values.toList() + + fun clearAnalyzeTimestampOf(file: Path) { + val managed = getOpenFile(file) ?: return + managed.analyzeTimestamp = Instant.DISTANT_PAST + } + + private fun resolveKtFile(path: Path): KtFile? { + val vfs = VirtualFileManager.getInstance() + .getFileSystem(StandardFileSystems.FILE_PROTOCOL) + + val virtualFile = vfs.refreshAndFindFileByPath(path.pathString) + ?: return null + + val psiFile = psiManager.findFile(virtualFile) + + return psiFile as? KtFile + } + + private fun getOrCreateDocument(ktFile: KtFile): Document? { + return psiDocumentManager.getDocument(ktFile) + } + + private fun updateDocumentContent(entry: ManagedFile, content: String) { + logger.info("Updating doc content for {}", entry.file) + + val normalized = content.replace("\r", "") + if (entry.inMemoryKtFile.text == normalized) return + + entry.inMemoryKtFile = entry.createInMemoryFileWithContent(psiFactory, content) + entry.lastModified = Clock.System.now() + entry.isDirty = true + } + + override fun close() { + entries.clear() + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index 1b5cc52b0c..f9b20ebc3a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -1,5 +1,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler +import com.itsaky.androidide.lsp.kotlin.FileEventConsumer +import com.itsaky.androidide.lsp.kotlin.KtFileManager import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolverFactory import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProviderFactory @@ -61,9 +63,13 @@ class CompilationEnvironment( var session: StandaloneAnalysisAPISession private set + var parser: KtPsiFactory private set + var fileManager: KtFileManager + private set + val psiManager: PsiManager get() = PsiManager.getInstance(session.project) @@ -101,6 +107,7 @@ class CompilationEnvironment( init { session = buildSession() parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) + fileManager = KtFileManager(parser, psiManager, psiDocumentManager) projectModel.addListener(this) } @@ -225,6 +232,7 @@ class CompilationEnvironment( } override fun close() { + fileManager.close() projectModel.removeListener(this) disposable.dispose() } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index 30cf39d8fb..a02e6ebe44 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -1,6 +1,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler -import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISessionBuilder +import com.itsaky.androidide.lsp.kotlin.FileEventConsumer +import com.itsaky.androidide.utils.DocumentUtils import org.jetbrains.kotlin.com.intellij.lang.Language import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager @@ -14,6 +15,7 @@ import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path import java.nio.file.Paths +import kotlin.io.path.extension import kotlin.io.path.pathString class Compiler( @@ -44,8 +46,19 @@ class Compiler( ) // must be initialized AFTER the compilation env has been initialized - fileSystem = VirtualFileManager.getInstance() - .getFileSystem(StandardFileSystems.FILE_PROTOCOL) + fileSystem = + VirtualFileManager.getInstance().getFileSystem(StandardFileSystems.FILE_PROTOCOL) + } + + fun compilationKindFor(file: Path): CompilationKind { + // TODO: This should return a different environment for Kotlin script files + return CompilationKind.Default + } + + fun compilationEnvironmentFor(file: Path): CompilationEnvironment? { + if (!DocumentUtils.isKotlinFile(file)) return null + + return compilationEnvironmentFor(compilationKindFor(file)) } fun compilationEnvironmentFor(compilationKind: CompilationKind): CompilationEnvironment = @@ -66,11 +79,7 @@ class Compiler( require(!content.contains('\r')) val psiFile = psiFileFactoryFor(compilationKind).createFileFromText( - file.pathString, - language, - content, - true, - false + file.pathString, language, content, true, false ) check(psiFile.virtualFile != null) { "No virtual-file associated with newly created psiFile" @@ -83,8 +92,7 @@ class Compiler( content: String, file: Path = Paths.get("dummy.virtual.kt"), compilationKind: CompilationKind = CompilationKind.Default - ): KtFile = - createPsiFileFor(content, file, KotlinLanguage.INSTANCE, compilationKind) as KtFile + ): KtFile = createPsiFileFor(content, file, KotlinLanguage.INSTANCE, compilationKind) as KtFile override fun close() { defaultCompilationEnv.close() diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt index fa8a60ffc2..ac2c38672f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -1,131 +1,66 @@ package com.itsaky.androidide.lsp.kotlin.diagnostic -import com.itsaky.androidide.lsp.kotlin.compiler.CompilationKind -import com.itsaky.androidide.lsp.kotlin.compiler.Compiler +import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment import com.itsaky.androidide.lsp.models.DiagnosticItem import com.itsaky.androidide.lsp.models.DiagnosticResult import com.itsaky.androidide.lsp.models.DiagnosticSeverity import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.FileManager -import com.itsaky.androidide.tasks.cancelIfActive import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CoroutineScope import org.jetbrains.kotlin.analysis.api.KaExperimentalApi -import org.jetbrains.kotlin.analysis.api.analyzeCopy +import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity -import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileModule -import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode -import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager import org.jetbrains.kotlin.com.intellij.psi.PsiFile -import org.jetbrains.kotlin.com.intellij.testFramework.LightVirtualFile -import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory import java.nio.file.Path -import java.time.Instant -import java.util.concurrent.ConcurrentHashMap -import kotlin.io.path.name -import kotlin.io.path.pathString -import org.jetbrains.kotlin.analysis.api.analyze as ktAnalyze - -class KotlinDiagnosticProvider( - private val compiler: Compiler, - private val scope: CoroutineScope -) : AutoCloseable { - - companion object { - private val logger = LoggerFactory.getLogger(KotlinDiagnosticProvider::class.java) +import kotlin.time.Clock +import kotlin.time.toKotlinInstant + +private val logger = LoggerFactory.getLogger("KotlinDiagnosticProvider") + +fun CompilationEnvironment.collectDiagnosticsFor(file: Path): DiagnosticResult = try { + logger.info("Analyzing file: {}", file) + return doAnalyze(file) +} catch (err: Throwable) { + if (err is CancellationException) { + logger.debug("analysis cancelled") + throw err } + logger.error("An error occurred analyzing file: {}", file, err) + return DiagnosticResult.NO_UPDATE +} - private val analyzeTimestamps = ConcurrentHashMap() - - fun analyze(file: Path): DiagnosticResult = - try { - logger.info("Analyzing file: {}", file) - return doAnalyze(file) - } catch (err: Throwable) { - if (err is CancellationException) { - logger.debug("analysis cancelled") - throw err - } - logger.error("An error occurred analyzing file: {}", file, err) - return DiagnosticResult.NO_UPDATE - } - - @OptIn(KaExperimentalApi::class) - private fun doAnalyze(file: Path): DiagnosticResult { - val modifiedAt = FileManager.getLastModified(file) - val analyzedAt = analyzeTimestamps[file] - if (analyzedAt?.isAfter(modifiedAt) == true) { - logger.debug("Skipping analysis. File unmodified.") - return DiagnosticResult.NO_UPDATE - } - - logger.info("fetch document contents") - val fileContents = FileManager.getDocumentContents(file) - .replace("\r", "") - - val env = compiler.compilationEnvironmentFor(CompilationKind.Default) - val virtualFile = compiler.fileSystem.refreshAndFindFileByPath(file.pathString) - if (virtualFile == null) { - logger.warn("Unable to find virtual file for path: {}", file.pathString) - return DiagnosticResult.NO_UPDATE - } - - val ktFile = env.psiManager.findFile(virtualFile) - if (ktFile == null) { - logger.warn("Unable to find KtFile for path: {}", file.pathString) - return DiagnosticResult.NO_UPDATE - } - - if (ktFile !is KtFile) { - logger.warn( - "Expected KtFile, but found {} for path:{}", - ktFile.javaClass, - file.pathString - ) - return DiagnosticResult.NO_UPDATE - } - - val inMemoryPsi = compiler.defaultKotlinParser - .createFile(file.name, fileContents) - inMemoryPsi.originalFile = ktFile - - val rawDiagnostics = analyzeCopy( - useSiteElement = inMemoryPsi, - resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, - ) { - logger.info("ktFile.text={}", inMemoryPsi.text) - inMemoryPsi.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) - } - - logger.info("Found {} diagnostics", rawDiagnostics.size) - - return DiagnosticResult( - file = file, - diagnostics = rawDiagnostics.map { rawDiagnostic -> - rawDiagnostic.toDiagnosticItem() - } - ).also { - analyzeTimestamps[file] = Instant.now() - } +@OptIn(KaExperimentalApi::class) +private fun CompilationEnvironment.doAnalyze(file: Path): DiagnosticResult { + val managed = fileManager.getOpenFile(file) + if (managed == null) { + logger.warn("Attempt to analyze non-open file: {}", file) + return DiagnosticResult.NO_UPDATE } - internal fun clearTimestamps() { - analyzeTimestamps.clear() + val analyzedAt = managed.analyzeTimestamp + val modifiedAt = FileManager.getLastModified(file) + if (analyzedAt > modifiedAt.toKotlinInstant()) { + logger.debug("Skipping analysis. File unmodified.") + return DiagnosticResult.NO_UPDATE } - internal fun clearTimestamp(file: Path) { - analyzeTimestamps.remove(file) + val rawDiagnostics = managed.analyze { ktFile -> + ktFile.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) } - override fun close() { - clearTimestamps() - scope.cancelIfActive("diagnostic provider is being destroyed") + logger.info("Found {} diagnostics", rawDiagnostics.size) + + return DiagnosticResult( + file = file, diagnostics = rawDiagnostics.map { rawDiagnostic -> + rawDiagnostic.toDiagnosticItem() + }).also { + managed.analyzeTimestamp = Clock.System.now() } } @@ -150,8 +85,8 @@ private fun KaSeverity.toDiagnosticSeverity(): DiagnosticSeverity { } private fun TextRange.toRange(containingFile: PsiFile): Range { - val doc = PsiDocumentManager.getInstance(containingFile.project) - .getDocument(containingFile) ?: return Range.NONE + val doc = PsiDocumentManager.getInstance(containingFile.project).getDocument(containingFile) + ?: return Range.NONE val startLine = doc.getLineNumber(startOffset) val startCol = startOffset - doc.getLineStartOffset(startLine) val endLine = doc.getLineNumber(endOffset) @@ -161,8 +96,7 @@ private fun TextRange.toRange(containingFile: PsiFile): Range { line = startLine, column = startCol, index = startOffset, - ), - end = Position( + ), end = Position( line = endLine, column = endCol, index = endOffset, From 466689312bd50bb40073a3a81d960fd9b8735eee Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 1 Apr 2026 18:59:01 +0530 Subject: [PATCH 21/29] fix: add initial K2-backed scope code completions Signed-off-by: Akash Yadav --- .../lsp/java/edits/BaseJavaEditHandler.kt | 14 +- lsp/kotlin/build.gradle.kts | 1 + .../lsp/kotlin/KotlinLanguageServer.kt | 11 +- .../completion/BaseKotlinEditHandler.kt | 23 ++ .../kotlin/completion/CompletionContext.kt | 17 + .../kotlin/completion/KotlinCompletionItem.kt | 50 +++ .../kotlin/completion/KotlinCompletions.kt | 338 ++++++++++++++++++ 7 files changed, 445 insertions(+), 9 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/BaseKotlinEditHandler.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/CompletionContext.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletionItem.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/BaseJavaEditHandler.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/BaseJavaEditHandler.kt index 71db6f6b05..4f12c45336 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/BaseJavaEditHandler.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/BaseJavaEditHandler.kt @@ -30,11 +30,11 @@ import io.github.rosemoe.sora.widget.CodeEditor */ open class BaseJavaEditHandler : DefaultEditHandler() { - override fun executeCommand(editor: CodeEditor, command: Command?) { - if (editor is ILspEditor) { - editor.executeCommand(command) - return - } - super.executeCommand(editor, command) - } + override fun executeCommand(editor: CodeEditor, command: Command?) { + if (editor is ILspEditor) { + editor.executeCommand(command) + return + } + super.executeCommand(editor, command) + } } diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 8af6820538..66f2a74f4b 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -38,6 +38,7 @@ dependencies { implementation(projects.lsp.api) implementation(projects.lsp.models) + implementation(projects.editorApi) implementation(projects.eventbusEvents) implementation(projects.subprojects.kotlinAnalysisApi) implementation(projects.shared) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index 5085d10fb3..3bd433a429 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -29,6 +29,7 @@ import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.IServerSettings import com.itsaky.androidide.lsp.kotlin.compiler.Compiler import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel +import com.itsaky.androidide.lsp.kotlin.completion.complete import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult @@ -160,7 +161,14 @@ class KotlinLanguageServer : ILanguageServer { } override fun complete(params: CompletionParams?): CompletionResult { - return CompletionResult.EMPTY + if (params == null) { + logger.warn("Cannot complete for null params") + return CompletionResult.EMPTY + } + + logger.debug("complete(position={}, file={})", params.position, params.file) + return compiler?.compilationEnvironmentFor(params.file)?.complete(params) + ?: CompletionResult.EMPTY } override suspend fun findReferences(params: ReferenceParams): ReferenceResult { @@ -268,7 +276,6 @@ class KotlinLanguageServer : ILanguageServer { compiler?.compilationEnvironmentFor(event.changedFile)?.apply { val content = FileManager.getDocumentContents(event.changedFile) - logger.info("Notifying KtFileManager for file {} with contents {}", event.changedFile, content) fileManager.onFileContentChanged(event.changedFile, content) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/BaseKotlinEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/BaseKotlinEditHandler.kt new file mode 100644 index 0000000000..81479a265e --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/BaseKotlinEditHandler.kt @@ -0,0 +1,23 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.editor.api.ILspEditor +import com.itsaky.androidide.lsp.edits.DefaultEditHandler +import com.itsaky.androidide.lsp.models.Command +import io.github.rosemoe.sora.widget.CodeEditor + +/** + * Implementation of [DefaultEditHandler] which avoids reflection in + * [DefaultEditHandler.executeCommand]. + * + * @author Akash Yadav + */ +open class BaseKotlinEditHandler : DefaultEditHandler() { + + override fun executeCommand(editor: CodeEditor, command: Command?) { + if (editor is ILspEditor) { + editor.executeCommand(command) + return + } + super.executeCommand(editor, command) + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/CompletionContext.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/CompletionContext.kt new file mode 100644 index 0000000000..a87b6891a9 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/CompletionContext.kt @@ -0,0 +1,17 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +/** + * The context for the providing code completions in a file. + */ +enum class CompletionContext { + + /** + * Scope completions (local variables, parameters, etc.) + */ + Scope, + + /** + * Member completions (properties, member functions, extension functions, etc.) + */ + Member, +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletionItem.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletionItem.kt new file mode 100644 index 0000000000..9847201e1b --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletionItem.kt @@ -0,0 +1,50 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.lsp.edits.IEditHandler +import com.itsaky.androidide.lsp.models.Command +import com.itsaky.androidide.lsp.models.CompletionItem +import com.itsaky.androidide.lsp.models.CompletionItemKind +import com.itsaky.androidide.lsp.models.ICompletionData +import com.itsaky.androidide.lsp.models.InsertTextFormat +import com.itsaky.androidide.lsp.models.MatchLevel +import com.itsaky.androidide.lsp.models.TextEdit + +class KotlinCompletionItem( + ideLabel: String, + detail: String, + insertText: String?, + insertTextFormat: InsertTextFormat?, + sortText: String?, + command: Command?, + completionKind: CompletionItemKind, + matchLevel: MatchLevel, + additionalTextEdits: List?, + data: ICompletionData?, + editHandler: IEditHandler = BaseKotlinEditHandler() +) : CompletionItem( + ideLabel, + detail, + insertText, + insertTextFormat, + sortText, + command, + completionKind, + matchLevel, + additionalTextEdits, + data, + editHandler +) { + + constructor() : this( + "", // label + "", // detail + null, // insertText + null, // insertTextFormat + null, // sortText + null, // command + CompletionItemKind.NONE, // kind + MatchLevel.NO_MATCH, // match level + ArrayList(), // additionalEdits + null // data + ) +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt new file mode 100644 index 0000000000..5505c9195a --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -0,0 +1,338 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.models.Command +import com.itsaky.androidide.lsp.models.CompletionItem +import com.itsaky.androidide.lsp.models.CompletionItemKind +import com.itsaky.androidide.lsp.models.CompletionParams +import com.itsaky.androidide.lsp.models.CompletionResult +import com.itsaky.androidide.lsp.models.InsertTextFormat +import com.itsaky.androidide.projects.FileManager +import kotlinx.coroutines.CancellationException +import org.jetbrains.kotlin.analysis.api.KaContextParameterApi +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.analyzeCopy +import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode +import org.jetbrains.kotlin.analysis.api.renderer.types.KaTypeRenderer +import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassifierSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaEnumEntrySymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaLocalVariableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.name +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType +import org.jetbrains.kotlin.psi.psiUtil.startOffset +import org.jetbrains.kotlin.types.Variance +import org.slf4j.LoggerFactory + +private const val KT_COMPLETION_PLACEHOLDER = "KT_COMPLETION_PLACEHOLDER" + +private val logger = LoggerFactory.getLogger("KotlinCompletions") + +/** + * Provide code completion for the given completion parameters. + * + * @param CompilationEnvironment The compilation environment to use for the code completion. + * @param params The completion parameters. + * @return The completion result. + */ +fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult { + val managedFile = fileManager.getOpenFile(params.file) + if (managedFile == null) { + logger.warn("No managed file for {}", params.file) + return CompletionResult.EMPTY + } + + // Need to use the original document contents here, instead of + // managedFile.inMemoryKtFile.text + val originalText = FileManager.getDocumentContents(params.file) + val requestPosition = params.position + val completionOffset = requestPosition.requireIndex() + val prefix = params.requirePrefix() + val partial = partialIdentifier(prefix) + + // insert placeholder to fix broken trees + val textWithPlaceholder = buildString { + append(originalText, 0, completionOffset) + append(KT_COMPLETION_PLACEHOLDER) + append(originalText, completionOffset, originalText.length) + } + + val completionKtFile = managedFile.createInMemoryFileWithContent(parser, textWithPlaceholder) + val elementAtOffset = completionKtFile.findElementAt(completionOffset) + + if (elementAtOffset == null) { + logger.error("Unable to locate element at position {}", requestPosition) + return CompletionResult.EMPTY + } + + return try { + analyzeCopy( + useSiteElement = completionKtFile, + resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, + ) { + val completionContext = determineCompletionContext(elementAtOffset) + val items = mutableListOf() + + when (completionContext) { + CompletionContext.Scope -> collectScopeCompletions( + element = elementAtOffset, + file = completionKtFile, + partial = partial, + to = items + ) + + CompletionContext.Member -> collectMemberCompletions( + element = elementAtOffset, + partial = partial, + to = items + ) + } + + CompletionResult(items) + } + } catch (e: Throwable) { + if (e is CancellationException) { + throw e + } + + logger.warn("An error occurred while computing completions for {}", params.file) + return CompletionResult.EMPTY + } +} + +private fun KaSession.collectMemberCompletions( + element: PsiElement, + partial: String, + to: MutableList +) { + val qualifiedExpr = element.getParentOfType(strict = false) + if (qualifiedExpr == null) { + logger.error("No qualified expression found requested position") + return + } + + val receiver = qualifiedExpr.receiverExpression + val receiverType = receiver.expressionType + + if (receiverType == null) { + logger.error("Unable to find receiver expression type") + return + } + + collectMembersFromType(receiverType, partial, to) + + if (qualifiedExpr is KtSafeQualifiedExpression) { + val nonNullType = receiverType.withNullability(isMarkedNullable = false) + collectMembersFromType(nonNullType, partial, to) + } +} + +private fun KaSession.collectMembersFromType( + receiverType: KaType, + partial: String, + to: MutableList +) { + +} + +private fun KaSession.collectScopeCompletions( + element: PsiElement, + file: KtFile, + partial: String, + to: MutableList +) { + // Find the nearest KtElement parent for scope resolution + val ktElement = element.getParentOfType(strict = false) + if (ktElement == null) { + logger.error("Cannot find parent of element {} with partial {}", element, partial) + return + } + + logger.info( + "Complete scope members of {}: [{}] matching '{}'", + ktElement, + ktElement.text, + partial + ) + + val scopeContext = file.scopeContext(ktElement) + val compositeScope = scopeContext.compositeScope() + + compositeScope.callables { name -> matchesPrefix(name, partial) } + .forEach { symbol -> + val item = callableSymbolToCompletionItem(symbol, partial) + if (item != null) { + to += item + } + } + + compositeScope.classifiers { name -> matchesPrefix(name, partial) } + .forEach { symbol -> + val item = classifierSymbolToCompletionItem(symbol, partial) + if (item != null) { + to += item + } + } +} + +private fun determineCompletionContext(element: PsiElement): CompletionContext { + // Walk up to find a qualified expression where we're the selector + val dotExpr = element.getParentOfType(strict = false) + if (dotExpr != null && isInSelectorPosition(element, dotExpr)) { + return CompletionContext.Member + } + + val safeExpr = element.getParentOfType(strict = false) + if (safeExpr != null && isInSelectorPosition(element, safeExpr)) { + return CompletionContext.Member + } + + return CompletionContext.Scope +} + +private fun isInSelectorPosition( + element: PsiElement, + qualifiedExpr: KtQualifiedExpression, +): Boolean { + val selector = qualifiedExpr.selectorExpression ?: return false + val elementOffset = element.startOffset + return elementOffset >= selector.startOffset +} + +@OptIn(KaExperimentalApi::class) +private fun KaSession.callableSymbolToCompletionItem( + symbol: KaCallableSymbol, + partial: String +): CompletionItem? { + val item = createSymbolCompletionItem(symbol, partial) ?: return null + val name = item.ideLabel + item.overrideTypeText = renderName(symbol.returnType) + + when (symbol) { + is KaNamedFunctionSymbol -> { + val params = symbol.valueParameters.joinToString(", ") { param -> + "${param.name.asString()}: ${renderName(param.returnType)}" + } + + val hasParams = symbol.valueParameters.isNotEmpty() + + item.detail = "${name}($params)" + item.insertTextFormat = InsertTextFormat.SNIPPET + item.insertText = if (hasParams) { + "${name}($0)" + } else { + "${name}()$0" + } + + if (hasParams) { + item.command = Command("Trigger parameter hints", Command.TRIGGER_PARAMETER_HINTS) + } + + // TODO(itsaky): provide method completion data in order to show API info + // in completion items + } + + // TODO: For properties, we can check if they're a compile-time constant + // and include that constant value in the "detail" field of the + // completion item + + else -> {} + } + + return item +} + +@OptIn(KaExperimentalApi::class) +private fun KaSession.classifierSymbolToCompletionItem( + symbol: KaClassifierSymbol, + partial: String +): CompletionItem? { + val item = createSymbolCompletionItem(symbol, partial) ?: return null + item.detail = when (symbol) { + is KaClassSymbol -> symbol.classId?.asFqNameString() ?: "" + is KaTypeAliasSymbol -> renderName(symbol.expandedType, KaTypeRendererForSource.WITH_QUALIFIED_NAMES) + is KaTypeParameterSymbol -> item.ideLabel + } + return item +} + +private fun KaSession.createSymbolCompletionItem( + symbol: KaSymbol, + partial: String +): CompletionItem? { + val name = symbol.name?.asString() ?: return null + + val item = KotlinCompletionItem() + item.ideLabel = name + item.completionKind = kindOf(symbol) + item.matchLevel = CompletionItem.matchLevel(name, partial) + + return item +} + +private fun KaSession.kindOf(symbol: KaSymbol): CompletionItemKind { + return when (symbol) { + is KaClassSymbol -> when (symbol.classKind) { + KaClassKind.CLASS -> CompletionItemKind.CLASS + KaClassKind.ENUM_CLASS -> CompletionItemKind.ENUM + KaClassKind.ANNOTATION_CLASS -> CompletionItemKind.ANNOTATION_TYPE + KaClassKind.OBJECT -> CompletionItemKind.CLASS + KaClassKind.COMPANION_OBJECT -> CompletionItemKind.CLASS + KaClassKind.INTERFACE -> CompletionItemKind.INTERFACE + KaClassKind.ANONYMOUS_OBJECT -> CompletionItemKind.CLASS + } + + is KaTypeParameterSymbol -> CompletionItemKind.TYPE_PARAMETER + is KaTypeAliasSymbol -> CompletionItemKind.CLASS + is KaFunctionSymbol -> when (symbol) { + is KaConstructorSymbol -> CompletionItemKind.CONSTRUCTOR + else -> CompletionItemKind.METHOD + } + + is KaPropertySymbol -> CompletionItemKind.PROPERTY + is KaLocalVariableSymbol -> CompletionItemKind.VARIABLE + is KaValueParameterSymbol -> CompletionItemKind.VARIABLE + is KaEnumEntrySymbol -> CompletionItemKind.ENUM_MEMBER + else -> CompletionItemKind.NONE + } +} + +@OptIn(KaExperimentalApi::class, KaContextParameterApi::class) +private fun KaSession.renderName( + type: KaType, + renderer: KaTypeRenderer = KaTypeRendererForSource.WITH_SHORT_NAMES, + position: Variance = Variance.INVARIANT +): String { + return type.run { + render(renderer, position) + } +} + +private fun partialIdentifier(prefix: String): String { + return prefix.takeLastWhile { char -> Character.isJavaIdentifierPart(char) } +} + +private fun matchesPrefix(name: Name, partial: String): Boolean { + if (partial.isEmpty()) return true + return name.asString().startsWith(partial, ignoreCase = true) +} From 2f982a73bfbed7e31c4ffd3baf26b5c62eed0b8a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 2 Apr 2026 17:32:28 +0530 Subject: [PATCH 22/29] feat: add member completions backed by K2 Signed-off-by: Akash Yadav --- .../kotlin/completion/KotlinCompletions.kt | 44 ++++++++++++------- 1 file changed, 29 insertions(+), 15 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt index 5505c9195a..07824fceb7 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -31,6 +31,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.name +import org.jetbrains.kotlin.analysis.api.types.KaClassType import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.name.Name @@ -147,12 +148,26 @@ private fun KaSession.collectMemberCompletions( } } +@OptIn(KaExperimentalApi::class) private fun KaSession.collectMembersFromType( receiverType: KaType, partial: String, to: MutableList ) { + val typeScope = receiverType.scope + if (typeScope != null) { + to += toCompletionItems(typeScope.getCallableSignatures { name -> matchesPrefix(name, partial) }.map { it.symbol }, partial) + to += toCompletionItems(typeScope.getClassifierSymbols { name -> matchesPrefix(name, partial) }, partial) + return + } + + // fallback approach when typeScope is not available + val classType = receiverType as? KaClassType ?: return + val classSymbol = classType.symbol as? KaClassSymbol ?: return + val memberScope = classSymbol.memberScope + to += toCompletionItems(memberScope.callables { name -> matchesPrefix(name, partial) }, partial) + to += toCompletionItems(memberScope.classifiers { name -> matchesPrefix(name, partial) }, partial) } private fun KaSession.collectScopeCompletions( @@ -178,23 +193,22 @@ private fun KaSession.collectScopeCompletions( val scopeContext = file.scopeContext(ktElement) val compositeScope = scopeContext.compositeScope() - compositeScope.callables { name -> matchesPrefix(name, partial) } - .forEach { symbol -> - val item = callableSymbolToCompletionItem(symbol, partial) - if (item != null) { - to += item - } - } - - compositeScope.classifiers { name -> matchesPrefix(name, partial) } - .forEach { symbol -> - val item = classifierSymbolToCompletionItem(symbol, partial) - if (item != null) { - to += item - } - } + to += toCompletionItems(compositeScope.callables { name -> matchesPrefix(name, partial) }, partial) + to += toCompletionItems(compositeScope.classifiers { name -> matchesPrefix(name, partial) }, partial) } +@JvmName("callablesToCompletionItems") +private fun KaSession.toCompletionItems(callables: Sequence, partial: String): Sequence = + callables.mapNotNull { + callableSymbolToCompletionItem(it, partial) + } + +@JvmName("classifiersToCompletionItems") +private fun KaSession.toCompletionItems(classifiers: Sequence, partial: String): Sequence = + classifiers.mapNotNull { + classifierSymbolToCompletionItem(it, partial) + } + private fun determineCompletionContext(element: PsiElement): CompletionContext { // Walk up to find a qualified expression where we're the selector val dotExpr = element.getParentOfType(strict = false) From 4129a47eb43f864ee236d9811377e2e9eaafc0cd Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 2 Apr 2026 19:21:30 +0530 Subject: [PATCH 23/29] feat: suggest local and imported extension functions Signed-off-by: Akash Yadav --- .../kotlin/completion/KotlinCompletions.kt | 144 ++++++++++++++---- 1 file changed, 112 insertions(+), 32 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt index 07824fceb7..d29bd1e5bd 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -16,6 +16,7 @@ import org.jetbrains.kotlin.analysis.api.analyzeCopy import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode import org.jetbrains.kotlin.analysis.api.renderer.types.KaTypeRenderer import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.scopes.KaScope import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol @@ -31,13 +32,13 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.name +import org.jetbrains.kotlin.analysis.api.symbols.receiverType import org.jetbrains.kotlin.analysis.api.types.KaClassType import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement -import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtQualifiedExpression import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression import org.jetbrains.kotlin.psi.psiUtil.getParentOfType @@ -92,21 +93,48 @@ fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, ) { val completionContext = determineCompletionContext(elementAtOffset) + + // Find the nearest KtElement parent for scope resolution + val ktElement = elementAtOffset.getParentOfType(strict = false) + val scopeContext = ktElement?.let { element -> completionKtFile.scopeContext(element) } + val compositeScope = scopeContext?.compositeScope() val items = mutableListOf() - when (completionContext) { - CompletionContext.Scope -> collectScopeCompletions( - element = elementAtOffset, - file = completionKtFile, - partial = partial, - to = items + if (ktElement == null) { + logger.error( + "Cannot find parent of element {} with partial {}", + elementAtOffset, + partial ) - CompletionContext.Member -> collectMemberCompletions( - element = elementAtOffset, - partial = partial, - to = items + return@analyzeCopy CompletionResult.EMPTY + } + + if (compositeScope == null) { + logger.error( + "Unable to get CompositeScope for element {} with partial {}", + compositeScope, + partial ) + return@analyzeCopy CompletionResult.EMPTY + } + + when (completionContext) { + CompletionContext.Scope -> + collectScopeCompletions( + ktElement = ktElement, + scope = compositeScope, + partial = partial, + to = items + ) + + CompletionContext.Member -> + collectMemberCompletions( + scope = compositeScope, + element = elementAtOffset, + partial = partial, + to = items + ) } CompletionResult(items) @@ -122,6 +150,7 @@ fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult } private fun KaSession.collectMemberCompletions( + scope: KaScope, element: PsiElement, partial: String, to: MutableList @@ -140,12 +169,22 @@ private fun KaSession.collectMemberCompletions( return } + logger.info( + "Complete members of {}: {} [{}] matching '{}'", + receiver, + receiverType, + receiver.text, + partial + ) + collectMembersFromType(receiverType, partial, to) if (qualifiedExpr is KtSafeQualifiedExpression) { val nonNullType = receiverType.withNullability(isMarkedNullable = false) collectMembersFromType(nonNullType, partial, to) } + + collectExtensionFunctions(scope, partial, receiverType, to) } @OptIn(KaExperimentalApi::class) @@ -156,8 +195,15 @@ private fun KaSession.collectMembersFromType( ) { val typeScope = receiverType.scope if (typeScope != null) { - to += toCompletionItems(typeScope.getCallableSignatures { name -> matchesPrefix(name, partial) }.map { it.symbol }, partial) - to += toCompletionItems(typeScope.getClassifierSymbols { name -> matchesPrefix(name, partial) }, partial) + val callables = + typeScope.getCallableSignatures { name -> matchesPrefix(name, partial) } + .map { it.symbol } + + val classifiers = + typeScope.getClassifierSymbols { name -> matchesPrefix(name, partial) } + + to += toCompletionItems(callables, partial) + to += toCompletionItems(classifiers, partial) return } @@ -166,22 +212,37 @@ private fun KaSession.collectMembersFromType( val classSymbol = classType.symbol as? KaClassSymbol ?: return val memberScope = classSymbol.memberScope - to += toCompletionItems(memberScope.callables { name -> matchesPrefix(name, partial) }, partial) - to += toCompletionItems(memberScope.classifiers { name -> matchesPrefix(name, partial) }, partial) + val callables = memberScope.callables { name -> matchesPrefix(name, partial) } + val classifiers = memberScope.classifiers { name -> matchesPrefix(name, partial) } + + to += toCompletionItems(callables, partial) + to += toCompletionItems(classifiers, partial) } -private fun KaSession.collectScopeCompletions( - element: PsiElement, - file: KtFile, +private fun KaSession.collectExtensionFunctions( + scope: KaScope, partial: String, + receiverType: KaType, to: MutableList ) { - // Find the nearest KtElement parent for scope resolution - val ktElement = element.getParentOfType(strict = false) - if (ktElement == null) { - logger.error("Cannot find parent of element {} with partial {}", element, partial) - return - } + val extensionSymbols = + scope.callables { name -> matchesPrefix(name, partial) } + .filter { symbol -> + if (!symbol.isExtension) return@filter false + + val extReceiverType = symbol.receiverType ?: return@filter false + receiverType.isSubtypeOf(extReceiverType) + } + + to += toCompletionItems(extensionSymbols, partial) +} + +private fun KaSession.collectScopeCompletions( + ktElement: KtElement, + scope: KaScope, + partial: String, + to: MutableList, +) { logger.info( "Complete scope members of {}: [{}] matching '{}'", @@ -190,21 +251,30 @@ private fun KaSession.collectScopeCompletions( partial ) - val scopeContext = file.scopeContext(ktElement) - val compositeScope = scopeContext.compositeScope() - - to += toCompletionItems(compositeScope.callables { name -> matchesPrefix(name, partial) }, partial) - to += toCompletionItems(compositeScope.classifiers { name -> matchesPrefix(name, partial) }, partial) + to += toCompletionItems( + scope.callables { name -> matchesPrefix(name, partial) }, + partial + ) + to += toCompletionItems( + scope.classifiers { name -> matchesPrefix(name, partial) }, + partial + ) } @JvmName("callablesToCompletionItems") -private fun KaSession.toCompletionItems(callables: Sequence, partial: String): Sequence = +private fun KaSession.toCompletionItems( + callables: Sequence, + partial: String +): Sequence = callables.mapNotNull { callableSymbolToCompletionItem(it, partial) } @JvmName("classifiersToCompletionItems") -private fun KaSession.toCompletionItems(classifiers: Sequence, partial: String): Sequence = +private fun KaSession.toCompletionItems( + classifiers: Sequence, + partial: String +): Sequence = classifiers.mapNotNull { classifierSymbolToCompletionItem(it, partial) } @@ -284,7 +354,11 @@ private fun KaSession.classifierSymbolToCompletionItem( val item = createSymbolCompletionItem(symbol, partial) ?: return null item.detail = when (symbol) { is KaClassSymbol -> symbol.classId?.asFqNameString() ?: "" - is KaTypeAliasSymbol -> renderName(symbol.expandedType, KaTypeRendererForSource.WITH_QUALIFIED_NAMES) + is KaTypeAliasSymbol -> renderName( + symbol.expandedType, + KaTypeRendererForSource.WITH_QUALIFIED_NAMES + ) + is KaTypeParameterSymbol -> item.ideLabel } return item @@ -347,6 +421,12 @@ private fun partialIdentifier(prefix: String): String { } private fun matchesPrefix(name: Name, partial: String): Boolean { + logger.info( + "'{}' matches '{}': {}", + name, + partial, + name.asString().startsWith(partial, ignoreCase = true) + ) if (partial.isEmpty()) return true return name.asString().startsWith(partial, ignoreCase = true) } From 7d736fafd995c202b2a5389a1e6bfebaaf207b9f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 2 Apr 2026 19:30:32 +0530 Subject: [PATCH 24/29] fix: do not suggest extension functions for scope completions This ensures that extension functions whose receiver type is not available in the current scope are not suggested for scope completions Signed-off-by: Akash Yadav --- .../kotlin/completion/KotlinCompletions.kt | 33 ++++++++++++------- 1 file changed, 22 insertions(+), 11 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt index d29bd1e5bd..4d868d382b 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -13,6 +13,7 @@ import org.jetbrains.kotlin.analysis.api.KaContextParameterApi import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyzeCopy +import org.jetbrains.kotlin.analysis.api.components.KaScopeContext import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode import org.jetbrains.kotlin.analysis.api.renderer.types.KaTypeRenderer import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource @@ -122,8 +123,9 @@ fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult when (completionContext) { CompletionContext.Scope -> collectScopeCompletions( - ktElement = ktElement, + scopeContext = scopeContext, scope = compositeScope, + ktElement = ktElement, partial = partial, to = items ) @@ -238,12 +240,12 @@ private fun KaSession.collectExtensionFunctions( } private fun KaSession.collectScopeCompletions( - ktElement: KtElement, + scopeContext: KaScopeContext, scope: KaScope, + ktElement: KtElement, partial: String, to: MutableList, ) { - logger.info( "Complete scope members of {}: [{}] matching '{}'", ktElement, @@ -251,14 +253,23 @@ private fun KaSession.collectScopeCompletions( partial ) - to += toCompletionItems( - scope.callables { name -> matchesPrefix(name, partial) }, - partial - ) - to += toCompletionItems( - scope.classifiers { name -> matchesPrefix(name, partial) }, - partial - ) + val callables = + scope.callables { name -> matchesPrefix(name, partial) } + .filter { symbol -> + + // always include non-extension functions + if (!symbol.isExtension) return@filter true + + // include extension functions with matching implicit receivers + val extReceiverType = symbol.receiverType ?: return@filter true + scopeContext.implicitReceivers.any { receiver -> + receiver.type.isSubtypeOf(extReceiverType) + } + } + val classifiers = scope.classifiers { name -> matchesPrefix(name, partial) } + + to += toCompletionItems(callables, partial) + to += toCompletionItems(classifiers, partial) } @JvmName("callablesToCompletionItems") From 87234f8331530425ede530f7e50ccae62440edf9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 3 Apr 2026 16:37:35 +0530 Subject: [PATCH 25/29] feat: add scope-sensitive keyword completions Signed-off-by: Akash Yadav --- .../lsp/kotlin/completion/ContextKeywords.kt | 53 ++++++ .../lsp/kotlin/completion/ContextResolver.kt | 166 +++++++++++++++++ .../kotlin/completion/DeclarationContext.kt | 25 +++ .../kotlin/completion/KotlinCompletions.kt | 119 ++++++------ .../lsp/kotlin/completion/ModifierFilter.kt | 174 ++++++++++++++++++ 5 files changed, 481 insertions(+), 56 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextKeywords.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextResolver.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/DeclarationContext.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ModifierFilter.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextKeywords.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextKeywords.kt new file mode 100644 index 0000000000..433963f83e --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextKeywords.kt @@ -0,0 +1,53 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import org.jetbrains.kotlin.lexer.KtKeywordToken +import org.jetbrains.kotlin.lexer.KtTokens.* + +/** + * + */ +object ContextKeywords { + + /** Hard keywords valid as *statement starters* inside a function body */ + val STATEMENT_KEYWORDS = setOf( + IF_KEYWORD, ELSE_KEYWORD, WHEN_KEYWORD, WHILE_KEYWORD, DO_KEYWORD, FOR_KEYWORD, + TRY_KEYWORD, RETURN_KEYWORD, THROW_KEYWORD, BREAK_KEYWORD, CONTEXT_KEYWORD, + VAL_KEYWORD, VAR_KEYWORD, FUN_KEYWORD,// local declarations + OBJECT_KEYWORD,// anonymous / local object + CLASS_KEYWORD,// local class (rare but legal) + ) + + /** Declaration starters at top-level / class body */ + val DECLARATION_KEYWORDS = setOf( + VAL_KEYWORD, VAR_KEYWORD, FUN_KEYWORD, CLASS_KEYWORD, INTERFACE_KEYWORD, OBJECT_KEYWORD, + TYPE_ALIAS_KEYWORD, CONSTRUCTOR_KEYWORD, INIT_KEYWORD, + ) + + val TOP_LEVEL_ONLY = setOf(PACKAGE_KEYWORD, IMPORT_KEYWORD) + + /** + * Resolve valid keywords for the given declaration context. + * + * @param ctx The declaration context. + * @return The keyword tokens for the declaration context. + */ + fun keywordsFor(ctx: DeclarationContext): Set = when (ctx) { + DeclarationContext.TOP_LEVEL, + DeclarationContext.SCRIPT_TOP_LEVEL -> TOP_LEVEL_ONLY + DECLARATION_KEYWORDS + + DeclarationContext.CLASS_BODY -> DECLARATION_KEYWORDS + + setOf(INIT_KEYWORD, CONSTRUCTOR_KEYWORD) + + DeclarationContext.INTERFACE_BODY -> setOf( + VAL_KEYWORD, VAR_KEYWORD, FUN_KEYWORD, CLASS_KEYWORD, + INTERFACE_KEYWORD, OBJECT_KEYWORD, TYPE_ALIAS_KEYWORD + ) + + DeclarationContext.OBJECT_BODY, + DeclarationContext.ENUM_BODY -> DECLARATION_KEYWORDS - setOf(CONSTRUCTOR_KEYWORD) + + DeclarationContext.ANNOTATION_BODY -> setOf(VAL_KEYWORD) // annotation params only + + DeclarationContext.FUNCTION_BODY -> STATEMENT_KEYWORDS + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextResolver.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextResolver.kt new file mode 100644 index 0000000000..3878cb57ac --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextResolver.kt @@ -0,0 +1,166 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.components.KaScopeContext +import org.jetbrains.kotlin.analysis.api.scopes.KaScope +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.lexer.KtModifierKeywordToken +import org.jetbrains.kotlin.lexer.KtTokens.MODIFIER_KEYWORDS +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassBody +import org.jetbrains.kotlin.psi.KtConstructor +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtModifierList +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression +import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.psiUtil.getNonStrictParentOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType +import org.jetbrains.kotlin.psi.psiUtil.parents +import org.jetbrains.kotlin.psi.psiUtil.startOffset +import org.slf4j.LoggerFactory + +/** + * Defines context at the cursor position. + */ +data class CursorContext( + val psiElement: PsiElement, + val ktFile: KtFile, + val ktElement: KtElement, + val scopeContext: KaScopeContext, + val compositeScope: KaScope, + val completionContext: CompletionContext, + val declarationContext: DeclarationContext, + val declarationKind: DeclarationKind, + val existingModifiers: Set, + val isInsideModifierList: Boolean, +) + + +private val logger = LoggerFactory.getLogger("ContextResolver") + +/** + * Resolves [CursorContext] at the given offset in the given [KtFile]. + */ +fun KaSession.resolveCursorContext(ktFile: KtFile, offset: Int): CursorContext? { + val psiElement = ktFile.findElementAt(offset) + if (psiElement == null) { + logger.error("Unable to find PSI element at offset {} in file {}", offset, ktFile) + return null + } + + val completionContext = determineCompletionContext(psiElement) + val ktElement = psiElement.getParentOfType(strict = false) + if (ktElement == null) { + logger.error("Cannot find parent of element {}", psiElement) + return null + } + + val scopeContext = ktFile.scopeContext(ktElement) + val compositeScope = scopeContext.compositeScope() + + // The element is typically a KtModifierList, an error node, + // or the incomplete declaration itself. + val modifierList = ktElement.getParentOfType(strict = false) + val existingModifiers = modifierList + ?.node?.getChildren(MODIFIER_KEYWORDS) + ?.mapNotNull { it.elementType as? KtModifierKeywordToken } + ?.toSet() + ?: emptySet() + + val declarationKind = resolveDeclarationKind(ktElement) + val declarationContext = resolveDeclarationContext(ktElement) + + return CursorContext( + psiElement = psiElement, + ktFile = ktFile, + ktElement = ktElement, + scopeContext = scopeContext, + compositeScope = compositeScope, + completionContext = completionContext, + declarationContext = declarationContext, + declarationKind = declarationKind, + existingModifiers = existingModifiers, + isInsideModifierList = modifierList != null, + ) +} + +private fun determineCompletionContext(element: PsiElement): CompletionContext { + // Walk up to find a qualified expression where we're the selector + val dotExpr = element.getParentOfType(strict = false) + if (dotExpr != null && isInSelectorPosition(element, dotExpr)) { + return CompletionContext.Member + } + + val safeExpr = element.getParentOfType(strict = false) + if (safeExpr != null && isInSelectorPosition(element, safeExpr)) { + return CompletionContext.Member + } + + return CompletionContext.Scope +} + +private fun isInSelectorPosition( + element: PsiElement, + qualifiedExpr: KtQualifiedExpression, +): Boolean { + val selector = qualifiedExpr.selectorExpression ?: return false + val elementOffset = element.startOffset + return elementOffset >= selector.startOffset +} + +private fun resolveDeclarationContext(element: KtElement): DeclarationContext { + for (ancestor in element.parents) { + when (ancestor) { + is KtClassBody -> { + return when (val owner = ancestor.parent) { + is KtClass -> when { + owner.isInterface() -> DeclarationContext.INTERFACE_BODY + owner.isEnum() -> DeclarationContext.ENUM_BODY + owner.isAnnotation() -> DeclarationContext.ANNOTATION_BODY + else -> DeclarationContext.CLASS_BODY + } + + is KtObjectDeclaration -> DeclarationContext.OBJECT_BODY + else -> DeclarationContext.CLASS_BODY + } + } + + is KtBlockExpression -> return DeclarationContext.FUNCTION_BODY + is KtFile -> return if (ancestor.isScript()) + DeclarationContext.SCRIPT_TOP_LEVEL + else + DeclarationContext.TOP_LEVEL + } + } + return DeclarationContext.TOP_LEVEL +} + +private fun resolveDeclarationKind(element: KtElement): DeclarationKind { + // Walk up to the nearest declaration owning this modifier list / position + return when (val declaration = element.getNonStrictParentOfType()) { + is KtClass -> when { + declaration.isInterface() -> DeclarationKind.INTERFACE + declaration.isEnum() -> DeclarationKind.ENUM_CLASS + declaration.isAnnotation() -> DeclarationKind.ANNOTATION_CLASS + else -> DeclarationKind.CLASS + } + + is KtObjectDeclaration -> DeclarationKind.OBJECT + is KtNamedFunction -> DeclarationKind.FUN + is KtProperty -> if (declaration.isVar) DeclarationKind.PROPERTY_VAR + else DeclarationKind.PROPERTY_VAL + + is KtTypeAlias -> DeclarationKind.TYPEALIAS + is KtConstructor<*> -> DeclarationKind.CONSTRUCTOR + null -> DeclarationKind.UNKNOWN // pure modifier list, no keyword yet + else -> DeclarationKind.UNKNOWN + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/DeclarationContext.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/DeclarationContext.kt new file mode 100644 index 0000000000..27f3de4703 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/DeclarationContext.kt @@ -0,0 +1,25 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +/** + * Defines the possible declaration contexts of the element at cursor position. + */ +enum class DeclarationContext { + TOP_LEVEL, + CLASS_BODY, + INTERFACE_BODY, + OBJECT_BODY, // includes companion object + ENUM_BODY, + FUNCTION_BODY, // local declarations & statements + SCRIPT_TOP_LEVEL, + ANNOTATION_BODY, +} + +/** + * Defines declaration kinds for element at cursor. + */ +enum class DeclarationKind { + CLASS, INTERFACE, OBJECT, ENUM_CLASS, ANNOTATION_CLASS, + FUN, PROPERTY_VAL, PROPERTY_VAR, + TYPEALIAS, CONSTRUCTOR, + UNKNOWN // e.g. modifier typed before any keyword yet +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt index 4d868d382b..51613960b2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -80,45 +80,37 @@ fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult append(originalText, completionOffset, originalText.length) } - val completionKtFile = managedFile.createInMemoryFileWithContent(parser, textWithPlaceholder) - val elementAtOffset = completionKtFile.findElementAt(completionOffset) - - if (elementAtOffset == null) { - logger.error("Unable to locate element at position {}", requestPosition) - return CompletionResult.EMPTY - } + val completionKtFile = + managedFile.createInMemoryFileWithContent( + psiFactory = parser, + content = textWithPlaceholder + ) return try { analyzeCopy( useSiteElement = completionKtFile, resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, ) { - val completionContext = determineCompletionContext(elementAtOffset) - - // Find the nearest KtElement parent for scope resolution - val ktElement = elementAtOffset.getParentOfType(strict = false) - val scopeContext = ktElement?.let { element -> completionKtFile.scopeContext(element) } - val compositeScope = scopeContext?.compositeScope() - val items = mutableListOf() - - if (ktElement == null) { + val cursorContext = resolveCursorContext(completionKtFile, completionOffset) + if (cursorContext == null) { logger.error( - "Cannot find parent of element {} with partial {}", - elementAtOffset, - partial + "Unable to determine context at offset {} in file {}", + completionOffset, + params.file ) - return@analyzeCopy CompletionResult.EMPTY } - if (compositeScope == null) { - logger.error( - "Unable to get CompositeScope for element {} with partial {}", - compositeScope, - partial - ) - return@analyzeCopy CompletionResult.EMPTY - } + val ( + psiElement, + _, + ktElement, + scopeContext, + compositeScope, + completionContext + ) = cursorContext + + val items = mutableListOf() when (completionContext) { CompletionContext.Scope -> @@ -133,12 +125,18 @@ fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult CompletionContext.Member -> collectMemberCompletions( scope = compositeScope, - element = elementAtOffset, + element = psiElement, partial = partial, to = items ) } + collectKeywordCompletions( + ctx = cursorContext, + partial = partial, + to = items + ) + CompletionResult(items) } } catch (e: Throwable) { @@ -272,6 +270,29 @@ private fun KaSession.collectScopeCompletions( to += toCompletionItems(classifiers, partial) } +private fun KaSession.collectKeywordCompletions( + ctx: CursorContext, + partial: String, + to: MutableList, +) { + fun kwItem(name: String) = + ktCompletionItem( + name = name, + kind = CompletionItemKind.KEYWORD, + partial = partial + ) + + if (!ctx.isInsideModifierList) { + ContextKeywords.keywordsFor(ctx.declarationContext).mapTo(to) { kw -> + kwItem(kw.value) + } + } + + ModifierFilter.validModifiers(ctx).mapTo(to) { kw -> + kwItem(kw.value) + } +} + @JvmName("callablesToCompletionItems") private fun KaSession.toCompletionItems( callables: Sequence, @@ -290,30 +311,6 @@ private fun KaSession.toCompletionItems( classifierSymbolToCompletionItem(it, partial) } -private fun determineCompletionContext(element: PsiElement): CompletionContext { - // Walk up to find a qualified expression where we're the selector - val dotExpr = element.getParentOfType(strict = false) - if (dotExpr != null && isInSelectorPosition(element, dotExpr)) { - return CompletionContext.Member - } - - val safeExpr = element.getParentOfType(strict = false) - if (safeExpr != null && isInSelectorPosition(element, safeExpr)) { - return CompletionContext.Member - } - - return CompletionContext.Scope -} - -private fun isInSelectorPosition( - element: PsiElement, - qualifiedExpr: KtQualifiedExpression, -): Boolean { - val selector = qualifiedExpr.selectorExpression ?: return false - val elementOffset = element.startOffset - return elementOffset >= selector.startOffset -} - @OptIn(KaExperimentalApi::class) private fun KaSession.callableSymbolToCompletionItem( symbol: KaCallableSymbol, @@ -379,12 +376,22 @@ private fun KaSession.createSymbolCompletionItem( symbol: KaSymbol, partial: String ): CompletionItem? { - val name = symbol.name?.asString() ?: return null + return ktCompletionItem( + name = symbol.name?.asString() ?: return null, + kind = kindOf(symbol), + partial = partial, + ) +} +private fun KaSession.ktCompletionItem( + name: String, + kind: CompletionItemKind, + partial: String, +): CompletionItem { val item = KotlinCompletionItem() item.ideLabel = name - item.completionKind = kindOf(symbol) - item.matchLevel = CompletionItem.matchLevel(name, partial) + item.completionKind = kind + item.matchLevel = CompletionItem.matchLevel(item.ideLabel, partial) return item } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ModifierFilter.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ModifierFilter.kt new file mode 100644 index 0000000000..5f0710e1d8 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ModifierFilter.kt @@ -0,0 +1,174 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import org.jetbrains.kotlin.com.intellij.psi.tree.TokenSet +import org.jetbrains.kotlin.lexer.KtModifierKeywordToken +import org.jetbrains.kotlin.lexer.KtTokens.* + +/** + * Helper for filtering modifier keywords for keyword completions. + */ +object ModifierFilter { + + /** + * Returns which modifier keywords are valid to suggest given the + * current context, declaration kind, and already-present modifiers. + */ + fun validModifiers( + ctx: CursorContext, + ): Set { + val (_, _, _, _, _, _, declCtx, declKind, existing, _) = ctx + + val candidates = MODIFIER_KEYWORDS_ARRAY.toMutableSet() + candidates -= existing + + // remove mutually exclusive groups + if (VISIBILITY_MODIFIERS.types.any { it in existing }) + candidates -= VISIBILITY_MODIFIERS.types() + if (MODALITY_MODIFIERS.types.any { it in existing }) + candidates -= MODALITY_MODIFIERS.types() + + when (declCtx) { + DeclarationContext.INTERFACE_BODY -> { + // interface members are open by default; sealed/final don't apply to members + candidates -= setOf(FINAL_KEYWORD, OPEN_KEYWORD, SEALED_KEYWORD) + + // inner classes not allowed in interfaces + candidates -= INNER_KEYWORD + } + + DeclarationContext.FUNCTION_BODY -> { + // local declarations: only a small subset of modifiers are legal + candidates.retainAll( + setOf( + INLINE_KEYWORD, NOINLINE_KEYWORD, CROSSINLINE_KEYWORD, + SUSPEND_KEYWORD, TAILREC_KEYWORD, + DATA_KEYWORD, // local data class (Kotlin 1.9+) + INNER_KEYWORD, + ) + ) + } + + DeclarationContext.OBJECT_BODY, + DeclarationContext.TOP_LEVEL, + DeclarationContext.SCRIPT_TOP_LEVEL -> candidates -= INNER_KEYWORD // inner only valid inside a class + + else -> Unit + } + + when (declKind) { + DeclarationKind.PROPERTY_VAL -> { + candidates -= setOf( + LATEINIT_KEYWORD, // lateinit requires var + VARARG_KEYWORD, + NOINLINE_KEYWORD, CROSSINLINE_KEYWORD, + TAILREC_KEYWORD, OPERATOR_KEYWORD, INFIX_KEYWORD, + INNER_KEYWORD, COMPANION_KEYWORD, DATA_KEYWORD, + ENUM_KEYWORD, ANNOTATION_KEYWORD, SEALED_KEYWORD, + VALUE_KEYWORD, + ) + + // const only on top-level or companion object val + if (declCtx !in setOf( + DeclarationContext.TOP_LEVEL, + DeclarationContext.OBJECT_BODY, + DeclarationContext.SCRIPT_TOP_LEVEL + ) + ) + candidates -= CONST_KEYWORD + } + + DeclarationKind.PROPERTY_VAR -> { + candidates -= setOf( + CONST_KEYWORD, // const requires val + VARARG_KEYWORD, NOINLINE_KEYWORD, CROSSINLINE_KEYWORD, + TAILREC_KEYWORD, OPERATOR_KEYWORD, INFIX_KEYWORD, + INNER_KEYWORD, COMPANION_KEYWORD, DATA_KEYWORD, + ENUM_KEYWORD, ANNOTATION_KEYWORD, SEALED_KEYWORD, + VALUE_KEYWORD, + ) + } + + DeclarationKind.FUN -> { + candidates -= setOf( + LATEINIT_KEYWORD, CONST_KEYWORD, VARARG_KEYWORD, + INNER_KEYWORD, COMPANION_KEYWORD, DATA_KEYWORD, + ENUM_KEYWORD, ANNOTATION_KEYWORD, SEALED_KEYWORD, + VALUE_KEYWORD, + ) + // abstract fun can't be inline/tailrec/external simultaneously + if (ABSTRACT_KEYWORD in existing) { + candidates -= setOf(INLINE_KEYWORD, TAILREC_KEYWORD, EXTERNAL_KEYWORD) + } + } + + DeclarationKind.CLASS -> { + candidates -= setOf( + LATEINIT_KEYWORD, CONST_KEYWORD, + VARARG_KEYWORD, NOINLINE_KEYWORD, CROSSINLINE_KEYWORD, + TAILREC_KEYWORD, OPERATOR_KEYWORD, INFIX_KEYWORD, + REIFIED_KEYWORD, + ) + + // sealed is a modality modifier and conflicts with open/final/abstract + if (SEALED_KEYWORD in existing) + candidates -= setOf(OPEN_KEYWORD, FINAL_KEYWORD, ABSTRACT_KEYWORD) + + // value class requires @JvmInline in practice, but `value` keyword is valid + } + + DeclarationKind.INTERFACE -> { + // interfaces are implicitly abstract; most modifiers don't apply + candidates.retainAll( + setOf( + PUBLIC_KEYWORD, PROTECTED_KEYWORD, PRIVATE_KEYWORD, INTERNAL_KEYWORD, + EXPECT_KEYWORD, ACTUAL_KEYWORD, + SEALED_KEYWORD, // sealed interface + EXTERNAL_KEYWORD, FUN_KEYWORD, // fun interface + ) + ) + } + + DeclarationKind.OBJECT -> { + candidates.retainAll( + setOf( + PUBLIC_KEYWORD, PROTECTED_KEYWORD, PRIVATE_KEYWORD, INTERNAL_KEYWORD, + EXPECT_KEYWORD, ACTUAL_KEYWORD, EXTERNAL_KEYWORD, + DATA_KEYWORD, // data object (Kotlin 1.9+) + COMPANION_KEYWORD, + ) + ) + } + + DeclarationKind.CONSTRUCTOR -> { + // constructors only take visibility modifiers + candidates.retainAll(VISIBILITY_MODIFIERS.types()) + } + + DeclarationKind.UNKNOWN -> { + // Cursor is after some modifiers but before any keyword. + // Keep all modifiers that are valid given what's already typed; + // the exclusion rules above already handled mutual exclusions. + } + + else -> Unit + } + + // expect and actual are mutually exclusive + if (EXPECT_KEYWORD in existing) candidates -= ACTUAL_KEYWORD + if (ACTUAL_KEYWORD in existing) candidates -= EXPECT_KEYWORD + + // noinline, crossinline and reified keywords are invalid if the + // function is not inline + if (INLINE_KEYWORD !in existing) { + candidates -= setOf(NOINLINE_KEYWORD, CROSSINLINE_KEYWORD, REIFIED_KEYWORD) + } + + return candidates + } + + private fun TokenSet.types(): Set = + types.filterIsInstance().toSet() + + private operator fun TokenSet.contains(token: KtModifierKeywordToken): Boolean = + this.contains(token) +} \ No newline at end of file From 7d78db1c62bfd24d9b1b301e781a0d0b0f1496ba Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 6 Apr 2026 22:52:04 +0530 Subject: [PATCH 26/29] feat: add indexing api and service implementation Signed-off-by: Akash Yadav --- .../services/builder/GradleBuildService.kt | 6 + gradle/libs.versions.toml | 8 +- lsp/indexing/build.gradle.kts | 19 + .../codeonthego/indexing/FilteredIndex.kt | 112 +++++ .../codeonthego/indexing/InMemoryIndex.kt | 226 +++++++++ .../codeonthego/indexing/MergedIndex.kt | 82 ++++ .../codeonthego/indexing/PersistentIndex.kt | 336 ++++++++++++++ .../codeonthego/indexing/api/Core.kt | 91 ++++ .../codeonthego/indexing/api/Index.kt | 105 +++++ .../codeonthego/indexing/api/Query.kt | 78 ++++ .../indexing/service/IndexRegistry.kt | 119 +++++ .../indexing/service/IndexingService.kt | 41 ++ .../service/IndexingServiceManager.kt | 158 +++++++ .../indexing/util/BackgroundIndexer.kt | 214 +++++++++ lsp/java/build.gradle.kts | 1 + .../androidide/lsp/java/JavaLanguageServer.kt | 19 +- lsp/jvm-symbol-index/build.gradle.kts | 24 + .../indexing/jvm/CombinedJarScanner.kt | 78 ++++ .../indexing/jvm/JarSymbolScanner.kt | 305 +++++++++++++ .../indexing/jvm/JvmIndexingService.kt | 149 ++++++ .../codeonthego/indexing/jvm/JvmSymbol.kt | 204 +++++++++ .../indexing/jvm/JvmSymbolDescriptor.kt | 409 +++++++++++++++++ .../indexing/jvm/JvmSymbolIndex.kt | 191 ++++++++ .../indexing/jvm/KotlinMetadataScanner.kt | 428 ++++++++++++++++++ lsp/jvm-symbol-models/build.gradle.kts | 37 ++ .../src/main/proto/jvm_symbol.proto | 210 +++++++++ lsp/kotlin/build.gradle.kts | 1 + .../lsp/kotlin/KotlinLanguageServer.kt | 7 + settings.gradle.kts | 3 + subprojects/projects/build.gradle.kts | 1 + .../androidide/projects/ProjectManagerImpl.kt | 22 +- 31 files changed, 3677 insertions(+), 7 deletions(-) create mode 100644 lsp/indexing/build.gradle.kts create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/MergedIndex.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/PersistentIndex.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Core.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Query.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexRegistry.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingService.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingServiceManager.kt create mode 100644 lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt create mode 100644 lsp/jvm-symbol-index/build.gradle.kts create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/CombinedJarScanner.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JarSymbolScanner.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbol.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt create mode 100644 lsp/jvm-symbol-models/build.gradle.kts create mode 100644 lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto diff --git a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt index 85f95e5324..d92830bbfb 100644 --- a/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt +++ b/app/src/main/java/com/itsaky/androidide/services/builder/GradleBuildService.kt @@ -449,6 +449,12 @@ class GradleBuildService : ), ) + buildServiceScope.launch { + ProjectManagerImpl.getInstance() + .indexingServiceManager + .onBuildCompleted() + } + EventBus.getDefault() .post( BuildCompletedEvent( diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dff1dee11d..b9e0b63487 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -2,7 +2,7 @@ activityKtx = "1.8.2" agp = "8.8.2" agp-tooling = "8.11.0" -appcompat = "1.6.1" +androidx-sqlite = "2.6.2" appcompatVersion = "1.7.1" bcprovJdk18on = "1.80" colorpickerview = "2.3.0" @@ -94,6 +94,8 @@ androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtxVersion" } androidx-recyclerview-v132 = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } androidx-security-crypto = { module = "androidx.security:security-crypto", version.ref = "securityCrypto" } +androidx-sqlite-ktx = { module = "androidx.sqlite:sqlite-ktx", version.ref = "androidx-sqlite" } +androidx-sqlite-framework = { module = "androidx.sqlite:sqlite-framework", version.ref = "androidx-sqlite" } androidx-viewpager2-v110beta02 = { module = "androidx.viewpager2:viewpager2", version.ref = "viewpager2" } bcpkix-jdk18on = { module = "org.bouncycastle:bcpkix-jdk18on", version.ref = "bcprovJdk18on" } bcprov-jdk18on = { module = "org.bouncycastle:bcprov-jdk18on", version.ref = "bcprovJdk18on" } @@ -118,10 +120,8 @@ desugar_jdk_libs-v215 = { module = "com.android.tools:desugar_jdk_libs", version google-genai = { module = "com.google.genai:google-genai", version.ref = "googleGenai" } gson-v2101 = { module = "com.google.code.gson:gson", version.ref = "gson" } koin-android = { module = "io.insert-koin:koin-android", version.ref = "koinAndroid" } -kotlin-stdlib = { module = "org.jetbrains.kotlin:kotlin-stdlib" } kotlinx-coroutines-core = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-core", version.ref = "kotlinxCoroutinesCore" } kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinxSerializationJson" } -play-services-oss-licenses = { module = "com.google.android.gms:play-services-oss-licenses", version.ref = "playServicesOssLicenses" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room" } @@ -307,6 +307,8 @@ androidx-monitor = { group = "androidx.test", name = "monitor", version.ref = "m monitor = { group = "androidx.test", name = "monitor", version.ref = "monitorVersion" } org-json = { module = "org.json:json", version = "20210307"} +kotlinx-metadata = { module = "org.jetbrains.kotlin:kotlin-metadata-jvm", version.ref = "kotlin" } + [plugins] android-application = { id = "com.android.application", version.ref = "agp" } android-library = { id = "com.android.library", version.ref = "agp" } diff --git a/lsp/indexing/build.gradle.kts b/lsp/indexing/build.gradle.kts new file mode 100644 index 0000000000..b81c2d47b3 --- /dev/null +++ b/lsp/indexing/build.gradle.kts @@ -0,0 +1,19 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.lsp.indexing" +} + +dependencies { + api(libs.androidx.annotation) + api(libs.androidx.sqlite.ktx) + api(libs.androidx.sqlite.framework) + api(libs.kotlinx.coroutines.core) + + api(projects.logger) +} diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt new file mode 100644 index 0000000000..d5a8527ca3 --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt @@ -0,0 +1,112 @@ +package org.appdevforall.codeonthego.indexing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.api.Indexable +import org.appdevforall.codeonthego.indexing.api.ReadableIndex +import java.io.Closeable +import java.util.concurrent.ConcurrentHashMap + +/** + * A read-only view over an index that only exposes entries + * from a set of active sources. + * + * The underlying index retains ALL data (it's a persistent cache). + * This view controls which subset is visible based on which + * sources (JAR paths, etc.) are currently "active." + * + * @param T The indexed type. + * @param backing The underlying index that holds all data. + */ +open class FilteredIndex( + private val backing: ReadableIndex, +) : ReadableIndex, Closeable { + + /** + * The set of source IDs whose entries are visible. + * Uses a concurrent set for thread-safe reads during queries. + */ + private val activeSources = ConcurrentHashMap.newKeySet() + + /** + * Make a source's entries visible in query results. + */ + fun activateSource(sourceId: String) { + activeSources.add(sourceId) + } + + /** + * Hide a source's entries from query results. + * The data remains in the backing index. + */ + fun deactivateSource(sourceId: String) { + activeSources.remove(sourceId) + } + + /** + * Replace the entire active set. Sources not in [sourceIds] + * become invisible; sources in [sourceIds] become visible. + * + * This is the typical call on project sync: pass in all + * current classpath JAR paths. + */ + fun setActiveSources(sourceIds: Set) { + activeSources.clear() + activeSources.addAll(sourceIds) + } + + /** + * Returns the current set of active source IDs. + */ + fun activeSources(): Set = + activeSources.toSet() + + /** + * Returns true if the source is currently active (visible). + */ + fun isActive(sourceId: String): Boolean = + sourceId in activeSources + + /** + * Returns true if the source exists in the backing index, + * regardless of whether it's active. + * + * Use this to check if a JAR needs indexing at all. + */ + suspend fun isCached(sourceId: String): Boolean = + backing.containsSource(sourceId) + + override fun query(query: IndexQuery): Flow { + // If the query already specifies a sourceId, check if it's active + if (query.sourceId != null && query.sourceId !in activeSources) { + return kotlinx.coroutines.flow.emptyFlow() + } + + return backing.query(query).filter { it.sourceId in activeSources } + } + + override suspend fun get(key: String): T? { + val entry = backing.get(key) ?: return null + return if (entry.sourceId in activeSources) entry else null + } + + override suspend fun containsSource(sourceId: String): Boolean { + return sourceId in activeSources && backing.containsSource(sourceId) + } + + override fun distinctValues(fieldName: String): Flow { + // This is imprecise — the backing index may return values + // from inactive sources. For exact results, we'd need to + // query all entries and filter. For package enumeration + // (the main use case), this approximation is acceptable + // since packages from inactive JARs are harmless — they + // just produce empty results when queried further. + return backing.distinctValues(fieldName) + } + + override fun close() { + activeSources.clear() + if (backing is Closeable) backing.close() + } +} \ No newline at end of file diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt new file mode 100644 index 0000000000..20067e998e --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt @@ -0,0 +1,226 @@ +package org.appdevforall.codeonthego.indexing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import org.appdevforall.codeonthego.indexing.api.Index +import org.appdevforall.codeonthego.indexing.api.IndexDescriptor +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.api.Indexable +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.collections.iterator +import kotlin.concurrent.read +import kotlin.concurrent.write + +/** + * A thread-safe, in-memory [Index] backed by [ConcurrentHashMap]. + * + * Optimized for small-to-medium datasets (source files, typically + * hundreds to low thousands of entries) that change frequently. + * + * Data layout: + * - [primaryMap]: key → entry (O(1) point lookup) + * - [sourceMap]: sourceId → set of keys (O(1) bulk removal) + * - [fieldMaps]: fieldName → (fieldValue → set of keys) (equality filter) + * - [prefixBuckets]: fieldName → (lowercased first char → list of (value, key)) + * Provides a ~36-way partition for prefix search. + * + * All mutations go through [lock] in write mode for consistency + * across the multiple maps. Reads use read mode. + * + * @param T The indexed entry type. + * @param descriptor Defines queryable fields and serialization. + */ +class InMemoryIndex( + override val descriptor: IndexDescriptor, + override val name: String = "memory:${descriptor.name}", +) : Index { + + private val primaryMap = ConcurrentHashMap(256) + private val sourceMap = ConcurrentHashMap>(32) + private val fieldMaps = ConcurrentHashMap>>() + private val prefixBuckets = ConcurrentHashMap>>() + + private val lock = ReentrantReadWriteLock() + + private data class PrefixEntry(val lowerValue: String, val key: String) + + init { + for (field in descriptor.fields) { + fieldMaps[field.name] = ConcurrentHashMap() + if (field.prefixSearchable) { + prefixBuckets[field.name] = ConcurrentHashMap() + } + } + } + + override fun query(query: IndexQuery): Flow = flow { + val keys = resolveMatchingKeys(query) + var emitted = 0 + val limit = if (query.limit <= 0) Int.MAX_VALUE else query.limit + + for (key in keys) { + if (emitted >= limit) break + val entry = primaryMap[key] ?: continue + emit(entry) + emitted++ + } + } + + override suspend fun get(key: String): T? = primaryMap[key] + + override suspend fun containsSource(sourceId: String): Boolean = + sourceMap.containsKey(sourceId) + + override fun distinctValues(fieldName: String): Flow = flow { + val fieldMap = fieldMaps[fieldName] ?: return@flow + lock.read { + for (value in fieldMap.keys) { + emit(value) + } + } + } + + override suspend fun insert(entries: Flow) { + entries.collect { entry -> insertSingle(entry) } + } + + override suspend fun insertAll(entries: Sequence) { + lock.write { + for (entry in entries) { + insertSingleLocked(entry) + } + } + } + + override suspend fun insert(entry: T) = insertSingle(entry) + + override suspend fun removeBySource(sourceId: String) = lock.write { + val keys = sourceMap.remove(sourceId) ?: return@write + for (key in keys) { + val entry = primaryMap.remove(key) ?: continue + removeFromSecondaryIndexes(entry) + } + } + + override suspend fun clear() = lock.write { + primaryMap.clear() + sourceMap.clear() + fieldMaps.values.forEach { it.clear() } + prefixBuckets.values.forEach { it.clear() } + } + + val size: Int get() = primaryMap.size + val sourceCount: Int get() = sourceMap.size + + /** + * Resolves the set of keys matching the query by intersecting + * the results of each predicate. + * + * Starts with the most selective predicate to minimize the + * intersection set. + */ + private fun resolveMatchingKeys(query: IndexQuery): Sequence = lock.read { + var candidates: Set? = null + + if (query.key != null) { + return@read if (primaryMap.containsKey(query.key)) { + sequenceOf(query.key) + } else { + emptySequence() + } + } + + if (query.sourceId != null) { + candidates = intersect(candidates, sourceMap[query.sourceId]) + } + + for ((field, value) in query.exactMatch) { + val fieldMap = fieldMaps[field] ?: return@read emptySequence() + candidates = intersect(candidates, fieldMap[value]) + } + + for ((field, prefix) in query.prefixMatch) { + val buckets = prefixBuckets[field] ?: return@read emptySequence() + val lowerPrefix = prefix.lowercase() + val firstChar = lowerPrefix.firstOrNull() ?: continue + val bucket = buckets[firstChar] ?: return@read emptySequence() + + val matching = bucket.asSequence() + .filter { it.lowerValue.startsWith(lowerPrefix) } + .map { it.key } + .toSet() + + candidates = intersect(candidates, matching) + } + + for ((field, mustExist) in query.presence) { + val fieldMap = fieldMaps[field] ?: return@read emptySequence() + val allKeysWithField = fieldMap.values.flatMapTo(mutableSetOf()) { it } + candidates = if (mustExist) { + intersect(candidates, allKeysWithField) + } else { + // Keys that DON'T have this field + val allKeys = primaryMap.keys.toMutableSet() + allKeys.removeAll(allKeysWithField) + intersect(candidates, allKeys) + } + } + + candidates?.asSequence() ?: primaryMap.keys.asSequence() + } + + private fun intersect(current: Set?, other: Set?): Set? { + if (other == null) return current + if (current == null) return other + return current.intersect(other) + } + + private fun insertSingle(entry: T) = lock.write { + insertSingleLocked(entry) + } + + private fun insertSingleLocked(entry: T) { + val existing = primaryMap[entry.key] + if (existing != null) { + removeFromSecondaryIndexes(existing) + } + + primaryMap[entry.key] = entry + sourceMap.getOrPut(entry.sourceId) { mutableSetOf() }.add(entry.key) + + val fields = descriptor.fieldValues(entry) + for ((fieldName, value) in fields) { + if (value == null) continue + + fieldMaps[fieldName] + ?.getOrPut(value) { mutableSetOf() } + ?.add(entry.key) + + val buckets = prefixBuckets[fieldName] + if (buckets != null) { + val lower = value.lowercase() + val firstChar = lower.firstOrNull() ?: continue + buckets.getOrPut(firstChar) { mutableListOf() } + .add(PrefixEntry(lower, entry.key)) + } + } + } + + private fun removeFromSecondaryIndexes(entry: T) { + val fields = descriptor.fieldValues(entry) + for ((fieldName, value) in fields) { + if (value == null) continue + + fieldMaps[fieldName]?.get(value)?.remove(entry.key) + + val buckets = prefixBuckets[fieldName] + if (buckets != null) { + val lower = value.lowercase() + val firstChar = lower.firstOrNull() ?: continue + buckets[firstChar]?.removeAll { it.key == entry.key } + } + } + // Note: sourceMap is handled by the caller + } +} diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/MergedIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/MergedIndex.kt new file mode 100644 index 0000000000..af39930033 --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/MergedIndex.kt @@ -0,0 +1,82 @@ +package org.appdevforall.codeonthego.indexing + +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.channelFlow +import kotlinx.coroutines.launch +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.api.Indexable +import org.appdevforall.codeonthego.indexing.api.ReadableIndex +import java.io.Closeable +import java.util.concurrent.ConcurrentHashMap + +/** + * Merges query results from multiple [ReadableIndex] instances. + * + * @param T The indexed type. + * @param indexes The indexes to merge, in priority order. + */ +class MergedIndex( + private val indexes: List>, +) : ReadableIndex, Closeable { + + constructor(vararg indexes: ReadableIndex) : this(indexes.toList()) + + override fun query(query: IndexQuery): Flow = channelFlow { + val seen = ConcurrentHashMap.newKeySet() + val limit = if (query.limit <= 0) Int.MAX_VALUE else query.limit + val emitted = java.util.concurrent.atomic.AtomicInteger(0) + + // Launch a producer coroutine per index. + // channelFlow provides structured concurrency: when the + // collector stops (limit reached), all producers are cancelled. + for (index in indexes) { + launch { + index.query(query).collect { entry -> + if (emitted.get() >= limit) { + return@collect + } + if (seen.add(entry.key)) { + send(entry) + if (emitted.incrementAndGet() >= limit) { + // Close the channel - cancels other producers + channel.close() + return@collect + } + } + } + } + } + } + + override suspend fun get(key: String): T? { + // First match wins (priority order) + for (index in indexes) { + val result = index.get(key) + if (result != null) return result + } + return null + } + + override suspend fun containsSource(sourceId: String): Boolean { + return indexes.any { it.containsSource(sourceId) } + } + + override fun distinctValues(fieldName: String): Flow = channelFlow { + val seen = ConcurrentHashMap.newKeySet() + for (index in indexes) { + launch { + index.distinctValues(fieldName).collect { value -> + if (seen.add(value)) { + send(value) + } + } + } + } + } + + override fun close() { + for (index in indexes) { + if (index is Closeable) index.close() + } + } +} diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/PersistentIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/PersistentIndex.kt new file mode 100644 index 0000000000..f3b0cf539b --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/PersistentIndex.kt @@ -0,0 +1,336 @@ +package org.appdevforall.codeonthego.indexing + +import android.content.ContentValues +import android.content.Context +import android.database.sqlite.SQLiteDatabase +import androidx.sqlite.db.SupportSQLiteDatabase +import androidx.sqlite.db.SupportSQLiteOpenHelper +import androidx.sqlite.db.framework.FrameworkSQLiteOpenHelperFactory +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.withContext +import org.appdevforall.codeonthego.indexing.api.Index +import org.appdevforall.codeonthego.indexing.api.IndexDescriptor +import org.appdevforall.codeonthego.indexing.api.IndexQuery +import org.appdevforall.codeonthego.indexing.api.Indexable +import kotlin.collections.iterator + +/** + * A persistent [Index] backed by SQLite via AndroidX. + * + * Creates a table dynamically based on the [IndexDescriptor]: + * ``` + * CREATE TABLE IF NOT EXISTS {name} ( + * _key TEXT PRIMARY KEY, + * _source_id TEXT NOT NULL, + * f_{field1} TEXT, + * f_{field1}_lower TEXT, -- if prefix-searchable + * f_{field2} TEXT, + * ... + * _payload BLOB NOT NULL + * ); + * ``` + * + * SQL indexes are created on: + * - `_source_id` (for bulk removal) + * - Each `f_{field}` (for equality filter) + * - Each `f_{field}_lower` (for prefix search via `LIKE 'prefix%'`) + * + * Uses WAL journal mode for concurrent read/write performance. + * Inserts are batched inside transactions for throughput. + * + * @param T The indexed entry type. + * @param descriptor Defines fields and serialization. + * @param context Android context (for database file location). + * @param dbName Database file name. Different index types can share + * a database (each gets its own table) or use separate files. + * @param batchSize Number of rows per INSERT transaction. + */ +class PersistentIndex( + override val descriptor: IndexDescriptor, + context: Context, + dbName: String, + override val name: String = "persistent:${descriptor.name}", + private val batchSize: Int = 500, +) : Index { + + private val tableName = descriptor.name.replace(Regex("[^a-zA-Z0-9_]"), "_") + + // Field column names: "f_{fieldName}" + private val fieldColumns = descriptor.fields.associate { field -> + field.name to "f_${field.name}" + } + + // Prefix-searchable fields also get a "_lower" column + private val prefixColumns = descriptor.fields + .filter { it.prefixSearchable } + .associate { it.name to "f_${it.name}_lower" } + + private val db: SupportSQLiteDatabase + + init { + val config = SupportSQLiteOpenHelper.Configuration.builder(context) + .name(dbName) + .callback(object : SupportSQLiteOpenHelper.Callback(1) { + override fun onCreate(db: SupportSQLiteDatabase) { + createTable(db) + } + + override fun onUpgrade( + db: SupportSQLiteDatabase, oldVersion: Int, newVersion: Int, + ) { + // TODO: Add migration support + db.execSQL("DROP TABLE IF EXISTS $tableName") + createTable(db) + } + + override fun onOpen(db: SupportSQLiteDatabase) { + } + }) + .build() + + db = FrameworkSQLiteOpenHelperFactory() + .create(config) + .writableDatabase + + // Ensure table exists (for shared databases) + createTable(db) + } + + override fun query(query: IndexQuery): Flow = flow { + val (sql, args) = buildSelectQuery(query) + val cursor = db.query(sql, args.toTypedArray()) + + cursor.use { + val payloadIdx = it.getColumnIndexOrThrow("_payload") + while (it.moveToNext()) { + val bytes = it.getBlob(payloadIdx) + emit(descriptor.deserialize(bytes)) + } + } + }.flowOn(Dispatchers.IO) + + override suspend fun get(key: String): T? = withContext(Dispatchers.IO) { + val cursor = db.query( + "SELECT _payload FROM $tableName WHERE _key = ? LIMIT 1", + arrayOf(key), + ) + cursor.use { + if (it.moveToFirst()) { + descriptor.deserialize(it.getBlob(0)) + } else null + } + } + + override suspend fun containsSource(sourceId: String): Boolean = + withContext(Dispatchers.IO) { + val cursor = db.query( + "SELECT 1 FROM $tableName WHERE _source_id = ? LIMIT 1", + arrayOf(sourceId), + ) + cursor.use { it.moveToFirst() } + } + + override fun distinctValues(fieldName: String): Flow = flow { + val col = fieldColumns[fieldName] + ?: throw IllegalArgumentException("Unknown field: $fieldName") + + val cursor = db.query("SELECT DISTINCT $col FROM $tableName WHERE $col IS NOT NULL") + cursor.use { + val idx = 0 + while (it.moveToNext()) { + emit(it.getString(idx)) + } + } + }.flowOn(Dispatchers.IO) + + /** + * Streaming insert from a [Flow]. + * + * Collects entries from the flow and inserts them in batched + * transactions. Each batch is a single SQLite transaction - + * this is orders of magnitude faster than one transaction per row. + * + * The flow is collected on [Dispatchers.IO]. + */ + override suspend fun insert(entries: Flow) = withContext(Dispatchers.IO) { + val batch = mutableListOf() + entries.collect { entry -> + batch.add(entry) + if (batch.size >= batchSize) { + insertBatch(batch) + batch.clear() + } + } + // Flush remaining + if (batch.isNotEmpty()) { + insertBatch(batch) + } + } + + override suspend fun insertAll(entries: Sequence) = withContext(Dispatchers.IO) { + val batch = mutableListOf() + for (entry in entries) { + batch.add(entry) + if (batch.size >= batchSize) { + insertBatch(batch) + batch.clear() + } + } + if (batch.isNotEmpty()) { + insertBatch(batch) + } + } + + override suspend fun insert(entry: T) = withContext(Dispatchers.IO) { + insertBatch(listOf(entry)) + } + + override suspend fun removeBySource(sourceId: String) = withContext(Dispatchers.IO) { + db.execSQL("DELETE FROM $tableName WHERE _source_id = ?", arrayOf(sourceId)) + } + + override suspend fun clear() = withContext(Dispatchers.IO) { + db.execSQL("DELETE FROM $tableName") + } + + override fun close() { + db.close() + } + + suspend fun size(): Int = withContext(Dispatchers.IO) { + val cursor = db.query("SELECT COUNT(*) FROM $tableName") + cursor.use { if (it.moveToFirst()) it.getInt(0) else 0 } + } + + private fun createTable(db: SupportSQLiteDatabase) { + val columns = buildString { + append("_key TEXT PRIMARY KEY, ") + append("_source_id TEXT NOT NULL, ") + + for (field in descriptor.fields) { + val col = fieldColumns[field.name]!! + append("$col TEXT, ") + + if (field.prefixSearchable) { + val lowerCol = prefixColumns[field.name]!! + append("$lowerCol TEXT, ") + } + } + + append("_payload BLOB NOT NULL") + } + + db.execSQL("CREATE TABLE IF NOT EXISTS $tableName ($columns)") + + // Indexes + db.execSQL( + "CREATE INDEX IF NOT EXISTS idx_${tableName}_source ON $tableName(_source_id)" + ) + + for (field in descriptor.fields) { + val col = fieldColumns[field.name]!! + db.execSQL( + "CREATE INDEX IF NOT EXISTS idx_${tableName}_$col ON $tableName($col)" + ) + + if (field.prefixSearchable) { + val lowerCol = prefixColumns[field.name]!! + db.execSQL( + "CREATE INDEX IF NOT EXISTS idx_${tableName}_$lowerCol ON $tableName($lowerCol)" + ) + } + } + } + + private fun insertBatch(entries: List) { + db.beginTransaction() + try { + for (entry in entries) { + val cv = ContentValues().apply { + put("_key", entry.key) + put("_source_id", entry.sourceId) + + val fields = descriptor.fieldValues(entry) + for ((fieldName, value) in fields) { + val col = fieldColumns[fieldName] ?: continue + put(col, value) + + val lowerCol = prefixColumns[fieldName] + if (lowerCol != null) { + put(lowerCol, value?.lowercase()) + } + } + + put("_payload", descriptor.serialize(entry)) + } + + db.insert( + tableName, + SQLiteDatabase.CONFLICT_REPLACE, + cv, + ) + } + db.setTransactionSuccessful() + } finally { + db.endTransaction() + } + } + + private data class SqlQuery(val sql: String, val args: List) + + private fun buildSelectQuery(query: IndexQuery): SqlQuery { + val where = StringBuilder() + val args = mutableListOf() + + fun and(clause: String, vararg values: String) { + if (where.isNotEmpty()) where.append(" AND ") + where.append(clause) + args.addAll(values) + } + + query.key?.let { and("_key = ?", it) } + query.sourceId?.let { and("_source_id = ?", it) } + + for ((field, value) in query.exactMatch) { + val col = fieldColumns[field] ?: continue + and("$col = ?", value) + } + + for ((field, prefix) in query.prefixMatch) { + val lowerCol = prefixColumns[field] + if (lowerCol != null) { + // Use the pre-lowercased column for index-friendly LIKE + and("$lowerCol LIKE ?", "${prefix.lowercase()}%") + } else { + // Fallback: case-sensitive prefix on the regular column + val col = fieldColumns[field] ?: continue + and("$col LIKE ?", "$prefix%") + } + } + + for ((field, mustExist) in query.presence) { + val col = fieldColumns[field] ?: continue + if (mustExist) { + and("$col IS NOT NULL") + } else { + and("$col IS NULL") + } + } + + val sql = buildString { + append("SELECT _payload FROM $tableName") + if (where.isNotEmpty()) { + append(" WHERE ") + append(where) + } + if (query.limit > 0) { + append(" LIMIT ${query.limit}") + } + } + + return SqlQuery(sql, args) + } +} diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Core.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Core.kt new file mode 100644 index 0000000000..b595e604ba --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Core.kt @@ -0,0 +1,91 @@ +package org.appdevforall.codeonthego.indexing.api + +/** + * Any object that can be stored in an index. + * + * The only requirements are a unique key (for deduplication and + * point lookups) and a source identifier (for bulk operations + * when the source changes). + * + * What constitutes a "key" and "source" depends entirely on + * the consumer: + * - For Kotlin symbols: key = FQN, source = JAR path or file path + * - For Android resources: key = resource ID, source = AAR path + * - For Python symbols: key = qualified name, source = .py file path + */ +interface Indexable { + + /** Unique identifier within the index. */ + val key: String + + /** + * Identifies the origin of this entry. + * All entries sharing a [sourceId] can be removed atomically + * via [WritableIndex.removeBySource]. + */ + val sourceId: String +} + +/** + * Describes how to index, serialize, and query a specific type. + * + * Acts as the bridge between domain objects and the storage layer. + * A single index instance is parameterized by one descriptor - + * different data types get different index instances. + * + * @param T The domain type being indexed. + */ +interface IndexDescriptor { + + /** + * A unique name for this index type. Used as the table name + * in persistent storage and the namespace in composite indexes. + */ + val name: String + + /** + * The fields that should be queryable. + * Defines the "schema" for this index type. + * + * The persistent layer will create SQL columns and indexes + * for each declared field. + */ + val fields: List + + /** + * Extract the queryable field values from an entry. + * + * The returned map's keys must be a subset of [fields]'s names. + * Null values mean the field is not applicable for this entry + * (e.g. receiverType is null for a non-extension function). + */ + fun fieldValues(entry: T): Map + + /** + * Serialize an entry to bytes for persistent storage. + * + * Use whatever format is appropriate - protobuf, JSON, + * custom binary. Called once on insert; the bytes are + * stored opaquely. + */ + fun serialize(entry: T): ByteArray + + /** + * Deserialize bytes back into an entry. + * Must be the inverse of [serialize]. + */ + fun deserialize(bytes: ByteArray): T +} + +/** + * Declares a queryable field on an [IndexDescriptor]. + * + * @param name The field name (used in queries and as the column name). + * @param prefixSearchable Whether this field supports prefix queries + * (e.g. name prefix for completions). Affects how + * the persistent layer creates SQL indexes. + */ +data class IndexField( + val name: String, + val prefixSearchable: Boolean = false, +) diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt new file mode 100644 index 0000000000..39f9846494 --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Index.kt @@ -0,0 +1,105 @@ +package org.appdevforall.codeonthego.indexing.api + +import kotlinx.coroutines.flow.Flow +import java.io.Closeable + +/** + * Read-only view of an index. + * + * All query methods return [Flow]s and results are produced lazily. + * The consumer decides how many to take, which dispatcher to + * collect on, and whether to buffer. + * + * @param T The indexed type. + */ +interface ReadableIndex { + + /** + * Query the index. Returns a lazy [Flow] of matching entries. + * + * Results are not guaranteed to be in any particular order + * unless the implementation specifies otherwise. + * + * If [IndexQuery.limit] is 0, all matches are emitted. + */ + fun query(query: IndexQuery): Flow + + /** + * Point lookup by key. Returns null if not found. + */ + suspend fun get(key: String): T? + + /** + * Fast existence check for a source. + */ + suspend fun containsSource(sourceId: String): Boolean + + /** + * Returns distinct values for a given field across all entries. + * + * Useful for enumerating packages, kinds, etc. without + * deserializing full entries. + * + * @param fieldName Must be one of the fields declared in the + * [IndexDescriptor]. + */ + fun distinctValues(fieldName: String): Flow +} + +/** + * Write interface for mutating an index. + * + * Accepts [Flow]s for streaming inserts so that the producer can + * yield entries one at a time without holding the entire set + * in memory. + */ +interface WritableIndex { + + /** + * Insert entries from a [Flow]. + * + * Entries are consumed lazily from the flow and batched + * internally for throughput. If an entry with the same key + * already exists, it is replaced. + * + * The flow is collected on the caller's dispatcher; the + * implementation handles its own threading for storage I/O. + */ + suspend fun insert(entries: Flow) + + /** + * Convenience: insert a sequence (also lazy). + */ + suspend fun insertAll(entries: Sequence) + + /** + * Convenience: insert a single entry. + */ + suspend fun insert(entry: T) + + /** + * Remove all entries from the given source. + */ + suspend fun removeBySource(sourceId: String) + + /** + * Remove all entries. + */ + suspend fun clear() +} + +/** + * A complete index with read, write, and lifecycle management. + * + * @param T The indexed type. + */ +interface Index : ReadableIndex, WritableIndex, Closeable { + + /** Human-readable name for logging. */ + val name: String + + /** The descriptor governing serialization and field extraction. */ + val descriptor: IndexDescriptor + + override fun close() {} +} diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Query.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Query.kt new file mode 100644 index 0000000000..8b3e1a6a12 --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/api/Query.kt @@ -0,0 +1,78 @@ +package org.appdevforall.codeonthego.indexing.api + +/** + * A query against an index. + * + * All predicates are ANDed together. The query is intentionally + * field-based (not type-specific) so the same query engine works + * for Kotlin symbols, Android resources, Python declarations, etc. + */ +data class IndexQuery( + /** Exact match predicates: field name → expected value. */ + val exactMatch: Map = emptyMap(), + + /** Prefix match predicates: field name → prefix (case-insensitive). */ + val prefixMatch: Map = emptyMap(), + + /** + * Presence predicates: field name → whether the field must be + * non-null (true) or null (false). + */ + val presence: Map = emptyMap(), + + /** Filter by source ID. */ + val sourceId: String? = null, + + /** Filter by key (exact). */ + val key: String? = null, + + /** Maximum number of results. 0 = unlimited (use with care). */ + val limit: Int = 200, +) { + companion object { + /** Match everything up to [limit]. */ + val ALL = IndexQuery() + + /** Exact key lookup. */ + fun byKey(key: String) = IndexQuery(key = key, limit = 1) + + /** All entries from a specific source. */ + fun bySource(sourceId: String) = IndexQuery(sourceId = sourceId, limit = 0) + } +} + +/** + * DSL builder for [IndexQuery]. + */ +class IndexQueryBuilder { + private val exact = mutableMapOf() + private val prefix = mutableMapOf() + private val pres = mutableMapOf() + var sourceId: String? = null + var key: String? = null + var limit: Int = 200 + + /** Exact match on a field. */ + fun eq(field: String, value: String) { exact[field] = value } + + /** Prefix match on a field (case-insensitive). */ + fun prefix(field: String, value: String) { prefix[field] = value } + + /** Field must be non-null. */ + fun exists(field: String) { pres[field] = true } + + /** Field must be null. */ + fun notExists(field: String) { pres[field] = false } + + fun build() = IndexQuery( + exactMatch = exact.toMap(), + prefixMatch = prefix.toMap(), + presence = pres.toMap(), + sourceId = sourceId, + key = key, + limit = limit, + ) +} + +inline fun indexQuery(block: IndexQueryBuilder.() -> Unit): IndexQuery = + IndexQueryBuilder().apply(block).build() diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexRegistry.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexRegistry.kt new file mode 100644 index 0000000000..e2639c5cae --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexRegistry.kt @@ -0,0 +1,119 @@ +package org.appdevforall.codeonthego.indexing.service + +import java.io.Closeable +import java.util.concurrent.ConcurrentHashMap + +/** + * A typed key for retrieving an index from the [IndexRegistry]. + * + * @param T The index type. Not restricted to [org.appdevforall.codeonthego.indexing.api.Index], can be a + * domain-specific facade. + */ +data class IndexKey( + val name: String, +) + +/** + * Central registry where [IndexingService]s publish their indexes + * and consumers (LSPs, etc.) retrieve them. + */ +class IndexRegistry : Closeable { + + private val indexes = ConcurrentHashMap() + private val listeners = ConcurrentHashMap Unit>>() + + /** + * Register an index. Replaces any previously registered index + * with the same key. + * + * If there are listeners waiting for this key, they are notified + * immediately. + */ + fun register(key: IndexKey, index: T) { + val old = indexes.put(key.name, index) + + // Close the old index if it's Closeable + if (old is Closeable && old !== index) { + old.close() + } + + // Notify listeners + listeners[key.name]?.forEach { listener -> + @Suppress("UNCHECKED_CAST") + (listener as (T) -> Unit).invoke(index) + } + } + + /** + * Retrieve an index by key. Returns null if not yet registered. + */ + @Suppress("UNCHECKED_CAST") + fun get(key: IndexKey): T? = + indexes[key.name] as? T + + /** + * Retrieve an index, throwing if not available. + */ + fun require(key: IndexKey): T = + get(key) ?: throw IllegalStateException( + "Index '${key.name}' is not registered. " + + "Has the corresponding IndexingService been initialized?" + ) + + /** + * Execute a block if the index is available. + */ + inline fun ifAvailable( + key: IndexKey, + block: (T) -> R, + ): R? { + val index = get(key) ?: return null + return block(index) + } + + /** + * Register a listener that will be called when an index + * is registered (or re-registered) with the given key. + * + * If the index is already registered, the listener is + * called immediately. + */ + fun onAvailable(key: IndexKey, listener: (T) -> Unit) { + @Suppress("UNCHECKED_CAST") + listeners.getOrPut(key.name) { mutableListOf() } + .add(listener as (Any) -> Unit) + + // If already registered, notify immediately + get(key)?.let { listener(it) } + } + + /** + * Unregister an index. The caller is responsible for closing it. + */ + fun unregister(key: IndexKey): T? { + @Suppress("UNCHECKED_CAST") + return indexes.remove(key.name) as? T + } + + /** + * Returns true if an index is registered for this key. + */ + fun isRegistered(key: IndexKey): Boolean = + indexes.containsKey(key.name) + + /** + * Returns all registered keys. + */ + fun registeredKeys(): Set = indexes.keys.toSet() + + /** + * Close and remove all registered indexes. + */ + override fun close() { + indexes.values.forEach { index -> + if (index is Closeable) index.close() + } + indexes.clear() + listeners.clear() + } +} \ No newline at end of file diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingService.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingService.kt new file mode 100644 index 0000000000..cbf074cce2 --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingService.kt @@ -0,0 +1,41 @@ +package org.appdevforall.codeonthego.indexing.service +import java.io.Closeable + +/** + * A service that knows how to build and maintain an index for a + * specific domain. + * + * Implementations should be stateless with respect to the project + * model because they receive it as a parameter, not as a constructor + * argument. This allows the same service instance to handle + * re-syncs without recreation. + */ +interface IndexingService : Closeable { + + /** + * Unique identifier for this service. + * Used for logging and debugging. + */ + val id: String + + /** + * The keys of the indexes this service registers. + * Used by the manager to verify all expected indexes + * are available after initialization. + */ + val providedKeys: List> + + /** + * Called once after the service is registered. + * + * Create your index instances here and register them + * with the [registry]. + */ + suspend fun initialize(registry: IndexRegistry) + + /** + * Called when the project is closed or the IDE shuts down. + * Release all resources. + */ + override fun close() {} +} \ No newline at end of file diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingServiceManager.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingServiceManager.kt new file mode 100644 index 0000000000..6575c5df63 --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/service/IndexingServiceManager.kt @@ -0,0 +1,158 @@ +package org.appdevforall.codeonthego.indexing.service + +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelChildren +import kotlinx.coroutines.launch +import org.slf4j.LoggerFactory +import java.io.Closeable +import java.util.concurrent.ConcurrentHashMap + +/** + * Manages the lifecycle of [IndexingService]s and the [IndexRegistry]. + */ +class IndexingServiceManager( + private val scope: CoroutineScope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + ), +) : Closeable { + + companion object { + private val log = LoggerFactory.getLogger(IndexingServiceManager::class.java) + } + + /** + * The central registry. All services register their indexes here. + * Consumers (LSPs, etc.) retrieve indexes from here. + */ + val registry = IndexRegistry() + + private val services = ConcurrentHashMap() + private var initialized = false + + /** + * Register an [IndexingService]. + * + * Must be called before [onProjectSynced]. Services are initialized + * in registration order. + * + * @throws IllegalStateException if called after initialization. + */ + fun register(service: IndexingService) { + check(!initialized) { + "Cannot register services after initialization. " + + "Register all services before the first onProjectSynced call." + } + + if (services.putIfAbsent(service.id, service) != null) { + log.warn("Attempt to re-register service with ID: {}", service.id) + return + } + + log.info("Registered indexing service: {}", service.id) + } + + /** + * Called after project sync (e.g. Gradle sync) completes. + * + * On the first call, initializes all registered services + * (creates indexes, registers them). On subsequent calls, + * notifies services of the updated project model. + * + * Services process the event concurrently. Failures in one + * service don't affect others (SupervisorJob). + */ + fun onProjectSynced() { + scope.launch { + if (!initialized) { + initializeServices() + initialized = true + } + } + } + + /** + * Called after a build completes. + */ + fun onBuildCompleted() { + if (!initialized) { + log.warn("onBuildCompleted called before initialization, ignoring") + return + } + } + + /** + * Called when source files change. + */ + fun onSourceChanged() { + if (!initialized) return + } + + /** + * Returns the registered service with the given ID, or null. + */ + fun getService(id: String): IndexingService? = + services[id] + + /** + * Returns all registered services. + */ + fun allServices(): List = + services.values.toList() + + /** + * Shut down all services and clear the registry. + */ + override fun close() { + log.info("Shutting down indexing services") + + // Cancel in-flight work + scope.coroutineContext.cancelChildren() + + // Close services in reverse registration order + services.values.reversed().forEach { service -> + try { + service.close() + log.debug("Closed service: {}", service.id) + } catch (e: Exception) { + log.error("Failed to close service: {}", service.id, e) + } + } + + services.clear() + registry.close() + initialized = false + + log.info("Indexing services shut down") + } + + private suspend fun initializeServices() { + log.info("Initializing {} indexing services", services.size) + + val allServices = allServices() + for (service in allServices) { + try { + service.initialize(registry) + log.info("Initialized service: {} (provides: {})", + service.id, + service.providedKeys.joinToString { it.name }, + ) + } catch (e: Exception) { + log.error("Failed to initialize service: {}", service.id, e) + } + } + + // Verify all promised keys are registered + for (service in allServices) { + for (key in service.providedKeys) { + if (!registry.isRegistered(key)) { + log.warn( + "Service '{}' promised index '{}' but did not register it", + service.id, key.name, + ) + } + } + } + } +} \ No newline at end of file diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt new file mode 100644 index 0000000000..1ab75e4074 --- /dev/null +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/util/BackgroundIndexer.kt @@ -0,0 +1,214 @@ +package org.appdevforall.codeonthego.indexing.util + +import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.cancelAndJoin +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.buffer +import kotlinx.coroutines.flow.catch +import kotlinx.coroutines.flow.flowOn +import kotlinx.coroutines.flow.onCompletion +import kotlinx.coroutines.flow.onStart +import kotlinx.coroutines.isActive +import kotlinx.coroutines.joinAll +import kotlinx.coroutines.launch +import org.appdevforall.codeonthego.indexing.api.Index +import org.appdevforall.codeonthego.indexing.api.Indexable +import org.slf4j.LoggerFactory +import java.io.Closeable +import java.util.concurrent.ConcurrentHashMap + +/** + * Callback for tracking indexing progress. + * Implementations must be thread-safe. + */ +fun interface IndexingProgressListener { + + /** + * Called with progress updates during indexing. + * + * @param sourceId The source being indexed. + * @param event What happened. + */ + fun onProgress(sourceId: String, event: IndexingEvent) +} + +sealed class IndexingEvent { + data object Started : IndexingEvent() + data class Progress(val processed: Int) : IndexingEvent() + data class Completed(val totalIndexed: Int) : IndexingEvent() + data class Failed(val error: Throwable) : IndexingEvent() + data object Skipped : IndexingEvent() +} + +/** + * Runs indexing operations in the background. + */ +class BackgroundIndexer( + private val index: Index, + private val scope: CoroutineScope = CoroutineScope( + SupervisorJob() + Dispatchers.Default + ), + /** + * Buffer capacity between the producer flow and the index writer. + * Higher values use more memory but tolerate more producer/consumer + * speed mismatch. + */ + private val bufferCapacity: Int = 64, +) : Closeable { + + companion object { + private val log = LoggerFactory.getLogger(BackgroundIndexer::class.java) + } + + var progressListener: IndexingProgressListener? = null + + private val activeJobs = ConcurrentHashMap() + + /** + * Index a single source. The [provider] returns a [Flow] that + * lazily produces entries so that it is NOT collected eagerly. + * + * If [skipIfExists] is true and the source is already indexed, + * this is a no-op. + * + * @param sourceId Identifies the source. + * @param skipIfExists Skip if already indexed. + * @param provider Lambda returning a lazy [Flow] of entries. + * Runs on [Dispatchers.IO]. + * @return The launched job, or null if skipped. + */ + fun indexSource( + sourceId: String, + skipIfExists: Boolean = true, + provider: (sourceId: String) -> Flow, + ): Job { + // Cancel any in-flight job for this source + activeJobs[sourceId]?.cancel() + + val job = scope.launch { + try { + if (skipIfExists && index.containsSource(sourceId)) { + log.debug("Skipping already-indexed: {}", sourceId) + progressListener?.onProgress(sourceId, IndexingEvent.Skipped) + return@launch + } + + log.info("Indexing: {}", sourceId) + + // Remove stale entries first + index.removeBySource(sourceId) + + if (!isActive) return@launch + + // Streaming pipeline: + // producer (IO) → buffer → consumer (index.insert) + // + // The producer emits entries lazily on Dispatchers.IO. + // The buffer decouples producer and consumer speeds. + // The index.insert collects from the buffered flow + // and batches into transactions internally. + var count = 0 + + val tracked = provider(sourceId) + .flowOn(Dispatchers.IO) + .buffer(bufferCapacity) + .onStart { + progressListener?.onProgress( + sourceId, IndexingEvent.Started + ) + } + .onCompletion { error -> + if (error == null) { + progressListener?.onProgress( + sourceId, IndexingEvent.Completed(count) + ) + log.info("Indexed {} entries from {}", count, sourceId) + } + } + .catch { error -> + log.error("Indexing failed for {}", sourceId, error) + progressListener?.onProgress( + sourceId, IndexingEvent.Failed(error) + ) + } + + // Wrap in a counting flow that reports progress + val counted = kotlinx.coroutines.flow.flow { + tracked.collect { entry -> + emit(entry) + count++ + if (count % 1000 == 0) { + progressListener?.onProgress( + sourceId, IndexingEvent.Progress(count) + ) + } + } + } + + index.insert(counted) + + } catch (e: CancellationException) { + log.debug("Indexing cancelled: {}", sourceId) + throw e + } catch (e: Exception) { + log.error("Indexing failed: {}", sourceId, e) + progressListener?.onProgress( + sourceId, IndexingEvent.Failed(e) + ) + } finally { + activeJobs.remove(sourceId) + } + } + + activeJobs[sourceId] = job + return job + } + + /** + * Index multiple sources in parallel. + * + * Each source gets its own coroutine. The [SupervisorJob] ensures + * that one failure doesn't cancel the others. + * + * @param sources The sources to index (e.g. a list of JAR paths). + * @param mapper Maps each source to a (sourceId, Flow) pair. + */ + fun indexSources( + sources: Collection, + skipIfExists: Boolean = true, + mapper: (S) -> Pair>, + ): List { + return sources.map { source -> + val (sourceId, flow) = mapper(source) + indexSource(sourceId, skipIfExists) { flow } + }.filterNotNull() + } + + /** + * Cancel all in-flight indexing and wait for completion. + */ + suspend fun cancelAll() { + activeJobs.values.toList().forEach { it.cancelAndJoin() } + } + + /** + * Wait for all in-flight indexing to complete. + */ + suspend fun awaitAll() { + activeJobs.values.toList().joinAll() + } + + /** + * Returns the number of currently active indexing jobs. + */ + val activeJobCount: Int get() = activeJobs.size + + override fun close() { + activeJobs.values.forEach { it.cancel() } + activeJobs.clear() + } +} diff --git a/lsp/java/build.gradle.kts b/lsp/java/build.gradle.kts index 7d43f1aefd..73322f24be 100644 --- a/lsp/java/build.gradle.kts +++ b/lsp/java/build.gradle.kts @@ -55,6 +55,7 @@ dependencies { implementation(projects.editorApi) implementation(projects.resources) implementation(projects.lsp.api) + implementation(projects.lsp.jvmSymbolIndex) implementation(projects.subprojects.libjdwp) implementation(projects.subprojects.javacServices) implementation(projects.idetooltips) diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/JavaLanguageServer.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/JavaLanguageServer.kt index c5f096f702..f5c1fb627c 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/JavaLanguageServer.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/JavaLanguageServer.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.lsp.java import androidx.annotation.RestrictTo +import com.itsaky.androidide.app.BaseApplication import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent @@ -43,7 +44,7 @@ import com.itsaky.androidide.lsp.java.providers.JavaDiagnosticProvider import com.itsaky.androidide.lsp.java.providers.JavaSelectionProvider import com.itsaky.androidide.lsp.java.providers.ReferenceProvider import com.itsaky.androidide.lsp.java.providers.SignatureProvider -import com.itsaky.androidide.lsp.java.providers.snippet.JavaSnippetRepository.init +import com.itsaky.androidide.lsp.java.providers.snippet.JavaSnippetRepository import com.itsaky.androidide.lsp.java.utils.AnalyzeTimer import com.itsaky.androidide.lsp.java.utils.CancelChecker.Companion.isCancelled import com.itsaky.androidide.lsp.models.CodeFormatResult @@ -73,6 +74,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.appdevforall.codeonthego.indexing.jvm.JvmIndexingService import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -117,7 +119,12 @@ class JavaLanguageServer : ILanguageServer { EventBus.getDefault().register(this) } - init() + val projectManager = ProjectManagerImpl.getInstance() + projectManager.indexingServiceManager.register( + service = JvmIndexingService(context = BaseApplication.baseInstance) + ) + + JavaSnippetRepository.init() } override fun shutdown() { @@ -150,6 +157,11 @@ class JavaLanguageServer : ILanguageServer { override fun setupWithProject(workspace: Workspace) { LSPEditorActions.ensureActionsMenuRegistered(JavaCodeActionsMenu) + (ProjectManagerImpl.getInstance() + .indexingServiceManager + .getService(JvmIndexingService.ID) as? JvmIndexingService?) + ?.refresh() + // Once we have project initialized // Destory the NO_MODULE_COMPILER instance JavaCompilerService.NO_MODULE_COMPILER.destroy() @@ -247,7 +259,8 @@ class JavaLanguageServer : ILanguageServer { } } - override fun formatCode(params: FormatCodeParams?): CodeFormatResult = CodeFormatProvider(settings).format(params) + override fun formatCode(params: FormatCodeParams?): CodeFormatResult = + CodeFormatProvider(settings).format(params) override fun handleFailure(failure: LSPFailure?): Boolean { return when (failure!!.type) { diff --git a/lsp/jvm-symbol-index/build.gradle.kts b/lsp/jvm-symbol-index/build.gradle.kts new file mode 100644 index 0000000000..959f2264be --- /dev/null +++ b/lsp/jvm-symbol-index/build.gradle.kts @@ -0,0 +1,24 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + alias(libs.plugins.android.library) + id("kotlin-android") +} + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.lsp.java.indexing" +} + +dependencies { + api(libs.google.protobuf.java) + api(libs.google.protobuf.kotlin) + api(libs.kotlinx.coroutines.core) + api(libs.kotlinx.metadata) + + api(projects.common) + api(projects.logger) + api(projects.lsp.indexing) + api(projects.lsp.jvmSymbolModels) + api(projects.subprojects.kotlinAnalysisApi) + api(projects.subprojects.projects) +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/CombinedJarScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/CombinedJarScanner.kt new file mode 100644 index 0000000000..f0bea84939 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/CombinedJarScanner.kt @@ -0,0 +1,78 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import org.jetbrains.org.objectweb.asm.AnnotationVisitor +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.ClassVisitor +import org.jetbrains.org.objectweb.asm.Opcodes +import org.slf4j.LoggerFactory +import java.io.ByteArrayOutputStream +import java.nio.file.Path +import java.util.jar.JarFile +import kotlin.io.path.pathString + +/** + * Scans a JAR and routes each class to the appropriate scanner: + * [KotlinMetadataScanner] for Kotlin classes, [JarSymbolScanner] for Java. + */ +object CombinedJarScanner { + + private val log = LoggerFactory.getLogger(CombinedJarScanner::class.java) + + fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Flow = flow { + val jar = try { + JarFile(jarPath.toFile()) + } catch (e: Exception) { + log.warn("Failed to open JAR: {}", jarPath, e) + return@flow + } + + jar.use { + val entries = jar.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.name.endsWith(".class")) continue + if (entry.name == "module-info.class" || entry.name == "package-info.class") continue + + try { + val bytes = jar.getInputStream(entry).use { input -> + val buf = ByteArrayOutputStream(entry.size.toInt().coerceAtLeast(1024)) + input.copyTo(buf) + buf.toByteArray() + } + + val symbols = if (hasKotlinMetadata(bytes)) { + KotlinMetadataScanner.parseKotlinClass(bytes.inputStream(), sourceId) + } else { + JarSymbolScanner.parseClassFile(bytes.inputStream(), sourceId) + } + + symbols?.forEach { emit(it) } + } catch (e: Exception) { + log.debug("Failed to parse {}: {}", entry.name, e.message) + } + } + } + } + .flowOn(Dispatchers.IO) + + private fun hasKotlinMetadata(classBytes: ByteArray): Boolean { + var found = false + try { + ClassReader(classBytes).accept(object : ClassVisitor(Opcodes.ASM9) { + override fun visitAnnotation( + descriptor: String?, + visible: Boolean + ): AnnotationVisitor? { + if (descriptor == "Lkotlin/Metadata;") found = true + return null + } + }, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + } catch (_: Exception) { + } + return found + } +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JarSymbolScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JarSymbolScanner.kt new file mode 100644 index 0000000000..89fd0ab492 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JarSymbolScanner.kt @@ -0,0 +1,305 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import org.jetbrains.org.objectweb.asm.AnnotationVisitor +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.ClassVisitor +import org.jetbrains.org.objectweb.asm.FieldVisitor +import org.jetbrains.org.objectweb.asm.MethodVisitor +import org.jetbrains.org.objectweb.asm.Opcodes +import org.jetbrains.org.objectweb.asm.Type +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.nio.file.Path +import java.util.jar.JarFile +import kotlin.io.path.pathString + +/** + * Scans JAR files using ASM and produces [JvmSymbol]s lazily. + * + * For Java class files, this gives complete information. + * For Kotlin class files, use [KotlinMetadataScanner] or + * [CombinedJarScanner] instead — ASM cannot see Kotlin-specific + * semantics like extensions, suspend, or nullable types. + */ +object JarSymbolScanner { + + private val log = LoggerFactory.getLogger(JarSymbolScanner::class.java) + + fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Flow = flow { + val jar = try { + JarFile(jarPath.toFile()) + } catch (e: Exception) { + log.warn("Failed to open JAR: {}", jarPath, e) + return@flow + } + + jar.use { + val entries = jar.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.name.endsWith(".class")) continue + if (entry.name == "module-info.class") continue + if (entry.name == "package-info.class") continue + + try { + jar.getInputStream(entry).use { input -> + for (symbol in parseClassFile(input, sourceId)) { + emit(symbol) + } + } + } catch (e: Exception) { + log.debug("Failed to parse {}: {}", entry.name, e.message) + } + } + } + } + .flowOn(Dispatchers.IO) + + internal fun parseClassFile(input: InputStream, sourceId: String): List { + val reader = ClassReader(input) + val collector = SymbolCollector(sourceId) + reader.accept(collector, ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES) + return collector.symbols + } + + private class SymbolCollector( + private val sourceId: String, + ) : ClassVisitor(Opcodes.ASM9) { + + val symbols = mutableListOf() + + private var className = "" + private var classFqName = "" + private var packageName = "" + private var shortClassName = "" + private var classAccess = 0 + private var isKotlinClass = false + private var superName: String? = null + private var interfaces: Array? = null + private var isInnerClass = false + private var classDeprecated = false + + override fun visit( + version: Int, access: Int, name: String, + signature: String?, superName: String?, + interfaces: Array?, + ) { + className = name + classFqName = name.replace('/', '.').replace('$', '.') + classAccess = access + this.superName = superName + this.interfaces = interfaces + classDeprecated = false + + val lastSlash = name.lastIndexOf('/') + packageName = if (lastSlash >= 0) name.substring(0, lastSlash).replace('/', '.') else "" + + val afterPackage = if (lastSlash >= 0) name.substring(lastSlash + 1) else name + shortClassName = afterPackage.replace('$', '.') + + isInnerClass = name.contains('$') + } + + override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor? { + if (descriptor == "Ljava/lang/Deprecated;") classDeprecated = true + if (descriptor == "Lkotlin/Metadata;") isKotlinClass = true + return null + } + + override fun visitEnd() { + if (!isPublicOrProtected(classAccess)) return + + val isAnonymous = isInnerClass && + shortClassName.split('.').last().firstOrNull()?.isDigit() == true + if (isAnonymous) return + + val kind = classKindFromAccess(classAccess) + val language = if (isKotlinClass) JvmSourceLanguage.KOTLIN else JvmSourceLanguage.JAVA + + val supertypes = buildList { + superName?.let { + if (it != "java/lang/Object") add(it.replace('/', '.')) + } + interfaces?.forEach { add(it.replace('/', '.')) } + } + + val containingClass = if (isInnerClass) { + classFqName.split('.').dropLast(1).joinToString(".") + } else "" + + symbols.add( + JvmSymbol( + key = classFqName, + sourceId = sourceId, + fqName = classFqName, + shortName = shortClassName.split('.').last(), + packageName = packageName, + kind = kind, + language = language, + visibility = visibilityFromAccess(classAccess), + isDeprecated = classDeprecated, + data = JvmClassInfo( + containingClassFqName = containingClass, + supertypeFqNames = supertypes, + isAbstract = hasFlag(classAccess, Opcodes.ACC_ABSTRACT), + isFinal = hasFlag(classAccess, Opcodes.ACC_FINAL), + isInner = isInnerClass && !hasFlag(classAccess, Opcodes.ACC_STATIC), + isStatic = isInnerClass && hasFlag(classAccess, Opcodes.ACC_STATIC), + ), + ) + ) + } + + override fun visitMethod( + access: Int, name: String, descriptor: String, + signature: String?, exceptions: Array?, + ): MethodVisitor? { + if (!isPublicOrProtected(access)) return null + if (!isPublicOrProtected(classAccess)) return null + if (name.startsWith("access$")) return null + if (hasFlag(access, Opcodes.ACC_BRIDGE)) return null + if (hasFlag(access, Opcodes.ACC_SYNTHETIC)) return null + if (name == "") return null + + val methodType = Type.getMethodType(descriptor) + val paramTypes = methodType.argumentTypes + val returnType = methodType.returnType + + val isConstructor = name == "" + val methodName = if (isConstructor) shortClassName.split('.').last() else name + val kind = if (isConstructor) JvmSymbolKind.CONSTRUCTOR else JvmSymbolKind.FUNCTION + val language = if (isKotlinClass) JvmSourceLanguage.KOTLIN else JvmSourceLanguage.JAVA + + val parameters = paramTypes.map { type -> + JvmParameterInfo( + name = "", // not available without -parameters flag + typeFqName = typeToFqName(type), + typeDisplay = typeToDisplay(type), + ) + } + + val fqName = "$classFqName.$methodName" + val key = "$fqName(${parameters.joinToString(",") { it.typeFqName }})" + + val signatureDisplay = buildString { + append("(") + append(parameters.joinToString(", ") { it.typeDisplay }) + append(")") + if (!isConstructor) { + append(": ") + append(typeToDisplay(returnType)) + } + } + + symbols.add( + JvmSymbol( + key = key, + sourceId = sourceId, + fqName = fqName, + shortName = methodName, + packageName = packageName, + kind = kind, + language = language, + visibility = visibilityFromAccess(access), + isDeprecated = classDeprecated, + data = JvmFunctionInfo( + containingClassFqName = classFqName, + returnTypeFqName = typeToFqName(returnType), + returnTypeDisplay = typeToDisplay(returnType), + parameterCount = paramTypes.size, + parameters = parameters, + signatureDisplay = signatureDisplay, + isStatic = hasFlag(access, Opcodes.ACC_STATIC), + isAbstract = hasFlag(access, Opcodes.ACC_ABSTRACT), + isFinal = hasFlag(access, Opcodes.ACC_FINAL), + ), + ) + ) + + return null + } + + override fun visitField( + access: Int, name: String, descriptor: String, + signature: String?, value: Any?, + ): FieldVisitor? { + if (!isPublicOrProtected(access)) return null + if (!isPublicOrProtected(classAccess)) return null + if (hasFlag(access, Opcodes.ACC_SYNTHETIC)) return null + + val fieldType = Type.getType(descriptor) + val kind = if (isKotlinClass) JvmSymbolKind.PROPERTY else JvmSymbolKind.FIELD + val language = if (isKotlinClass) JvmSourceLanguage.KOTLIN else JvmSourceLanguage.JAVA + val fqName = "$classFqName.$name" + + symbols.add( + JvmSymbol( + key = fqName, + sourceId = sourceId, + fqName = fqName, + shortName = name, + packageName = packageName, + kind = kind, + language = language, + visibility = visibilityFromAccess(access), + isDeprecated = classDeprecated, + data = JvmFieldInfo( + containingClassFqName = classFqName, + typeFqName = typeToFqName(fieldType), + typeDisplay = typeToDisplay(fieldType), + isStatic = hasFlag(access, Opcodes.ACC_STATIC), + isFinal = hasFlag(access, Opcodes.ACC_FINAL), + constantValue = value?.toString() ?: "", + ), + ) + ) + + return null + } + + private fun isPublicOrProtected(access: Int) = + hasFlag(access, Opcodes.ACC_PUBLIC) || hasFlag(access, Opcodes.ACC_PROTECTED) + + private fun hasFlag(access: Int, flag: Int) = (access and flag) != 0 + + private fun classKindFromAccess(access: Int) = when { + hasFlag(access, Opcodes.ACC_ANNOTATION) -> JvmSymbolKind.ANNOTATION_CLASS + hasFlag(access, Opcodes.ACC_ENUM) -> JvmSymbolKind.ENUM + hasFlag(access, Opcodes.ACC_INTERFACE) -> JvmSymbolKind.INTERFACE + else -> JvmSymbolKind.CLASS + } + + private fun visibilityFromAccess(access: Int) = when { + hasFlag(access, Opcodes.ACC_PUBLIC) -> JvmVisibility.PUBLIC + hasFlag(access, Opcodes.ACC_PROTECTED) -> JvmVisibility.PROTECTED + hasFlag(access, Opcodes.ACC_PRIVATE) -> JvmVisibility.PRIVATE + else -> JvmVisibility.PACKAGE_PRIVATE + } + + private fun typeToFqName(type: Type): String = when (type.sort) { + Type.VOID -> "void" + Type.BOOLEAN -> "boolean" + Type.BYTE -> "byte" + Type.CHAR -> "char" + Type.SHORT -> "short" + Type.INT -> "int" + Type.LONG -> "long" + Type.FLOAT -> "float" + Type.DOUBLE -> "double" + Type.ARRAY -> typeToFqName(type.elementType) + "[]".repeat(type.dimensions) + Type.OBJECT -> type.className + else -> type.className + } + + private fun typeToDisplay(type: Type): String = when (type.sort) { + Type.VOID -> "void" + Type.ARRAY -> typeToDisplay(type.elementType) + "[]".repeat(type.dimensions) + Type.OBJECT -> type.className.substringAfterLast('.') + else -> typeToFqName(type) + } + } +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt new file mode 100644 index 0000000000..a7cb834f81 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt @@ -0,0 +1,149 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import android.content.Context +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.projects.api.AndroidModule +import com.itsaky.androidide.projects.api.ModuleProject +import com.itsaky.androidide.projects.models.bootClassPaths +import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.cancel +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.appdevforall.codeonthego.indexing.service.IndexKey +import org.appdevforall.codeonthego.indexing.service.IndexRegistry +import org.appdevforall.codeonthego.indexing.service.IndexingService +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.nio.file.Paths +import kotlin.io.path.exists +import kotlin.io.path.extension +import kotlin.io.path.pathString + +/** + * Well-known key for the JVM symbol index. + * + * Both the Kotlin and Java LSPs use this key to retrieve the + * shared index from the [IndexRegistry]. + */ +val JVM_SYMBOL_INDEX = IndexKey("jvm-symbols") + +/** + * [IndexingService] that scans classpath JARs/AARs and builds + * a [JvmSymbolIndex]. + * + * Thread safety: all methods are called from the + * [IndexingServiceManager][org.appdevforall.codeonthego.indexing.service.IndexingServiceManager]'s + * coroutine scope. The [JvmSymbolIndex] handles its own internal thread safety. + */ +class JvmIndexingService( + private val context: Context, +) : IndexingService { + + companion object { + const val ID = "jvm-indexing-service" + private val log = LoggerFactory.getLogger(JvmIndexingService::class.java) + } + + override val id = ID + + override val providedKeys = listOf(JVM_SYMBOL_INDEX) + + private var index: JvmSymbolIndex? = null + private var indexingMutex = Mutex() + private val coroutineScope = CoroutineScope(Dispatchers.Default) + + override suspend fun initialize(registry: IndexRegistry) { + val jvmIndex = JvmSymbolIndex.create(context) + this.index = jvmIndex + registry.register(JVM_SYMBOL_INDEX, jvmIndex) + log.info("JVM symbol index initialized") + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("UNUSED") + fun onProjectSynced() { + refresh() + } + + fun refresh() { + coroutineScope.launch { + indexingMutex.withLock { + reindexLibraries() + } + } + } + + private suspend fun reindexLibraries() { + val index = this.index ?: run { + log.warn("Not indexing libraries. Index not initialized.") + return + } + + val workspace = ProjectManagerImpl.getInstance().workspace ?: run { + log.warn("Not indexing libraries. Workspace model not available.") + return + } + + val currentJars = + workspace.subProjects + .asSequence() + .filterIsInstance() + .filter { it.path != workspace.rootProject.path } + .flatMap { project -> + buildList { + if (project is AndroidModule) { + addAll(project.bootClassPaths) + } + + addAll(project.getCompileClasspaths(excludeSourceGeneratedClassPath = true)) + } + } + .filter { jar -> jar.exists() && isIndexableJar(jar.toPath()) } + .map { jar -> jar.absolutePath } + .toSet() + + log.info("{} JARs on classpath", currentJars.size) + + // Step 1: Set the active set - this is instant. + // JARs not in the set become invisible to queries. + // JARs in the set that are already cached become + // visible immediately. + index.setActiveLibraries(currentJars) + + // Step 2: Index any JARs not yet in the cache. + // Already-cached JARs are skipped (cheap existence check). + // Newly cached JARs are automatically visible because + // they're already in the active set. + var newCount = 0 + for (jarPath in currentJars) { + if (!index.isLibraryCached(jarPath)) { + newCount++ + index.indexLibrary(jarPath) { sourceId -> + CombinedJarScanner.scan(Paths.get(jarPath), sourceId) + } + } + } + + if (newCount > 0) { + log.info("{} new JARs submitted for background indexing", newCount) + } else { + log.info("All JARs already cached, nothing to index") + } + } + + override fun close() { + coroutineScope.cancelIfActive("indexing service closed") + index?.close() + index = null + } + + private fun isIndexableJar(path: Path): Boolean { + val ext = path.extension.lowercase() + return ext == "jar" || ext == "aar" + } +} \ No newline at end of file diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbol.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbol.kt new file mode 100644 index 0000000000..fdbd2c20bd --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbol.kt @@ -0,0 +1,204 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import org.appdevforall.codeonthego.indexing.api.Indexable + +enum class JvmSymbolKind { + CLASS, INTERFACE, ENUM, ENUM_ENTRY, ANNOTATION_CLASS, + OBJECT, COMPANION_OBJECT, DATA_CLASS, VALUE_CLASS, + SEALED_CLASS, SEALED_INTERFACE, + FUNCTION, EXTENSION_FUNCTION, CONSTRUCTOR, + PROPERTY, EXTENSION_PROPERTY, FIELD, + TYPE_ALIAS; + + val isCallable: Boolean + get() = this in CALLABLE_KINDS + + val isClassifier: Boolean + get() = this in CLASSIFIER_KINDS + + val isExtension: Boolean + get() = this == EXTENSION_FUNCTION || this == EXTENSION_PROPERTY + + companion object { + val CALLABLE_KINDS = setOf( + FUNCTION, EXTENSION_FUNCTION, CONSTRUCTOR, + PROPERTY, EXTENSION_PROPERTY, FIELD, + ) + val CLASSIFIER_KINDS = setOf( + CLASS, INTERFACE, ENUM, ANNOTATION_CLASS, + OBJECT, COMPANION_OBJECT, DATA_CLASS, + VALUE_CLASS, SEALED_CLASS, SEALED_INTERFACE, + TYPE_ALIAS, + ) + } +} + +enum class JvmSourceLanguage { JAVA, KOTLIN } + +enum class JvmVisibility { + PUBLIC, PROTECTED, INTERNAL, PRIVATE, PACKAGE_PRIVATE; + + val isAccessibleOutsideClass: Boolean + get() = this == PUBLIC || this == PROTECTED || this == INTERNAL +} + +/** + * A symbol from a JVM class file (JAR/AAR). + * + * Common identity fields live here. Type-specific details live in + * [data], which is one of: + * - [JvmClassInfo] for classes, interfaces, enums, objects, etc. + * - [JvmFunctionInfo] for functions, extension functions, constructors + * - [JvmFieldInfo] for Java fields and Kotlin properties + * - [JvmEnumEntryInfo] for enum constants + * - [JvmTypeAliasInfo] for Kotlin type aliases + */ +data class JvmSymbol( + override val key: String, + override val sourceId: String, + + val fqName: String, + val shortName: String, + val packageName: String, + val kind: JvmSymbolKind, + val language: JvmSourceLanguage, + val visibility: JvmVisibility = JvmVisibility.PUBLIC, + val isDeprecated: Boolean = false, + + val data: JvmSymbolInfo, +) : Indexable { + + val isTopLevel: Boolean + get() = data.containingClassFqName.isEmpty() + + val isExtension: Boolean + get() = kind.isExtension + + val receiverTypeFqName: String? + get() = when (val d = data) { + is JvmFunctionInfo -> d.kotlin?.receiverTypeFqName?.takeIf { it.isNotEmpty() } + is JvmFieldInfo -> d.kotlin?.receiverTypeFqName?.takeIf { it.isNotEmpty() } + else -> null + } + + val containingClassFqName: String + get() = data.containingClassFqName + + val returnTypeDisplay: String + get() = when (val d = data) { + is JvmFunctionInfo -> d.returnTypeDisplay + is JvmFieldInfo -> d.typeDisplay + else -> "" + } + + val signatureDisplay: String + get() = when (val d = data) { + is JvmFunctionInfo -> d.signatureDisplay + else -> "" + } +} + +/** + * Base for all type-specific symbol data. + * Every variant provides [containingClassFqName] (empty for top-level). + */ +sealed interface JvmSymbolInfo { + val containingClassFqName: String +} + +data class JvmClassInfo( + override val containingClassFqName: String = "", + val supertypeFqNames: List = emptyList(), + val typeParameters: List = emptyList(), + val isAbstract: Boolean = false, + val isFinal: Boolean = false, + val isInner: Boolean = false, + val isStatic: Boolean = false, + val kotlin: KotlinClassInfo? = null, +) : JvmSymbolInfo + +data class KotlinClassInfo( + val isData: Boolean = false, + val isValue: Boolean = false, + val isSealed: Boolean = false, + val isFunInterface: Boolean = false, + val isExpect: Boolean = false, + val isActual: Boolean = false, + val isExternal: Boolean = false, + val sealedSubclasses: List = emptyList(), + val companionObjectName: String = "", +) + +data class JvmFunctionInfo( + override val containingClassFqName: String = "", + val returnTypeFqName: String = "", + val returnTypeDisplay: String = "", + val parameterCount: Int = 0, + val parameters: List = emptyList(), + val signatureDisplay: String = "", + val typeParameters: List = emptyList(), + val isStatic: Boolean = false, + val isAbstract: Boolean = false, + val isFinal: Boolean = false, + val kotlin: KotlinFunctionInfo? = null, +) : JvmSymbolInfo + +data class JvmParameterInfo( + val name: String, + val typeFqName: String, + val typeDisplay: String, + val hasDefaultValue: Boolean = false, + val isCrossinline: Boolean = false, + val isNoinline: Boolean = false, + val isVararg: Boolean = false, +) + +data class KotlinFunctionInfo( + val receiverTypeFqName: String = "", + val receiverTypeDisplay: String = "", + val isSuspend: Boolean = false, + val isInline: Boolean = false, + val isInfix: Boolean = false, + val isOperator: Boolean = false, + val isTailrec: Boolean = false, + val isExternal: Boolean = false, + val isExpect: Boolean = false, + val isActual: Boolean = false, + val isReturnTypeNullable: Boolean = false, +) + +data class JvmFieldInfo( + override val containingClassFqName: String = "", + val typeFqName: String = "", + val typeDisplay: String = "", + val isStatic: Boolean = false, + val isFinal: Boolean = false, + val constantValue: String = "", + val kotlin: KotlinPropertyInfo? = null, +) : JvmSymbolInfo + +data class KotlinPropertyInfo( + val receiverTypeFqName: String = "", + val receiverTypeDisplay: String = "", + val isConst: Boolean = false, + val isLateinit: Boolean = false, + val hasGetter: Boolean = false, + val hasSetter: Boolean = false, + val isDelegated: Boolean = false, + val isExpect: Boolean = false, + val isActual: Boolean = false, + val isExternal: Boolean = false, + val isTypeNullable: Boolean = false, +) + +data class JvmEnumEntryInfo( + override val containingClassFqName: String = "", + val ordinal: Int = 0, +) : JvmSymbolInfo + +data class JvmTypeAliasInfo( + override val containingClassFqName: String = "", + val expandedTypeFqName: String = "", + val expandedTypeDisplay: String = "", + val typeParameters: List = emptyList(), +) : JvmSymbolInfo diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt new file mode 100644 index 0000000000..949240eafd --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt @@ -0,0 +1,409 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import org.appdevforall.codeonthego.indexing.api.IndexDescriptor +import org.appdevforall.codeonthego.indexing.api.IndexField +import org.appdevforall.codeonthego.indexing.jvm.proto.JvmSymbolProtos +import org.appdevforall.codeonthego.indexing.jvm.proto.JvmSymbolProtos.JvmSymbolData + +/** + * [IndexDescriptor] for [JvmSymbol]. + * + * Queryable fields: + * - `name` : prefix-searchable, for completion + * - `package` : exact, for package-scoped queries + * - `kind` : exact, for filtering by CLASS/FUNCTION/etc. + * - `receiverType` : exact, for extension function matching + * - `containingClass`: exact, for member lookup + * - `language` : exact, for Java-only or Kotlin-only queries + * + * Blob serialization uses Protobuf with oneof for type-specific data. + */ +object JvmSymbolDescriptor : IndexDescriptor { + + override val name: String = "jvm_symbols" + + override val fields: List = listOf( + IndexField(name = "name", prefixSearchable = true), + IndexField(name = "package"), + IndexField(name = "kind"), + IndexField(name = "receiverType"), + IndexField(name = "containingClass"), + IndexField(name = "language"), + ) + + override fun fieldValues(entry: JvmSymbol): Map = mapOf( + "name" to entry.shortName, + "package" to entry.packageName, + "kind" to entry.kind.name, + "receiverType" to entry.receiverTypeFqName, + "containingClass" to entry.containingClassFqName.ifEmpty { null }, + "language" to entry.language.name, + ) + + override fun serialize(entry: JvmSymbol): ByteArray = + toProto(entry).toByteArray() + + override fun deserialize(bytes: ByteArray): JvmSymbol = + fromProto(JvmSymbolData.parseFrom(bytes)) + + private fun toProto(s: JvmSymbol): JvmSymbolData { + val builder = JvmSymbolData.newBuilder() + .setFqName(s.fqName) + .setShortName(s.shortName) + .setPackageName(s.packageName) + .setSourceId(s.sourceId) + .setKind(kindToProto(s.kind)) + .setLanguage(languageToProto(s.language)) + .setVisibility(visibilityToProto(s.visibility)) + .setIsDeprecated(s.isDeprecated) + + when (val data = s.data) { + is JvmClassInfo -> builder.setClassData(classInfoToProto(data)) + is JvmFunctionInfo -> builder.setFunctionData(functionInfoToProto(data)) + is JvmFieldInfo -> builder.setFieldData(fieldInfoToProto(data)) + is JvmEnumEntryInfo -> builder.setEnumEntryData(enumEntryToProto(data)) + is JvmTypeAliasInfo -> builder.setTypeAliasData(typeAliasToProto(data)) + } + + return builder.build() + } + + private fun classInfoToProto(d: JvmClassInfo): JvmSymbolProtos.ClassData { + val builder = JvmSymbolProtos.ClassData.newBuilder() + .setContainingClassFqName(d.containingClassFqName) + .addAllSupertypeFqNames(d.supertypeFqNames) + .addAllTypeParameters(d.typeParameters) + .setIsAbstract(d.isAbstract) + .setIsFinal(d.isFinal) + .setIsInner(d.isInner) + .setIsStatic(d.isStatic) + + d.kotlin?.let { kd -> + builder.setKotlin( + JvmSymbolProtos.KotlinClassData.newBuilder() + .setIsData(kd.isData) + .setIsValue(kd.isValue) + .setIsSealed(kd.isSealed) + .setIsFunInterface(kd.isFunInterface) + .setIsExpect(kd.isExpect) + .setIsActual(kd.isActual) + .setIsExternal(kd.isExternal) + .addAllSealedSubclasses(kd.sealedSubclasses) + .setCompanionObjectName(kd.companionObjectName) + ) + } + + return builder.build() + } + + private fun functionInfoToProto(d: JvmFunctionInfo): JvmSymbolProtos.FunctionData { + val builder = JvmSymbolProtos.FunctionData.newBuilder() + .setContainingClassFqName(d.containingClassFqName) + .setReturnTypeFqName(d.returnTypeFqName) + .setReturnTypeDisplay(d.returnTypeDisplay) + .setParameterCount(d.parameterCount) + .addAllParameters(d.parameters.map { paramToProto(it) }) + .setSignatureDisplay(d.signatureDisplay) + .addAllTypeParameters(d.typeParameters) + .setIsStatic(d.isStatic) + .setIsAbstract(d.isAbstract) + .setIsFinal(d.isFinal) + + d.kotlin?.let { kd -> + builder.setKotlin( + JvmSymbolProtos.KotlinFunctionData.newBuilder() + .setReceiverTypeFqName(kd.receiverTypeFqName) + .setReceiverTypeDisplay(kd.receiverTypeDisplay) + .setIsSuspend(kd.isSuspend) + .setIsInline(kd.isInline) + .setIsInfix(kd.isInfix) + .setIsOperator(kd.isOperator) + .setIsTailrec(kd.isTailrec) + .setIsExternal(kd.isExternal) + .setIsExpect(kd.isExpect) + .setIsActual(kd.isActual) + .setIsReturnTypeNullable(kd.isReturnTypeNullable) + ) + } + + return builder.build() + } + + private fun paramToProto(p: JvmParameterInfo): JvmSymbolProtos.ParameterData = + JvmSymbolProtos.ParameterData.newBuilder() + .setName(p.name) + .setTypeFqName(p.typeFqName) + .setTypeDisplay(p.typeDisplay) + .setHasDefaultValue(p.hasDefaultValue) + .setIsCrossinline(p.isCrossinline) + .setIsNoinline(p.isNoinline) + .setIsVararg(p.isVararg) + .build() + + private fun fieldInfoToProto(d: JvmFieldInfo): JvmSymbolProtos.FieldData { + val builder = JvmSymbolProtos.FieldData.newBuilder() + .setContainingClassFqName(d.containingClassFqName) + .setTypeFqName(d.typeFqName) + .setTypeDisplay(d.typeDisplay) + .setIsStatic(d.isStatic) + .setIsFinal(d.isFinal) + .setConstantValue(d.constantValue) + + d.kotlin?.let { kd -> + builder.setKotlin( + JvmSymbolProtos.KotlinPropertyData.newBuilder() + .setReceiverTypeFqName(kd.receiverTypeFqName) + .setReceiverTypeDisplay(kd.receiverTypeDisplay) + .setIsConst(kd.isConst) + .setIsLateinit(kd.isLateinit) + .setHasGetter(kd.hasGetter) + .setHasSetter(kd.hasSetter) + .setIsDelegated(kd.isDelegated) + .setIsExpect(kd.isExpect) + .setIsActual(kd.isActual) + .setIsExternal(kd.isExternal) + .setIsTypeNullable(kd.isTypeNullable) + ) + } + + return builder.build() + } + + private fun enumEntryToProto(d: JvmEnumEntryInfo): JvmSymbolProtos.EnumEntryData = + JvmSymbolProtos.EnumEntryData.newBuilder() + .setContainingEnumFqName(d.containingClassFqName) + .setOrdinal(d.ordinal) + .build() + + private fun typeAliasToProto(d: JvmTypeAliasInfo): JvmSymbolProtos.TypeAliasData = + JvmSymbolProtos.TypeAliasData.newBuilder() + .setExpandedTypeFqName(d.expandedTypeFqName) + .setExpandedTypeDisplay(d.expandedTypeDisplay) + .addAllTypeParameters(d.typeParameters) + .build() + + private fun fromProto(p: JvmSymbolData): JvmSymbol { + val kind = kindFromProto(p.kind) + val data = dataFromProto(p) + + val key = when { + kind.isCallable && kind != JvmSymbolKind.PROPERTY + && kind != JvmSymbolKind.EXTENSION_PROPERTY + && kind != JvmSymbolKind.FIELD -> { + val params = (data as? JvmFunctionInfo) + ?.parameters + ?.joinToString(",") { it.typeFqName } + ?: "" + "${p.fqName}($params)" + } + else -> p.fqName + } + + return JvmSymbol( + key = key, + sourceId = p.sourceId, + fqName = p.fqName, + shortName = p.shortName, + packageName = p.packageName, + kind = kind, + language = languageFromProto(p.language), + visibility = visibilityFromProto(p.visibility), + isDeprecated = p.isDeprecated, + data = data, + ) + } + + private fun dataFromProto(p: JvmSymbolData): JvmSymbolInfo = when (p.dataCase) { + JvmSymbolData.DataCase.CLASS_DATA -> classInfoFromProto(p.classData) + JvmSymbolData.DataCase.FUNCTION_DATA -> functionInfoFromProto(p.functionData) + JvmSymbolData.DataCase.FIELD_DATA -> fieldInfoFromProto(p.fieldData) + JvmSymbolData.DataCase.ENUM_ENTRY_DATA -> enumEntryFromProto(p.enumEntryData) + JvmSymbolData.DataCase.TYPE_ALIAS_DATA -> typeAliasFromProto(p.typeAliasData) + else -> JvmClassInfo() // fallback + } + + private fun classInfoFromProto(p: JvmSymbolProtos.ClassData): JvmClassInfo { + val kotlin = if (p.hasKotlin()) { + val kd = p.kotlin + KotlinClassInfo( + isData = kd.isData, + isValue = kd.isValue, + isSealed = kd.isSealed, + isFunInterface = kd.isFunInterface, + isExpect = kd.isExpect, + isActual = kd.isActual, + isExternal = kd.isExternal, + sealedSubclasses = kd.sealedSubclassesList.toList(), + companionObjectName = kd.companionObjectName, + ) + } else null + + return JvmClassInfo( + containingClassFqName = p.containingClassFqName, + supertypeFqNames = p.supertypeFqNamesList.toList(), + typeParameters = p.typeParametersList.toList(), + isAbstract = p.isAbstract, + isFinal = p.isFinal, + isInner = p.isInner, + isStatic = p.isStatic, + kotlin = kotlin, + ) + } + + private fun functionInfoFromProto(p: JvmSymbolProtos.FunctionData): JvmFunctionInfo { + val kotlin = if (p.hasKotlin()) { + val kd = p.kotlin + KotlinFunctionInfo( + receiverTypeFqName = kd.receiverTypeFqName, + receiverTypeDisplay = kd.receiverTypeDisplay, + isSuspend = kd.isSuspend, + isInline = kd.isInline, + isInfix = kd.isInfix, + isOperator = kd.isOperator, + isTailrec = kd.isTailrec, + isExternal = kd.isExternal, + isExpect = kd.isExpect, + isActual = kd.isActual, + isReturnTypeNullable = kd.isReturnTypeNullable, + ) + } else null + + return JvmFunctionInfo( + containingClassFqName = p.containingClassFqName, + returnTypeFqName = p.returnTypeFqName, + returnTypeDisplay = p.returnTypeDisplay, + parameterCount = p.parameterCount, + parameters = p.parametersList.map { paramFromProto(it) }, + signatureDisplay = p.signatureDisplay, + typeParameters = p.typeParametersList.toList(), + isStatic = p.isStatic, + isAbstract = p.isAbstract, + isFinal = p.isFinal, + kotlin = kotlin, + ) + } + + private fun paramFromProto(p: JvmSymbolProtos.ParameterData): JvmParameterInfo = + JvmParameterInfo( + name = p.name, + typeFqName = p.typeFqName, + typeDisplay = p.typeDisplay, + hasDefaultValue = p.hasDefaultValue, + isCrossinline = p.isCrossinline, + isNoinline = p.isNoinline, + isVararg = p.isVararg, + ) + + private fun fieldInfoFromProto(p: JvmSymbolProtos.FieldData): JvmFieldInfo { + val kotlin = if (p.hasKotlin()) { + val kd = p.kotlin + KotlinPropertyInfo( + receiverTypeFqName = kd.receiverTypeFqName, + receiverTypeDisplay = kd.receiverTypeDisplay, + isConst = kd.isConst, + isLateinit = kd.isLateinit, + hasGetter = kd.hasGetter, + hasSetter = kd.hasSetter, + isDelegated = kd.isDelegated, + isExpect = kd.isExpect, + isActual = kd.isActual, + isExternal = kd.isExternal, + isTypeNullable = kd.isTypeNullable, + ) + } else null + + return JvmFieldInfo( + containingClassFqName = p.containingClassFqName, + typeFqName = p.typeFqName, + typeDisplay = p.typeDisplay, + isStatic = p.isStatic, + isFinal = p.isFinal, + constantValue = p.constantValue, + kotlin = kotlin, + ) + } + + private fun enumEntryFromProto(p: JvmSymbolProtos.EnumEntryData): JvmEnumEntryInfo = + JvmEnumEntryInfo( + containingClassFqName = p.containingEnumFqName, + ordinal = p.ordinal, + ) + + private fun typeAliasFromProto(p: JvmSymbolProtos.TypeAliasData): JvmTypeAliasInfo = + JvmTypeAliasInfo( + expandedTypeFqName = p.expandedTypeFqName, + expandedTypeDisplay = p.expandedTypeDisplay, + typeParameters = p.typeParametersList.toList(), + ) + + private fun kindToProto(k: JvmSymbolKind) = when (k) { + JvmSymbolKind.CLASS -> JvmSymbolProtos.JvmSymbolKind.KIND_CLASS + JvmSymbolKind.INTERFACE -> JvmSymbolProtos.JvmSymbolKind.KIND_INTERFACE + JvmSymbolKind.ENUM -> JvmSymbolProtos.JvmSymbolKind.KIND_ENUM + JvmSymbolKind.ENUM_ENTRY -> JvmSymbolProtos.JvmSymbolKind.KIND_ENUM_ENTRY + JvmSymbolKind.ANNOTATION_CLASS -> JvmSymbolProtos.JvmSymbolKind.KIND_ANNOTATION_CLASS + JvmSymbolKind.OBJECT -> JvmSymbolProtos.JvmSymbolKind.KIND_OBJECT + JvmSymbolKind.COMPANION_OBJECT -> JvmSymbolProtos.JvmSymbolKind.KIND_COMPANION_OBJECT + JvmSymbolKind.DATA_CLASS -> JvmSymbolProtos.JvmSymbolKind.KIND_DATA_CLASS + JvmSymbolKind.VALUE_CLASS -> JvmSymbolProtos.JvmSymbolKind.KIND_VALUE_CLASS + JvmSymbolKind.SEALED_CLASS -> JvmSymbolProtos.JvmSymbolKind.KIND_SEALED_CLASS + JvmSymbolKind.SEALED_INTERFACE -> JvmSymbolProtos.JvmSymbolKind.KIND_SEALED_INTERFACE + JvmSymbolKind.FUNCTION -> JvmSymbolProtos.JvmSymbolKind.KIND_FUNCTION + JvmSymbolKind.EXTENSION_FUNCTION -> JvmSymbolProtos.JvmSymbolKind.KIND_EXTENSION_FUNCTION + JvmSymbolKind.CONSTRUCTOR -> JvmSymbolProtos.JvmSymbolKind.KIND_CONSTRUCTOR + JvmSymbolKind.PROPERTY -> JvmSymbolProtos.JvmSymbolKind.KIND_PROPERTY + JvmSymbolKind.EXTENSION_PROPERTY -> JvmSymbolProtos.JvmSymbolKind.KIND_EXTENSION_PROPERTY + JvmSymbolKind.FIELD -> JvmSymbolProtos.JvmSymbolKind.KIND_FIELD + JvmSymbolKind.TYPE_ALIAS -> JvmSymbolProtos.JvmSymbolKind.KIND_TYPE_ALIAS + } + + private fun kindFromProto(k: JvmSymbolProtos.JvmSymbolKind) = when (k) { + JvmSymbolProtos.JvmSymbolKind.KIND_CLASS -> JvmSymbolKind.CLASS + JvmSymbolProtos.JvmSymbolKind.KIND_INTERFACE -> JvmSymbolKind.INTERFACE + JvmSymbolProtos.JvmSymbolKind.KIND_ENUM -> JvmSymbolKind.ENUM + JvmSymbolProtos.JvmSymbolKind.KIND_ENUM_ENTRY -> JvmSymbolKind.ENUM_ENTRY + JvmSymbolProtos.JvmSymbolKind.KIND_ANNOTATION_CLASS -> JvmSymbolKind.ANNOTATION_CLASS + JvmSymbolProtos.JvmSymbolKind.KIND_OBJECT -> JvmSymbolKind.OBJECT + JvmSymbolProtos.JvmSymbolKind.KIND_COMPANION_OBJECT -> JvmSymbolKind.COMPANION_OBJECT + JvmSymbolProtos.JvmSymbolKind.KIND_DATA_CLASS -> JvmSymbolKind.DATA_CLASS + JvmSymbolProtos.JvmSymbolKind.KIND_VALUE_CLASS -> JvmSymbolKind.VALUE_CLASS + JvmSymbolProtos.JvmSymbolKind.KIND_SEALED_CLASS -> JvmSymbolKind.SEALED_CLASS + JvmSymbolProtos.JvmSymbolKind.KIND_SEALED_INTERFACE -> JvmSymbolKind.SEALED_INTERFACE + JvmSymbolProtos.JvmSymbolKind.KIND_FUNCTION -> JvmSymbolKind.FUNCTION + JvmSymbolProtos.JvmSymbolKind.KIND_EXTENSION_FUNCTION -> JvmSymbolKind.EXTENSION_FUNCTION + JvmSymbolProtos.JvmSymbolKind.KIND_CONSTRUCTOR -> JvmSymbolKind.CONSTRUCTOR + JvmSymbolProtos.JvmSymbolKind.KIND_PROPERTY -> JvmSymbolKind.PROPERTY + JvmSymbolProtos.JvmSymbolKind.KIND_EXTENSION_PROPERTY -> JvmSymbolKind.EXTENSION_PROPERTY + JvmSymbolProtos.JvmSymbolKind.KIND_FIELD -> JvmSymbolKind.FIELD + JvmSymbolProtos.JvmSymbolKind.KIND_TYPE_ALIAS -> JvmSymbolKind.TYPE_ALIAS + else -> JvmSymbolKind.CLASS + } + + private fun languageToProto(l: JvmSourceLanguage) = when (l) { + JvmSourceLanguage.JAVA -> JvmSymbolProtos.JvmSourceLanguage.LANGUAGE_JAVA + JvmSourceLanguage.KOTLIN -> JvmSymbolProtos.JvmSourceLanguage.LANGUAGE_KOTLIN + } + + private fun languageFromProto(l: JvmSymbolProtos.JvmSourceLanguage) = when (l) { + JvmSymbolProtos.JvmSourceLanguage.LANGUAGE_JAVA -> JvmSourceLanguage.JAVA + JvmSymbolProtos.JvmSourceLanguage.LANGUAGE_KOTLIN -> JvmSourceLanguage.KOTLIN + else -> JvmSourceLanguage.JAVA + } + + private fun visibilityToProto(v: JvmVisibility) = when (v) { + JvmVisibility.PUBLIC -> JvmSymbolProtos.JvmVisibility.VISIBILITY_PUBLIC + JvmVisibility.PROTECTED -> JvmSymbolProtos.JvmVisibility.VISIBILITY_PROTECTED + JvmVisibility.INTERNAL -> JvmSymbolProtos.JvmVisibility.VISIBILITY_INTERNAL + JvmVisibility.PRIVATE -> JvmSymbolProtos.JvmVisibility.VISIBILITY_PRIVATE + JvmVisibility.PACKAGE_PRIVATE -> JvmSymbolProtos.JvmVisibility.VISIBILITY_PACKAGE_PRIVATE + } + + private fun visibilityFromProto(v: JvmSymbolProtos.JvmVisibility) = when (v) { + JvmSymbolProtos.JvmVisibility.VISIBILITY_PUBLIC -> JvmVisibility.PUBLIC + JvmSymbolProtos.JvmVisibility.VISIBILITY_PROTECTED -> JvmVisibility.PROTECTED + JvmSymbolProtos.JvmVisibility.VISIBILITY_INTERNAL -> JvmVisibility.INTERNAL + JvmSymbolProtos.JvmVisibility.VISIBILITY_PRIVATE -> JvmVisibility.PRIVATE + JvmSymbolProtos.JvmVisibility.VISIBILITY_PACKAGE_PRIVATE -> JvmVisibility.PACKAGE_PRIVATE + else -> JvmVisibility.PUBLIC + } +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt new file mode 100644 index 0000000000..23bd938484 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt @@ -0,0 +1,191 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import android.content.Context +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.take +import org.appdevforall.codeonthego.indexing.FilteredIndex +import org.appdevforall.codeonthego.indexing.InMemoryIndex +import org.appdevforall.codeonthego.indexing.MergedIndex +import org.appdevforall.codeonthego.indexing.PersistentIndex +import org.appdevforall.codeonthego.indexing.api.indexQuery +import org.appdevforall.codeonthego.indexing.util.BackgroundIndexer +import java.io.Closeable + +/** + * Main entry point for JVM symbol indexing. + * + * Combines a persistent index (libraries) with an in-memory index + * (source files) behind a merged view. Source symbols take priority. + */ +class JvmSymbolIndex private constructor( + /** Persistent cache — stores every JAR ever indexed. */ + val libraryCache: PersistentIndex, + + /** Filtered view — only shows JARs on the current classpath. */ + val libraryView: FilteredIndex, + + /** In-memory index for source file symbols. */ + val sourceIndex: InMemoryIndex, + + /** Merged view: source (priority) + active libraries. */ + val merged: MergedIndex, + + /** Background indexer writing to the cache. */ + val libraryIndexer: BackgroundIndexer, +) : Closeable { + + companion object { + + const val DB_NAME_DEFAULT = "jvm_symbol_index.db" + const val INDEX_NAME_LIBRARY = "jvm-library-cache" + const val INDEX_NAME_SOURCES = "jvm-sources" + + fun create( + context: Context, + dbName: String = DB_NAME_DEFAULT, + ): JvmSymbolIndex { + val cache = PersistentIndex( + descriptor = JvmSymbolDescriptor, + context = context, + dbName = dbName, + name = INDEX_NAME_LIBRARY, + ) + + val view = FilteredIndex(cache) + + val sources = InMemoryIndex( + descriptor = JvmSymbolDescriptor, + name = INDEX_NAME_SOURCES, + ) + + // Sources win over libraries + val merged = MergedIndex(sources, view) + val indexer = BackgroundIndexer(cache) + return JvmSymbolIndex( + libraryCache = cache, + libraryView = view, + sourceIndex = sources, + merged = merged, + libraryIndexer = indexer + ) + } + } + + /** + * Make a library visible in query results. + * + * If the library is already cached (indexed previously), + * this is instant. If not, call [indexLibrary] first. + */ + fun activateLibrary(sourceId: String) { + libraryView.activateSource(sourceId) + } + + /** + * Hide a library from query results. + * The cached index data is retained for future reuse. + */ + fun deactivateLibrary(sourceId: String) { + libraryView.deactivateSource(sourceId) + } + + /** + * Replace the entire active library set. + * + * Typical call after project sync: pass all current classpath + * JAR paths. Libraries not in the set become invisible. + * Libraries in the set that are already cached become + * instantly visible. + */ + fun setActiveLibraries(sourceIds: Set) { + libraryView.setActiveSources(sourceIds) + } + + /** + * Check if a library is already cached (regardless of whether + * it's currently active). + */ + suspend fun isLibraryCached(sourceId: String): Boolean = + libraryView.isCached(sourceId) + + /** + * Index a library JAR/AAR into the persistent cache. + * + * This does NOT make the library visible in queries — + * call [activateLibrary] after indexing completes. + * + * Skips if already cached. Call [reindexLibrary] to force. + */ + fun indexLibrary( + sourceId: String, + provider: (sourceId: String) -> Flow, + ) = libraryIndexer.indexSource(sourceId, skipIfExists = true, provider) + + fun reindexLibrary( + sourceId: String, + provider: (sourceId: String) -> Flow, + ) = libraryIndexer.indexSource(sourceId, skipIfExists = false, provider) + + suspend fun updateSourceFile(sourceId: String, symbols: Sequence) { + sourceIndex.removeBySource(sourceId) + sourceIndex.insertAll(symbols) + } + + suspend fun removeSourceFile(sourceId: String) { + sourceIndex.removeBySource(sourceId) + } + + fun findByPrefix(prefix: String, limit: Int = 200): Flow = + merged.query(indexQuery { prefix("name", prefix); this.limit = limit }) + + fun findByPrefix( + prefix: String, kinds: Set, limit: Int = 200, + ): Flow = + merged.query(indexQuery { prefix("name", prefix); this.limit = 0 }) + .filter { it.kind in kinds } + .take(limit) + + fun findExtensionsFor( + receiverTypeFqName: String, namePrefix: String = "", limit: Int = 200, + ): Flow = merged.query(indexQuery { + eq("receiverType", receiverTypeFqName) + if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + this.limit = limit + }) + + fun findTopLevelCallablesInPackage( + packageName: String, namePrefix: String = "", limit: Int = 200, + ): Flow = merged.query(indexQuery { + eq("package", packageName) + if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + this.limit = 0 + }).filter { it.kind.isCallable && it.isTopLevel }.take(limit) + + fun findClassifiersInPackage( + packageName: String, namePrefix: String = "", limit: Int = 200, + ): Flow = merged.query(indexQuery { + eq("package", packageName) + if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + this.limit = 0 + }).filter { it.kind.isClassifier }.take(limit) + + fun findMembersOf( + classFqName: String, namePrefix: String = "", limit: Int = 200, + ): Flow = merged.query(indexQuery { + eq("containingClass", classFqName) + if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + this.limit = limit + }) + + suspend fun findByFqName(fqName: String): JvmSymbol? = merged.get(fqName) + + fun allPackages(): Flow = merged.distinctValues("package") + + suspend fun awaitLibraryIndexing() = libraryIndexer.awaitAll() + + override fun close() { + libraryIndexer.close() + merged.close() + } +} \ No newline at end of file diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt new file mode 100644 index 0000000000..b4160f8b98 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt @@ -0,0 +1,428 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.flowOn +import org.jetbrains.org.objectweb.asm.AnnotationVisitor +import org.jetbrains.org.objectweb.asm.ClassReader +import org.jetbrains.org.objectweb.asm.ClassVisitor +import org.jetbrains.org.objectweb.asm.Opcodes +import org.slf4j.LoggerFactory +import java.io.InputStream +import java.nio.file.Path +import java.util.jar.JarFile +import kotlin.io.path.pathString +import kotlin.metadata.ClassKind +import kotlin.metadata.KmClass +import kotlin.metadata.KmClassifier +import kotlin.metadata.KmFunction +import kotlin.metadata.KmPackage +import kotlin.metadata.KmProperty +import kotlin.metadata.KmType +import kotlin.metadata.Modality +import kotlin.metadata.Visibility +import kotlin.metadata.declaresDefaultValue +import kotlin.metadata.isConst +import kotlin.metadata.isDelegated +import kotlin.metadata.isExpect +import kotlin.metadata.isExternal +import kotlin.metadata.isInfix +import kotlin.metadata.isInline +import kotlin.metadata.isLateinit +import kotlin.metadata.isNullable +import kotlin.metadata.isOperator +import kotlin.metadata.isSuspend +import kotlin.metadata.isTailrec +import kotlin.metadata.jvm.KotlinClassMetadata +import kotlin.metadata.jvm.Metadata +import kotlin.metadata.kind +import kotlin.metadata.modality +import kotlin.metadata.visibility + +/** + * Scans JAR files using Kotlin metadata to produce [JvmSymbol]s + * with full Kotlin semantics (extensions, suspend, inline, etc.). + * + * Skips non-Kotlin class files (no `@Metadata` annotation). + */ +object KotlinMetadataScanner { + + private val log = LoggerFactory.getLogger(KotlinMetadataScanner::class.java) + + fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Flow = flow { + val jar = try { + JarFile(jarPath.toFile()) + } catch (e: Exception) { + log.warn("Failed to open JAR: {}", jarPath, e) + return@flow + } + + jar.use { + val entries = jar.entries() + while (entries.hasMoreElements()) { + val entry = entries.nextElement() + if (!entry.name.endsWith(".class")) continue + if (entry.name == "module-info.class") continue + + try { + jar.getInputStream(entry).use { input -> + parseKotlinClass(input, sourceId)?.forEach { emit(it) } + } + } catch (e: Exception) { + log.debug("Failed to parse {}: {}", entry.name, e.message) + } + } + } + } + .flowOn(Dispatchers.IO) + + internal fun parseKotlinClass(input: InputStream, sourceId: String): List? { + val reader = ClassReader(input) + val collector = MetadataCollector() + reader.accept( + collector, + ClassReader.SKIP_CODE or ClassReader.SKIP_DEBUG or ClassReader.SKIP_FRAMES + ) + + val header = collector.metadataHeader ?: return null + + val metadata = try { + KotlinClassMetadata.readStrict(header) + } catch (e: Exception) { + log.debug("Failed to read Kotlin metadata: {}", e.message) + return null + } + + return when (metadata) { + is KotlinClassMetadata.Class -> + extractFromClass(metadata.kmClass, sourceId) + + is KotlinClassMetadata.FileFacade -> + extractFromPackage(metadata.kmPackage, collector.packageName, sourceId) + + is KotlinClassMetadata.MultiFileClassPart -> + extractFromPackage(metadata.kmPackage, collector.packageName, sourceId) + + else -> null + } + } + + private fun extractFromClass( + klass: KmClass, sourceId: String, + ): List { + val symbols = mutableListOf() + val classFqName = klass.name.replace('/', '.') + val packageName = classFqName.substringBeforeLast('.', "") + val shortName = classFqName.substringAfterLast('.') + + val kind = when (klass.kind) { + ClassKind.INTERFACE -> JvmSymbolKind.INTERFACE + ClassKind.ENUM_CLASS -> JvmSymbolKind.ENUM + ClassKind.ANNOTATION_CLASS -> JvmSymbolKind.ANNOTATION_CLASS + ClassKind.OBJECT -> JvmSymbolKind.OBJECT + ClassKind.COMPANION_OBJECT -> JvmSymbolKind.COMPANION_OBJECT + ClassKind.CLASS -> JvmSymbolKind.CLASS + else -> JvmSymbolKind.CLASS + } + + val supertypes = klass.supertypes.mapNotNull { supertype -> + when (val c = supertype.classifier) { + is KmClassifier.Class -> c.name.replace('/', '.') + else -> null + } + } + + symbols.add( + JvmSymbol( + key = classFqName, + sourceId = sourceId, + fqName = classFqName, + shortName = shortName, + packageName = packageName, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = kmVisibility(klass.visibility), + data = JvmClassInfo( + supertypeFqNames = supertypes, + typeParameters = klass.typeParameters.map { it.name }, + isAbstract = klass.modality == Modality.ABSTRACT, + isFinal = klass.modality == Modality.FINAL, + kotlin = KotlinClassInfo( + isSealed = klass.modality == Modality.SEALED, + sealedSubclasses = klass.sealedSubclasses.map { it.replace('/', '.') }, + ), + ), + ) + ) + + for (fn in klass.functions) { + extractFunction(fn, classFqName, packageName, sourceId)?.let { symbols.add(it) } + } + + for (prop in klass.properties) { + extractProperty(prop, classFqName, packageName, sourceId)?.let { symbols.add(it) } + } + + if (kind == JvmSymbolKind.ENUM) { + klass.kmEnumEntries.forEachIndexed { ordinal, entry -> + symbols.add( + JvmSymbol( + key = "$classFqName.$entry", + sourceId = sourceId, + fqName = "$classFqName.$entry", + shortName = entry.name, + packageName = packageName, + kind = JvmSymbolKind.ENUM_ENTRY, + language = JvmSourceLanguage.KOTLIN, + data = JvmEnumEntryInfo( + containingClassFqName = classFqName, + ordinal = ordinal, + ), + ) + ) + } + } + + return symbols + } + + private fun extractFromPackage( + pkg: KmPackage, + packageName: String, + sourceId: String, + ): List { + val symbols = mutableListOf() + + for (fn in pkg.functions) { + extractFunction(fn, "", packageName, sourceId)?.let { symbols.add(it) } + } + + for (prop in pkg.properties) { + extractProperty(prop, "", packageName, sourceId)?.let { symbols.add(it) } + } + + for (alias in pkg.typeAliases) { + val fqName = if (packageName.isEmpty()) alias.name else "$packageName.${alias.name}" + symbols.add( + JvmSymbol( + key = fqName, + sourceId = sourceId, + fqName = fqName, + shortName = alias.name, + packageName = packageName, + kind = JvmSymbolKind.TYPE_ALIAS, + language = JvmSourceLanguage.KOTLIN, + visibility = kmVisibility(alias.visibility), + data = JvmTypeAliasInfo( + expandedTypeFqName = kmTypeToFqName(alias.expandedType), + expandedTypeDisplay = kmTypeToDisplay(alias.expandedType), + typeParameters = alias.typeParameters.map { it.name }, + ), + ) + ) + } + + return symbols + } + + private fun extractFunction( + fn: KmFunction, + containingClass: String, + packageName: String, + sourceId: String, + ): JvmSymbol? { + val vis = kmVisibility(fn.visibility) + if (vis == JvmVisibility.PRIVATE) return null + + val receiverType = fn.receiverParameterType + val isExtension = receiverType != null + val kind = if (isExtension) JvmSymbolKind.EXTENSION_FUNCTION else JvmSymbolKind.FUNCTION + + val parameters = fn.valueParameters.map { param -> + JvmParameterInfo( + name = param.name, + typeFqName = kmTypeToFqName(param.type), + typeDisplay = kmTypeToDisplay(param.type), + hasDefaultValue = param.declaresDefaultValue, + isVararg = param.varargElementType != null, + ) + } + + val baseFqName = if (containingClass.isNotEmpty()) + "$containingClass.${fn.name}" else "$packageName.${fn.name}" + val key = "$baseFqName(${parameters.joinToString(",") { it.typeFqName }})" + + val signatureDisplay = buildString { + append("(") + append(parameters.joinToString(", ") { "${it.name}: ${it.typeDisplay}" }) + append("): ") + append(kmTypeToDisplay(fn.returnType)) + } + + return JvmSymbol( + key = key, + sourceId = sourceId, + fqName = baseFqName, + shortName = fn.name, + packageName = packageName, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = vis, + data = JvmFunctionInfo( + containingClassFqName = containingClass, + returnTypeFqName = kmTypeToFqName(fn.returnType), + returnTypeDisplay = kmTypeToDisplay(fn.returnType), + parameterCount = parameters.size, + parameters = parameters, + signatureDisplay = signatureDisplay, + typeParameters = fn.typeParameters.map { it.name }, + kotlin = KotlinFunctionInfo( + receiverTypeFqName = receiverType?.let { kmTypeToFqName(it) } ?: "", + receiverTypeDisplay = receiverType?.let { kmTypeToDisplay(it) } ?: "", + isSuspend = fn.isSuspend, + isInline = fn.isInline, + isInfix = fn.isInfix, + isOperator = fn.isOperator, + isTailrec = fn.isTailrec, + isExternal = fn.isExternal, + isExpect = fn.isExpect, + isReturnTypeNullable = fn.returnType.isNullable, + ), + ), + ) + } + + private fun extractProperty( + prop: KmProperty, + containingClass: String, + packageName: String, + sourceId: String, + ): JvmSymbol? { + val vis = kmVisibility(prop.visibility) + if (vis == JvmVisibility.PRIVATE) return null + + val receiverType = prop.receiverParameterType + val isExtension = receiverType != null + val kind = if (isExtension) JvmSymbolKind.EXTENSION_PROPERTY else JvmSymbolKind.PROPERTY + + val fqName = if (containingClass.isNotEmpty()) + "$containingClass.${prop.name}" else "$packageName.${prop.name}" + + return JvmSymbol( + key = fqName, + sourceId = sourceId, + fqName = fqName, + shortName = prop.name, + packageName = packageName, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = vis, + data = JvmFieldInfo( + containingClassFqName = containingClass, + typeFqName = kmTypeToFqName(prop.returnType), + typeDisplay = kmTypeToDisplay(prop.returnType), + kotlin = KotlinPropertyInfo( + receiverTypeFqName = receiverType?.let { kmTypeToFqName(it) } ?: "", + receiverTypeDisplay = receiverType?.let { kmTypeToDisplay(it) } ?: "", + isConst = prop.isConst, + isLateinit = prop.isLateinit, + hasGetter = prop.getter != null, + hasSetter = prop.setter != null, + isDelegated = prop.isDelegated, + isTypeNullable = prop.returnType.isNullable, + ), + ), + ) + } + + private fun kmTypeToFqName(type: KmType): String = when (val c = type.classifier) { + is KmClassifier.Class -> c.name.replace('/', '.') + is KmClassifier.TypeAlias -> c.name.replace('/', '.') + is KmClassifier.TypeParameter -> "T${c.id}" + } + + private fun kmTypeToDisplay(type: KmType): String { + val base = kmTypeToFqName(type).substringAfterLast('.') + val args = type.arguments.mapNotNull { it.type?.let { t -> kmTypeToDisplay(t) } } + return buildString { + append(base) + if (args.isNotEmpty()) append("<${args.joinToString(", ")}>") + if (type.isNullable) append("?") + } + } + + private fun kmVisibility(vis: Visibility) = when (vis) { + Visibility.PUBLIC -> JvmVisibility.PUBLIC + Visibility.PROTECTED -> JvmVisibility.PROTECTED + Visibility.INTERNAL -> JvmVisibility.INTERNAL + Visibility.PRIVATE, Visibility.PRIVATE_TO_THIS, Visibility.LOCAL -> JvmVisibility.PRIVATE + } + + private class MetadataCollector : ClassVisitor(Opcodes.ASM9) { + var metadataHeader: Metadata? = null + var packageName = "" + + private var metadataKind: Int? = null + private var metadataVersion: IntArray? = null + private var data1: Array? = null + private var data2: Array? = null + private var extraString: String? = null + private var pn: String? = null + private var extraInt: Int? = null + + override fun visit( + version: Int, access: Int, name: String, + signature: String?, superName: String?, interfaces: Array?, + ) { + val lastSlash = name.lastIndexOf('/') + packageName = if (lastSlash >= 0) name.substring(0, lastSlash).replace('/', '.') else "" + } + + override fun visitAnnotation(descriptor: String?, visible: Boolean): AnnotationVisitor? { + if (descriptor != "Lkotlin/Metadata;") return null + + return object : AnnotationVisitor(Opcodes.ASM9) { + override fun visit(name: String?, value: Any?) { + when (name) { + "k" -> metadataKind = value as? Int + "xi" -> extraInt = value as? Int + "xs" -> extraString = value as? String + "pn" -> pn = value as? String + } + } + + override fun visitArray(name: String?): AnnotationVisitor = + object : AnnotationVisitor(Opcodes.ASM9) { + private val values = mutableListOf() + override fun visit(n: String?, value: Any?) { + value?.let { values.add(it) } + } + + override fun visitEnd() { + when (name) { + "mv" -> metadataVersion = + values.filterIsInstance().toIntArray() + + "d1" -> data1 = values.filterIsInstance().toTypedArray() + "d2" -> data2 = values.filterIsInstance().toTypedArray() + } + } + } + + override fun visitEnd() { + val kind = metadataKind ?: return + metadataHeader = Metadata( + kind = kind, + metadataVersion = metadataVersion ?: intArrayOf(), + data1 = data1 ?: emptyArray(), + data2 = data2 ?: emptyArray(), + extraString = extraString ?: "", + packageName = pn ?: "", + extraInt = extraInt ?: 0, + ) + } + } + } + } +} diff --git a/lsp/jvm-symbol-models/build.gradle.kts b/lsp/jvm-symbol-models/build.gradle.kts new file mode 100644 index 0000000000..40417fdcbf --- /dev/null +++ b/lsp/jvm-symbol-models/build.gradle.kts @@ -0,0 +1,37 @@ +import com.google.protobuf.gradle.id +import com.itsaky.androidide.plugins.conf.configureProtoc + +plugins { + id("java-library") + id("org.jetbrains.kotlin.jvm") + alias(libs.plugins.google.protobuf) +} + +configureProtoc(protobuf = protobuf, protocVersion = libs.versions.protobuf.asProvider()) + +protobuf { + plugins { + id("kotlin-ext") { + artifact = "dev.hsbrysk:protoc-gen-kotlin-ext:${libs.versions.protoc.gen.kotlin.ext.get()}:jdk8@jar" + } + } + generateProtoTasks { + all().forEach { task -> + task.plugins { + id("kotlin-ext") { + outputSubDir = "kotlin" + } + } + task.builtins { + getByName("java") { + option("lite") + } + } + } + } +} + +dependencies { + api(libs.google.protobuf.java) + api(libs.google.protobuf.kotlin) +} diff --git a/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto b/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto new file mode 100644 index 0000000000..a925e45979 --- /dev/null +++ b/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto @@ -0,0 +1,210 @@ +syntax = "proto3"; + +package org.appdevforall.codeonthego.indexing.jvm; + +option java_package = "org.appdevforall.codeonthego.indexing.jvm.proto"; +option java_outer_classname = "JvmSymbolProtos"; +option java_multiple_files = false; + +message JvmSymbolData { + + string fq_name = 1; + string short_name = 2; + string package_name = 3; + string source_id = 4; + + JvmSymbolKind kind = 5; + JvmSourceLanguage language = 6; + JvmVisibility visibility = 7; + bool is_deprecated = 8; + + oneof data { + ClassData class_data = 20; + FunctionData function_data = 21; + FieldData field_data = 22; + EnumEntryData enum_entry_data = 23; + TypeAliasData type_alias_data = 24; + } +} + +message ClassData { + + // FQN of the enclosing class (empty for top-level classes) + string containing_class_fq_name = 1; + + // Direct supertypes + repeated string supertype_fq_names = 2; + + // Type parameters: ["T", "R : Comparable"] + repeated string type_parameters = 3; + + // Modifiers + bool is_abstract = 4; + bool is_final = 5; + bool is_inner = 6; + bool is_static = 7; // static nested class in Java + + KotlinClassData kotlin = 10; +} + +message KotlinClassData { + bool is_data = 1; + bool is_value = 2; // inline/value class + bool is_sealed = 3; + bool is_fun_interface = 4; + bool is_expect = 5; + bool is_actual = 6; + bool is_external = 7; + + // Sealed subclass FQNs (only for sealed classes/interfaces) + repeated string sealed_subclasses = 8; + + // Companion object name (empty if none or uses default "Companion") + string companion_object_name = 9; +} + +message FunctionData { + + // FQN of the containing class (empty for top-level functions) + string containing_class_fq_name = 1; + + // Return type + string return_type_fq_name = 2; + string return_type_display = 3; + + // Parameters + int32 parameter_count = 4; + repeated ParameterData parameters = 5; + + // Human-readable signature: "(count: Int, sep: String): String" + string signature_display = 6; + + // Type parameters: ["T", "R : Comparable"] + repeated string type_parameters = 7; + + // Modifiers + bool is_static = 8; + bool is_abstract = 9; + bool is_final = 10; + + KotlinFunctionData kotlin = 20; +} + +message ParameterData { + string name = 1; + string type_fq_name = 2; + string type_display = 3; + bool has_default_value = 4; + + bool is_crossinline = 5; + bool is_noinline = 6; + bool is_vararg = 7; +} + +message KotlinFunctionData { + // Extension receiver type + string receiver_type_fq_name = 1; + string receiver_type_display = 2; + + // Modifiers + bool is_suspend = 3; + bool is_inline = 4; + bool is_infix = 5; + bool is_operator = 6; + bool is_tailrec = 7; + bool is_external = 8; + bool is_expect = 9; + bool is_actual = 10; + + bool is_return_type_nullable = 11; +} + +message FieldData { + + // FQN of the containing class (empty for top-level properties) + string containing_class_fq_name = 1; + + // Type of the field/property + string type_fq_name = 2; + string type_display = 3; + + // Modifiers + bool is_static = 4; + bool is_final = 5; + + // Constant value (for compile-time constants, as string repr) + string constant_value = 6; + + KotlinPropertyData kotlin = 20; +} + +message KotlinPropertyData { + // Extension receiver type + string receiver_type_fq_name = 1; + string receiver_type_display = 2; + + bool is_const = 3; + bool is_lateinit = 4; + bool has_getter = 5; + bool has_setter = 6; + bool is_delegated = 7; + bool is_expect = 8; + bool is_actual = 9; + bool is_external = 10; + + bool is_type_nullable = 11; +} + +message EnumEntryData { + // FQN of the containing enum class + string containing_enum_fq_name = 1; + + // Ordinal position + int32 ordinal = 2; +} + +message TypeAliasData { + // The type this alias expands to + string expanded_type_fq_name = 1; + string expanded_type_display = 2; + + // Type parameters: ["T"] + repeated string type_parameters = 3; +} + +enum JvmSymbolKind { + KIND_UNKNOWN = 0; + KIND_CLASS = 1; + KIND_INTERFACE = 2; + KIND_ENUM = 3; + KIND_ENUM_ENTRY = 4; + KIND_ANNOTATION_CLASS = 5; + KIND_OBJECT = 6; + KIND_COMPANION_OBJECT = 7; + KIND_DATA_CLASS = 8; + KIND_VALUE_CLASS = 9; + KIND_SEALED_CLASS = 10; + KIND_SEALED_INTERFACE = 11; + KIND_FUNCTION = 12; + KIND_EXTENSION_FUNCTION = 13; + KIND_CONSTRUCTOR = 14; + KIND_PROPERTY = 15; + KIND_EXTENSION_PROPERTY = 16; + KIND_FIELD = 17; + KIND_TYPE_ALIAS = 18; +} + +enum JvmSourceLanguage { + LANGUAGE_UNKNOWN = 0; + LANGUAGE_JAVA = 1; + LANGUAGE_KOTLIN = 2; +} + +enum JvmVisibility { + VISIBILITY_UNKNOWN = 0; + VISIBILITY_PUBLIC = 1; + VISIBILITY_PROTECTED = 2; + VISIBILITY_INTERNAL = 3; + VISIBILITY_PRIVATE = 4; + VISIBILITY_PACKAGE_PRIVATE = 5; +} diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 66f2a74f4b..54c0fa6206 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -37,6 +37,7 @@ dependencies { kapt(projects.annotationProcessors) implementation(projects.lsp.api) + implementation(projects.lsp.jvmSymbolIndex) implementation(projects.lsp.models) implementation(projects.editorApi) implementation(projects.eventbusEvents) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index 3bd433a429..bf0e4d9ddd 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -43,6 +43,7 @@ import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.FileManager +import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.utils.DocumentUtils import com.itsaky.androidide.utils.Environment @@ -55,6 +56,7 @@ import kotlinx.coroutines.cancel import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext +import org.appdevforall.codeonthego.indexing.jvm.JvmIndexingService import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -122,6 +124,11 @@ class KotlinLanguageServer : ILanguageServer { override fun setupWithProject(workspace: Workspace) { logger.info("setupWithProject called, initialized={}", initialized) + (ProjectManagerImpl.getInstance() + .indexingServiceManager + .getService(JvmIndexingService.ID) as? JvmIndexingService?) + ?.refresh() + val jdkHome = Environment.JAVA_HOME.toPath() val jdkRelease = IJdkDistributionProvider.DEFAULT_JAVA_RELEASE val intellijPluginRoot = Paths.get( diff --git a/settings.gradle.kts b/settings.gradle.kts index dfb9c6f997..6165b7a722 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -144,7 +144,10 @@ include( ":xml-inflater", ":lsp:api", ":lsp:models", + ":lsp:indexing", ":lsp:java", + ":lsp:jvm-symbol-index", + ":lsp:jvm-symbol-models", ":lsp:kotlin", ":lsp:kotlin-core", ":lsp:kotlin-stdlib-generator", diff --git a/subprojects/projects/build.gradle.kts b/subprojects/projects/build.gradle.kts index 79054954dd..9d2683ae80 100644 --- a/subprojects/projects/build.gradle.kts +++ b/subprojects/projects/build.gradle.kts @@ -23,6 +23,7 @@ dependencies { api(projects.eventbus) api(projects.eventbusEvents) + api(projects.lsp.indexing) api(projects.subprojects.projectModels) api(projects.subprojects.toolingApi) diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt index 32ab789a01..357fda6b78 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/ProjectManagerImpl.kt @@ -53,6 +53,8 @@ import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.toList import kotlinx.coroutines.withContext +import org.appdevforall.codeonthego.indexing.service.IndexingService +import org.appdevforall.codeonthego.indexing.service.IndexingServiceManager import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -75,8 +77,19 @@ import kotlin.io.path.pathString class ProjectManagerImpl : IProjectManager, EventReceiver { + + private var _indexingServiceManager: IndexingServiceManager? = null lateinit var projectPath: String + val indexingServiceManager: IndexingServiceManager + get() { + if (_indexingServiceManager == null) { + _indexingServiceManager = IndexingServiceManager() + } + + return _indexingServiceManager!! + } + @Volatile internal var pluginProjectCached: Boolean? = null @@ -89,7 +102,7 @@ class ProjectManagerImpl : override val projectDirPath: String get() = projectPath - override val projectSyncIssues: List? + override val projectSyncIssues: List get() = gradleBuild?.syncIssueList ?: emptyList() companion object { @@ -140,6 +153,10 @@ class ProjectManagerImpl : gradleBuild.syncIssueList, ) + withStopWatch("notify indexing services") { + indexingServiceManager.onProjectSynced() + } + withStopWatch("Setup project") { val indexerScope = CoroutineScope(Dispatchers.Default) val modulesFlow = @@ -232,6 +249,9 @@ class ProjectManagerImpl : this.workspace = null pluginProjectCached = null + _indexingServiceManager?.close() + _indexingServiceManager = null + (this.androidBuildVariants as? MutableMap?)?.clear() } From c35aa9ffdba547b36d4a39e2b9bdc18c9b76f046 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Apr 2026 22:08:51 +0530 Subject: [PATCH 27/29] fix: metadata version is sometimes not parsed Signed-off-by: Akash Yadav --- .../codeonthego/indexing/jvm/KotlinMetadataScanner.kt | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt index b4160f8b98..691d50dd25 100644 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt @@ -385,6 +385,11 @@ object KotlinMetadataScanner { return object : AnnotationVisitor(Opcodes.ASM9) { override fun visit(name: String?, value: Any?) { when (name) { + "mv" -> { + if (value is IntArray) { + metadataVersion = value.copyOf() + } + } "k" -> metadataKind = value as? Int "xi" -> extraInt = value as? Int "xs" -> extraString = value as? String From bede4dbe30be56554a505a425d7f5feba2ea7fc5 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 8 Apr 2026 11:36:07 +0530 Subject: [PATCH 28/29] fix: make JvmSymbolIndex exclusive to external libraries Signed-off-by: Akash Yadav --- .../indexing/jvm/JvmIndexingService.kt | 19 +++---- ...ymbolIndex.kt => JvmLibrarySymbolIndex.kt} | 57 +++++-------------- 2 files changed, 22 insertions(+), 54 deletions(-) rename lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/{JvmSymbolIndex.kt => JvmLibrarySymbolIndex.kt} (72%) diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt index a7cb834f81..a85c2c271e 100644 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt @@ -8,7 +8,6 @@ import com.itsaky.androidide.projects.models.bootClassPaths import com.itsaky.androidide.tasks.cancelIfActive import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.cancel import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock @@ -20,25 +19,23 @@ import org.greenrobot.eventbus.ThreadMode import org.slf4j.LoggerFactory import java.nio.file.Path import java.nio.file.Paths -import kotlin.io.path.exists import kotlin.io.path.extension -import kotlin.io.path.pathString /** - * Well-known key for the JVM symbol index. + * Well-known key for the JVM library symbol index. * * Both the Kotlin and Java LSPs use this key to retrieve the * shared index from the [IndexRegistry]. */ -val JVM_SYMBOL_INDEX = IndexKey("jvm-symbols") +val JVM_LIBRARY_SYMBOL_INDEX = IndexKey("jvm-library-symbols") /** * [IndexingService] that scans classpath JARs/AARs and builds - * a [JvmSymbolIndex]. + * a [JvmLibrarySymbolIndex]. * * Thread safety: all methods are called from the * [IndexingServiceManager][org.appdevforall.codeonthego.indexing.service.IndexingServiceManager]'s - * coroutine scope. The [JvmSymbolIndex] handles its own internal thread safety. + * coroutine scope. The [JvmLibrarySymbolIndex] handles its own internal thread safety. */ class JvmIndexingService( private val context: Context, @@ -51,16 +48,16 @@ class JvmIndexingService( override val id = ID - override val providedKeys = listOf(JVM_SYMBOL_INDEX) + override val providedKeys = listOf(JVM_LIBRARY_SYMBOL_INDEX) - private var index: JvmSymbolIndex? = null + private var index: JvmLibrarySymbolIndex? = null private var indexingMutex = Mutex() private val coroutineScope = CoroutineScope(Dispatchers.Default) override suspend fun initialize(registry: IndexRegistry) { - val jvmIndex = JvmSymbolIndex.create(context) + val jvmIndex = JvmLibrarySymbolIndex.create(context) this.index = jvmIndex - registry.register(JVM_SYMBOL_INDEX, jvmIndex) + registry.register(JVM_LIBRARY_SYMBOL_INDEX, jvmIndex) log.info("JVM symbol index initialized") } diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt similarity index 72% rename from lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt rename to lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt index 23bd938484..13f2df3a67 100644 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt @@ -5,32 +5,21 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.take import org.appdevforall.codeonthego.indexing.FilteredIndex -import org.appdevforall.codeonthego.indexing.InMemoryIndex -import org.appdevforall.codeonthego.indexing.MergedIndex import org.appdevforall.codeonthego.indexing.PersistentIndex import org.appdevforall.codeonthego.indexing.api.indexQuery import org.appdevforall.codeonthego.indexing.util.BackgroundIndexer import java.io.Closeable /** - * Main entry point for JVM symbol indexing. - * - * Combines a persistent index (libraries) with an in-memory index - * (source files) behind a merged view. Source symbols take priority. + * An index of symbols from external Java libraries (JARs). */ -class JvmSymbolIndex private constructor( +class JvmLibrarySymbolIndex private constructor( /** Persistent cache — stores every JAR ever indexed. */ val libraryCache: PersistentIndex, /** Filtered view — only shows JARs on the current classpath. */ val libraryView: FilteredIndex, - /** In-memory index for source file symbols. */ - val sourceIndex: InMemoryIndex, - - /** Merged view: source (priority) + active libraries. */ - val merged: MergedIndex, - /** Background indexer writing to the cache. */ val libraryIndexer: BackgroundIndexer, ) : Closeable { @@ -39,12 +28,11 @@ class JvmSymbolIndex private constructor( const val DB_NAME_DEFAULT = "jvm_symbol_index.db" const val INDEX_NAME_LIBRARY = "jvm-library-cache" - const val INDEX_NAME_SOURCES = "jvm-sources" fun create( context: Context, dbName: String = DB_NAME_DEFAULT, - ): JvmSymbolIndex { + ): JvmLibrarySymbolIndex { val cache = PersistentIndex( descriptor = JvmSymbolDescriptor, context = context, @@ -54,19 +42,10 @@ class JvmSymbolIndex private constructor( val view = FilteredIndex(cache) - val sources = InMemoryIndex( - descriptor = JvmSymbolDescriptor, - name = INDEX_NAME_SOURCES, - ) - - // Sources win over libraries - val merged = MergedIndex(sources, view) val indexer = BackgroundIndexer(cache) - return JvmSymbolIndex( + return JvmLibrarySymbolIndex( libraryCache = cache, libraryView = view, - sourceIndex = sources, - merged = merged, libraryIndexer = indexer ) } @@ -127,28 +106,19 @@ class JvmSymbolIndex private constructor( provider: (sourceId: String) -> Flow, ) = libraryIndexer.indexSource(sourceId, skipIfExists = false, provider) - suspend fun updateSourceFile(sourceId: String, symbols: Sequence) { - sourceIndex.removeBySource(sourceId) - sourceIndex.insertAll(symbols) - } - - suspend fun removeSourceFile(sourceId: String) { - sourceIndex.removeBySource(sourceId) - } - fun findByPrefix(prefix: String, limit: Int = 200): Flow = - merged.query(indexQuery { prefix("name", prefix); this.limit = limit }) + libraryView.query(indexQuery { prefix("name", prefix); this.limit = limit }) fun findByPrefix( prefix: String, kinds: Set, limit: Int = 200, ): Flow = - merged.query(indexQuery { prefix("name", prefix); this.limit = 0 }) + libraryView.query(indexQuery { prefix("name", prefix); this.limit = 0 }) .filter { it.kind in kinds } .take(limit) fun findExtensionsFor( receiverTypeFqName: String, namePrefix: String = "", limit: Int = 200, - ): Flow = merged.query(indexQuery { + ): Flow = libraryView.query(indexQuery { eq("receiverType", receiverTypeFqName) if (namePrefix.isNotEmpty()) prefix("name", namePrefix) this.limit = limit @@ -156,7 +126,7 @@ class JvmSymbolIndex private constructor( fun findTopLevelCallablesInPackage( packageName: String, namePrefix: String = "", limit: Int = 200, - ): Flow = merged.query(indexQuery { + ): Flow = libraryView.query(indexQuery { eq("package", packageName) if (namePrefix.isNotEmpty()) prefix("name", namePrefix) this.limit = 0 @@ -164,7 +134,7 @@ class JvmSymbolIndex private constructor( fun findClassifiersInPackage( packageName: String, namePrefix: String = "", limit: Int = 200, - ): Flow = merged.query(indexQuery { + ): Flow = libraryView.query(indexQuery { eq("package", packageName) if (namePrefix.isNotEmpty()) prefix("name", namePrefix) this.limit = 0 @@ -172,20 +142,21 @@ class JvmSymbolIndex private constructor( fun findMembersOf( classFqName: String, namePrefix: String = "", limit: Int = 200, - ): Flow = merged.query(indexQuery { + ): Flow = libraryView.query(indexQuery { eq("containingClass", classFqName) if (namePrefix.isNotEmpty()) prefix("name", namePrefix) this.limit = limit }) - suspend fun findByFqName(fqName: String): JvmSymbol? = merged.get(fqName) + suspend fun findByFqName(fqName: String): JvmSymbol? = libraryView.get(fqName) - fun allPackages(): Flow = merged.distinctValues("package") + fun allPackages(): Flow = libraryView.distinctValues("package") suspend fun awaitLibraryIndexing() = libraryIndexer.awaitAll() override fun close() { + libraryCache.close() libraryIndexer.close() - merged.close() + libraryView.close() } } \ No newline at end of file From 9eb4ffebe85c812d90983ff074471100b7798d85 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 8 Apr 2026 16:52:54 +0530 Subject: [PATCH 29/29] feat: add module resolver to resolve library modules from source path Signed-off-by: Akash Yadav --- .../language/treesitter/TreeSitterLanguage.kt | 312 +++++++++--------- .../indexing/jvm/JvmLibrarySymbolIndex.kt | 26 +- .../indexing/jvm/JvmSymbolDescriptor.kt | 33 +- .../kotlin/compiler/CompilationEnvironment.kt | 10 +- .../lsp/kotlin/compiler/Compiler.kt | 4 +- .../lsp/kotlin/compiler/KotlinProjectModel.kt | 57 +++- .../lsp/kotlin/compiler/ModuleResolver.kt | 25 ++ .../kotlin/completion/KotlinCompletions.kt | 12 +- .../completion/SymbolVisibilityChecker.kt | 87 +++++ .../diagnostic/KotlinDiagnosticProvider.kt | 3 +- 10 files changed, 367 insertions(+), 202 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ModuleResolver.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/SymbolVisibilityChecker.kt diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguage.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguage.kt index 4c7d677fc3..bd641f3a64 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguage.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguage.kt @@ -27,6 +27,7 @@ import com.itsaky.androidide.editor.schemes.LanguageSpecProvider.getLanguageSpec import com.itsaky.androidide.editor.schemes.LocalCaptureSpecProvider.newLocalCaptureSpec import com.itsaky.androidide.editor.utils.isNonBlankLine import com.itsaky.androidide.treesitter.TSLanguage +import com.itsaky.androidide.treesitter.TreeSitter import com.itsaky.androidide.utils.IntPair import io.github.rosemoe.sora.editor.ts.TsTheme import io.github.rosemoe.sora.lang.Language.INTERRUPTION_LEVEL_STRONG @@ -42,156 +43,167 @@ import java.io.File * @author Akash Yadav */ abstract class TreeSitterLanguage( - context: Context, - lang: TSLanguage, - private val langType: String + context: Context, + lang: TSLanguage, + private val langType: String ) : IDELanguage() { - private var languageSpec = - getLanguageSpec(context, langType, lang, newLocalCaptureSpec(langType)) - private var tsTheme = TsTheme(languageSpec.spec.tsQuery) - private lateinit var _indentProvider: TreeSitterIndentProvider - private val analyzer by lazy { TreeSitterAnalyzeManager(languageSpec.spec, tsTheme) } - private val newlineHandlersLazy by lazy { createNewlineHandlers() } - - private var languageScheme: LanguageScheme? = null - - private val indentProvider: TreeSitterIndentProvider - get() { - if (!this::_indentProvider.isInitialized) { - this._indentProvider = TreeSitterIndentProvider( - languageSpec, - analyzer.analyzeWorker!!, - getTabSize() - ) - } - - return _indentProvider - } - - companion object { - - private val log = LoggerFactory.getLogger(TreeSitterLanguage::class.java) - private const val DEF_IDENT_ADV = 0 - } - - fun setupWith(scheme: IDEColorScheme?) { - val langScheme = scheme?.languages?.get(langType) - this.languageScheme = langScheme - this.analyzer.langScheme = languageScheme - langScheme?.styles?.forEach { tsTheme.putStyleRule(it.key, it.value.makeStyle()) } - } - - override fun addBreakpoint(line: Int) { - this.analyzer.addBreakpoint(line) - } - - override fun removeBreakpoint(line: Int) { - this.analyzer.removeBreakpoint(line) - } - - override fun removeAllBreakpoints() { - this.analyzer.removeAllBreakpoints() - } - - override fun toggleBreakpoint(line: Int) { - this.analyzer.toggleBreakpoint(line) - } - - override fun highlightLine(line: Int) { - this.analyzer.highlightLine(line) - } - - override fun unhighlightLines() { - this.analyzer.unhighlightLines() - } - - override fun getAnalyzeManager(): AnalyzeManager { - return this.analyzer - } - - override fun getSymbolPairs(): SymbolPairMatch { - return CommonSymbolPairs() - } - - open fun createNewlineHandlers(): Array { - return emptyArray() - } - - override fun getNewlineHandlers(): Array { - return newlineHandlersLazy - } - - override fun getInterruptionLevel(): Int { - return INTERRUPTION_LEVEL_STRONG - } - - override fun getIndentAdvance( - content: ContentReference, - line: Int, - column: Int, - spaceCountOnLine: Int, - tabCountOnLine: Int - ): Int { - return try { - if (line == content.reference.lineCount - 1) { - // line + 1 does not exist - // TODO(itsaky): Update this implementation when this behavior is fixed in sora-editor - return DEF_IDENT_ADV - } - - var linesToReq = LongArray(1) - linesToReq[0] = IntPair.pack(line, column) - - if (content.reference.isNonBlankLine(line + 1)) { - // consider the indentation of the next line only if it is non-blank - linesToReq += IntPair.pack(line + 1, 0) - } - - val indents = this.indentProvider.getIndentsForLines( - content = content.reference, - positions = linesToReq, - ) - - if (indents.size == 1) { - val indent = indents[0] - if (indent == TreeSitterIndentProvider.INDENTATION_ERR) { - return DEF_IDENT_ADV - } - - return indent - (spaceCountOnLine + (tabCountOnLine * getTabSize())) - } - - val (indentLine, indentNxtLine) = indents - if (indentLine == TreeSitterIndentProvider.INDENTATION_ERR - || indentNxtLine == TreeSitterIndentProvider.INDENTATION_ERR) { - log.debug( - "expectedIndent[{}]={}, expectedIndentNextLine[{}]={}, returning default indent advance", - line, indentLine, line + 1, indentNxtLine) - return DEF_IDENT_ADV - } - - return indentNxtLine - indentLine - } catch (e: Exception) { - log.error("An error occurred computing indentation at line:column::{}:{}", line, column, e) - DEF_IDENT_ADV - } - - } - - override fun destroy() { - this.languageSpec.close() - this.languageScheme = null - } - - /** A [Factory] creates instance of a specific [TreeSitterLanguage] implementation. */ - fun interface Factory { - - /** - * Create the instance of the [TreeSitterLanguage] implementation. - * - * @param context The current context. - */ - fun create(context: Context): T - } + private var languageSpec = + getLanguageSpec(context, langType, lang, newLocalCaptureSpec(langType)) + private var tsTheme = TsTheme(languageSpec.spec.tsQuery) + private lateinit var _indentProvider: TreeSitterIndentProvider + private val analyzer by lazy { TreeSitterAnalyzeManager(languageSpec.spec, tsTheme) } + private val newlineHandlersLazy by lazy { createNewlineHandlers() } + + private var languageScheme: LanguageScheme? = null + + private val indentProvider: TreeSitterIndentProvider + get() { + if (!this::_indentProvider.isInitialized) { + this._indentProvider = TreeSitterIndentProvider( + languageSpec, + analyzer.analyzeWorker!!, + getTabSize() + ) + } + + return _indentProvider + } + + companion object { + + init { + TreeSitter.loadLibrary() + } + + private val log = LoggerFactory.getLogger(TreeSitterLanguage::class.java) + private const val DEF_IDENT_ADV = 0 + } + + fun setupWith(scheme: IDEColorScheme?) { + val langScheme = scheme?.languages?.get(langType) + this.languageScheme = langScheme + this.analyzer.langScheme = languageScheme + langScheme?.styles?.forEach { tsTheme.putStyleRule(it.key, it.value.makeStyle()) } + } + + override fun addBreakpoint(line: Int) { + this.analyzer.addBreakpoint(line) + } + + override fun removeBreakpoint(line: Int) { + this.analyzer.removeBreakpoint(line) + } + + override fun removeAllBreakpoints() { + this.analyzer.removeAllBreakpoints() + } + + override fun toggleBreakpoint(line: Int) { + this.analyzer.toggleBreakpoint(line) + } + + override fun highlightLine(line: Int) { + this.analyzer.highlightLine(line) + } + + override fun unhighlightLines() { + this.analyzer.unhighlightLines() + } + + override fun getAnalyzeManager(): AnalyzeManager { + return this.analyzer + } + + override fun getSymbolPairs(): SymbolPairMatch { + return CommonSymbolPairs() + } + + open fun createNewlineHandlers(): Array { + return emptyArray() + } + + override fun getNewlineHandlers(): Array { + return newlineHandlersLazy + } + + override fun getInterruptionLevel(): Int { + return INTERRUPTION_LEVEL_STRONG + } + + override fun getIndentAdvance( + content: ContentReference, + line: Int, + column: Int, + spaceCountOnLine: Int, + tabCountOnLine: Int + ): Int { + return try { + if (line == content.reference.lineCount - 1) { + // line + 1 does not exist + // TODO(itsaky): Update this implementation when this behavior is fixed in sora-editor + return DEF_IDENT_ADV + } + + var linesToReq = LongArray(1) + linesToReq[0] = IntPair.pack(line, column) + + if (content.reference.isNonBlankLine(line + 1)) { + // consider the indentation of the next line only if it is non-blank + linesToReq += IntPair.pack(line + 1, 0) + } + + val indents = this.indentProvider.getIndentsForLines( + content = content.reference, + positions = linesToReq, + ) + + if (indents.size == 1) { + val indent = indents[0] + if (indent == TreeSitterIndentProvider.INDENTATION_ERR) { + return DEF_IDENT_ADV + } + + return indent - (spaceCountOnLine + (tabCountOnLine * getTabSize())) + } + + val (indentLine, indentNxtLine) = indents + if (indentLine == TreeSitterIndentProvider.INDENTATION_ERR + || indentNxtLine == TreeSitterIndentProvider.INDENTATION_ERR + ) { + log.debug( + "expectedIndent[{}]={}, expectedIndentNextLine[{}]={}, returning default indent advance", + line, indentLine, line + 1, indentNxtLine + ) + return DEF_IDENT_ADV + } + + return indentNxtLine - indentLine + } catch (e: Exception) { + log.error( + "An error occurred computing indentation at line:column::{}:{}", + line, + column, + e + ) + DEF_IDENT_ADV + } + + } + + override fun destroy() { + this.languageSpec.close() + this.languageScheme = null + } + + /** A [Factory] creates instance of a specific [TreeSitterLanguage] implementation. */ + fun interface Factory { + + /** + * Create the instance of the [TreeSitterLanguage] implementation. + * + * @param context The current context. + */ + fun create(context: Context): T + } } diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt index 13f2df3a67..ec52e5d633 100644 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt @@ -7,6 +7,10 @@ import kotlinx.coroutines.flow.take import org.appdevforall.codeonthego.indexing.FilteredIndex import org.appdevforall.codeonthego.indexing.PersistentIndex import org.appdevforall.codeonthego.indexing.api.indexQuery +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolDescriptor.KEY_CONTAINING_CLASS +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolDescriptor.KEY_NAME +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolDescriptor.KEY_PACKAGE +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolDescriptor.KEY_RECEIVER_TYPE import org.appdevforall.codeonthego.indexing.util.BackgroundIndexer import java.io.Closeable @@ -107,50 +111,50 @@ class JvmLibrarySymbolIndex private constructor( ) = libraryIndexer.indexSource(sourceId, skipIfExists = false, provider) fun findByPrefix(prefix: String, limit: Int = 200): Flow = - libraryView.query(indexQuery { prefix("name", prefix); this.limit = limit }) + libraryView.query(indexQuery { prefix(KEY_NAME, prefix); this.limit = limit }) fun findByPrefix( prefix: String, kinds: Set, limit: Int = 200, ): Flow = - libraryView.query(indexQuery { prefix("name", prefix); this.limit = 0 }) + libraryView.query(indexQuery { prefix(KEY_NAME, prefix); this.limit = 0 }) .filter { it.kind in kinds } .take(limit) fun findExtensionsFor( receiverTypeFqName: String, namePrefix: String = "", limit: Int = 200, ): Flow = libraryView.query(indexQuery { - eq("receiverType", receiverTypeFqName) - if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + eq(KEY_RECEIVER_TYPE, receiverTypeFqName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) this.limit = limit }) fun findTopLevelCallablesInPackage( packageName: String, namePrefix: String = "", limit: Int = 200, ): Flow = libraryView.query(indexQuery { - eq("package", packageName) - if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + eq(KEY_PACKAGE, packageName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) this.limit = 0 }).filter { it.kind.isCallable && it.isTopLevel }.take(limit) fun findClassifiersInPackage( packageName: String, namePrefix: String = "", limit: Int = 200, ): Flow = libraryView.query(indexQuery { - eq("package", packageName) - if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + eq(KEY_PACKAGE, packageName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) this.limit = 0 }).filter { it.kind.isClassifier }.take(limit) fun findMembersOf( classFqName: String, namePrefix: String = "", limit: Int = 200, ): Flow = libraryView.query(indexQuery { - eq("containingClass", classFqName) - if (namePrefix.isNotEmpty()) prefix("name", namePrefix) + eq(KEY_CONTAINING_CLASS, classFqName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) this.limit = limit }) suspend fun findByFqName(fqName: String): JvmSymbol? = libraryView.get(fqName) - fun allPackages(): Flow = libraryView.distinctValues("package") + fun allPackages(): Flow = libraryView.distinctValues(KEY_PACKAGE) suspend fun awaitLibraryIndexing() = libraryIndexer.awaitAll() diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt index 949240eafd..4d34d1b55d 100644 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt @@ -16,28 +16,35 @@ import org.appdevforall.codeonthego.indexing.jvm.proto.JvmSymbolProtos.JvmSymbol * - `containingClass`: exact, for member lookup * - `language` : exact, for Java-only or Kotlin-only queries * - * Blob serialization uses Protobuf with oneof for type-specific data. + * Blob serialization uses Protobuf with `oneof` for type-specific data. */ object JvmSymbolDescriptor : IndexDescriptor { + const val KEY_NAME = "name" + const val KEY_PACKAGE = "package" + const val KEY_KIND = "kind" + const val KEY_RECEIVER_TYPE = "receiverType" + const val KEY_CONTAINING_CLASS = "containingClass" + const val KEY_LANGUAGE = "language" + override val name: String = "jvm_symbols" override val fields: List = listOf( - IndexField(name = "name", prefixSearchable = true), - IndexField(name = "package"), - IndexField(name = "kind"), - IndexField(name = "receiverType"), - IndexField(name = "containingClass"), - IndexField(name = "language"), + IndexField(name = KEY_NAME, prefixSearchable = true), + IndexField(name = KEY_PACKAGE), + IndexField(name = KEY_KIND), + IndexField(name = KEY_RECEIVER_TYPE), + IndexField(name = KEY_CONTAINING_CLASS), + IndexField(name = KEY_LANGUAGE), ) override fun fieldValues(entry: JvmSymbol): Map = mapOf( - "name" to entry.shortName, - "package" to entry.packageName, - "kind" to entry.kind.name, - "receiverType" to entry.receiverTypeFqName, - "containingClass" to entry.containingClassFqName.ifEmpty { null }, - "language" to entry.language.name, + KEY_NAME to entry.shortName, + KEY_PACKAGE to entry.packageName, + KEY_KIND to entry.kind.name, + KEY_RECEIVER_TYPE to entry.receiverTypeFqName, + KEY_CONTAINING_CLASS to entry.containingClassFqName.ifEmpty { null }, + KEY_LANGUAGE to entry.language.name, ) override fun serialize(entry: JvmSymbol): ByteArray = diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index f9b20ebc3a..6bb19535d9 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -1,7 +1,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler -import com.itsaky.androidide.lsp.kotlin.FileEventConsumer import com.itsaky.androidide.lsp.kotlin.KtFileManager +import com.itsaky.androidide.lsp.kotlin.completion.SymbolVisibilityChecker import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolverFactory import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProviderFactory @@ -51,7 +51,7 @@ import kotlin.io.path.pathString * @param jdkHome Path to the JDK installation directory. * @param jdkRelease The JDK release version at [jdkHome]. */ -class CompilationEnvironment( +internal class CompilationEnvironment( val projectModel: KotlinProjectModel, val intellijPluginRoot: Path, val jdkHome: Path, @@ -82,6 +82,12 @@ class CompilationEnvironment( val coreApplicationEnvironment: CoreApplicationEnvironment get() = session.coreApplicationEnvironment + val moduleResolver: ModuleResolver? + get() = projectModel.moduleResolver + + val symbolVisibilityChecker: SymbolVisibilityChecker? + get() = projectModel.symbolVisibilityChecker + private val envMessageCollector = object : MessageCollector { override fun clear() { } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index a02e6ebe44..48bde185b6 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -1,6 +1,5 @@ package com.itsaky.androidide.lsp.kotlin.compiler -import com.itsaky.androidide.lsp.kotlin.FileEventConsumer import com.itsaky.androidide.utils.DocumentUtils import org.jetbrains.kotlin.com.intellij.lang.Language import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems @@ -15,10 +14,9 @@ import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path import java.nio.file.Paths -import kotlin.io.path.extension import kotlin.io.path.pathString -class Compiler( +internal class Compiler( projectModel: KotlinProjectModel, intellijPluginRoot: Path, jdkHome: Path, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt index e78b8646c1..06bd635704 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt @@ -1,17 +1,20 @@ package com.itsaky.androidide.lsp.kotlin.compiler +import com.itsaky.androidide.lsp.kotlin.completion.SymbolVisibilityChecker import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.api.ModuleProject import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.models.bootClassPaths +import org.jetbrains.kotlin.analysis.api.projectStructure.KaLibraryModule import org.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule import org.jetbrains.kotlin.analysis.project.structure.builder.KtModuleProviderBuilder import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtLibraryModule import org.jetbrains.kotlin.analysis.project.structure.builder.buildKtSourceModule -import org.jetbrains.kotlin.config.LanguageVersion import org.jetbrains.kotlin.platform.TargetPlatform import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.slf4j.LoggerFactory +import java.nio.file.Path +import kotlin.io.path.nameWithoutExtension /** * Holds the project structure derived from a [Workspace]. @@ -24,15 +27,23 @@ import org.slf4j.LoggerFactory * (build complete), it notifies registered listeners so they can * refresh their sessions. */ -class KotlinProjectModel { +internal class KotlinProjectModel { private val logger = LoggerFactory.getLogger(KotlinProjectModel::class.java) private var workspace: Workspace? = null private var platform: TargetPlatform = JvmPlatforms.defaultJvmPlatform + private var _moduleResolver: ModuleResolver? = null + private var _symbolVisibilityChecker: SymbolVisibilityChecker? = null private val listeners = mutableListOf() + val moduleResolver: ModuleResolver? + get() = _moduleResolver + + val symbolVisibilityChecker: SymbolVisibilityChecker? + get() = _symbolVisibilityChecker + /** * The kind of change that occurred. */ @@ -93,49 +104,55 @@ class KotlinProjectModel { this.platform = this@KotlinProjectModel.platform val moduleProjects = workspace.subProjects + .asSequence() .filterIsInstance() .filter { it.path != workspace.rootProject.path } + val jarToModMap = mutableMapOf() + + fun addLibrary(path: Path): KaLibraryModule { + val module = addModule(buildKtLibraryModule { + this.platform = this@KotlinProjectModel.platform + this.libraryName = path.nameWithoutExtension + addBinaryRoot(path) + }) + + jarToModMap[path] = module + return module + } + val bootClassPaths = moduleProjects .filterIsInstance() .flatMap { project -> project.bootClassPaths + .asSequence() .filter { it.exists() } - .map { bootClassPath -> - addModule(buildKtLibraryModule { - this.platform = this@KotlinProjectModel.platform - this.libraryName = bootClassPath.nameWithoutExtension - addBinaryRoot(bootClassPath.toPath()) - }) - } + .map { it.toPath() } + .map(::addLibrary) } val libraryDependencies = moduleProjects .flatMap { it.getCompileClasspaths() } .filter { it.exists() } - .associateWith { library -> - addModule(buildKtLibraryModule { - this.platform = this@KotlinProjectModel.platform - this.libraryName = library.nameWithoutExtension - addBinaryRoot(library.toPath()) - }) - } + .map { it.toPath() } + .associateWith(::addLibrary) val subprojectsAsModules = mutableMapOf() fun getOrCreateModule(project: ModuleProject): KaSourceModule { subprojectsAsModules[project]?.let { return it } + val sourceRoots = project.getSourceDirectories().map { it.toPath() } val module = buildKtSourceModule { this.platform = this@KotlinProjectModel.platform this.moduleName = project.name - addSourceRoots(project.getSourceDirectories().map { it.toPath() }) + addSourceRoots(sourceRoots) bootClassPaths.forEach { addRegularDependency(it) } project.getCompileClasspaths(excludeSourceGeneratedClassPath = true) .forEach { classpath -> - val libDep = libraryDependencies[classpath] + val libDep = libraryDependencies[classpath.toPath()] if (libDep == null) { logger.error( "Skipping non-existent classpath classpath: {}", @@ -156,6 +173,10 @@ class KotlinProjectModel { } moduleProjects.forEach { addModule(getOrCreateModule(it)) } + + val moduleResolver = ModuleResolver(jarMap = jarToModMap) + _moduleResolver = moduleResolver + _symbolVisibilityChecker = SymbolVisibilityChecker(moduleResolver) } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ModuleResolver.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ModuleResolver.kt new file mode 100644 index 0000000000..704d02978a --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ModuleResolver.kt @@ -0,0 +1,25 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import org.jetbrains.kotlin.analysis.api.projectStructure.KaLibraryModule +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.slf4j.LoggerFactory +import java.nio.file.Path +import java.nio.file.Paths + +internal class ModuleResolver( + private val jarMap: Map, +) { + companion object { + private val logger = LoggerFactory.getLogger(ModuleResolver::class.java) + } + + /** + * Find the module that declares the given source ID (JAR, source file, etc.) + */ + fun findDeclaringModule(sourceId: String): KaModule? { + val path = Paths.get(sourceId) + jarMap[path]?.let { return it } + + return null + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt index 51613960b2..ca003ee1c8 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -38,12 +38,10 @@ import org.jetbrains.kotlin.analysis.api.types.KaClassType import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.name.Name -import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtQualifiedExpression import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression import org.jetbrains.kotlin.psi.psiUtil.getParentOfType -import org.jetbrains.kotlin.psi.psiUtil.startOffset import org.jetbrains.kotlin.types.Variance import org.slf4j.LoggerFactory @@ -58,7 +56,7 @@ private val logger = LoggerFactory.getLogger("KotlinCompletions") * @param params The completion parameters. * @return The completion result. */ -fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult { +internal fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult { val managedFile = fileManager.getOpenFile(params.file) if (managedFile == null) { logger.warn("No managed file for {}", params.file) @@ -91,6 +89,12 @@ fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult useSiteElement = completionKtFile, resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, ) { + val symbolVisibilityChecker = this@complete.symbolVisibilityChecker + if (symbolVisibilityChecker == null) { + logger.error("No symbol visibility checker available!") + return@analyzeCopy CompletionResult.EMPTY + } + val cursorContext = resolveCursorContext(completionKtFile, completionOffset) if (cursorContext == null) { logger.error( @@ -117,6 +121,7 @@ fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult collectScopeCompletions( scopeContext = scopeContext, scope = compositeScope, + symbolVisibilityChecker = symbolVisibilityChecker, ktElement = ktElement, partial = partial, to = items @@ -240,6 +245,7 @@ private fun KaSession.collectExtensionFunctions( private fun KaSession.collectScopeCompletions( scopeContext: KaScopeContext, scope: KaScope, + symbolVisibilityChecker: SymbolVisibilityChecker, ktElement: KtElement, partial: String, to: MutableList, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/SymbolVisibilityChecker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/SymbolVisibilityChecker.kt new file mode 100644 index 0000000000..010b187e41 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/SymbolVisibilityChecker.kt @@ -0,0 +1,87 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.lsp.kotlin.compiler.ModuleResolver +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol +import org.appdevforall.codeonthego.indexing.jvm.JvmVisibility +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.jetbrains.kotlin.analysis.api.projectStructure.allDirectDependencies +import java.util.concurrent.ConcurrentHashMap + +internal class SymbolVisibilityChecker( + private val moduleResolver: ModuleResolver, +) { + // visibility check cache, for memoization + // useSiteModule -> list of modules visible from useSiteModule + private val moduleVisibilityCache = ConcurrentHashMap>() + + fun isVisible( + symbol: JvmSymbol, + useSiteModule: KaModule, + useSitePackage: String? = null, + ): Boolean { + val declaringModule = moduleResolver.findDeclaringModule(symbol.sourceId) + ?: return false + + if (!isReachable(useSiteModule, declaringModule)) return false + if (!arePlatformCompatible(useSiteModule, declaringModule)) return false + if (!isDeclarationVisible(symbol, useSiteModule, declaringModule, useSitePackage)) return false + + return true + } + + fun isReachable(useSiteModule: KaModule, declaringModule: KaModule): Boolean { + if (useSiteModule == declaringModule) return true + if (moduleVisibilityCache[useSiteModule]?.contains(declaringModule) == true) return true + + // walk the dependency graph + val visited = mutableSetOf() + val queue = ArrayDeque() + queue.add(useSiteModule) + + while (queue.isNotEmpty()) { + val current = queue.removeFirst() + if (!visited.add(current)) continue + if (current == declaringModule) return true + + queue.addAll(current.allDirectDependencies()) + } + + return false + } + + fun arePlatformCompatible(useSiteModule: KaModule, declaringModule: KaModule): Boolean { + val usePlatform = useSiteModule.targetPlatform + val declPlatform = declaringModule.targetPlatform + + // the declaring platform must be a superset of, or equal to the use + // site platform + return declPlatform.componentPlatforms.all { declComp -> + usePlatform.componentPlatforms.any { useComp -> + useComp == declComp || useComp.platformName == declComp.platformName + } + } + } + + fun isDeclarationVisible( + symbol: JvmSymbol, + useSiteModule: KaModule, + declaringModule: KaModule, + useSitePackage: String? = null, + ): Boolean { + val isSamePackage = useSitePackage != null && useSitePackage == symbol.packageName + + // TODO(itsaky): this should check whether the use-site element + // is contained in a class that is a descendant of the + // class declaring the given symbol. + // For now, we assume true in all cases. + val isDescendant = true + + return when (symbol.visibility) { + JvmVisibility.PUBLIC -> true + JvmVisibility.PRIVATE -> false + JvmVisibility.INTERNAL -> useSiteModule == declaringModule + JvmVisibility.PROTECTED -> isSamePackage || isDescendant + JvmVisibility.PACKAGE_PRIVATE -> isSamePackage + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt index ac2c38672f..4472c1a652 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -9,7 +9,6 @@ import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.FileManager import kotlinx.coroutines.CancellationException import org.jetbrains.kotlin.analysis.api.KaExperimentalApi -import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter import org.jetbrains.kotlin.analysis.api.diagnostics.KaDiagnosticWithPsi import org.jetbrains.kotlin.analysis.api.diagnostics.KaSeverity @@ -23,7 +22,7 @@ import kotlin.time.toKotlinInstant private val logger = LoggerFactory.getLogger("KotlinDiagnosticProvider") -fun CompilationEnvironment.collectDiagnosticsFor(file: Path): DiagnosticResult = try { +internal fun CompilationEnvironment.collectDiagnosticsFor(file: Path): DiagnosticResult = try { logger.info("Analyzing file: {}", file) return doAnalyze(file) } catch (err: Throwable) {