From 1b069cb95eb38a7a277d7023ca999ef754a5c007 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 19 Mar 2026 22:07:34 +0530 Subject: [PATCH 01/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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/49] 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) { From 1fdd5b349a8cbac886aff587fde9ee4ce663a985 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 8 Apr 2026 19:26:31 +0530 Subject: [PATCH 30/49] feat: add support for completing non-imported symbols Signed-off-by: Akash Yadav --- .../editor/adapters/CompletionListAdapter.kt | 348 +++++++++--------- .../language/CommonCompletionProvider.kt | 96 ++--- .../androidide/editor/language/IDELanguage.kt | 184 ++++----- .../lsp/kotlin/KotlinLanguageServer.kt | 8 +- .../kotlin/compiler/CompilationEnvironment.kt | 13 +- .../lsp/kotlin/compiler/Compiler.kt | 2 +- .../lsp/kotlin/compiler/KotlinProjectModel.kt | 9 + .../kotlin/completion/KotlinCompletions.kt | 161 ++++++-- .../lsp/edits/DefaultEditHandler.kt | 211 ++++++----- 9 files changed, 583 insertions(+), 449 deletions(-) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/adapters/CompletionListAdapter.kt b/editor/src/main/java/com/itsaky/androidide/editor/adapters/CompletionListAdapter.kt index a43471c8cc..67b3bfe11f 100755 --- a/editor/src/main/java/com/itsaky/androidide/editor/adapters/CompletionListAdapter.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/adapters/CompletionListAdapter.kt @@ -25,6 +25,7 @@ import android.view.LayoutInflater import android.view.View import android.view.ViewGroup import android.widget.TextView +import androidx.core.view.isVisible import com.itsaky.androidide.editor.R import com.itsaky.androidide.editor.databinding.LayoutCompletionItemBinding import com.itsaky.androidide.lookup.Lookup @@ -55,177 +56,178 @@ import com.itsaky.androidide.lsp.models.CompletionItem as LspCompletionItem class CompletionListAdapter : EditorCompletionAdapter() { - override fun getItemHeight(): Int { - return TypedValue.applyDimension( - TypedValue.COMPLEX_UNIT_DIP, - 40f, - Resources.getSystem().displayMetrics - ) - .toInt() - } - - override fun getView( - position: Int, - convertView: View?, - parent: ViewGroup?, - isCurrentCursorPosition: Boolean, - ): View { - val binding = - convertView?.let { LayoutCompletionItemBinding.bind(it) } - ?: LayoutCompletionItemBinding.inflate(LayoutInflater.from(context), parent, false) - val item = getItem(position) as LspCompletionItem - val label = item.ideLabel - val desc = item.detail - var type: String? = item.completionKind.toString() - val header = if (type!!.isEmpty()) "O" else type[0].toString() - if (item.overrideTypeText != null) { - type = item.overrideTypeText - } - binding.completionIconText.text = header - binding.completionLabel.text = label - binding.completionType.text = type - binding.completionDetail.text = desc - binding.completionIconText.setTypeface(customOrJBMono(EditorPreferences.useCustomFont), - Typeface.BOLD) - if (desc.isEmpty()) { - binding.completionDetail.visibility = View.GONE - } - - binding.completionApiInfo.visibility = View.GONE - - applyColorScheme(binding, isCurrentCursorPosition) - showApiInfoIfNeeded(item, binding.completionApiInfo) - return binding.root - } - - private fun applyColorScheme(binding: LayoutCompletionItemBinding, isCurrent: Boolean) { - setItemBackground(binding, isCurrent) - var color = getThemeColor(COMPLETION_WND_TEXT_LABEL) - if (color != 0) { - binding.completionLabel.setTextColor(color) - binding.completionIconText.setTextColor(color) - } - - color = getThemeColor(COMPLETION_WND_TEXT_DETAIL) - if (color != 0) { - binding.completionDetail.setTextColor(color) - } - - color = getThemeColor(COMPLETION_WND_TEXT_API) - if (color != 0) { - binding.completionApiInfo.setTextColor(color) - } - - color = getThemeColor(COMPLETION_WND_TEXT_TYPE) - if (color != 0) { - binding.completionType.setTextColor(color) - } - } - - private fun setItemBackground(binding: LayoutCompletionItemBinding, isCurrent: Boolean) { - val color = - if (isCurrent) getThemeColor(SchemeAndroidIDE.COMPLETION_WND_BG_CURRENT_ITEM) - else 0 - - val cornerRadius = binding.root.context.resources - .getDimensionPixelSize(R.dimen.completion_window_corner_radius).toFloat() - - val gd = GradientDrawable().apply { - setColor(color) - setCornerRadius(cornerRadius) - } - - binding.root.background = gd - } - - private fun showApiInfoIfNeeded(item: LspCompletionItem, textView: TextView) { - executeAsync({ - if (!isValidForApiVersion(item)) { - return@executeAsync null - } - - val data = item.data - val versions = - Lookup.getDefault().lookup(ApiVersions.COMPLETION_LOOKUP_KEY) ?: return@executeAsync null - val className = - when (data) { - is ClassCompletionData -> data.className - is MemberCompletionData -> data.classInfo.className - else -> return@executeAsync null - } - val kind = item.completionKind - - val clazz = versions.getClass(className) ?: return@executeAsync null - var info: Info? = clazz - - if (data is MethodCompletionData) { - if ( - kind == METHOD && data.erasedParameterTypes.isNotEmpty() && data.memberName.isNotBlank() - ) { - val method = clazz.getMethod(data.memberName, *data.erasedParameterTypes.toTypedArray()) - if (method != null) { - info = method - } - } else if (kind == FIELD && data.memberName.isNotBlank()) { - val field = clazz.getField(data.memberName) - if (field != null) { - info = field - } - } - } - val sb = StringBuilder() - if (info!!.since > 1) { - sb.append(textView.context.getString(msg_api_info_since, info.since)) - sb.append("\n") - } - - if (info.removed > 0) { - sb.append(textView.context.getString(msg_api_info_removed, info.removed)) - sb.append("\n") - } - - if (info.deprecated > 0) { - sb.append(textView.context.getString(msg_api_info_deprecated, info.deprecated)) - sb.append("\n") - } - - return@executeAsync sb - }) { - if (it.isNullOrBlank()) { - textView.visibility = View.GONE - return@executeAsync - } - - textView.text = it - textView.visibility = View.VISIBLE - } - } - - private fun isValidForApiVersion(item: LspCompletionItem?): Boolean { - if (item == null) { - return false - } - val type = item.completionKind - val data = item.data - return if ( // These represent a class type - (type === CLASS || - type === INTERFACE || - type === ENUM || - - // These represent a method type - type === METHOD || - type === CONSTRUCTOR || - - // A field type - type === FIELD) && data != null - ) { - val className = - when (data) { - is ClassCompletionData -> data.className - is MemberCompletionData -> data.classInfo.className - else -> null - } - !TextUtils.isEmpty(className) - } else false - } + override fun getItemHeight(): Int { + return TypedValue.applyDimension( + TypedValue.COMPLEX_UNIT_DIP, + 40f, + Resources.getSystem().displayMetrics + ) + .toInt() + } + + override fun getView( + position: Int, + convertView: View?, + parent: ViewGroup?, + isCurrentCursorPosition: Boolean, + ): View { + val binding = + convertView?.let { LayoutCompletionItemBinding.bind(it) } + ?: LayoutCompletionItemBinding.inflate(LayoutInflater.from(context), parent, false) + val item = getItem(position) as LspCompletionItem + val label = item.ideLabel + val desc = item.detail + var type: String? = item.completionKind.toString() + val header = if (type!!.isEmpty()) "O" else type[0].toString() + if (item.overrideTypeText != null) { + type = item.overrideTypeText + } + binding.completionIconText.text = header + binding.completionLabel.text = label + binding.completionType.text = type + binding.completionDetail.text = desc + binding.completionIconText.setTypeface( + customOrJBMono(EditorPreferences.useCustomFont), + Typeface.BOLD + ) + binding.completionApiInfo.visibility = View.GONE + binding.completionDetail.isVisible = desc.isNotEmpty() + + applyColorScheme(binding, isCurrentCursorPosition) + showApiInfoIfNeeded(item, binding.completionApiInfo) + return binding.root + } + + private fun applyColorScheme(binding: LayoutCompletionItemBinding, isCurrent: Boolean) { + setItemBackground(binding, isCurrent) + var color = getThemeColor(COMPLETION_WND_TEXT_LABEL) + if (color != 0) { + binding.completionLabel.setTextColor(color) + binding.completionIconText.setTextColor(color) + } + + color = getThemeColor(COMPLETION_WND_TEXT_DETAIL) + if (color != 0) { + binding.completionDetail.setTextColor(color) + } + + color = getThemeColor(COMPLETION_WND_TEXT_API) + if (color != 0) { + binding.completionApiInfo.setTextColor(color) + } + + color = getThemeColor(COMPLETION_WND_TEXT_TYPE) + if (color != 0) { + binding.completionType.setTextColor(color) + } + } + + private fun setItemBackground(binding: LayoutCompletionItemBinding, isCurrent: Boolean) { + val color = + if (isCurrent) getThemeColor(SchemeAndroidIDE.COMPLETION_WND_BG_CURRENT_ITEM) + else 0 + + val cornerRadius = binding.root.context.resources + .getDimensionPixelSize(R.dimen.completion_window_corner_radius).toFloat() + + val gd = GradientDrawable().apply { + setColor(color) + setCornerRadius(cornerRadius) + } + + binding.root.background = gd + } + + private fun showApiInfoIfNeeded(item: LspCompletionItem, textView: TextView) { + executeAsync({ + if (!isValidForApiVersion(item)) { + return@executeAsync null + } + + val data = item.data + val versions = + Lookup.getDefault().lookup(ApiVersions.COMPLETION_LOOKUP_KEY) + ?: return@executeAsync null + val className = + when (data) { + is ClassCompletionData -> data.className + is MemberCompletionData -> data.classInfo.className + else -> return@executeAsync null + } + val kind = item.completionKind + + val clazz = versions.getClass(className) ?: return@executeAsync null + var info: Info? = clazz + + if (data is MethodCompletionData) { + if ( + kind == METHOD && data.erasedParameterTypes.isNotEmpty() && data.memberName.isNotBlank() + ) { + val method = + clazz.getMethod(data.memberName, *data.erasedParameterTypes.toTypedArray()) + if (method != null) { + info = method + } + } else if (kind == FIELD && data.memberName.isNotBlank()) { + val field = clazz.getField(data.memberName) + if (field != null) { + info = field + } + } + } + val sb = StringBuilder() + if (info!!.since > 1) { + sb.append(textView.context.getString(msg_api_info_since, info.since)) + sb.append("\n") + } + + if (info.removed > 0) { + sb.append(textView.context.getString(msg_api_info_removed, info.removed)) + sb.append("\n") + } + + if (info.deprecated > 0) { + sb.append(textView.context.getString(msg_api_info_deprecated, info.deprecated)) + sb.append("\n") + } + + return@executeAsync sb + }) { + if (it.isNullOrBlank()) { + textView.visibility = View.GONE + return@executeAsync + } + + textView.text = it + textView.visibility = View.VISIBLE + } + } + + private fun isValidForApiVersion(item: LspCompletionItem?): Boolean { + if (item == null) { + return false + } + val type = item.completionKind + val data = item.data + return if ( // These represent a class type + (type === CLASS || + type === INTERFACE || + type === ENUM || + + // These represent a method type + type === METHOD || + type === CONSTRUCTOR || + + // A field type + type === FIELD) && data != null + ) { + val className = + when (data) { + is ClassCompletionData -> data.className + is MemberCompletionData -> data.classInfo.className + else -> null + } + !TextUtils.isEmpty(className) + } else false + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/CommonCompletionProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/CommonCompletionProvider.kt index d78c153867..01fc98b186 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/CommonCompletionProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/CommonCompletionProvider.kt @@ -39,59 +39,61 @@ import java.util.concurrent.CancellationException * @author Akash Yadav */ internal class CommonCompletionProvider( - private val server: ILanguageServer, - private val cancelChecker: CompletionCancelChecker + private val server: ILanguageServer, + private val cancelChecker: CompletionCancelChecker ) { - companion object { + companion object { - private val log = LoggerFactory.getLogger(CommonCompletionProvider::class.java) - } + private val log = LoggerFactory.getLogger(CommonCompletionProvider::class.java) + } - /** - * Computes completion items using the provided language server instance. - * - * @param content The reference to the content of the editor. - * @param file The file to compute completions for. - * @param position The position of the cursor in the content. - * @return The computed completion items. May return an empty list if the there was an error - * computing the completion items. - */ - inline fun complete( - content: ContentReference, - file: Path, - position: CharPosition, - prefixMatcher: (Char) -> Boolean - ): List { - val completionResult = - try { - setupLookupForCompletion(file) - val prefix = CompletionHelper.computePrefix(content, position, prefixMatcher) - val params = - CompletionParams(Position(position.line, position.column, position.index), file, - cancelChecker) - params.content = content - params.prefix = prefix - server.complete(params) - } catch (e: Throwable) { + /** + * Computes completion items using the provided language server instance. + * + * @param content The reference to the content of the editor. + * @param file The file to compute completions for. + * @param position The position of the cursor in the content. + * @return The computed completion items. May return an empty list if the there was an error + * computing the completion items. + */ + inline fun complete( + content: ContentReference, + file: Path, + position: CharPosition, + prefixMatcher: (Char) -> Boolean + ): List { + val completionResult = + try { + setupLookupForCompletion(file) + val prefix = CompletionHelper.computePrefix(content, position, prefixMatcher) + val params = + CompletionParams( + Position(position.line, position.column, position.index), file, + cancelChecker + ) + params.content = content + params.prefix = prefix + server.complete(params) + } catch (e: Throwable) { - if (e is CancellationException) { - log.debug("Completion process cancelled") - } + if (e is CancellationException) { + log.debug("Completion process cancelled") + } - // Do not log if completion was interrupted or cancelled - if (!(e is CancellationException || e is CompletionCancelledException)) { - if (!server.handleFailure(LSPFailure(COMPLETION, e))) { - log.error("Unable to compute completions", e) - } - } - CompletionResult.EMPTY - } + // Do not log if completion was interrupted or cancelled + if (!(e is CancellationException || e is CompletionCancelledException)) { + if (!server.handleFailure(LSPFailure(COMPLETION, e))) { + log.error("Unable to compute completions", e) + } + } + CompletionResult.EMPTY + } - if (completionResult == CompletionResult.EMPTY) { - return listOf() - } + if (completionResult == CompletionResult.EMPTY) { + return listOf() + } - return completionResult.items - } + return completionResult.items + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/IDELanguage.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/IDELanguage.kt index 653ffc88ad..7b38f93c05 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/IDELanguage.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/IDELanguage.kt @@ -42,95 +42,97 @@ import java.nio.file.Paths */ abstract class IDELanguage : Language { - private var formatter: Formatter? = null - - protected open val languageServer: ILanguageServer? - get() = null - - open fun getTabSize(): Int { - return EditorPreferences.tabSize - } - - open fun addBreakpoint(line: Int) {} - open fun addBreakpoints(lines: Iterable) = lines.forEach(::addBreakpoint) - open fun removeBreakpoint(line: Int) {} - open fun removeBreakpoints(lines: Iterable) = lines.forEach(::removeBreakpoint) - open fun removeAllBreakpoints() {} - open fun toggleBreakpoint(line: Int) {} - open fun highlightLine(line: Int) {} - open fun unhighlightLines() {} - - @Throws(CompletionCancelledException::class) - override fun requireAutoComplete( - content: ContentReference, - position: CharPosition, - publisher: CompletionPublisher, - extraArguments: Bundle - ) { - try { - val cancelChecker = CompletionCancelChecker(publisher) - Lookup.getDefault().register(ICancelChecker::class.java, cancelChecker) - doComplete(content, position, publisher, cancelChecker, extraArguments) - } finally { - Lookup.getDefault().unregister( - ICancelChecker::class.java) - } - } - - private fun doComplete( - content: ContentReference, - position: CharPosition, - publisher: CompletionPublisher, - cancelChecker: CompletionCancelChecker, - extraArguments: Bundle - ) { - val server = languageServer ?: return - val path = extraArguments.getString(IEditor.KEY_FILE, null) - if (path == null) { - log.warn("Cannot provide completions. No file provided.") - return - } - - val completionProvider = CommonCompletionProvider(server, cancelChecker) - val file = Paths.get(path) - val completionItems = completionProvider.complete(content, file, - position) { checkIsCompletionChar(it) } - publisher.setUpdateThreshold(1) - (publisher as IDECompletionPublisher).addLSPItems(completionItems) - } - - /** - * Check if the given character is a completion character. - * - * @param c The character to check. - * @return `true` if the character is completion char, `false` otherwise. - */ - protected open fun checkIsCompletionChar(c: Char): Boolean { - return false - } - - override fun useTab(): Boolean { - return !EditorPreferences.useSoftTab - } - - override fun getFormatter(): Formatter { - return formatter ?: LSPFormatter(languageServer).also { formatter = it } - } - - override fun getIndentAdvance( - content: ContentReference, - line: Int, - column: Int - ): Int { - return getIndentAdvance(content.getLine(line).substring(0, column)) - } - - open fun getIndentAdvance(line: String): Int { - return 0 - } - - companion object { - - private val log = LoggerFactory.getLogger(IDELanguage::class.java) - } + private var formatter: Formatter? = null + + protected open val languageServer: ILanguageServer? + get() = null + + open fun getTabSize(): Int { + return EditorPreferences.tabSize + } + + open fun addBreakpoint(line: Int) {} + open fun addBreakpoints(lines: Iterable) = lines.forEach(::addBreakpoint) + open fun removeBreakpoint(line: Int) {} + open fun removeBreakpoints(lines: Iterable) = lines.forEach(::removeBreakpoint) + open fun removeAllBreakpoints() {} + open fun toggleBreakpoint(line: Int) {} + open fun highlightLine(line: Int) {} + open fun unhighlightLines() {} + + @Throws(CompletionCancelledException::class) + override fun requireAutoComplete( + content: ContentReference, + position: CharPosition, + publisher: CompletionPublisher, + extraArguments: Bundle + ) { + try { + val cancelChecker = CompletionCancelChecker(publisher) + Lookup.getDefault().register(ICancelChecker::class.java, cancelChecker) + doComplete(content, position, publisher, cancelChecker, extraArguments) + } finally { + Lookup.getDefault().unregister( + ICancelChecker::class.java + ) + } + } + + private fun doComplete( + content: ContentReference, + position: CharPosition, + publisher: CompletionPublisher, + cancelChecker: CompletionCancelChecker, + extraArguments: Bundle + ) { + val server = languageServer ?: return + val path = extraArguments.getString(IEditor.KEY_FILE, null) + if (path == null) { + log.warn("Cannot provide completions. No file provided.") + return + } + + val completionProvider = CommonCompletionProvider(server, cancelChecker) + val file = Paths.get(path) + val completionItems = + completionProvider.complete(content, file, position) { checkIsCompletionChar(it) } + + publisher.setUpdateThreshold(1) + (publisher as IDECompletionPublisher).addLSPItems(completionItems) + } + + /** + * Check if the given character is a completion character. + * + * @param c The character to check. + * @return `true` if the character is completion char, `false` otherwise. + */ + protected open fun checkIsCompletionChar(c: Char): Boolean { + return false + } + + override fun useTab(): Boolean { + return !EditorPreferences.useSoftTab + } + + override fun getFormatter(): Formatter { + return formatter ?: LSPFormatter(languageServer).also { formatter = it } + } + + override fun getIndentAdvance( + content: ContentReference, + line: Int, + column: Int + ): Int { + return getIndentAdvance(content.getLine(line).substring(0, column)) + } + + open fun getIndentAdvance(line: String): Int { + return 0 + } + + companion object { + + private val log = LoggerFactory.getLogger(IDELanguage::class.java) + } } \ 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 bf0e4d9ddd..c7e9223978 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,12 @@ class KotlinLanguageServer : ILanguageServer { override fun setupWithProject(workspace: Workspace) { logger.info("setupWithProject called, initialized={}", initialized) - (ProjectManagerImpl.getInstance() + val indexingServiceManager = ProjectManagerImpl.getInstance() .indexingServiceManager - .getService(JvmIndexingService.ID) as? JvmIndexingService?) - ?.refresh() + val jvmIndexingService = + indexingServiceManager.getService(JvmIndexingService.ID) as? JvmIndexingService? + + jvmIndexingService?.refresh() val jdkHome = Environment.JAVA_HOME.toPath() val jdkRelease = IJdkDistributionProvider.DEFAULT_JAVA_RELEASE 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 6bb19535d9..c7a322289d 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 @@ -52,7 +52,7 @@ import kotlin.io.path.pathString * @param jdkRelease The JDK release version at [jdkHome]. */ internal class CompilationEnvironment( - val projectModel: KotlinProjectModel, + val project: KotlinProjectModel, val intellijPluginRoot: Path, val jdkHome: Path, val jdkRelease: Int, @@ -82,11 +82,8 @@ internal class CompilationEnvironment( val coreApplicationEnvironment: CoreApplicationEnvironment get() = session.coreApplicationEnvironment - val moduleResolver: ModuleResolver? - get() = projectModel.moduleResolver - val symbolVisibilityChecker: SymbolVisibilityChecker? - get() = projectModel.symbolVisibilityChecker + get() = project.symbolVisibilityChecker private val envMessageCollector = object : MessageCollector { override fun clear() { @@ -115,7 +112,7 @@ internal class CompilationEnvironment( parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) fileManager = KtFileManager(parser, psiManager, psiDocumentManager) - projectModel.addListener(this) + project.addListener(this) } private fun buildSession(): StandaloneAnalysisAPISession { @@ -127,7 +124,7 @@ internal class CompilationEnvironment( compilerConfiguration = configuration, ) { buildKtModuleProvider { - projectModel.configureModules(this) + this@CompilationEnvironment.project.configureModules(this) } } @@ -239,7 +236,7 @@ internal class CompilationEnvironment( override fun close() { fileManager.close() - projectModel.removeListener(this) + project.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 48bde185b6..9d501b0d65 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 @@ -35,7 +35,7 @@ internal class Compiler( init { defaultCompilationEnv = CompilationEnvironment( - projectModel = projectModel, + project = projectModel, intellijPluginRoot = intellijPluginRoot, jdkHome = jdkHome, jdkRelease = jdkRelease, 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 06bd635704..4354826bb1 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,10 +1,13 @@ package com.itsaky.androidide.lsp.kotlin.compiler import com.itsaky.androidide.lsp.kotlin.completion.SymbolVisibilityChecker +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.api.Workspace import com.itsaky.androidide.projects.models.bootClassPaths +import org.appdevforall.codeonthego.indexing.jvm.JVM_LIBRARY_SYMBOL_INDEX +import org.appdevforall.codeonthego.indexing.jvm.JvmLibrarySymbolIndex 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 @@ -44,6 +47,12 @@ internal class KotlinProjectModel { val symbolVisibilityChecker: SymbolVisibilityChecker? get() = _symbolVisibilityChecker + val libraryIndex: JvmLibrarySymbolIndex? + get() = ProjectManagerImpl.getInstance() + .indexingServiceManager + .registry + .get(JVM_LIBRARY_SYMBOL_INDEX) + /** * The kind of change that occurred. */ 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 ca003ee1c8..c83fb63749 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 @@ -1,14 +1,26 @@ package com.itsaky.androidide.lsp.kotlin.completion +import com.itsaky.androidide.lsp.api.describeSnippet import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.models.ClassCompletionData 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.lsp.models.MethodCompletionData +import com.itsaky.androidide.lsp.models.SnippetDescription import com.itsaky.androidide.projects.FileManager +import com.itsaky.androidide.projects.ProjectManagerImpl import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.runBlocking +import org.appdevforall.codeonthego.indexing.jvm.JVM_LIBRARY_SYMBOL_INDEX +import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind +import org.appdevforall.codeonthego.indexing.jvm.JvmTypeAliasInfo import org.jetbrains.kotlin.analysis.api.KaContextParameterApi import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession @@ -44,6 +56,7 @@ import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression import org.jetbrains.kotlin.psi.psiUtil.getParentOfType import org.jetbrains.kotlin.types.Variance import org.slf4j.LoggerFactory +import kotlin.math.log private const val KT_COMPLETION_PLACEHOLDER = "KT_COMPLETION_PLACEHOLDER" @@ -118,14 +131,16 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi when (completionContext) { CompletionContext.Scope -> - collectScopeCompletions( - scopeContext = scopeContext, - scope = compositeScope, - symbolVisibilityChecker = symbolVisibilityChecker, - ktElement = ktElement, - partial = partial, - to = items - ) + runBlocking { + collectScopeCompletions( + scopeContext = scopeContext, + scope = compositeScope, + symbolVisibilityChecker = symbolVisibilityChecker, + ktElement = ktElement, + partial = partial, + to = items + ) + } CompletionContext.Member -> collectMemberCompletions( @@ -242,7 +257,7 @@ private fun KaSession.collectExtensionFunctions( to += toCompletionItems(extensionSymbols, partial) } -private fun KaSession.collectScopeCompletions( +private suspend fun KaSession.collectScopeCompletions( scopeContext: KaScopeContext, scope: KaScope, symbolVisibilityChecker: SymbolVisibilityChecker, @@ -274,6 +289,76 @@ private fun KaSession.collectScopeCompletions( to += toCompletionItems(callables, partial) to += toCompletionItems(classifiers, partial) + + val librarySymbolIndex = ProjectManagerImpl + .getInstance() + .indexingServiceManager + .registry + .get(JVM_LIBRARY_SYMBOL_INDEX) + + if (librarySymbolIndex == null) { + logger.warn("Unable to find JVM library symbol index") + return + } + + val useSiteModule = this.useSiteModule + librarySymbolIndex.findByPrefix(partial) + .collect { symbol -> + val isVisible = symbolVisibilityChecker.isVisible( + symbol = symbol, + useSiteModule = useSiteModule, + useSitePackage = ktElement.containingKtFile.packageDirective?.name + ) + + if (!isVisible) return@collect + + if (symbol.kind.isCallable && !symbol.isTopLevel && !symbol.isExtension) { + // member-level, non-imported callable symbols should not be + // completed in scope completions + return@collect + } + + // TODO: filter-out callables with a receiver type whose receiver + // is not an implicit receiver at the current use-site + + val item = ktCompletionItem( + name = symbol.shortName, + kind = kindOf(symbol), + partial = partial, + ) + + item.overrideTypeText = symbol.returnTypeDisplay + when (symbol.kind) { + JvmSymbolKind.FUNCTION, JvmSymbolKind.CONSTRUCTOR -> { + val data = symbol.data as JvmFunctionInfo + item.detail = data.signatureDisplay + item.setInsertTextForFunction(symbol.shortName, data.parameterCount > 0, partial) + + if (symbol.kind == JvmSymbolKind.CONSTRUCTOR) { + item.overrideTypeText = symbol.shortName + } + } + + JvmSymbolKind.TYPE_ALIAS -> { + item.detail = (symbol.data as JvmTypeAliasInfo).expandedTypeFqName + } + + in JvmSymbolKind.CLASSIFIER_KINDS -> { + val classInfo = symbol.data as JvmClassInfo + item.detail = symbol.fqName + item.data = ClassCompletionData( + className = symbol.fqName, + isNested = classInfo.isInner, + topLevelClass = classInfo.containingClassFqName, + ) + } + + else -> {} + } + + logger.debug("Adding completion item: {}", item) + to += item + } } private fun KaSession.collectKeywordCompletions( @@ -335,16 +420,7 @@ private fun KaSession.callableSymbolToCompletionItem( 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) - } + item.setInsertTextForFunction(name, hasParams, partial) // TODO(itsaky): provide method completion data in order to show API info // in completion items @@ -360,6 +436,25 @@ private fun KaSession.callableSymbolToCompletionItem( return item } +private fun CompletionItem.setInsertTextForFunction( + name: String, + hasParams: Boolean, + partial: String, +) { + insertTextFormat = InsertTextFormat.SNIPPET + insertText = if (hasParams) { + "${name}($0)" + } else { + "${name}()$0" + } + + snippetDescription = describeSnippet(prefix = partial, allowCommandExecution = true) + + if (hasParams) { + command = Command("Trigger parameter hints", Command.TRIGGER_PARAMETER_HINTS) + } +} + @OptIn(KaExperimentalApi::class) private fun KaSession.classifierSymbolToCompletionItem( symbol: KaClassifierSymbol, @@ -429,6 +524,28 @@ private fun KaSession.kindOf(symbol: KaSymbol): CompletionItemKind { } } +private fun KaSession.kindOf(symbol: JvmSymbol): CompletionItemKind = + when (symbol.kind) { + JvmSymbolKind.CLASS -> CompletionItemKind.CLASS + JvmSymbolKind.INTERFACE -> CompletionItemKind.INTERFACE + JvmSymbolKind.ENUM -> CompletionItemKind.ENUM + JvmSymbolKind.ENUM_ENTRY -> CompletionItemKind.ENUM_MEMBER + JvmSymbolKind.ANNOTATION_CLASS -> CompletionItemKind.ANNOTATION_TYPE + JvmSymbolKind.OBJECT -> CompletionItemKind.CLASS + JvmSymbolKind.COMPANION_OBJECT -> CompletionItemKind.CLASS + JvmSymbolKind.DATA_CLASS -> CompletionItemKind.CLASS + JvmSymbolKind.VALUE_CLASS -> CompletionItemKind.CLASS + JvmSymbolKind.SEALED_CLASS -> CompletionItemKind.CLASS + JvmSymbolKind.SEALED_INTERFACE -> CompletionItemKind.INTERFACE + JvmSymbolKind.FUNCTION -> CompletionItemKind.FUNCTION + JvmSymbolKind.EXTENSION_FUNCTION -> CompletionItemKind.FUNCTION + JvmSymbolKind.CONSTRUCTOR -> CompletionItemKind.CONSTRUCTOR + JvmSymbolKind.PROPERTY -> CompletionItemKind.PROPERTY + JvmSymbolKind.EXTENSION_PROPERTY -> CompletionItemKind.PROPERTY + JvmSymbolKind.FIELD -> CompletionItemKind.FIELD + JvmSymbolKind.TYPE_ALIAS -> CompletionItemKind.CLASS + } + @OptIn(KaExperimentalApi::class, KaContextParameterApi::class) private fun KaSession.renderName( type: KaType, @@ -445,12 +562,6 @@ 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) } diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt index 9665847f60..fb49916024 100644 --- a/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/edits/DefaultEditHandler.kt @@ -35,105 +35,114 @@ import org.slf4j.LoggerFactory */ open class DefaultEditHandler : IEditHandler { - companion object { - - private val log = LoggerFactory.getLogger(DefaultEditHandler::class.java) - } - - override fun performEdits( - item: CompletionItem, - editor: CodeEditor, - text: Content, - line: Int, - column: Int, - index: Int - ) { - if (Looper.myLooper() != Looper.getMainLooper()) { - ThreadUtils.runOnUiThread { performEditsInternal(item, editor, text, line, column, index) } - return - } - - performEditsInternal(item, editor, text, line, column, index) - } - - protected open fun performEditsInternal( - item: CompletionItem, - editor: CodeEditor, - text: Content, - line: Int, - column: Int, - index: Int - ) { - if (item.insertTextFormat == SNIPPET) { - insertSnippet(item, editor, text, line, column, index) - return - } - - val start = getIdentifierStart(text.getLine(line), column) - text.delete(line, start, line, column) - editor.commitText(item.insertText) - - text.beginBatchEdit() - if (item.additionalEditHandler != null) { - item.additionalEditHandler!!.performEdits(item, editor, text, line, column, index) - } else if (item.additionalTextEdits != null && item.additionalTextEdits!!.isNotEmpty()) { - RewriteHelper.performEdits(item.additionalTextEdits!!, editor) - } - text.beginBatchEdit() - - executeCommand(editor, item.command) - } - - protected open fun insertSnippet( - item: CompletionItem, - editor: CodeEditor, - text: Content, - line: Int, - column: Int, - index: Int - ) { - val snippetDescription = item.snippetDescription!! - val snippet = CodeSnippetParser.parse(item.insertText) - val prefixLength = snippetDescription.selectedLength - val selectedText = text.subSequence(index - prefixLength, index).toString() - var actionIndex = index - if (snippetDescription.deleteSelected) { - text.delete(index - prefixLength, index) - actionIndex -= prefixLength - } - editor.snippetController.startSnippet(actionIndex, snippet, selectedText) - - if (snippetDescription.allowCommandExecution) { - executeCommand(editor, item.command) - } - } - - protected open fun executeCommand(editor: CodeEditor, command: Command?) { - if (command == null) { - return - } - - try { - val klass = editor::class.java - val method = klass.getMethod("executeCommand", Command::class.java) - method.isAccessible = true - method.invoke(editor, command) - } catch (th: Throwable) { - log.error("Unable to invoke 'executeCommand(Command) method in IDEEditor.", th) - } - } - - protected open fun getIdentifierStart(text: CharSequence, end: Int): Int { - var start = end - while (start > 0) { - if (isPartialPart(text[start - 1])) { - start-- - continue - } - break - } - return start - } - - protected open fun isPartialPart(c: Char) = Character.isJavaIdentifierPart(c) + companion object { + + private val log = LoggerFactory.getLogger(DefaultEditHandler::class.java) + } + + override fun performEdits( + item: CompletionItem, + editor: CodeEditor, + text: Content, + line: Int, + column: Int, + index: Int + ) { + if (Looper.myLooper() != Looper.getMainLooper()) { + ThreadUtils.runOnUiThread { + performEditsInternal( + item, + editor, + text, + line, + column, + index + ) + } + return + } + + performEditsInternal(item, editor, text, line, column, index) + } + + protected open fun performEditsInternal( + item: CompletionItem, + editor: CodeEditor, + text: Content, + line: Int, + column: Int, + index: Int + ) { + if (item.insertTextFormat == SNIPPET) { + insertSnippet(item, editor, text, line, column, index) + return + } + + val start = getIdentifierStart(text.getLine(line), column) + text.delete(line, start, line, column) + editor.commitText(item.insertText) + + text.beginBatchEdit() + if (item.additionalEditHandler != null) { + item.additionalEditHandler!!.performEdits(item, editor, text, line, column, index) + } else if (item.additionalTextEdits != null && item.additionalTextEdits!!.isNotEmpty()) { + RewriteHelper.performEdits(item.additionalTextEdits!!, editor) + } + text.beginBatchEdit() + + executeCommand(editor, item.command) + } + + protected open fun insertSnippet( + item: CompletionItem, + editor: CodeEditor, + text: Content, + line: Int, + column: Int, + index: Int + ) { + val snippetDescription = item.snippetDescription!! + val snippet = CodeSnippetParser.parse(item.insertText) + val prefixLength = snippetDescription.selectedLength + val selectedText = text.subSequence(index - prefixLength, index).toString() + var actionIndex = index + if (snippetDescription.deleteSelected) { + text.delete(index - prefixLength, index) + actionIndex -= prefixLength + } + editor.snippetController.startSnippet(actionIndex, snippet, selectedText) + + if (snippetDescription.allowCommandExecution) { + executeCommand(editor, item.command) + } + } + + protected open fun executeCommand(editor: CodeEditor, command: Command?) { + if (command == null) { + return + } + + try { + val klass = editor::class.java + val method = klass.getMethod("executeCommand", Command::class.java) + method.isAccessible = true + method.invoke(editor, command) + } catch (th: Throwable) { + log.error("Unable to invoke 'executeCommand(Command) method in IDEEditor.", th) + } + } + + protected open fun getIdentifierStart(text: CharSequence, end: Int): Int { + var start = end + while (start > 0) { + if (isPartialPart(text[start - 1])) { + start-- + continue + } + break + } + return start + } + + protected open fun isPartialPart(c: Char) = Character.isJavaIdentifierPart(c) } From c78d5abd0ef424882e700f0cd53e889e66228e39 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 8 Apr 2026 20:03:43 +0530 Subject: [PATCH 31/49] fix: use Kotlin context receivers feature Signed-off-by: Akash Yadav --- lsp/kotlin/build.gradle.kts | 4 + .../kotlin/compiler/CompilationEnvironment.kt | 10 + .../lsp/kotlin/completion/ContextResolver.kt | 15 +- .../kotlin/completion/KotlinCompletions.kt | 187 +++++++----------- 4 files changed, 98 insertions(+), 118 deletions(-) diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 54c0fa6206..cf1a60bc68 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -25,6 +25,10 @@ plugins { android { namespace = "${BuildConfig.PACKAGE_NAME}.lsp.kotlin" + + kotlin.compilerOptions { + freeCompilerArgs.add("-Xcontext-receivers") + } } kapt { 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 c7a322289d..88521661c9 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 @@ -2,6 +2,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler import com.itsaky.androidide.lsp.kotlin.KtFileManager import com.itsaky.androidide.lsp.kotlin.completion.SymbolVisibilityChecker +import org.appdevforall.codeonthego.indexing.jvm.JvmLibrarySymbolIndex 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 @@ -85,6 +86,15 @@ internal class CompilationEnvironment( val symbolVisibilityChecker: SymbolVisibilityChecker? get() = project.symbolVisibilityChecker + val requireSymbolVisibilityChecker: SymbolVisibilityChecker + get() = checkNotNull(symbolVisibilityChecker) + + val libraryIndex: JvmLibrarySymbolIndex? + get() = project.libraryIndex + + val requireLibraryIndex: JvmLibrarySymbolIndex + get() = checkNotNull(libraryIndex) + private val envMessageCollector = object : MessageCollector { override fun clear() { } 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 index 3878cb57ac..2830cc4499 100644 --- 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 @@ -35,13 +35,19 @@ data class CursorContext( val ktFile: KtFile, val ktElement: KtElement, val scopeContext: KaScopeContext, - val compositeScope: KaScope, + val scope: KaScope, val completionContext: CompletionContext, val declarationContext: DeclarationContext, val declarationKind: DeclarationKind, val existingModifiers: Set, val isInsideModifierList: Boolean, -) + val partial: String, +) { + private val importFqns: List by lazy { + ktFile.importDirectives + .mapNotNull { it.importedFqName?.asString() } + } +} private val logger = LoggerFactory.getLogger("ContextResolver") @@ -49,7 +55,7 @@ private val logger = LoggerFactory.getLogger("ContextResolver") /** * Resolves [CursorContext] at the given offset in the given [KtFile]. */ -fun KaSession.resolveCursorContext(ktFile: KtFile, offset: Int): CursorContext? { +fun KaSession.resolveCursorContext(ktFile: KtFile, offset: Int, partial: String): CursorContext? { val psiElement = ktFile.findElementAt(offset) if (psiElement == null) { logger.error("Unable to find PSI element at offset {} in file {}", offset, ktFile) @@ -83,12 +89,13 @@ fun KaSession.resolveCursorContext(ktFile: KtFile, offset: Int): CursorContext? ktFile = ktFile, ktElement = ktElement, scopeContext = scopeContext, - compositeScope = compositeScope, + scope = compositeScope, completionContext = completionContext, declarationContext = declarationContext, declarationKind = declarationKind, existingModifiers = existingModifiers, isInsideModifierList = modifierList != null, + partial = partial, ) } 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 c83fb63749..5f6af4f451 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 @@ -9,13 +9,9 @@ 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.lsp.models.MethodCompletionData -import com.itsaky.androidide.lsp.models.SnippetDescription import com.itsaky.androidide.projects.FileManager -import com.itsaky.androidide.projects.ProjectManagerImpl import kotlinx.coroutines.CancellationException import kotlinx.coroutines.runBlocking -import org.appdevforall.codeonthego.indexing.jvm.JVM_LIBRARY_SYMBOL_INDEX import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol @@ -25,11 +21,9 @@ 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 -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 @@ -48,15 +42,12 @@ 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.KtElement import org.jetbrains.kotlin.psi.KtQualifiedExpression import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression import org.jetbrains.kotlin.psi.psiUtil.getParentOfType import org.jetbrains.kotlin.types.Variance import org.slf4j.LoggerFactory -import kotlin.math.log private const val KT_COMPLETION_PLACEHOLDER = "KT_COMPLETION_PLACEHOLDER" @@ -102,13 +93,7 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi 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) + val cursorContext = resolveCursorContext(completionKtFile, completionOffset, partial) if (cursorContext == null) { logger.error( "Unable to determine context at offset {} in file {}", @@ -118,46 +103,21 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi return@analyzeCopy CompletionResult.EMPTY } - val ( - psiElement, - _, - ktElement, - scopeContext, - compositeScope, - completionContext - ) = cursorContext - - val items = mutableListOf() - - when (completionContext) { - CompletionContext.Scope -> - runBlocking { - collectScopeCompletions( - scopeContext = scopeContext, - scope = compositeScope, - symbolVisibilityChecker = symbolVisibilityChecker, - ktElement = ktElement, - partial = partial, - to = items - ) + context(cursorContext) { + runBlocking { + val items = mutableListOf() + when (cursorContext.completionContext) { + CompletionContext.Scope -> + collectScopeCompletions(to = items) + + CompletionContext.Member -> + collectMemberCompletions(to = items) } - CompletionContext.Member -> - collectMemberCompletions( - scope = compositeScope, - element = psiElement, - partial = partial, - to = items - ) + collectKeywordCompletions(to = items) + CompletionResult(items) + } } - - collectKeywordCompletions( - ctx = cursorContext, - partial = partial, - to = items - ) - - CompletionResult(items) } } catch (e: Throwable) { if (e is CancellationException) { @@ -169,13 +129,11 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi } } +context(ctx: CursorContext) private fun KaSession.collectMemberCompletions( - scope: KaScope, - element: PsiElement, - partial: String, to: MutableList ) { - val qualifiedExpr = element.getParentOfType(strict = false) + val qualifiedExpr = ctx.psiElement.getParentOfType(strict = false) if (qualifiedExpr == null) { logger.error("No qualified expression found requested position") return @@ -194,36 +152,36 @@ private fun KaSession.collectMemberCompletions( receiver, receiverType, receiver.text, - partial + ctx.partial ) - collectMembersFromType(receiverType, partial, to) + collectMembersFromType(receiverType, to) if (qualifiedExpr is KtSafeQualifiedExpression) { val nonNullType = receiverType.withNullability(isMarkedNullable = false) - collectMembersFromType(nonNullType, partial, to) + collectMembersFromType(nonNullType, to) } - collectExtensionFunctions(scope, partial, receiverType, to) + collectExtensionFunctions(receiverType, to) } +context(ctx: CursorContext) @OptIn(KaExperimentalApi::class) private fun KaSession.collectMembersFromType( receiverType: KaType, - partial: String, to: MutableList ) { val typeScope = receiverType.scope if (typeScope != null) { val callables = - typeScope.getCallableSignatures { name -> matchesPrefix(name, partial) } + typeScope.getCallableSignatures { name -> matchesPrefix(name) } .map { it.symbol } val classifiers = - typeScope.getClassifierSymbols { name -> matchesPrefix(name, partial) } + typeScope.getClassifierSymbols { name -> matchesPrefix(name) } - to += toCompletionItems(callables, partial) - to += toCompletionItems(classifiers, partial) + to += toCompletionItems(callables) + to += toCompletionItems(classifiers) return } @@ -232,21 +190,20 @@ private fun KaSession.collectMembersFromType( val classSymbol = classType.symbol as? KaClassSymbol ?: return val memberScope = classSymbol.memberScope - val callables = memberScope.callables { name -> matchesPrefix(name, partial) } - val classifiers = memberScope.classifiers { name -> matchesPrefix(name, partial) } + val callables = memberScope.callables { name -> matchesPrefix(name) } + val classifiers = memberScope.classifiers { name -> matchesPrefix(name) } - to += toCompletionItems(callables, partial) - to += toCompletionItems(classifiers, partial) + to += toCompletionItems(callables) + to += toCompletionItems(classifiers) } +context(ctx: CursorContext) private fun KaSession.collectExtensionFunctions( - scope: KaScope, - partial: String, receiverType: KaType, to: MutableList ) { val extensionSymbols = - scope.callables { name -> matchesPrefix(name, partial) } + ctx.scope.callables { name -> matchesPrefix(name) } .filter { symbol -> if (!symbol.isExtension) return@filter false @@ -254,26 +211,26 @@ private fun KaSession.collectExtensionFunctions( receiverType.isSubtypeOf(extReceiverType) } - to += toCompletionItems(extensionSymbols, partial) + to += toCompletionItems(extensionSymbols) } +context(env: CompilationEnvironment, ctx: CursorContext) private suspend fun KaSession.collectScopeCompletions( - scopeContext: KaScopeContext, - scope: KaScope, - symbolVisibilityChecker: SymbolVisibilityChecker, - ktElement: KtElement, - partial: String, to: MutableList, ) { + val ktElement = ctx.ktElement + val scope = ctx.scope + val scopeContext = ctx.scopeContext + logger.info( "Complete scope members of {}: [{}] matching '{}'", ktElement, ktElement.text, - partial + ctx.partial ) val callables = - scope.callables { name -> matchesPrefix(name, partial) } + scope.callables { name -> matchesPrefix(name) } .filter { symbol -> // always include non-extension functions @@ -285,26 +242,28 @@ private suspend fun KaSession.collectScopeCompletions( receiver.type.isSubtypeOf(extReceiverType) } } - val classifiers = scope.classifiers { name -> matchesPrefix(name, partial) } - to += toCompletionItems(callables, partial) - to += toCompletionItems(classifiers, partial) + val classifiers = scope.classifiers { name -> matchesPrefix(name) } - val librarySymbolIndex = ProjectManagerImpl - .getInstance() - .indexingServiceManager - .registry - .get(JVM_LIBRARY_SYMBOL_INDEX) + to += toCompletionItems(callables) + to += toCompletionItems(classifiers) + val visibilityChecker = env.symbolVisibilityChecker + if (visibilityChecker == null) { + logger.warn("No visibility checker found") + return + } + + val librarySymbolIndex = env.libraryIndex if (librarySymbolIndex == null) { logger.warn("Unable to find JVM library symbol index") return } val useSiteModule = this.useSiteModule - librarySymbolIndex.findByPrefix(partial) + librarySymbolIndex.findByPrefix(ctx.partial) .collect { symbol -> - val isVisible = symbolVisibilityChecker.isVisible( + val isVisible = visibilityChecker.isVisible( symbol = symbol, useSiteModule = useSiteModule, useSitePackage = ktElement.containingKtFile.packageDirective?.name @@ -324,7 +283,6 @@ private suspend fun KaSession.collectScopeCompletions( val item = ktCompletionItem( name = symbol.shortName, kind = kindOf(symbol), - partial = partial, ) item.overrideTypeText = symbol.returnTypeDisplay @@ -332,7 +290,10 @@ private suspend fun KaSession.collectScopeCompletions( JvmSymbolKind.FUNCTION, JvmSymbolKind.CONSTRUCTOR -> { val data = symbol.data as JvmFunctionInfo item.detail = data.signatureDisplay - item.setInsertTextForFunction(symbol.shortName, data.parameterCount > 0, partial) + item.setInsertTextForFunction( + name = symbol.shortName, + hasParams = data.parameterCount > 0, + ) if (symbol.kind == JvmSymbolKind.CONSTRUCTOR) { item.overrideTypeText = symbol.shortName @@ -361,16 +322,14 @@ private suspend fun KaSession.collectScopeCompletions( } } +context(ctx: CursorContext) 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) { @@ -384,30 +343,30 @@ private fun KaSession.collectKeywordCompletions( } } +context(ctx: CursorContext) @JvmName("callablesToCompletionItems") private fun KaSession.toCompletionItems( callables: Sequence, - partial: String ): Sequence = callables.mapNotNull { - callableSymbolToCompletionItem(it, partial) + callableSymbolToCompletionItem(it) } +context(ctx: CursorContext) @JvmName("classifiersToCompletionItems") private fun KaSession.toCompletionItems( classifiers: Sequence, - partial: String ): Sequence = classifiers.mapNotNull { - classifierSymbolToCompletionItem(it, partial) + classifierSymbolToCompletionItem(it) } +context(ctx: CursorContext) @OptIn(KaExperimentalApi::class) private fun KaSession.callableSymbolToCompletionItem( symbol: KaCallableSymbol, - partial: String ): CompletionItem? { - val item = createSymbolCompletionItem(symbol, partial) ?: return null + val item = createSymbolCompletionItem(symbol) ?: return null val name = item.ideLabel item.overrideTypeText = renderName(symbol.returnType) @@ -420,7 +379,7 @@ private fun KaSession.callableSymbolToCompletionItem( val hasParams = symbol.valueParameters.isNotEmpty() item.detail = "${name}($params)" - item.setInsertTextForFunction(name, hasParams, partial) + item.setInsertTextForFunction(name, hasParams) // TODO(itsaky): provide method completion data in order to show API info // in completion items @@ -436,10 +395,10 @@ private fun KaSession.callableSymbolToCompletionItem( return item } +context(ctx: CursorContext) private fun CompletionItem.setInsertTextForFunction( name: String, hasParams: Boolean, - partial: String, ) { insertTextFormat = InsertTextFormat.SNIPPET insertText = if (hasParams) { @@ -448,19 +407,19 @@ private fun CompletionItem.setInsertTextForFunction( "${name}()$0" } - snippetDescription = describeSnippet(prefix = partial, allowCommandExecution = true) + snippetDescription = describeSnippet(prefix = ctx.partial, allowCommandExecution = true) if (hasParams) { command = Command("Trigger parameter hints", Command.TRIGGER_PARAMETER_HINTS) } } +context(ctx: CursorContext) @OptIn(KaExperimentalApi::class) private fun KaSession.classifierSymbolToCompletionItem( symbol: KaClassifierSymbol, - partial: String ): CompletionItem? { - val item = createSymbolCompletionItem(symbol, partial) ?: return null + val item = createSymbolCompletionItem(symbol) ?: return null item.detail = when (symbol) { is KaClassSymbol -> symbol.classId?.asFqNameString() ?: "" is KaTypeAliasSymbol -> renderName( @@ -473,26 +432,25 @@ private fun KaSession.classifierSymbolToCompletionItem( return item } +context(ctx: CursorContext) private fun KaSession.createSymbolCompletionItem( symbol: KaSymbol, - partial: String ): CompletionItem? { return ktCompletionItem( name = symbol.name?.asString() ?: return null, kind = kindOf(symbol), - partial = partial, ) } +context(ctx: CursorContext) private fun KaSession.ktCompletionItem( name: String, kind: CompletionItemKind, - partial: String, ): CompletionItem { val item = KotlinCompletionItem() item.ideLabel = name item.completionKind = kind - item.matchLevel = CompletionItem.matchLevel(item.ideLabel, partial) + item.matchLevel = CompletionItem.matchLevel(item.ideLabel, ctx.partial) return item } @@ -561,7 +519,8 @@ 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) +context(ctx: CursorContext) +private fun matchesPrefix(name: Name): Boolean { + if (ctx.partial.isEmpty()) return true + return name.asString().startsWith(ctx.partial, ignoreCase = true) } From 47d1738513aa12513ccba67dfc263ede63889528 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Apr 2026 12:25:27 +0530 Subject: [PATCH 32/49] fix: auto-import un-imported classes Signed-off-by: Akash Yadav --- .../lsp/java/edits/AdvancedJavaEditHandler.kt | 53 ++++---- .../lsp/java/edits/ClassImportEditHandler.kt | 24 ++-- lsp/kotlin/build.gradle.kts | 2 +- .../androidide/lsp/kotlin/KtFileManager.kt | 19 ++- .../kotlin/compiler/CompilationEnvironment.kt | 2 +- .../lsp/kotlin/compiler/KotlinProjectModel.kt | 2 +- .../completion/AdvancedKotlinEditHandler.kt | 43 +++++++ .../lsp/kotlin/completion/ContextKeywords.kt | 53 -------- .../KotlinClassImportEditHandler.kt | 27 ++++ .../kotlin/completion/KotlinCompletions.kt | 119 +++++++++++++++--- .../diagnostic/KotlinDiagnosticProvider.kt | 20 +-- .../lsp/kotlin/utils/ContextKeywords.kt | 74 +++++++++++ .../{completion => utils}/ContextResolver.kt | 72 ++++------- .../androidide/lsp/kotlin/utils/EditExts.kt | 95 ++++++++++++++ .../{completion => utils}/ModifierFilter.kt | 12 +- .../androidide/lsp/kotlin/utils/SymbolExts.kt | 25 ++++ .../SymbolVisibilityChecker.kt | 2 +- .../androidide/lsp/models/CompletionData.kt | 42 +++---- .../androidide/lsp/util/RewriteHelper.kt | 43 ++++--- 19 files changed, 504 insertions(+), 225 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt delete 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/KotlinClassImportEditHandler.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ContextKeywords.kt rename lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/{completion => utils}/ContextResolver.kt (73%) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/EditExts.kt rename lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/{completion => utils}/ModifierFilter.kt (93%) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolExts.kt rename lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/{completion => utils}/SymbolVisibilityChecker.kt (98%) diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/AdvancedJavaEditHandler.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/AdvancedJavaEditHandler.kt index 7b8fed0d0d..387d546cb4 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/AdvancedJavaEditHandler.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/AdvancedJavaEditHandler.kt @@ -32,32 +32,33 @@ import java.nio.file.Path */ abstract class AdvancedJavaEditHandler(protected val file: Path) : BaseJavaEditHandler() { - override fun performEdits( - item: CompletionItem, - editor: CodeEditor, - text: Content, - line: Int, - column: Int, - index: Int - ) { - val compiler = JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(file, false) ?: return) - performEdits(compiler, editor, item) + override fun performEdits( + item: CompletionItem, + editor: CodeEditor, + text: Content, + line: Int, + column: Int, + index: Int + ) { + val compiler = JavaCompilerProvider.get( + IProjectManager.getInstance().findModuleForFile(file, false) ?: return + ) + performEdits(compiler, editor, item) - executeCommand(editor, item.command) - } + executeCommand(editor, item.command) + } - /** - * Java edit handlers which require instance of the compiler should override this method instead - * of [performEdits]. - * - * @param compiler The compiler service instance. - * @param editor The editor to perform edits on. - * @param completionItem The completion item which contains required data. - */ - abstract fun performEdits( - compiler: JavaCompilerService, - editor: CodeEditor, - completionItem: CompletionItem - ) + /** + * Java edit handlers which require instance of the compiler should override this method instead + * of [performEdits]. + * + * @param compiler The compiler service instance. + * @param editor The editor to perform edits on. + * @param completionItem The completion item which contains required data. + */ + abstract fun performEdits( + compiler: JavaCompilerService, + editor: CodeEditor, + completionItem: CompletionItem + ) } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/ClassImportEditHandler.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/ClassImportEditHandler.kt index 6812e9df78..d78a8e673d 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/ClassImportEditHandler.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/edits/ClassImportEditHandler.kt @@ -34,17 +34,17 @@ import java.nio.file.Path */ class ClassImportEditHandler(val imports: Set, file: Path) : AdvancedJavaEditHandler(file) { - override fun performEdits( - compiler: JavaCompilerService, - editor: CodeEditor, - completionItem: CompletionItem - ) { - val data = completionItem.data as? ClassCompletionData ?: return - val className = data.className - val edits = EditHelper.addImportIfNeeded(compiler, file, imports, className) + override fun performEdits( + compiler: JavaCompilerService, + editor: CodeEditor, + completionItem: CompletionItem + ) { + val data = completionItem.data as? ClassCompletionData ?: return + val className = data.className + val edits = EditHelper.addImportIfNeeded(compiler, file, imports, className) - if (edits.isNotEmpty()) { - RewriteHelper.performEdits(edits, editor) - } - } + if (edits.isNotEmpty()) { + RewriteHelper.performEdits(edits, editor) + } + } } diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index cf1a60bc68..a4fcabb56b 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -27,7 +27,7 @@ android { namespace = "${BuildConfig.PACKAGE_NAME}.lsp.kotlin" kotlin.compilerOptions { - freeCompilerArgs.add("-Xcontext-receivers") + freeCompilerArgs.addAll("-Xcontext-parameters") } } 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 index 2941cfb2c1..c711534897 100644 --- 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 @@ -1,5 +1,6 @@ package com.itsaky.androidide.lsp.kotlin +import com.itsaky.androidide.projects.FileManager import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.analyzeCopy @@ -125,7 +126,6 @@ class KtFileManager( updateDocumentContent(entry, content) logger.debug("File opened and managed: {}", path) - return } override fun onFileContentChanged(path: Path, content: String) { @@ -150,7 +150,22 @@ class KtFileManager( logger.debug("File closed: {}", path) } - fun getOpenFile(path: Path): ManagedFile? = entries[path] + fun getOpenFile(path: Path): ManagedFile? { + val managed = entries[path] + if (managed != null) { + return managed + } + + val activeDocument = FileManager.getActiveDocument(path) + if (activeDocument != null) { + // document is active, but we were not notified + // open it now + onFileOpened(path, activeDocument.content) + return entries[path] + } + + return null + } fun allOpenFiles(): Collection = entries.values.toList() 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 88521661c9..714179a548 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.KtFileManager -import com.itsaky.androidide.lsp.kotlin.completion.SymbolVisibilityChecker +import com.itsaky.androidide.lsp.kotlin.utils.SymbolVisibilityChecker import org.appdevforall.codeonthego.indexing.jvm.JvmLibrarySymbolIndex import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolverFactory 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 4354826bb1..12e11306d4 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,6 +1,6 @@ package com.itsaky.androidide.lsp.kotlin.compiler -import com.itsaky.androidide.lsp.kotlin.completion.SymbolVisibilityChecker +import com.itsaky.androidide.lsp.kotlin.utils.SymbolVisibilityChecker import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.api.AndroidModule import com.itsaky.androidide.projects.api.ModuleProject diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt new file mode 100644 index 0000000000..c63a908733 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.lsp.kotlin.KtFileManager +import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext +import com.itsaky.androidide.lsp.models.CompletionItem +import io.github.rosemoe.sora.text.Content +import io.github.rosemoe.sora.widget.CodeEditor +import org.slf4j.LoggerFactory + +internal abstract class AdvancedKotlinEditHandler( + protected val analysisContext: AnalysisContext, +) : BaseKotlinEditHandler() { + + companion object { + private val logger = LoggerFactory.getLogger(AdvancedKotlinEditHandler::class.java) + } + + override fun performEdits( + item: CompletionItem, + editor: CodeEditor, + text: Content, + line: Int, + column: Int, + index: Int + ) { + val managedFile = analysisContext.env.fileManager.getOpenFile(analysisContext.file) + if (managedFile == null) { + logger.error("Unable to perform edit. File not open.") + return + } + + performEdits(managedFile, editor, item) + if (item.command != null) { + executeCommand(editor, item.command) + } + } + + abstract fun performEdits( + managedFile: KtFileManager.ManagedFile, + editor: CodeEditor, + item: CompletionItem + ) +} \ No newline at end of file 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 deleted file mode 100644 index 433963f83e..0000000000 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextKeywords.kt +++ /dev/null @@ -1,53 +0,0 @@ -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/KotlinClassImportEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinClassImportEditHandler.kt new file mode 100644 index 0000000000..d58b2950ee --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinClassImportEditHandler.kt @@ -0,0 +1,27 @@ +package com.itsaky.androidide.lsp.kotlin.completion + +import com.itsaky.androidide.lsp.kotlin.KtFileManager +import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext +import com.itsaky.androidide.lsp.kotlin.utils.insertImport +import com.itsaky.androidide.lsp.models.ClassCompletionData +import com.itsaky.androidide.lsp.models.CompletionItem +import com.itsaky.androidide.lsp.util.RewriteHelper +import io.github.rosemoe.sora.widget.CodeEditor + +internal class KotlinClassImportEditHandler( + analysisContext: AnalysisContext, +) : AdvancedKotlinEditHandler(analysisContext) { + override fun performEdits( + managedFile: KtFileManager.ManagedFile, + editor: CodeEditor, + item: CompletionItem + ) { + val data = item.data as? ClassCompletionData ?: return + context(analysisContext) { + val edits = insertImport(data.className) + if (edits.isNotEmpty()) { + RewriteHelper.performEdits(edits, editor) + } + } + } +} \ 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 5f6af4f451..e1ca3b905f 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 @@ -2,6 +2,11 @@ package com.itsaky.androidide.lsp.kotlin.completion import com.itsaky.androidide.lsp.api.describeSnippet import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext +import com.itsaky.androidide.lsp.kotlin.utils.ContextKeywords +import com.itsaky.androidide.lsp.kotlin.utils.ModifierFilter +import com.itsaky.androidide.lsp.kotlin.utils.containingTopLevelClassDeclaration +import com.itsaky.androidide.lsp.kotlin.utils.resolveAnalysisContext import com.itsaky.androidide.lsp.models.ClassCompletionData import com.itsaky.androidide.lsp.models.Command import com.itsaky.androidide.lsp.models.CompletionItem @@ -19,6 +24,7 @@ import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind import org.appdevforall.codeonthego.indexing.jvm.JvmTypeAliasInfo import org.jetbrains.kotlin.analysis.api.KaContextParameterApi import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaIdeApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyzeCopy import org.jetbrains.kotlin.analysis.api.projectStructure.KaDanglingFileResolutionMode @@ -26,6 +32,7 @@ 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.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassifierSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol @@ -42,10 +49,13 @@ 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.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 @@ -93,8 +103,16 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi useSiteElement = completionKtFile, resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, ) { - val cursorContext = resolveCursorContext(completionKtFile, completionOffset, partial) - if (cursorContext == null) { + val ctx = + resolveAnalysisContext( + env = this@complete, + file = params.file, + ktFile = completionKtFile, + offset = completionOffset, + partial = partial + ) + + if (ctx == null) { logger.error( "Unable to determine context at offset {} in file {}", completionOffset, @@ -103,10 +121,11 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi return@analyzeCopy CompletionResult.EMPTY } - context(cursorContext) { + context(ctx) { runBlocking { val items = mutableListOf() - when (cursorContext.completionContext) { + val completionContext = determineCompletionContext(ctx.psiElement) + when (completionContext) { CompletionContext.Scope -> collectScopeCompletions(to = items) @@ -129,7 +148,7 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi } } -context(ctx: CursorContext) +context(ctx: AnalysisContext) private fun KaSession.collectMemberCompletions( to: MutableList ) { @@ -165,7 +184,7 @@ private fun KaSession.collectMemberCompletions( collectExtensionFunctions(receiverType, to) } -context(ctx: CursorContext) +context(ctx: AnalysisContext) @OptIn(KaExperimentalApi::class) private fun KaSession.collectMembersFromType( receiverType: KaType, @@ -197,7 +216,7 @@ private fun KaSession.collectMembersFromType( to += toCompletionItems(classifiers) } -context(ctx: CursorContext) +context(ctx: AnalysisContext) private fun KaSession.collectExtensionFunctions( receiverType: KaType, to: MutableList @@ -214,7 +233,7 @@ private fun KaSession.collectExtensionFunctions( to += toCompletionItems(extensionSymbols) } -context(env: CompilationEnvironment, ctx: CursorContext) +context(env: CompilationEnvironment, ctx: AnalysisContext) private suspend fun KaSession.collectScopeCompletions( to: MutableList, ) { @@ -248,6 +267,13 @@ private suspend fun KaSession.collectScopeCompletions( to += toCompletionItems(callables) to += toCompletionItems(classifiers) + collectUnimportedSymbols(to) +} + +context(env: CompilationEnvironment, ctx: AnalysisContext) +private suspend fun KaSession.collectUnimportedSymbols( + to: MutableList +) { val visibilityChecker = env.symbolVisibilityChecker if (visibilityChecker == null) { logger.warn("No visibility checker found") @@ -266,7 +292,7 @@ private suspend fun KaSession.collectScopeCompletions( val isVisible = visibilityChecker.isVisible( symbol = symbol, useSiteModule = useSiteModule, - useSitePackage = ktElement.containingKtFile.packageDirective?.name + useSitePackage = ctx.ktElement.containingKtFile.packageDirective?.name ) if (!isVisible) return@collect @@ -307,7 +333,7 @@ private suspend fun KaSession.collectScopeCompletions( in JvmSymbolKind.CLASSIFIER_KINDS -> { val classInfo = symbol.data as JvmClassInfo item.detail = symbol.fqName - item.data = ClassCompletionData( + item.setClassCompletionData( className = symbol.fqName, isNested = classInfo.isInner, topLevelClass = classInfo.containingClassFqName, @@ -322,7 +348,7 @@ private suspend fun KaSession.collectScopeCompletions( } } -context(ctx: CursorContext) +context(ctx: AnalysisContext) private fun KaSession.collectKeywordCompletions( to: MutableList, ) { @@ -343,7 +369,7 @@ private fun KaSession.collectKeywordCompletions( } } -context(ctx: CursorContext) +context(ctx: AnalysisContext) @JvmName("callablesToCompletionItems") private fun KaSession.toCompletionItems( callables: Sequence, @@ -352,7 +378,7 @@ private fun KaSession.toCompletionItems( callableSymbolToCompletionItem(it) } -context(ctx: CursorContext) +context(ctx: AnalysisContext) @JvmName("classifiersToCompletionItems") private fun KaSession.toCompletionItems( classifiers: Sequence, @@ -361,7 +387,7 @@ private fun KaSession.toCompletionItems( classifierSymbolToCompletionItem(it) } -context(ctx: CursorContext) +context(ctx: AnalysisContext) @OptIn(KaExperimentalApi::class) private fun KaSession.callableSymbolToCompletionItem( symbol: KaCallableSymbol, @@ -395,7 +421,7 @@ private fun KaSession.callableSymbolToCompletionItem( return item } -context(ctx: CursorContext) +context(ctx: AnalysisContext) private fun CompletionItem.setInsertTextForFunction( name: String, hasParams: Boolean, @@ -414,8 +440,8 @@ private fun CompletionItem.setInsertTextForFunction( } } -context(ctx: CursorContext) -@OptIn(KaExperimentalApi::class) +context(ctx: AnalysisContext) +@OptIn(KaExperimentalApi::class, KaIdeApi::class) private fun KaSession.classifierSymbolToCompletionItem( symbol: KaClassifierSymbol, ): CompletionItem? { @@ -429,10 +455,38 @@ private fun KaSession.classifierSymbolToCompletionItem( is KaTypeParameterSymbol -> item.ideLabel } + + if (symbol is KaClassLikeSymbol) { + val classFqn = symbol.classId?.asFqNameString() + if (classFqn != null) { + item.setClassCompletionData( + className = classFqn, + isNested = symbol.classId?.isNestedClass ?: false, + topLevelClass = symbol.containingTopLevelClassDeclaration?.classId?.asFqNameString() + ?: "" + ) + } + } + return item } -context(ctx: CursorContext) +context(ctx: AnalysisContext) +private fun CompletionItem.setClassCompletionData( + className: String, + isNested: Boolean = false, + topLevelClass: String = "", +) { + data = ClassCompletionData( + className, + isNested, + topLevelClass + ) + + additionalEditHandler = KotlinClassImportEditHandler(analysisContext = ctx) +} + +context(ctx: AnalysisContext) private fun KaSession.createSymbolCompletionItem( symbol: KaSymbol, ): CompletionItem? { @@ -442,7 +496,7 @@ private fun KaSession.createSymbolCompletionItem( ) } -context(ctx: CursorContext) +context(ctx: AnalysisContext) private fun KaSession.ktCompletionItem( name: String, kind: CompletionItemKind, @@ -519,8 +573,33 @@ private fun partialIdentifier(prefix: String): String { return prefix.takeLastWhile { char -> Character.isJavaIdentifierPart(char) } } -context(ctx: CursorContext) +context(ctx: AnalysisContext) private fun matchesPrefix(name: Name): Boolean { if (ctx.partial.isEmpty()) return true return name.asString().startsWith(ctx.partial, ignoreCase = true) } + +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 +} + 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 4472c1a652..fce0b8c721 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,6 +1,7 @@ package com.itsaky.androidide.lsp.kotlin.diagnostic import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.utils.toRange import com.itsaky.androidide.lsp.models.DiagnosticItem import com.itsaky.androidide.lsp.models.DiagnosticResult import com.itsaky.androidide.lsp.models.DiagnosticSeverity @@ -83,22 +84,3 @@ private fun KaSeverity.toDiagnosticSeverity(): DiagnosticSeverity { } } -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, - ) - ) -} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ContextKeywords.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ContextKeywords.kt new file mode 100644 index 0000000000..c446c08c14 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ContextKeywords.kt @@ -0,0 +1,74 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.completion.DeclarationContext +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( + KtTokens.IF_KEYWORD, + KtTokens.ELSE_KEYWORD, + KtTokens.WHEN_KEYWORD, + KtTokens.WHILE_KEYWORD, + KtTokens.DO_KEYWORD, + KtTokens.FOR_KEYWORD, + KtTokens.TRY_KEYWORD, + KtTokens.RETURN_KEYWORD, + KtTokens.THROW_KEYWORD, + KtTokens.BREAK_KEYWORD, + KtTokens.CONTEXT_KEYWORD, + KtTokens.VAL_KEYWORD, + KtTokens.VAR_KEYWORD, + KtTokens.FUN_KEYWORD,// local declarations + KtTokens.OBJECT_KEYWORD,// anonymous / local object + KtTokens.CLASS_KEYWORD,// local class (rare but legal) + ) + + /** Declaration starters at top-level / class body */ + val DECLARATION_KEYWORDS = setOf( + KtTokens.VAL_KEYWORD, + KtTokens.VAR_KEYWORD, + KtTokens.FUN_KEYWORD, + KtTokens.CLASS_KEYWORD, + KtTokens.INTERFACE_KEYWORD, + KtTokens.OBJECT_KEYWORD, + KtTokens.TYPE_ALIAS_KEYWORD, + KtTokens.CONSTRUCTOR_KEYWORD, + KtTokens.INIT_KEYWORD, + ) + + val TOP_LEVEL_ONLY = setOf(KtTokens.PACKAGE_KEYWORD, KtTokens.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(KtTokens.INIT_KEYWORD, KtTokens.CONSTRUCTOR_KEYWORD) + + DeclarationContext.INTERFACE_BODY -> setOf( + KtTokens.VAL_KEYWORD, + KtTokens.VAR_KEYWORD, + KtTokens.FUN_KEYWORD, + KtTokens.CLASS_KEYWORD, + KtTokens.INTERFACE_KEYWORD, + KtTokens.OBJECT_KEYWORD, + KtTokens.TYPE_ALIAS_KEYWORD + ) + + DeclarationContext.OBJECT_BODY, + DeclarationContext.ENUM_BODY -> DECLARATION_KEYWORDS - setOf(KtTokens.CONSTRUCTOR_KEYWORD) + + DeclarationContext.ANNOTATION_BODY -> setOf(KtTokens.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/utils/ContextResolver.kt similarity index 73% rename from lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ContextResolver.kt rename to lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ContextResolver.kt index 2830cc4499..44299eb427 100644 --- 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/utils/ContextResolver.kt @@ -1,5 +1,8 @@ -package com.itsaky.androidide.lsp.kotlin.completion +package com.itsaky.androidide.lsp.kotlin.utils +import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.completion.DeclarationContext +import com.itsaky.androidide.lsp.kotlin.completion.DeclarationKind import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.components.KaScopeContext import org.jetbrains.kotlin.analysis.api.scopes.KaScope @@ -11,58 +14,60 @@ 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 +import java.nio.file.Path + +private val logger = LoggerFactory.getLogger("ContextResolver") /** * Defines context at the cursor position. */ -data class CursorContext( +internal data class AnalysisContext( + val env: CompilationEnvironment, + val file: Path, val psiElement: PsiElement, val ktFile: KtFile, val ktElement: KtElement, val scopeContext: KaScopeContext, val scope: KaScope, - val completionContext: CompletionContext, val declarationContext: DeclarationContext, val declarationKind: DeclarationKind, val existingModifiers: Set, val isInsideModifierList: Boolean, val partial: String, -) { - private val importFqns: List by lazy { - ktFile.importDirectives - .mapNotNull { it.importedFqName?.asString() } - } -} - - -private val logger = LoggerFactory.getLogger("ContextResolver") +) /** - * Resolves [CursorContext] at the given offset in the given [KtFile]. + * Resolves [AnalysisContext] at the given offset in the given [KtFile]. + * + * @param env The compilation environment. + * @param ktFile The Kotlin file. + * @param offset The offset to resolve context at. + * @param partial The partial identifier at the cursor position. */ -fun KaSession.resolveCursorContext(ktFile: KtFile, offset: Int, partial: String): CursorContext? { +internal fun KaSession.resolveAnalysisContext( + env: CompilationEnvironment, + file: Path, + ktFile: KtFile, + offset: Int, + partial: String +): AnalysisContext? { 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) @@ -84,13 +89,14 @@ fun KaSession.resolveCursorContext(ktFile: KtFile, offset: Int, partial: String) val declarationKind = resolveDeclarationKind(ktElement) val declarationContext = resolveDeclarationContext(ktElement) - return CursorContext( + return AnalysisContext( + env = env, + file = file, psiElement = psiElement, ktFile = ktFile, ktElement = ktElement, scopeContext = scopeContext, scope = compositeScope, - completionContext = completionContext, declarationContext = declarationContext, declarationKind = declarationKind, existingModifiers = existingModifiers, @@ -99,30 +105,6 @@ fun KaSession.resolveCursorContext(ktFile: KtFile, offset: Int, partial: String) ) } -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) { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/EditExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/EditExts.kt new file mode 100644 index 0000000000..0c395847f4 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/EditExts.kt @@ -0,0 +1,95 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import org.jetbrains.kotlin.com.intellij.openapi.util.TextRange +import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiFile +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("EditExts") + +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, + ) + ) +} + +context(ctx: AnalysisContext) +internal fun insertImport(fqn: String): List { + val imports = ctx.ktFile.importDirectives + val importText = "import $fqn" + for (import in imports) { + val thisFqn = import.importedFqName?.asString() ?: "" + if (thisFqn == fqn) return emptyList() + if (thisFqn.substringBeforeLast('.') + ".*" == fqn) return emptyList() + + if (fqn < thisFqn) { + logger.info("insert '{}' before '{}'", importText, thisFqn) + return insertBefore(import, importText + System.lineSeparator()) + } + } + + if (imports.isNotEmpty()) { + val last = imports[imports.size - 1] + logger.info("insert {} after last import: {}", importText, last.text) + return insertAfter(last, System.lineSeparator() + importText) + } + + ctx.ktFile.packageDirective?.also { pkg -> + logger.info("insert {} after package stmt: {}", importText, pkg.text) + return insertAfter(pkg, System.lineSeparator() + importText) + } + + logger.info("insert {} at top", importText) + val start = Position(0, 0) + return listOf( + TextEdit( + range = Range(start, start), + newText = importText + System.lineSeparator() + ) + ) +} + +context(ctx: AnalysisContext) +internal fun insertBefore(element: PsiElement, text: String): List { + val range = rangeOf(element) + return listOf( + TextEdit( + range = Range(range.start, range.start), + newText = text + ) + ) +} + +context(ctx: AnalysisContext) +internal fun insertAfter(element: PsiElement, text: String): List { + val range = rangeOf(element) + return listOf( + TextEdit( + range = Range(range.end, range.end), + newText = text + ) + ) +} + +context(ctx: AnalysisContext) +internal fun rangeOf(element: PsiElement): Range { + return element.textRange.toRange(ctx.ktFile) +} \ No newline at end of file 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/utils/ModifierFilter.kt similarity index 93% rename from lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/ModifierFilter.kt rename to lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ModifierFilter.kt index 5f0710e1d8..406f9f0ac8 100644 --- 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/utils/ModifierFilter.kt @@ -1,5 +1,7 @@ -package com.itsaky.androidide.lsp.kotlin.completion +package com.itsaky.androidide.lsp.kotlin.utils +import com.itsaky.androidide.lsp.kotlin.completion.DeclarationContext +import com.itsaky.androidide.lsp.kotlin.completion.DeclarationKind import org.jetbrains.kotlin.com.intellij.psi.tree.TokenSet import org.jetbrains.kotlin.lexer.KtModifierKeywordToken import org.jetbrains.kotlin.lexer.KtTokens.* @@ -7,16 +9,18 @@ import org.jetbrains.kotlin.lexer.KtTokens.* /** * Helper for filtering modifier keywords for keyword completions. */ -object ModifierFilter { +internal object ModifierFilter { /** * Returns which modifier keywords are valid to suggest given the * current context, declaration kind, and already-present modifiers. */ fun validModifiers( - ctx: CursorContext, + ctx: AnalysisContext, ): Set { - val (_, _, _, _, _, _, declCtx, declKind, existing, _) = ctx + val existing = ctx.existingModifiers + val declCtx = ctx.declarationContext + val declKind = ctx.declarationKind val candidates = MODIFIER_KEYWORDS_ARRAY.toMutableSet() candidates -= existing diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolExts.kt new file mode 100644 index 0000000000..502d5c3aea --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolExts.kt @@ -0,0 +1,25 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.analysis.api.KaContextParameterApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.components.containingDeclaration +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol + +@OptIn(KaContextParameterApi::class) +context(session: KaSession) +val KaSymbol.containingTopLevelClassDeclaration: KaClassSymbol? + get() { + var current: KaSymbol? = this + + var lastClass: KaClassSymbol? = null + + while (current != null) { + if (current is KaClassSymbol) { + lastClass = current + } + current = current.containingDeclaration + } + + return lastClass + } \ No newline at end of file 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/utils/SymbolVisibilityChecker.kt similarity index 98% rename from lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/SymbolVisibilityChecker.kt rename to lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt index 010b187e41..c8d0398ff6 100644 --- 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/utils/SymbolVisibilityChecker.kt @@ -1,4 +1,4 @@ -package com.itsaky.androidide.lsp.kotlin.completion +package com.itsaky.androidide.lsp.kotlin.utils import com.itsaky.androidide.lsp.kotlin.compiler.ModuleResolver import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CompletionData.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CompletionData.kt index a90a310ea3..7ff9a2cb59 100644 --- a/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CompletionData.kt +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/models/CompletionData.kt @@ -34,19 +34,19 @@ interface ICompletionData data class ClassCompletionData @JvmOverloads constructor(val className: String, val isNested: Boolean = false, val topLevelClass: String = "") : - ICompletionData { - val simpleName: String - get() { - return className.substringAfterLast(delimiter = '.') - } + ICompletionData { + val simpleName: String + get() { + return className.substringAfterLast(delimiter = '.') + } - val nameWithoutTopLevel: String - get() { - if (!isNested) { - return className - } - return className.substring(topLevelClass.length + 1) - } + val nameWithoutTopLevel: String + get() { + if (!isNested) { + return className + } + return className.substring(topLevelClass.length + 1) + } } /** @@ -56,14 +56,14 @@ constructor(val className: String, val isNested: Boolean = false, val topLevelCl * @property classInfo Information about the class [memberName] is a member of. */ interface MemberCompletionData : ICompletionData { - val memberName: String - val classInfo: ClassCompletionData + val memberName: String + val classInfo: ClassCompletionData } /** Information about a field-related completion item. */ data class FieldCompletionData( - override val memberName: String, - override val classInfo: ClassCompletionData + override val memberName: String, + override val classInfo: ClassCompletionData ) : MemberCompletionData /** @@ -73,9 +73,9 @@ data class FieldCompletionData( * @property plusOverloads The number of existing overloaded versions of this method. */ data class MethodCompletionData( - override val memberName: String, - override val classInfo: ClassCompletionData, - val parameterTypes: List, - val erasedParameterTypes: List, - val plusOverloads: Int + override val memberName: String, + override val classInfo: ClassCompletionData, + val parameterTypes: List, + val erasedParameterTypes: List, + val plusOverloads: Int ) : MemberCompletionData diff --git a/lsp/models/src/main/java/com/itsaky/androidide/lsp/util/RewriteHelper.kt b/lsp/models/src/main/java/com/itsaky/androidide/lsp/util/RewriteHelper.kt index cc117da656..5ff6979c1e 100644 --- a/lsp/models/src/main/java/com/itsaky/androidide/lsp/util/RewriteHelper.kt +++ b/lsp/models/src/main/java/com/itsaky/androidide/lsp/util/RewriteHelper.kt @@ -22,24 +22,29 @@ import com.itsaky.androidide.lsp.models.TextEdit import io.github.rosemoe.sora.widget.CodeEditor /** @author Akash Yadav */ -class RewriteHelper { - companion object { - @UiThread - @JvmStatic - fun performEdits(edits: List, editor: CodeEditor) { - if (edits.isEmpty()) { - return - } +object RewriteHelper { + @UiThread + @JvmStatic + fun performEdits(edits: List, editor: CodeEditor) { + if (edits.isEmpty()) { + return + } - edits.forEach { - val s = it.range.start - val e = it.range.end - if (s == e) { - editor.text.insert(s.line, s.column, it.newText) - } else { - editor.text.replace(s.line, s.column, e.line, e.column, it.newText) - } - } - } - } + edits.forEach { + val s = it.range.start + val e = it.range.end + editor.text.apply { + if (s == e) { + val line = s.line + var column = s.column + if (column > getColumnCount(line)) { + column = getColumnCount(line) + } + editor.text.insert(line, column, it.newText) + } else { + editor.text.replace(s.line, s.column, e.line, e.column, it.newText) + } + } + } + } } From eed2d03841c1fb8f992f08d8b003ffeb8961246b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Apr 2026 15:16:35 +0530 Subject: [PATCH 33/49] fix: use internal name repr for library index Signed-off-by: Akash Yadav --- .../indexing/jvm/JarSymbolScanner.kt | 87 ++++----- .../codeonthego/indexing/jvm/JvmSymbol.kt | 165 +++++++++++------- .../indexing/jvm/JvmSymbolDescriptor.kt | 82 ++++----- .../indexing/jvm/KotlinMetadataScanner.kt | 88 +++++----- .../src/main/proto/jvm_symbol.proto | 36 ++-- .../kotlin/completion/KotlinCompletions.kt | 4 +- 6 files changed, 253 insertions(+), 209 deletions(-) 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 index 89fd0ab492..19a8422d69 100644 --- 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 @@ -122,20 +122,20 @@ object JarSymbolScanner { val supertypes = buildList { superName?.let { - if (it != "java/lang/Object") add(it.replace('/', '.')) + if (it != "java/lang/Object") add(it) } - interfaces?.forEach { add(it.replace('/', '.')) } + interfaces?.forEach { add(it) } } val containingClass = if (isInnerClass) { - classFqName.split('.').dropLast(1).joinToString(".") + className.substringBeforeLast('$') } else "" symbols.add( JvmSymbol( - key = classFqName, + key = className, sourceId = sourceId, - fqName = classFqName, + name = classFqName, shortName = shortClassName.split('.').last(), packageName = packageName, kind = kind, @@ -143,8 +143,9 @@ object JarSymbolScanner { visibility = visibilityFromAccess(classAccess), isDeprecated = classDeprecated, data = JvmClassInfo( - containingClassFqName = containingClass, - supertypeFqNames = supertypes, + internalName = className, + containingClassName = containingClass, + supertypeNames = supertypes, isAbstract = hasFlag(classAccess, Opcodes.ACC_ABSTRACT), isFinal = hasFlag(classAccess, Opcodes.ACC_FINAL), isInner = isInnerClass && !hasFlag(classAccess, Opcodes.ACC_STATIC), @@ -177,21 +178,21 @@ object JarSymbolScanner { val parameters = paramTypes.map { type -> JvmParameterInfo( name = "", // not available without -parameters flag - typeFqName = typeToFqName(type), - typeDisplay = typeToDisplay(type), + typeName = typeToName(type), + typeDisplayName = typeToDisplayName(type), ) } - val fqName = "$classFqName.$methodName" - val key = "$fqName(${parameters.joinToString(",") { it.typeFqName }})" + val fqName = "$className#$methodName" + val key = "$fqName(${parameters.joinToString(",") { it.typeName }})" val signatureDisplay = buildString { append("(") - append(parameters.joinToString(", ") { it.typeDisplay }) + append(parameters.joinToString(", ") { it.typeDisplayName }) append(")") if (!isConstructor) { append(": ") - append(typeToDisplay(returnType)) + append(typeToDisplayName(returnType)) } } @@ -199,7 +200,7 @@ object JarSymbolScanner { JvmSymbol( key = key, sourceId = sourceId, - fqName = fqName, + name = fqName, shortName = methodName, packageName = packageName, kind = kind, @@ -207,9 +208,9 @@ object JarSymbolScanner { visibility = visibilityFromAccess(access), isDeprecated = classDeprecated, data = JvmFunctionInfo( - containingClassFqName = classFqName, - returnTypeFqName = typeToFqName(returnType), - returnTypeDisplay = typeToDisplay(returnType), + containingClassName = className, + returnTypeName = typeToName(returnType), + returnTypeDisplayName = typeToDisplayName(returnType), parameterCount = paramTypes.size, parameters = parameters, signatureDisplay = signatureDisplay, @@ -234,13 +235,13 @@ object JarSymbolScanner { 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" + val iName = "$className#$name" symbols.add( JvmSymbol( - key = fqName, + key = iName, sourceId = sourceId, - fqName = fqName, + name = iName, shortName = name, packageName = packageName, kind = kind, @@ -248,9 +249,9 @@ object JarSymbolScanner { visibility = visibilityFromAccess(access), isDeprecated = classDeprecated, data = JvmFieldInfo( - containingClassFqName = classFqName, - typeFqName = typeToFqName(fieldType), - typeDisplay = typeToDisplay(fieldType), + containingClassName = className, + typeName = typeToName(fieldType), + typeDisplayName = typeToDisplayName(fieldType), isStatic = hasFlag(access, Opcodes.ACC_STATIC), isFinal = hasFlag(access, Opcodes.ACC_FINAL), constantValue = value?.toString() ?: "", @@ -280,26 +281,34 @@ object JarSymbolScanner { 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 typeToName(type: Type): String = when (type.sort) { + Type.VOID -> "V" + Type.BOOLEAN -> "Z" + Type.BYTE -> "B" + Type.CHAR -> "C" + Type.SHORT -> "S" + Type.INT -> "I" + Type.LONG -> "J" + Type.FLOAT -> "F" + Type.DOUBLE -> "D" + Type.ARRAY -> "[".repeat(type.dimensions) + typeToName(type.elementType) + Type.OBJECT -> type.internalName + else -> type.internalName } - private fun typeToDisplay(type: Type): String = when (type.sort) { + private fun typeToDisplayName(type: Type): String = when (type.sort) { + 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.VOID -> "void" - Type.ARRAY -> typeToDisplay(type.elementType) + "[]".repeat(type.dimensions) + Type.ARRAY -> typeToDisplayName(type.elementType) + "[]".repeat(type.dimensions) Type.OBJECT -> type.className.substringAfterLast('.') - else -> typeToFqName(type) + else -> typeToName(type) } } } 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 index fdbd2c20bd..81e74380d3 100644 --- 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 @@ -54,40 +54,40 @@ enum class JvmVisibility { * - [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, + override val key: String, + override val sourceId: String, + + val name: 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() + get() = data.containingClassName.isEmpty() val isExtension: Boolean get() = kind.isExtension - val receiverTypeFqName: String? + val receiverTypeName: String? get() = when (val d = data) { - is JvmFunctionInfo -> d.kotlin?.receiverTypeFqName?.takeIf { it.isNotEmpty() } - is JvmFieldInfo -> d.kotlin?.receiverTypeFqName?.takeIf { it.isNotEmpty() } + is JvmFunctionInfo -> d.kotlin?.receiverTypeName?.takeIf { it.isNotEmpty() } + is JvmFieldInfo -> d.kotlin?.receiverTypeName?.takeIf { it.isNotEmpty() } else -> null } - val containingClassFqName: String - get() = data.containingClassFqName + val containingClassName: String + get() = data.containingClassName val returnTypeDisplay: String get() = when (val d = data) { - is JvmFunctionInfo -> d.returnTypeDisplay - is JvmFieldInfo -> d.typeDisplay + is JvmFunctionInfo -> d.returnTypeDisplayName + is JvmFieldInfo -> d.typeDisplayName else -> "" } @@ -100,21 +100,32 @@ data class JvmSymbol( /** * Base for all type-specific symbol data. - * Every variant provides [containingClassFqName] (empty for top-level). + * Every variant provides [containingClassName] (empty for top-level). */ sealed interface JvmSymbolInfo { - val containingClassFqName: String + + /** + * The internal name of the containing class. + */ + val containingClassName: String + + /** + * The fully qualified name of the containing class, in dot format. + */ + val containingClassFqName: String + get() = containingClassName.toFqName() } 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, + val internalName: String = "", + override val containingClassName: String = "", + val supertypeNames: 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( @@ -130,32 +141,38 @@ data class KotlinClassInfo( ) 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 + override val containingClassName: String = "", + val returnTypeName: String = "", + val returnTypeDisplayName: 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 { + val returnTypeFqName: String + get() = returnTypeName.toFqName() +} data class JvmParameterInfo( val name: String, - val typeFqName: String, - val typeDisplay: String, + val typeName: String, + val typeDisplayName: String, val hasDefaultValue: Boolean = false, val isCrossinline: Boolean = false, val isNoinline: Boolean = false, val isVararg: Boolean = false, -) +) { + val typeFqName: String + get() = typeName.toFqName() +} data class KotlinFunctionInfo( - val receiverTypeFqName: String = "", - val receiverTypeDisplay: String = "", + val receiverTypeName: String = "", + val receiverTypeDisplayName: String = "", val isSuspend: Boolean = false, val isInline: Boolean = false, val isInfix: Boolean = false, @@ -165,21 +182,27 @@ data class KotlinFunctionInfo( val isExpect: Boolean = false, val isActual: Boolean = false, val isReturnTypeNullable: Boolean = false, -) +) { + val receiverTypeFqName: String + get() = receiverTypeName.toFqName() +} 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 + override val containingClassName: String = "", + val typeName: String = "", + val typeDisplayName: String = "", + val isStatic: Boolean = false, + val isFinal: Boolean = false, + val constantValue: String = "", + val kotlin: KotlinPropertyInfo? = null, +) : JvmSymbolInfo { + val typeFqName: String + get() = typeName.toFqName() +} data class KotlinPropertyInfo( - val receiverTypeFqName: String = "", - val receiverTypeDisplay: String = "", + val receiverTypeName: String = "", + val receiverTypeDisplayName: String = "", val isConst: Boolean = false, val isLateinit: Boolean = false, val hasGetter: Boolean = false, @@ -189,16 +212,26 @@ data class KotlinPropertyInfo( val isActual: Boolean = false, val isExternal: Boolean = false, val isTypeNullable: Boolean = false, -) +) { + val receiverTypeFqName: String + get() = receiverTypeName.toFqName() +} data class JvmEnumEntryInfo( - override val containingClassFqName: String = "", - val ordinal: Int = 0, + override val containingClassName: String = "", + val ordinal: Int = 0, ) : JvmSymbolInfo data class JvmTypeAliasInfo( - override val containingClassFqName: String = "", - val expandedTypeFqName: String = "", - val expandedTypeDisplay: String = "", - val typeParameters: List = emptyList(), -) : JvmSymbolInfo + override val containingClassName: String = "", + val expandedTypeName: String = "", + val expandedTypeDisplayName: String = "", + val typeParameters: List = emptyList(), +) : JvmSymbolInfo { + val expandedTypeFqName: String + get() = expandedTypeName.toFqName() +} + +private fun String.toFqName() = + replace('/', '.') + .replace('$', '.') \ No newline at end of file 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 4d34d1b55d..c414709759 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 @@ -42,8 +42,8 @@ object JvmSymbolDescriptor : IndexDescriptor { 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_RECEIVER_TYPE to entry.receiverTypeName, + KEY_CONTAINING_CLASS to entry.containingClassName.ifEmpty { null }, KEY_LANGUAGE to entry.language.name, ) @@ -55,7 +55,7 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun toProto(s: JvmSymbol): JvmSymbolData { val builder = JvmSymbolData.newBuilder() - .setFqName(s.fqName) + .setName(s.name) .setShortName(s.shortName) .setPackageName(s.packageName) .setSourceId(s.sourceId) @@ -77,8 +77,8 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun classInfoToProto(d: JvmClassInfo): JvmSymbolProtos.ClassData { val builder = JvmSymbolProtos.ClassData.newBuilder() - .setContainingClassFqName(d.containingClassFqName) - .addAllSupertypeFqNames(d.supertypeFqNames) + .setContainingClassName(d.containingClassName) + .addAllSupertypeNames(d.supertypeNames) .addAllTypeParameters(d.typeParameters) .setIsAbstract(d.isAbstract) .setIsFinal(d.isFinal) @@ -105,9 +105,9 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun functionInfoToProto(d: JvmFunctionInfo): JvmSymbolProtos.FunctionData { val builder = JvmSymbolProtos.FunctionData.newBuilder() - .setContainingClassFqName(d.containingClassFqName) - .setReturnTypeFqName(d.returnTypeFqName) - .setReturnTypeDisplay(d.returnTypeDisplay) + .setContainingClassName(d.containingClassName) + .setReturnTypeName(d.returnTypeName) + .setReturnTypeDisplayName(d.returnTypeDisplayName) .setParameterCount(d.parameterCount) .addAllParameters(d.parameters.map { paramToProto(it) }) .setSignatureDisplay(d.signatureDisplay) @@ -119,8 +119,8 @@ object JvmSymbolDescriptor : IndexDescriptor { d.kotlin?.let { kd -> builder.setKotlin( JvmSymbolProtos.KotlinFunctionData.newBuilder() - .setReceiverTypeFqName(kd.receiverTypeFqName) - .setReceiverTypeDisplay(kd.receiverTypeDisplay) + .setReceiverTypeName(kd.receiverTypeName) + .setReceiverTypeDisplayName(kd.receiverTypeDisplayName) .setIsSuspend(kd.isSuspend) .setIsInline(kd.isInline) .setIsInfix(kd.isInfix) @@ -139,8 +139,8 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun paramToProto(p: JvmParameterInfo): JvmSymbolProtos.ParameterData = JvmSymbolProtos.ParameterData.newBuilder() .setName(p.name) - .setTypeFqName(p.typeFqName) - .setTypeDisplay(p.typeDisplay) + .setTypeName(p.typeName) + .setTypeDisplayName(p.typeDisplayName) .setHasDefaultValue(p.hasDefaultValue) .setIsCrossinline(p.isCrossinline) .setIsNoinline(p.isNoinline) @@ -149,9 +149,9 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun fieldInfoToProto(d: JvmFieldInfo): JvmSymbolProtos.FieldData { val builder = JvmSymbolProtos.FieldData.newBuilder() - .setContainingClassFqName(d.containingClassFqName) - .setTypeFqName(d.typeFqName) - .setTypeDisplay(d.typeDisplay) + .setContainingClassName(d.containingClassName) + .setTypeName(d.typeName) + .setTypeDisplayName(d.typeDisplayName) .setIsStatic(d.isStatic) .setIsFinal(d.isFinal) .setConstantValue(d.constantValue) @@ -159,8 +159,8 @@ object JvmSymbolDescriptor : IndexDescriptor { d.kotlin?.let { kd -> builder.setKotlin( JvmSymbolProtos.KotlinPropertyData.newBuilder() - .setReceiverTypeFqName(kd.receiverTypeFqName) - .setReceiverTypeDisplay(kd.receiverTypeDisplay) + .setReceiverTypeName(kd.receiverTypeName) + .setReceiverTypeDisplayName(kd.receiverTypeDisplayName) .setIsConst(kd.isConst) .setIsLateinit(kd.isLateinit) .setHasGetter(kd.hasGetter) @@ -178,14 +178,14 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun enumEntryToProto(d: JvmEnumEntryInfo): JvmSymbolProtos.EnumEntryData = JvmSymbolProtos.EnumEntryData.newBuilder() - .setContainingEnumFqName(d.containingClassFqName) + .setContainingEnumName(d.containingClassName) .setOrdinal(d.ordinal) .build() private fun typeAliasToProto(d: JvmTypeAliasInfo): JvmSymbolProtos.TypeAliasData = JvmSymbolProtos.TypeAliasData.newBuilder() - .setExpandedTypeFqName(d.expandedTypeFqName) - .setExpandedTypeDisplay(d.expandedTypeDisplay) + .setExpandedTypeName(d.expandedTypeName) + .setExpandedTypeDisplayName(d.expandedTypeDisplayName) .addAllTypeParameters(d.typeParameters) .build() @@ -199,17 +199,17 @@ object JvmSymbolDescriptor : IndexDescriptor { && kind != JvmSymbolKind.FIELD -> { val params = (data as? JvmFunctionInfo) ?.parameters - ?.joinToString(",") { it.typeFqName } + ?.joinToString(",") { it.typeName } ?: "" - "${p.fqName}($params)" + "${p.name}($params)" } - else -> p.fqName + else -> p.name } return JvmSymbol( key = key, sourceId = p.sourceId, - fqName = p.fqName, + name = p.name, shortName = p.shortName, packageName = p.packageName, kind = kind, @@ -246,8 +246,8 @@ object JvmSymbolDescriptor : IndexDescriptor { } else null return JvmClassInfo( - containingClassFqName = p.containingClassFqName, - supertypeFqNames = p.supertypeFqNamesList.toList(), + containingClassName = p.containingClassName, + supertypeNames = p.supertypeNamesList.toList(), typeParameters = p.typeParametersList.toList(), isAbstract = p.isAbstract, isFinal = p.isFinal, @@ -261,8 +261,8 @@ object JvmSymbolDescriptor : IndexDescriptor { val kotlin = if (p.hasKotlin()) { val kd = p.kotlin KotlinFunctionInfo( - receiverTypeFqName = kd.receiverTypeFqName, - receiverTypeDisplay = kd.receiverTypeDisplay, + receiverTypeName = kd.receiverTypeName, + receiverTypeDisplayName = kd.receiverTypeDisplayName, isSuspend = kd.isSuspend, isInline = kd.isInline, isInfix = kd.isInfix, @@ -276,9 +276,9 @@ object JvmSymbolDescriptor : IndexDescriptor { } else null return JvmFunctionInfo( - containingClassFqName = p.containingClassFqName, - returnTypeFqName = p.returnTypeFqName, - returnTypeDisplay = p.returnTypeDisplay, + containingClassName = p.containingClassName, + returnTypeName = p.returnTypeName, + returnTypeDisplayName = p.returnTypeDisplayName, parameterCount = p.parameterCount, parameters = p.parametersList.map { paramFromProto(it) }, signatureDisplay = p.signatureDisplay, @@ -293,8 +293,8 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun paramFromProto(p: JvmSymbolProtos.ParameterData): JvmParameterInfo = JvmParameterInfo( name = p.name, - typeFqName = p.typeFqName, - typeDisplay = p.typeDisplay, + typeName = p.typeName, + typeDisplayName = p.typeDisplayName, hasDefaultValue = p.hasDefaultValue, isCrossinline = p.isCrossinline, isNoinline = p.isNoinline, @@ -305,8 +305,8 @@ object JvmSymbolDescriptor : IndexDescriptor { val kotlin = if (p.hasKotlin()) { val kd = p.kotlin KotlinPropertyInfo( - receiverTypeFqName = kd.receiverTypeFqName, - receiverTypeDisplay = kd.receiverTypeDisplay, + receiverTypeName = kd.receiverTypeName, + receiverTypeDisplayName = kd.receiverTypeDisplayName, isConst = kd.isConst, isLateinit = kd.isLateinit, hasGetter = kd.hasGetter, @@ -320,9 +320,9 @@ object JvmSymbolDescriptor : IndexDescriptor { } else null return JvmFieldInfo( - containingClassFqName = p.containingClassFqName, - typeFqName = p.typeFqName, - typeDisplay = p.typeDisplay, + containingClassName = p.containingClassName, + typeName = p.typeName, + typeDisplayName = p.typeDisplayName, isStatic = p.isStatic, isFinal = p.isFinal, constantValue = p.constantValue, @@ -332,14 +332,14 @@ object JvmSymbolDescriptor : IndexDescriptor { private fun enumEntryFromProto(p: JvmSymbolProtos.EnumEntryData): JvmEnumEntryInfo = JvmEnumEntryInfo( - containingClassFqName = p.containingEnumFqName, + containingClassName = p.containingEnumName, ordinal = p.ordinal, ) private fun typeAliasFromProto(p: JvmSymbolProtos.TypeAliasData): JvmTypeAliasInfo = JvmTypeAliasInfo( - expandedTypeFqName = p.expandedTypeFqName, - expandedTypeDisplay = p.expandedTypeDisplay, + expandedTypeName = p.expandedTypeName, + expandedTypeDisplayName = p.expandedTypeDisplayName, typeParameters = p.typeParametersList.toList(), ) 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 691d50dd25..59dc810c8a 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 @@ -112,9 +112,11 @@ object KotlinMetadataScanner { klass: KmClass, sourceId: String, ): List { val symbols = mutableListOf() - val classFqName = klass.name.replace('/', '.') - val packageName = classFqName.substringBeforeLast('.', "") - val shortName = classFqName.substringAfterLast('.') + val className = klass.name + val packageName = className.substringBeforeLast('/') + .replace('/', '.') + val shortName = className.substringAfterLast('/') + .substringAfterLast('$') val kind = when (klass.kind) { ClassKind.INTERFACE -> JvmSymbolKind.INTERFACE @@ -128,23 +130,23 @@ object KotlinMetadataScanner { val supertypes = klass.supertypes.mapNotNull { supertype -> when (val c = supertype.classifier) { - is KmClassifier.Class -> c.name.replace('/', '.') + is KmClassifier.Class -> c.name else -> null } } symbols.add( JvmSymbol( - key = classFqName, + key = className, sourceId = sourceId, - fqName = classFqName, + name = className, shortName = shortName, packageName = packageName, kind = kind, language = JvmSourceLanguage.KOTLIN, visibility = kmVisibility(klass.visibility), data = JvmClassInfo( - supertypeFqNames = supertypes, + supertypeNames = supertypes, typeParameters = klass.typeParameters.map { it.name }, isAbstract = klass.modality == Modality.ABSTRACT, isFinal = klass.modality == Modality.FINAL, @@ -157,26 +159,26 @@ object KotlinMetadataScanner { ) for (fn in klass.functions) { - extractFunction(fn, classFqName, packageName, sourceId)?.let { symbols.add(it) } + extractFunction(fn, className, packageName, sourceId)?.let { symbols.add(it) } } for (prop in klass.properties) { - extractProperty(prop, classFqName, packageName, sourceId)?.let { symbols.add(it) } + extractProperty(prop, className, packageName, sourceId)?.let { symbols.add(it) } } if (kind == JvmSymbolKind.ENUM) { klass.kmEnumEntries.forEachIndexed { ordinal, entry -> symbols.add( JvmSymbol( - key = "$classFqName.$entry", + key = "$className#$entry", sourceId = sourceId, - fqName = "$classFqName.$entry", + name = "$className#$entry", shortName = entry.name, packageName = packageName, kind = JvmSymbolKind.ENUM_ENTRY, language = JvmSourceLanguage.KOTLIN, data = JvmEnumEntryInfo( - containingClassFqName = classFqName, + containingClassName = className, ordinal = ordinal, ), ) @@ -208,15 +210,15 @@ object KotlinMetadataScanner { JvmSymbol( key = fqName, sourceId = sourceId, - fqName = fqName, + name = 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), + expandedTypeName = kmTypeToName(alias.expandedType), + expandedTypeDisplayName = kmTypeToDisplayName(alias.expandedType), typeParameters = alias.typeParameters.map { it.name }, ), ) @@ -242,44 +244,44 @@ object KotlinMetadataScanner { val parameters = fn.valueParameters.map { param -> JvmParameterInfo( name = param.name, - typeFqName = kmTypeToFqName(param.type), - typeDisplay = kmTypeToDisplay(param.type), + typeName = kmTypeToName(param.type), + typeDisplayName = kmTypeToDisplayName(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 name = if (containingClass.isNotEmpty()) + "$containingClass#${fn.name}" else "$packageName#${fn.name}" + val key = "$name(${parameters.joinToString(",") { it.typeFqName }})" val signatureDisplay = buildString { append("(") - append(parameters.joinToString(", ") { "${it.name}: ${it.typeDisplay}" }) + append(parameters.joinToString(", ") { "${it.name}: ${it.typeDisplayName}" }) append("): ") - append(kmTypeToDisplay(fn.returnType)) + append(kmTypeToDisplayName(fn.returnType)) } return JvmSymbol( key = key, sourceId = sourceId, - fqName = baseFqName, + name = name, shortName = fn.name, packageName = packageName, kind = kind, language = JvmSourceLanguage.KOTLIN, visibility = vis, data = JvmFunctionInfo( - containingClassFqName = containingClass, - returnTypeFqName = kmTypeToFqName(fn.returnType), - returnTypeDisplay = kmTypeToDisplay(fn.returnType), + containingClassName = containingClass, + returnTypeName = kmTypeToName(fn.returnType), + returnTypeDisplayName = kmTypeToDisplayName(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) } ?: "", + receiverTypeName = receiverType?.let { kmTypeToName(it) } ?: "", + receiverTypeDisplayName = receiverType?.let { kmTypeToDisplayName(it) } ?: "", isSuspend = fn.isSuspend, isInline = fn.isInline, isInfix = fn.isInfix, @@ -306,25 +308,25 @@ object KotlinMetadataScanner { 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}" + val name = if (containingClass.isNotEmpty()) + "$containingClass#${prop.name}" else "$packageName#${prop.name}" return JvmSymbol( - key = fqName, + key = name, sourceId = sourceId, - fqName = fqName, + name = name, shortName = prop.name, packageName = packageName, kind = kind, language = JvmSourceLanguage.KOTLIN, visibility = vis, data = JvmFieldInfo( - containingClassFqName = containingClass, - typeFqName = kmTypeToFqName(prop.returnType), - typeDisplay = kmTypeToDisplay(prop.returnType), + containingClassName = containingClass, + typeName = kmTypeToName(prop.returnType), + typeDisplayName = kmTypeToDisplayName(prop.returnType), kotlin = KotlinPropertyInfo( - receiverTypeFqName = receiverType?.let { kmTypeToFqName(it) } ?: "", - receiverTypeDisplay = receiverType?.let { kmTypeToDisplay(it) } ?: "", + receiverTypeName = receiverType?.let { kmTypeToName(it) } ?: "", + receiverTypeDisplayName = receiverType?.let { kmTypeToDisplayName(it) } ?: "", isConst = prop.isConst, isLateinit = prop.isLateinit, hasGetter = prop.getter != null, @@ -336,15 +338,15 @@ object KotlinMetadataScanner { ) } - private fun kmTypeToFqName(type: KmType): String = when (val c = type.classifier) { - is KmClassifier.Class -> c.name.replace('/', '.') - is KmClassifier.TypeAlias -> c.name.replace('/', '.') + private fun kmTypeToName(type: KmType): String = when (val c = type.classifier) { + is KmClassifier.Class -> c.name + is KmClassifier.TypeAlias -> c.name 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) } } + private fun kmTypeToDisplayName(type: KmType): String { + val base = kmTypeToDisplayName(type).substringAfterLast('.') + val args = type.arguments.mapNotNull { it.type?.let { t -> kmTypeToDisplayName(t) } } return buildString { append(base) if (args.isNotEmpty()) append("<${args.joinToString(", ")}>") diff --git a/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto b/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto index a925e45979..0c92e0ab8e 100644 --- a/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto +++ b/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto @@ -8,7 +8,7 @@ option java_multiple_files = false; message JvmSymbolData { - string fq_name = 1; + string name = 1; string short_name = 2; string package_name = 3; string source_id = 4; @@ -30,10 +30,10 @@ message JvmSymbolData { message ClassData { // FQN of the enclosing class (empty for top-level classes) - string containing_class_fq_name = 1; + string containing_class_name = 1; // Direct supertypes - repeated string supertype_fq_names = 2; + repeated string supertype_names = 2; // Type parameters: ["T", "R : Comparable"] repeated string type_parameters = 3; @@ -66,11 +66,11 @@ message KotlinClassData { message FunctionData { // FQN of the containing class (empty for top-level functions) - string containing_class_fq_name = 1; + string containing_class_name = 1; // Return type - string return_type_fq_name = 2; - string return_type_display = 3; + string return_type_name = 2; + string return_type_display_name = 3; // Parameters int32 parameter_count = 4; @@ -92,8 +92,8 @@ message FunctionData { message ParameterData { string name = 1; - string type_fq_name = 2; - string type_display = 3; + string type_name = 2; + string type_display_name = 3; bool has_default_value = 4; bool is_crossinline = 5; @@ -103,8 +103,8 @@ message ParameterData { message KotlinFunctionData { // Extension receiver type - string receiver_type_fq_name = 1; - string receiver_type_display = 2; + string receiver_type_name = 1; + string receiver_type_display_name = 2; // Modifiers bool is_suspend = 3; @@ -122,11 +122,11 @@ message KotlinFunctionData { message FieldData { // FQN of the containing class (empty for top-level properties) - string containing_class_fq_name = 1; + string containing_class_name = 1; // Type of the field/property - string type_fq_name = 2; - string type_display = 3; + string type_name = 2; + string type_display_name = 3; // Modifiers bool is_static = 4; @@ -140,8 +140,8 @@ message FieldData { message KotlinPropertyData { // Extension receiver type - string receiver_type_fq_name = 1; - string receiver_type_display = 2; + string receiver_type_name = 1; + string receiver_type_display_name = 2; bool is_const = 3; bool is_lateinit = 4; @@ -157,7 +157,7 @@ message KotlinPropertyData { message EnumEntryData { // FQN of the containing enum class - string containing_enum_fq_name = 1; + string containing_enum_name = 1; // Ordinal position int32 ordinal = 2; @@ -165,8 +165,8 @@ message EnumEntryData { message TypeAliasData { // The type this alias expands to - string expanded_type_fq_name = 1; - string expanded_type_display = 2; + string expanded_type_name = 1; + string expanded_type_display_name = 2; // Type parameters: ["T"] repeated string type_parameters = 3; 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 e1ca3b905f..9b1b1e25b3 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 @@ -332,9 +332,9 @@ private suspend fun KaSession.collectUnimportedSymbols( in JvmSymbolKind.CLASSIFIER_KINDS -> { val classInfo = symbol.data as JvmClassInfo - item.detail = symbol.fqName + item.detail = symbol.name item.setClassCompletionData( - className = symbol.fqName, + className = symbol.name, isNested = classInfo.isInner, topLevelClass = classInfo.containingClassFqName, ) From e6fcf6e7788e9b90912fade661222f005b71238f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Apr 2026 15:31:36 +0530 Subject: [PATCH 34/49] fix: filter-out ext syms with inapplicable receivers Signed-off-by: Akash Yadav --- .../kotlin/completion/KotlinCompletions.kt | 31 +++++++++++++++++-- 1 file changed, 29 insertions(+), 2 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 9b1b1e25b3..409f85f946 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 @@ -50,6 +50,8 @@ 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.ClassId +import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtQualifiedExpression @@ -303,8 +305,22 @@ private suspend fun KaSession.collectUnimportedSymbols( return@collect } - // TODO: filter-out callables with a receiver type whose receiver - // is not an implicit receiver at the current use-site + if (symbol.isExtension) { + val receiverTypeName = symbol.receiverTypeName + if (receiverTypeName != null) { + val receiverClassId = internalNameToClassId(receiverTypeName) + val receiverType = findClass(receiverClassId) + if (receiverType != null) { + val satisfiesImplicitReceivers = ctx.scopeContext.implicitReceivers.any { receiver -> + receiver.type.isSubtypeOf(receiverType) + } + + // the extension property/function's receiver type + // is not available in current context, so ignore this sym + if (!satisfiesImplicitReceivers) return@collect + } else return@collect + } + } val item = ktCompletionItem( name = symbol.shortName, @@ -348,6 +364,17 @@ private suspend fun KaSession.collectUnimportedSymbols( } } +private fun internalNameToClassId(internalName: String): ClassId { + val isLocal = false + val packageName = internalName.substringBeforeLast('/') + val relativeName = internalName.substringAfterLast('/') + return ClassId( + packageFqName = FqName.fromSegments(packageName.split('.')), + relativeClassName = FqName.fromSegments(relativeName.split('$')), + isLocal = isLocal + ) +} + context(ctx: AnalysisContext) private fun KaSession.collectKeywordCompletions( to: MutableList, From 9fa3d4a1b2189eff187ecc0d2a30662010b47dd9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Apr 2026 19:38:10 +0530 Subject: [PATCH 35/49] feat: add Kotlin source file index Signed-off-by: Akash Yadav --- .../{PersistentIndex.kt => SQLiteIndex.kt} | 9 +- lsp/jvm-symbol-index/build.gradle.kts | 1 + .../indexing/jvm/JvmLibrarySymbolIndex.kt | 6 +- .../jvm/KotlinSourceIndexingService.kt | 162 +++++++++++++++ .../indexing/jvm/KotlinSourceScanner.kt | 195 ++++++++++++++++++ .../indexing/jvm/KotlinSourceSymbolIndex.kt | 173 ++++++++++++++++ .../lsp/kotlin/KotlinLanguageServer.kt | 8 + .../kotlin/compiler/CompilationEnvironment.kt | 4 + .../lsp/kotlin/compiler/KotlinProjectModel.kt | 8 + .../kotlin/completion/KotlinCompletions.kt | 141 +++++++------ 10 files changed, 637 insertions(+), 70 deletions(-) rename lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/{PersistentIndex.kt => SQLiteIndex.kt} (97%) create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/PersistentIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt similarity index 97% rename from lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/PersistentIndex.kt rename to lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt index f3b0cf539b..8d885e533a 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/PersistentIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt @@ -18,7 +18,7 @@ import org.appdevforall.codeonthego.indexing.api.Indexable import kotlin.collections.iterator /** - * A persistent [Index] backed by SQLite via AndroidX. + * An [Index] backed by SQLite via AndroidX. * * Creates a table dynamically based on the [IndexDescriptor]: * ``` @@ -44,14 +44,15 @@ import kotlin.collections.iterator * @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 + * @param dbName Database file name. Pass `null` to create an in-memory database + * that is discarded when closed. 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( +class SQLiteIndex( override val descriptor: IndexDescriptor, context: Context, - dbName: String, + dbName: String?, override val name: String = "persistent:${descriptor.name}", private val batchSize: Int = 500, ) : Index { diff --git a/lsp/jvm-symbol-index/build.gradle.kts b/lsp/jvm-symbol-index/build.gradle.kts index 959f2264be..796860d0e3 100644 --- a/lsp/jvm-symbol-index/build.gradle.kts +++ b/lsp/jvm-symbol-index/build.gradle.kts @@ -21,4 +21,5 @@ dependencies { api(projects.lsp.jvmSymbolModels) api(projects.subprojects.kotlinAnalysisApi) api(projects.subprojects.projects) + api(projects.lsp.kotlinCore) } 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 ec52e5d633..c961203f11 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 @@ -5,7 +5,7 @@ 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.PersistentIndex +import org.appdevforall.codeonthego.indexing.SQLiteIndex 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 @@ -19,7 +19,7 @@ import java.io.Closeable */ class JvmLibrarySymbolIndex private constructor( /** Persistent cache — stores every JAR ever indexed. */ - val libraryCache: PersistentIndex, + val libraryCache: SQLiteIndex, /** Filtered view — only shows JARs on the current classpath. */ val libraryView: FilteredIndex, @@ -37,7 +37,7 @@ class JvmLibrarySymbolIndex private constructor( context: Context, dbName: String = DB_NAME_DEFAULT, ): JvmLibrarySymbolIndex { - val cache = PersistentIndex( + val cache = SQLiteIndex( descriptor = JvmSymbolDescriptor, context = context, dbName = dbName, diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt new file mode 100644 index 0000000000..2630d4ac2c --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt @@ -0,0 +1,162 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import android.content.Context +import com.itsaky.androidide.eventbus.events.editor.DocumentSaveEvent +import com.itsaky.androidide.eventbus.events.file.FileCreationEvent +import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent +import com.itsaky.androidide.eventbus.events.file.FileRenameEvent +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.projects.api.ModuleProject +import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +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.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Well-known registry key for the Kotlin source symbol index. + */ +val KOTLIN_SOURCE_SYMBOL_INDEX = IndexKey("kotlin-source-symbols") + +/** + * [IndexingService] that scans all Kotlin source files in the open project and + * maintains an in-memory [KotlinSourceSymbolIndex]. + */ +class KotlinSourceIndexingService( + private val context: Context, +) : IndexingService { + + companion object { + const val ID = "kotlin-source-indexing-service" + private val log = LoggerFactory.getLogger(KotlinSourceIndexingService::class.java) + } + + override val id = ID + override val providedKeys = listOf(KOTLIN_SOURCE_SYMBOL_INDEX) + + private var index: KotlinSourceSymbolIndex? = null + private val refreshMutex = Mutex() + private val coroutineScope = CoroutineScope(Dispatchers.Default) + + override suspend fun initialize(registry: IndexRegistry) { + val sourceIndex = KotlinSourceSymbolIndex.create(context) + this.index = sourceIndex + registry.register(KOTLIN_SOURCE_SYMBOL_INDEX, sourceIndex) + + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } + + log.info("Kotlin source symbol index initialized") + } + + override fun close() { + EventBus.getDefault().unregister(this) + coroutineScope.cancelIfActive("Kotlin source indexing service closed") + index?.close() + index = null + } + + /** + * Scans all `.kt` source files across all project modules and indexes any + * file not yet present in the in-memory index. + */ + fun refresh() { + coroutineScope.launch { + refreshMutex.withLock { indexAllSourceFiles() } + } + } + + private suspend fun indexAllSourceFiles() { + val index = this.index ?: run { + log.warn("Kotlin source index not initialized; skipping refresh") + return + } + + val workspace = ProjectManagerImpl.getInstance().workspace ?: run { + log.warn("Workspace model not available; skipping Kotlin source scan") + return + } + + val sourceFiles = workspace.subProjects + .asSequence() + .filterIsInstance() + .flatMap { module -> module.getSourceDirectories().asSequence() } + .filter { it.exists() && it.isDirectory } + .flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" } } + .map { it.absolutePath } + .toList() + + log.info("Found {} Kotlin source files to index", sourceFiles.size) + + var submitted = 0 + for (filePath in sourceFiles) { + if (!index.isFileCached(filePath)) { + submitted++ + index.indexFile(filePath) + } + } + + if (submitted > 0) { + log.info("{} Kotlin source files submitted for background indexing", submitted) + } else { + log.info("All Kotlin source files already cached, nothing to index") + } + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("UNUSED") + fun onFileCreated(event: FileCreationEvent) { + if (!event.file.isKotlinSource) return + val filePath = event.file.absolutePath + log.debug("File created, indexing: {}", filePath) + index?.indexFile(filePath) + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("UNUSED") + fun onFileSaved(event: DocumentSaveEvent) { + val filePath = event.savedFile.toAbsolutePath().toString() + if (!filePath.endsWith(".kt")) return + log.debug("File saved, re-indexing: {}", filePath) + index?.reindexFile(filePath) + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("UNUSED") + fun onFileDeleted(event: FileDeletionEvent) { + if (!event.file.isKotlinSource) return + val filePath = event.file.absolutePath + log.debug("File deleted, removing from index: {}", filePath) + index?.removeFile(filePath) + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("UNUSED") + fun onFileRenamed(event: FileRenameEvent) { + val oldPath = event.file.absolutePath + val newPath = event.newFile.absolutePath + + if (event.file.isKotlinSource) { + log.debug("File renamed, removing old path from index: {}", oldPath) + index?.removeFile(oldPath) + } + + if (event.newFile.isKotlinSource) { + log.debug("File renamed, indexing new path: {}", newPath) + index?.indexFile(newPath) + } + } + + private val File.isKotlinSource: Boolean + get() = isFile && extension == "kt" +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt new file mode 100644 index 0000000000..b91327e6ca --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt @@ -0,0 +1,195 @@ +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.appdevforall.codeonthego.lsp.kotlin.index.FileIndex +import org.appdevforall.codeonthego.lsp.kotlin.index.IndexedSymbol +import org.appdevforall.codeonthego.lsp.kotlin.index.IndexedSymbolKind +import org.appdevforall.codeonthego.lsp.kotlin.parser.KotlinParser +import org.appdevforall.codeonthego.lsp.kotlin.symbol.SymbolBuilder +import org.appdevforall.codeonthego.lsp.kotlin.symbol.Visibility +import org.slf4j.LoggerFactory +import java.io.File + +/** + * Parses a Kotlin source file and produces [JvmSymbol] entries for indexing. + * + * Uses tree-sitter (via [KotlinParser]) for fast, error-tolerant parsing and + * [SymbolBuilder] to extract declarations. The resulting symbols are represented + * using the shared [JvmSymbol] model so they can be stored in any + * [org.appdevforall.codeonthego.indexing.api.Index] that accepts [JvmSymbol]. + * + * Thread safety: each call to [scan] creates its own [KotlinParser] instance, + * so concurrent calls are safe. + */ +object KotlinSourceScanner { + + private val log = LoggerFactory.getLogger(KotlinSourceScanner::class.java) + + /** + * Parses the Kotlin source file at [filePath] and emits a [JvmSymbol] for + * each indexed declaration (classifiers, functions, properties, type aliases + * — both top-level and members). + * + * @param filePath Absolute path to the `.kt` file on disk. + * @param sourceId The [JvmSymbol.sourceId] to stamp on every emitted symbol. + * Typically the same as [filePath] so that [removeBySource] + * can remove all symbols from a specific file atomically. + */ + fun scan(filePath: String, sourceId: String): Flow = flow { + val file = File(filePath) + if (!file.exists() || !file.isFile) return@flow + + val content = try { + file.readText() + } catch (e: Exception) { + log.warn("Failed to read source file: {}", filePath, e) + return@flow + } + + KotlinParser().use { parser -> + val result = parser.parse(content, filePath) + result.tree.use { syntaxTree -> + val symbolTable = SymbolBuilder.build(syntaxTree, filePath) + val fileIndex = FileIndex.fromSymbolTable(symbolTable) + + // findByPrefix("", 0) returns all symbols because every name starts with "". + val allSymbols = fileIndex.findByPrefix("", 0) + for (symbol in allSymbols) { + toJvmSymbol(symbol, sourceId)?.let { emit(it) } + } + } + } + }.flowOn(Dispatchers.IO) + + private fun toJvmSymbol(symbol: IndexedSymbol, sourceId: String): JvmSymbol? { + val kind = mapKind(symbol) ?: return null + val visibility = mapVisibility(symbol.visibility) + + val data: JvmSymbolInfo = when { + symbol.kind.isClass -> JvmClassInfo( + internalName = symbol.fqName, + containingClassName = symbol.containingClass ?: "", + supertypeNames = symbol.superTypes, + typeParameters = symbol.typeParameters, + kotlin = KotlinClassInfo( + isData = symbol.kind == IndexedSymbolKind.DATA_CLASS, + isValue = symbol.kind == IndexedSymbolKind.VALUE_CLASS, + ), + ) + + symbol.kind == IndexedSymbolKind.FUNCTION + || symbol.kind == IndexedSymbolKind.CONSTRUCTOR -> { + val params = symbol.parameters.map { param -> + JvmParameterInfo( + name = param.name, + typeName = param.type, + typeDisplayName = param.type, + hasDefaultValue = param.hasDefault, + isVararg = param.isVararg, + ) + } + JvmFunctionInfo( + containingClassName = symbol.containingClass ?: "", + returnTypeName = symbol.returnType ?: "Unit", + returnTypeDisplayName = symbol.returnType ?: "Unit", + parameterCount = params.size, + parameters = params, + signatureDisplay = buildSignatureDisplay(symbol), + typeParameters = symbol.typeParameters, + kotlin = symbol.receiverType?.let { receiverType -> + KotlinFunctionInfo( + receiverTypeName = receiverType, + receiverTypeDisplayName = receiverType, + ) + }, + ) + } + + symbol.kind == IndexedSymbolKind.PROPERTY -> JvmFieldInfo( + containingClassName = symbol.containingClass ?: "", + typeName = symbol.returnType ?: "Any", + typeDisplayName = symbol.returnType ?: "Any", + kotlin = symbol.receiverType?.let { receiverType -> + KotlinPropertyInfo( + receiverTypeName = receiverType, + receiverTypeDisplayName = receiverType, + ) + }, + ) + + symbol.kind == IndexedSymbolKind.TYPE_ALIAS -> JvmTypeAliasInfo( + expandedTypeName = symbol.returnType ?: "", + expandedTypeDisplayName = symbol.returnType ?: "", + typeParameters = symbol.typeParameters, + ) + + else -> return null + } + + val key = when { + kind == JvmSymbolKind.FUNCTION + || kind == JvmSymbolKind.EXTENSION_FUNCTION + || kind == JvmSymbolKind.CONSTRUCTOR -> { + val paramTypes = symbol.parameters.joinToString(",") { it.type } + "${symbol.fqName}($paramTypes)" + } + else -> symbol.fqName + } + + return JvmSymbol( + key = key, + sourceId = sourceId, + name = symbol.fqName, + shortName = symbol.name, + packageName = symbol.packageName, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + isDeprecated = symbol.deprecated, + data = data, + ) + } + + private fun mapKind(symbol: IndexedSymbol): JvmSymbolKind? = when (symbol.kind) { + IndexedSymbolKind.CLASS -> JvmSymbolKind.CLASS + IndexedSymbolKind.INTERFACE -> JvmSymbolKind.INTERFACE + IndexedSymbolKind.OBJECT -> JvmSymbolKind.OBJECT + IndexedSymbolKind.ENUM_CLASS -> JvmSymbolKind.ENUM + IndexedSymbolKind.ANNOTATION_CLASS -> JvmSymbolKind.ANNOTATION_CLASS + IndexedSymbolKind.DATA_CLASS -> JvmSymbolKind.DATA_CLASS + IndexedSymbolKind.VALUE_CLASS -> JvmSymbolKind.VALUE_CLASS + IndexedSymbolKind.FUNCTION -> { + if (symbol.receiverType != null) JvmSymbolKind.EXTENSION_FUNCTION + else JvmSymbolKind.FUNCTION + } + IndexedSymbolKind.CONSTRUCTOR -> JvmSymbolKind.CONSTRUCTOR + IndexedSymbolKind.PROPERTY -> { + if (symbol.receiverType != null) JvmSymbolKind.EXTENSION_PROPERTY + else JvmSymbolKind.PROPERTY + } + IndexedSymbolKind.TYPE_ALIAS -> JvmSymbolKind.TYPE_ALIAS + } + + private fun mapVisibility(visibility: Visibility): JvmVisibility = when (visibility) { + Visibility.PUBLIC -> JvmVisibility.PUBLIC + Visibility.PROTECTED -> JvmVisibility.PROTECTED + Visibility.INTERNAL -> JvmVisibility.INTERNAL + Visibility.PRIVATE -> JvmVisibility.PRIVATE + } + + private fun buildSignatureDisplay(symbol: IndexedSymbol): String = buildString { + symbol.receiverType?.let { append(it).append('.') } + if (symbol.typeParameters.isNotEmpty()) { + append('<') + append(symbol.typeParameters.joinToString()) + append('>') + } + append('(') + append(symbol.parameters.joinToString { "${it.name}: ${it.type}" }) + append(')') + symbol.returnType?.let { append(": ").append(it) } + } +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt new file mode 100644 index 0000000000..f888fdfed5 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt @@ -0,0 +1,173 @@ +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.SQLiteIndex +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 + +/** + * An index of symbols extracted from Kotlin source files in the project. + * + * Unlike [JvmLibrarySymbolIndex], which accumulates a persistent on-disk cache + * of library JARs across IDE sessions, this index is deliberately **in-memory**: + * it is rebuilt from scratch on each project open and discarded when the project + * closes. This is correct because source files are cheap to re-parse (tree-sitter + * is fast) and the index must always reflect the current on-disk state. + */ +class KotlinSourceSymbolIndex private constructor( + val sourceIndex: SQLiteIndex, + val sourceIndexer: BackgroundIndexer, +) : Closeable { + + companion object { + + const val INDEX_NAME_SOURCE = "kotlin-source-index" + + /** + * Creates a [KotlinSourceSymbolIndex] backed by an in-memory SQLite database. + * + * The [context] is required by the AndroidX SQLite helpers even for in-memory + * databases; it is not used for any file I/O in this case. + */ + fun create(context: Context): KotlinSourceSymbolIndex { + // dbName = null → AndroidX SQLiteOpenHelper creates an in-memory database. + val index = SQLiteIndex( + descriptor = JvmSymbolDescriptor, + context = context, + dbName = null, + name = INDEX_NAME_SOURCE, + ) + val indexer = BackgroundIndexer(index) + return KotlinSourceSymbolIndex( + sourceIndex = index, + sourceIndexer = indexer, + ) + } + } + + /** + * Indexes the symbols in [filePath], skipping the file if it was already + * indexed in this session. + * + * Use [reindexFile] to force re-parsing (e.g. after a save event). + */ + fun indexFile( + filePath: String, + provider: (sourceId: String) -> Flow = { sourceId -> + KotlinSourceScanner.scan(filePath, sourceId) + }, + ) = sourceIndexer.indexSource(filePath, skipIfExists = true, provider) + + /** + * Re-indexes [filePath] unconditionally, removing any previously indexed + * symbols for that file first. + * + * Call this after the file is saved to disk. + */ + fun reindexFile( + filePath: String, + provider: (sourceId: String) -> Flow = { sourceId -> + KotlinSourceScanner.scan(filePath, sourceId) + }, + ) = sourceIndexer.indexSource(filePath, skipIfExists = false, provider) + + /** + * Removes all symbols that originate from [filePath] from the index. + * + * Implemented by scheduling an indexing job with an empty provider so that + * the [BackgroundIndexer] properly cancels any in-flight job for the same + * source before clearing the entries. + */ + fun removeFile(filePath: String) { + sourceIndexer.indexSource( + sourceId = filePath, + skipIfExists = false, + ) { kotlinx.coroutines.flow.emptyFlow() } + } + + /** + * Returns `true` if [filePath] has already been indexed in this session. + */ + suspend fun isFileCached(filePath: String): Boolean = + sourceIndex.containsSource(filePath) + + /** Prefix-based completion across all source symbols. */ + fun findByPrefix(prefix: String, limit: Int = 200): Flow = + sourceIndex.query(indexQuery { prefix(KEY_NAME, prefix); this.limit = limit }) + + /** Prefix-based completion filtered to specific [kinds]. */ + fun findByPrefix( + prefix: String, + kinds: Set, + limit: Int = 200, + ): Flow = + sourceIndex.query(indexQuery { prefix(KEY_NAME, prefix); this.limit = 0 }) + .filter { it.kind in kinds } + .take(limit) + + /** Find extension functions / properties declared for [receiverTypeFqName]. */ + fun findExtensionsFor( + receiverTypeFqName: String, + namePrefix: String = "", + limit: Int = 200, + ): Flow = sourceIndex.query(indexQuery { + eq(KEY_RECEIVER_TYPE, receiverTypeFqName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) + this.limit = limit + }) + + /** Top-level callable symbols (functions, properties) in a package. */ + fun findTopLevelCallablesInPackage( + packageName: String, + namePrefix: String = "", + limit: Int = 200, + ): Flow = sourceIndex.query(indexQuery { + eq(KEY_PACKAGE, packageName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) + this.limit = 0 + }).filter { it.kind.isCallable && it.isTopLevel }.take(limit) + + /** Top-level classifier symbols (classes, interfaces, objects…) in a package. */ + fun findClassifiersInPackage( + packageName: String, + namePrefix: String = "", + limit: Int = 200, + ): Flow = sourceIndex.query(indexQuery { + eq(KEY_PACKAGE, packageName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) + this.limit = 0 + }).filter { it.kind.isClassifier }.take(limit) + + /** Members of a specific class (functions, properties). */ + fun findMembersOf( + classFqName: String, + namePrefix: String = "", + limit: Int = 200, + ): Flow = sourceIndex.query(indexQuery { + eq(KEY_CONTAINING_CLASS, classFqName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) + this.limit = limit + }) + + /** Point lookup by fully-qualified name. */ + suspend fun findByFqName(fqName: String): JvmSymbol? = sourceIndex.get(fqName) + + /** All distinct package names present in the index. */ + fun allPackages(): Flow = sourceIndex.distinctValues(KEY_PACKAGE) + + /** Suspends until all in-flight indexing jobs complete. */ + suspend fun awaitIndexing() = sourceIndexer.awaitAll() + + override fun close() { + sourceIndexer.close() + sourceIndex.close() + } +} 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 c7e9223978..394d7cdcaf 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 @@ -57,6 +57,7 @@ import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.coroutines.withContext import org.appdevforall.codeonthego.indexing.jvm.JvmIndexingService +import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceIndexingService import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -104,6 +105,10 @@ class KotlinLanguageServer : ILanguageServer { if (!EventBus.getDefault().isRegistered(this)) { EventBus.getDefault().register(this) } + + ProjectManagerImpl.getInstance().indexingServiceManager.register( + KotlinSourceIndexingService(context = BaseApplication.baseInstance) + ) } override fun shutdown() { @@ -128,8 +133,11 @@ class KotlinLanguageServer : ILanguageServer { .indexingServiceManager val jvmIndexingService = indexingServiceManager.getService(JvmIndexingService.ID) as? JvmIndexingService? + val kotlinSourceIndexingService = + indexingServiceManager.getService(KotlinSourceIndexingService.ID) as? KotlinSourceIndexingService? jvmIndexingService?.refresh() + kotlinSourceIndexingService?.refresh() val jdkHome = Environment.JAVA_HOME.toPath() val jdkRelease = IJdkDistributionProvider.DEFAULT_JAVA_RELEASE 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 714179a548..b747556854 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 @@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler import com.itsaky.androidide.lsp.kotlin.KtFileManager import com.itsaky.androidide.lsp.kotlin.utils.SymbolVisibilityChecker import org.appdevforall.codeonthego.indexing.jvm.JvmLibrarySymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceSymbolIndex 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 @@ -95,6 +96,9 @@ internal class CompilationEnvironment( val requireLibraryIndex: JvmLibrarySymbolIndex get() = checkNotNull(libraryIndex) + val sourceIndex: KotlinSourceSymbolIndex? + get() = project.sourceIndex + private val envMessageCollector = object : MessageCollector { override fun clear() { } 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 12e11306d4..e4f70f35e4 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 @@ -8,6 +8,8 @@ import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.projects.models.bootClassPaths import org.appdevforall.codeonthego.indexing.jvm.JVM_LIBRARY_SYMBOL_INDEX import org.appdevforall.codeonthego.indexing.jvm.JvmLibrarySymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.KOTLIN_SOURCE_SYMBOL_INDEX +import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceSymbolIndex 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 @@ -53,6 +55,12 @@ internal class KotlinProjectModel { .registry .get(JVM_LIBRARY_SYMBOL_INDEX) + val sourceIndex: KotlinSourceSymbolIndex? + get() = ProjectManagerImpl.getInstance() + .indexingServiceManager + .registry + .get(KOTLIN_SOURCE_SYMBOL_INDEX) + /** * The kind of change that occurred. */ 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 409f85f946..dfb8f2ae8c 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 @@ -22,6 +22,7 @@ import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind import org.appdevforall.codeonthego.indexing.jvm.JvmTypeAliasInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmVisibility import org.jetbrains.kotlin.analysis.api.KaContextParameterApi import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaIdeApi @@ -276,92 +277,106 @@ context(env: CompilationEnvironment, ctx: AnalysisContext) private suspend fun KaSession.collectUnimportedSymbols( to: MutableList ) { + val currentPackage = ctx.ktElement.containingKtFile.packageDirective?.name + val useSiteModule = this.useSiteModule + + // Library symbols: JAR-based, use full SymbolVisibilityChecker val visibilityChecker = env.symbolVisibilityChecker if (visibilityChecker == null) { logger.warn("No visibility checker found") return } - val librarySymbolIndex = env.libraryIndex - if (librarySymbolIndex == null) { - logger.warn("Unable to find JVM library symbol index") - return - } + env.libraryIndex?.findByPrefix(ctx.partial) + ?.collect { symbol -> + val isVisible = visibilityChecker.isVisible( + symbol = symbol, + useSiteModule = useSiteModule, + useSitePackage = currentPackage, + ) + if (!isVisible) return@collect + buildUnimportedSymbolItem(symbol)?.let { to += it } + } + + // Source symbols: project .kt files — skip private and same-package symbols + env.sourceIndex?.findByPrefix(ctx.partial) + ?.collect { symbol -> + if (symbol.packageName == currentPackage) return@collect - val useSiteModule = this.useSiteModule - librarySymbolIndex.findByPrefix(ctx.partial) - .collect { symbol -> val isVisible = visibilityChecker.isVisible( symbol = symbol, useSiteModule = useSiteModule, - useSitePackage = ctx.ktElement.containingKtFile.packageDirective?.name + useSitePackage = currentPackage ) if (!isVisible) return@collect - if (symbol.kind.isCallable && !symbol.isTopLevel && !symbol.isExtension) { - // member-level, non-imported callable symbols should not be - // completed in scope completions - return@collect - } + buildUnimportedSymbolItem(symbol)?.let { to += it } + } +} + +context(ctx: AnalysisContext) +private fun KaSession.buildUnimportedSymbolItem(symbol: JvmSymbol): CompletionItem? { + if (symbol.kind.isCallable && !symbol.isTopLevel && !symbol.isExtension) { + // member-level, non-extension callable symbols should not be + // completed in scope completions + return null + } - if (symbol.isExtension) { - val receiverTypeName = symbol.receiverTypeName - if (receiverTypeName != null) { - val receiverClassId = internalNameToClassId(receiverTypeName) - val receiverType = findClass(receiverClassId) - if (receiverType != null) { - val satisfiesImplicitReceivers = ctx.scopeContext.implicitReceivers.any { receiver -> - receiver.type.isSubtypeOf(receiverType) - } - - // the extension property/function's receiver type - // is not available in current context, so ignore this sym - if (!satisfiesImplicitReceivers) return@collect - } else return@collect + if (symbol.isExtension) { + val receiverTypeName = symbol.receiverTypeName + if (receiverTypeName != null) { + val receiverClassId = internalNameToClassId(receiverTypeName) + val receiverType = findClass(receiverClassId) + if (receiverType != null) { + val satisfiesImplicitReceivers = ctx.scopeContext.implicitReceivers.any { receiver -> + receiver.type.isSubtypeOf(receiverType) } - } + // the extension property/function's receiver type + // is not available in current context, so ignore this sym + if (!satisfiesImplicitReceivers) return null + } else return null + } + } - val item = ktCompletionItem( + val item = ktCompletionItem( + name = symbol.shortName, + kind = kindOf(symbol), + ) + + item.overrideTypeText = symbol.returnTypeDisplay + when (symbol.kind) { + JvmSymbolKind.FUNCTION, JvmSymbolKind.CONSTRUCTOR -> { + val data = symbol.data as JvmFunctionInfo + item.detail = data.signatureDisplay + item.setInsertTextForFunction( name = symbol.shortName, - kind = kindOf(symbol), + hasParams = data.parameterCount > 0, ) + if (symbol.kind == JvmSymbolKind.CONSTRUCTOR) { + item.overrideTypeText = symbol.shortName + } + } - item.overrideTypeText = symbol.returnTypeDisplay - when (symbol.kind) { - JvmSymbolKind.FUNCTION, JvmSymbolKind.CONSTRUCTOR -> { - val data = symbol.data as JvmFunctionInfo - item.detail = data.signatureDisplay - item.setInsertTextForFunction( - name = symbol.shortName, - hasParams = data.parameterCount > 0, - ) - - if (symbol.kind == JvmSymbolKind.CONSTRUCTOR) { - item.overrideTypeText = symbol.shortName - } - } - - JvmSymbolKind.TYPE_ALIAS -> { - item.detail = (symbol.data as JvmTypeAliasInfo).expandedTypeFqName - } + JvmSymbolKind.TYPE_ALIAS -> { + item.detail = (symbol.data as JvmTypeAliasInfo).expandedTypeFqName + } - in JvmSymbolKind.CLASSIFIER_KINDS -> { - val classInfo = symbol.data as JvmClassInfo - item.detail = symbol.name - item.setClassCompletionData( - className = symbol.name, - isNested = classInfo.isInner, - topLevelClass = classInfo.containingClassFqName, - ) - } + in JvmSymbolKind.CLASSIFIER_KINDS -> { + val classInfo = symbol.data as JvmClassInfo + item.detail = symbol.name + item.setClassCompletionData( + className = symbol.name, + isNested = classInfo.isInner, + topLevelClass = classInfo.containingClassFqName, + ) + } - else -> {} - } + else -> {} + } - logger.debug("Adding completion item: {}", item) - to += item - } + logger.debug("Adding completion item: {}", item) + return item } private fun internalNameToClassId(internalName: String): ClassId { From ab5c7d9bc51af97236e3c74affcc966aff979925 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 10 Apr 2026 18:21:22 +0530 Subject: [PATCH 36/49] fix: infinite loop when converting type names to display names Signed-off-by: Akash Yadav --- .../codeonthego/indexing/jvm/KotlinMetadataScanner.kt | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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 59dc810c8a..a0c6580600 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 @@ -345,7 +345,8 @@ object KotlinMetadataScanner { } private fun kmTypeToDisplayName(type: KmType): String { - val base = kmTypeToDisplayName(type).substringAfterLast('.') + val base = kmTypeToName(type).substringAfterLast('/') + .substringAfterLast('$') val args = type.arguments.mapNotNull { it.type?.let { t -> kmTypeToDisplayName(t) } } return buildString { append(base) From 0ec7b4b12037eb52c44e6818ed2a32b7bedfce64 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 10 Apr 2026 18:22:26 +0530 Subject: [PATCH 37/49] fix: update ModuleResolver to resolve KaSourceModule from file path Signed-off-by: Akash Yadav --- .../lsp/kotlin/compiler/KotlinProjectModel.kt | 7 ++++++- .../lsp/kotlin/compiler/ModuleResolver.kt | 13 ++++++++++++- .../lsp/kotlin/completion/KotlinCompletions.kt | 4 ++-- 3 files changed, 20 insertions(+), 4 deletions(-) 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 e4f70f35e4..4069873445 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 @@ -155,6 +155,7 @@ internal class KotlinProjectModel { .associateWith(::addLibrary) val subprojectsAsModules = mutableMapOf() + val sourceRootToModuleMap = mutableMapOf() fun getOrCreateModule(project: ModuleProject): KaSourceModule { subprojectsAsModules[project]?.let { return it } @@ -186,12 +187,16 @@ internal class KotlinProjectModel { } subprojectsAsModules[project] = module + sourceRoots.forEach { root -> sourceRootToModuleMap[root] = module } return module } moduleProjects.forEach { addModule(getOrCreateModule(it)) } - val moduleResolver = ModuleResolver(jarMap = jarToModMap) + val moduleResolver = ModuleResolver( + jarMap = jarToModMap, + sourceRootMap = sourceRootToModuleMap, + ) _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 index 704d02978a..d1372a0852 100644 --- 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 @@ -2,24 +2,35 @@ 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.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule import org.slf4j.LoggerFactory import java.nio.file.Path import java.nio.file.Paths internal class ModuleResolver( private val jarMap: Map, + private val sourceRootMap: Map = emptyMap(), ) { companion object { private val logger = LoggerFactory.getLogger(ModuleResolver::class.java) } /** - * Find the module that declares the given source ID (JAR, source file, etc.) + * Find the module that declares the given source ID. + * + * - For library JARs, the source ID is the JAR path — looked up directly. + * - For source files, the source ID is the `.kt` file path — resolved by + * finding the source root directory that is an ancestor of that path. */ fun findDeclaringModule(sourceId: String): KaModule? { val path = Paths.get(sourceId) jarMap[path]?.let { return it } + // Walk source roots to find which module owns this file. + for ((root, module) in sourceRootMap) { + if (path.startsWith(root)) return module + } + 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 dfb8f2ae8c..dd564eb93b 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 @@ -364,9 +364,9 @@ private fun KaSession.buildUnimportedSymbolItem(symbol: JvmSymbol): CompletionIt in JvmSymbolKind.CLASSIFIER_KINDS -> { val classInfo = symbol.data as JvmClassInfo - item.detail = symbol.name + item.detail = symbol.fqName item.setClassCompletionData( - className = symbol.name, + className = symbol.fqName, isNested = classInfo.isInner, topLevelClass = classInfo.containingClassFqName, ) From bf9b2da0c391d08e84b9b29c83276e27c28a889a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 10 Apr 2026 18:22:43 +0530 Subject: [PATCH 38/49] fix: update KotlinSourceScanner to use internal names in index Signed-off-by: Akash Yadav --- .../codeonthego/indexing/jvm/JvmSymbol.kt | 3 + .../indexing/jvm/KotlinSourceScanner.kt | 626 +++++++++++++----- 2 files changed, 453 insertions(+), 176 deletions(-) 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 index 81e74380d3..4e6cb46bac 100644 --- 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 @@ -68,6 +68,9 @@ data class JvmSymbol( val data: JvmSymbolInfo, ) : Indexable { + val fqName: String + get() = name.toFqName() + val isTopLevel: Boolean get() = data.containingClassName.isEmpty() diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt index b91327e6ca..3f5cb2725b 100644 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt @@ -4,192 +4,466 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn -import org.appdevforall.codeonthego.lsp.kotlin.index.FileIndex -import org.appdevforall.codeonthego.lsp.kotlin.index.IndexedSymbol -import org.appdevforall.codeonthego.lsp.kotlin.index.IndexedSymbolKind +import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceScanner.fqnToInternalName +import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceScanner.scan import org.appdevforall.codeonthego.lsp.kotlin.parser.KotlinParser +import org.appdevforall.codeonthego.lsp.kotlin.symbol.ClassKind +import org.appdevforall.codeonthego.lsp.kotlin.symbol.ClassSymbol +import org.appdevforall.codeonthego.lsp.kotlin.symbol.FunctionSymbol +import org.appdevforall.codeonthego.lsp.kotlin.symbol.PropertySymbol +import org.appdevforall.codeonthego.lsp.kotlin.symbol.Symbol import org.appdevforall.codeonthego.lsp.kotlin.symbol.SymbolBuilder +import org.appdevforall.codeonthego.lsp.kotlin.symbol.TypeAliasSymbol +import org.appdevforall.codeonthego.lsp.kotlin.symbol.TypeReference import org.appdevforall.codeonthego.lsp.kotlin.symbol.Visibility import org.slf4j.LoggerFactory import java.io.File /** - * Parses a Kotlin source file and produces [JvmSymbol] entries for indexing. + * Parses a Kotlin source file and produces [JvmSymbol] entries for indexing, + * working directly from the [SymbolBuilder] output. * - * Uses tree-sitter (via [KotlinParser]) for fast, error-tolerant parsing and - * [SymbolBuilder] to extract declarations. The resulting symbols are represented - * using the shared [JvmSymbol] model so they can be stored in any - * [org.appdevforall.codeonthego.indexing.api.Index] that accepts [JvmSymbol]. + * Type references coming from source (which are as-written, dot-separated) + * are converted to internal names via [fqnToInternalName], which applies the + * standard Java naming convention: lowercase segments are package components, + * uppercase-starting segments are class components. * - * Thread safety: each call to [scan] creates its own [KotlinParser] instance, - * so concurrent calls are safe. + * Thread safety: each call to [scan] creates its own [KotlinParser] instance. */ object KotlinSourceScanner { - private val log = LoggerFactory.getLogger(KotlinSourceScanner::class.java) - - /** - * Parses the Kotlin source file at [filePath] and emits a [JvmSymbol] for - * each indexed declaration (classifiers, functions, properties, type aliases - * — both top-level and members). - * - * @param filePath Absolute path to the `.kt` file on disk. - * @param sourceId The [JvmSymbol.sourceId] to stamp on every emitted symbol. - * Typically the same as [filePath] so that [removeBySource] - * can remove all symbols from a specific file atomically. - */ - fun scan(filePath: String, sourceId: String): Flow = flow { - val file = File(filePath) - if (!file.exists() || !file.isFile) return@flow - - val content = try { - file.readText() - } catch (e: Exception) { - log.warn("Failed to read source file: {}", filePath, e) - return@flow - } - - KotlinParser().use { parser -> - val result = parser.parse(content, filePath) - result.tree.use { syntaxTree -> - val symbolTable = SymbolBuilder.build(syntaxTree, filePath) - val fileIndex = FileIndex.fromSymbolTable(symbolTable) - - // findByPrefix("", 0) returns all symbols because every name starts with "". - val allSymbols = fileIndex.findByPrefix("", 0) - for (symbol in allSymbols) { - toJvmSymbol(symbol, sourceId)?.let { emit(it) } - } - } - } - }.flowOn(Dispatchers.IO) - - private fun toJvmSymbol(symbol: IndexedSymbol, sourceId: String): JvmSymbol? { - val kind = mapKind(symbol) ?: return null - val visibility = mapVisibility(symbol.visibility) - - val data: JvmSymbolInfo = when { - symbol.kind.isClass -> JvmClassInfo( - internalName = symbol.fqName, - containingClassName = symbol.containingClass ?: "", - supertypeNames = symbol.superTypes, - typeParameters = symbol.typeParameters, - kotlin = KotlinClassInfo( - isData = symbol.kind == IndexedSymbolKind.DATA_CLASS, - isValue = symbol.kind == IndexedSymbolKind.VALUE_CLASS, - ), - ) - - symbol.kind == IndexedSymbolKind.FUNCTION - || symbol.kind == IndexedSymbolKind.CONSTRUCTOR -> { - val params = symbol.parameters.map { param -> - JvmParameterInfo( - name = param.name, - typeName = param.type, - typeDisplayName = param.type, - hasDefaultValue = param.hasDefault, - isVararg = param.isVararg, - ) - } - JvmFunctionInfo( - containingClassName = symbol.containingClass ?: "", - returnTypeName = symbol.returnType ?: "Unit", - returnTypeDisplayName = symbol.returnType ?: "Unit", - parameterCount = params.size, - parameters = params, - signatureDisplay = buildSignatureDisplay(symbol), - typeParameters = symbol.typeParameters, - kotlin = symbol.receiverType?.let { receiverType -> - KotlinFunctionInfo( - receiverTypeName = receiverType, - receiverTypeDisplayName = receiverType, - ) - }, - ) - } - - symbol.kind == IndexedSymbolKind.PROPERTY -> JvmFieldInfo( - containingClassName = symbol.containingClass ?: "", - typeName = symbol.returnType ?: "Any", - typeDisplayName = symbol.returnType ?: "Any", - kotlin = symbol.receiverType?.let { receiverType -> - KotlinPropertyInfo( - receiverTypeName = receiverType, - receiverTypeDisplayName = receiverType, - ) - }, - ) - - symbol.kind == IndexedSymbolKind.TYPE_ALIAS -> JvmTypeAliasInfo( - expandedTypeName = symbol.returnType ?: "", - expandedTypeDisplayName = symbol.returnType ?: "", - typeParameters = symbol.typeParameters, - ) - - else -> return null - } - - val key = when { - kind == JvmSymbolKind.FUNCTION - || kind == JvmSymbolKind.EXTENSION_FUNCTION - || kind == JvmSymbolKind.CONSTRUCTOR -> { - val paramTypes = symbol.parameters.joinToString(",") { it.type } - "${symbol.fqName}($paramTypes)" - } - else -> symbol.fqName - } - - return JvmSymbol( - key = key, - sourceId = sourceId, - name = symbol.fqName, - shortName = symbol.name, - packageName = symbol.packageName, - kind = kind, - language = JvmSourceLanguage.KOTLIN, - visibility = visibility, - isDeprecated = symbol.deprecated, - data = data, - ) - } - - private fun mapKind(symbol: IndexedSymbol): JvmSymbolKind? = when (symbol.kind) { - IndexedSymbolKind.CLASS -> JvmSymbolKind.CLASS - IndexedSymbolKind.INTERFACE -> JvmSymbolKind.INTERFACE - IndexedSymbolKind.OBJECT -> JvmSymbolKind.OBJECT - IndexedSymbolKind.ENUM_CLASS -> JvmSymbolKind.ENUM - IndexedSymbolKind.ANNOTATION_CLASS -> JvmSymbolKind.ANNOTATION_CLASS - IndexedSymbolKind.DATA_CLASS -> JvmSymbolKind.DATA_CLASS - IndexedSymbolKind.VALUE_CLASS -> JvmSymbolKind.VALUE_CLASS - IndexedSymbolKind.FUNCTION -> { - if (symbol.receiverType != null) JvmSymbolKind.EXTENSION_FUNCTION - else JvmSymbolKind.FUNCTION - } - IndexedSymbolKind.CONSTRUCTOR -> JvmSymbolKind.CONSTRUCTOR - IndexedSymbolKind.PROPERTY -> { - if (symbol.receiverType != null) JvmSymbolKind.EXTENSION_PROPERTY - else JvmSymbolKind.PROPERTY - } - IndexedSymbolKind.TYPE_ALIAS -> JvmSymbolKind.TYPE_ALIAS - } - - private fun mapVisibility(visibility: Visibility): JvmVisibility = when (visibility) { - Visibility.PUBLIC -> JvmVisibility.PUBLIC - Visibility.PROTECTED -> JvmVisibility.PROTECTED - Visibility.INTERNAL -> JvmVisibility.INTERNAL - Visibility.PRIVATE -> JvmVisibility.PRIVATE - } - - private fun buildSignatureDisplay(symbol: IndexedSymbol): String = buildString { - symbol.receiverType?.let { append(it).append('.') } - if (symbol.typeParameters.isNotEmpty()) { - append('<') - append(symbol.typeParameters.joinToString()) - append('>') - } - append('(') - append(symbol.parameters.joinToString { "${it.name}: ${it.type}" }) - append(')') - symbol.returnType?.let { append(": ").append(it) } - } + private val log = LoggerFactory.getLogger(KotlinSourceScanner::class.java) + + /** + * Parses the Kotlin source file at [filePath] and emits a [JvmSymbol] for + * each public/internal declaration found (classes and their members, + * top-level functions, properties, and type aliases). + */ + fun scan(filePath: String, sourceId: String): Flow = flow { + val file = File(filePath) + if (!file.exists() || !file.isFile) return@flow + + val content = try { + file.readText() + } catch (e: Exception) { + log.warn("Failed to read source file: {}", filePath, e) + return@flow + } + + KotlinParser().use { parser -> + val result = parser.parse(content, filePath) + result.tree.use { syntaxTree -> + val symbolTable = SymbolBuilder.build(syntaxTree, filePath) + // Internal prefix for this file's package: "com/example" + val pkgInternal = symbolTable.packageName.replace('.', '/') + + for (symbol in symbolTable.topLevelSymbols) { + for (jvmSymbol in toJvmSymbols( + symbol, + pkgInternal, + containingClass = "", + sourceId + )) { + emit(jvmSymbol) + } + } + } + } + }.flowOn(Dispatchers.IO) + + private fun toJvmSymbols( + symbol: Symbol, + pkgInternal: String, + containingClass: String, + sourceId: String, + ): List = when (symbol) { + is ClassSymbol -> classSymbols(symbol, pkgInternal, containingClass, sourceId) + is FunctionSymbol -> listOfNotNull( + functionSymbol( + symbol, + pkgInternal, + containingClass, + sourceId + ) + ) + + is PropertySymbol -> listOfNotNull( + propertySymbol( + symbol, + pkgInternal, + containingClass, + sourceId + ) + ) + + is TypeAliasSymbol -> listOfNotNull( + typeAliasSymbol( + symbol, + pkgInternal, + containingClass, + sourceId + ) + ) + + else -> emptyList() + } + + private fun classSymbols( + symbol: ClassSymbol, + pkgInternal: String, + containingClass: String, + sourceId: String, + ): List { + // Enum entries use a member-style key relative to their containing enum. + if (symbol.kind == ClassKind.ENUM_ENTRY) { + return listOf(enumEntrySymbol(symbol, pkgInternal, containingClass, sourceId)) + } + + val visibility = mapVisibility(symbol.visibility) + if (visibility == JvmVisibility.PRIVATE) return emptyList() + + // Internal name: "com/example/Outer$Inner" for nested, "com/example/Outer" for top-level. + val classInternalName = buildClassInternalName(symbol.name, pkgInternal, containingClass) + val packageName = pkgInternal.replace('/', '.') + + val kind = mapClassKind(symbol) + + val classSymbol = JvmSymbol( + key = classInternalName, + sourceId = sourceId, + name = classInternalName, + shortName = symbol.name, + packageName = packageName, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmClassInfo( + internalName = classInternalName, + containingClassName = containingClass, + supertypeNames = symbol.superTypes.map { fqnToInternalName(it.name) }, + typeParameters = symbol.typeParameters.map { it.name }, + isAbstract = symbol.modifiers.isAbstract, + isFinal = symbol.modifiers.isFinal, + isInner = symbol.modifiers.isInner, + kotlin = KotlinClassInfo( + isData = symbol.modifiers.isData, + isValue = symbol.modifiers.isValue, + isSealed = symbol.modifiers.isSealed, + ), + ), + ) + + val result = mutableListOf(classSymbol) + + // Primary constructor (not always in the member scope, depending on SymbolBuilder). + symbol.primaryConstructor?.let { ctor -> + if (!ctor.isPrimaryConstructor || ctor !in (symbol.memberScope?.allSymbols + ?: emptyList()) + ) { + functionSymbol(ctor, pkgInternal, classInternalName, sourceId)?.let { result += it } + } + } + + // All members: nested classes, secondary constructors, functions, properties. + for (member in symbol.memberScope?.allSymbols ?: emptyList()) { + result += toJvmSymbols(member, pkgInternal, classInternalName, sourceId) + } + + return result + } + + private fun enumEntrySymbol( + symbol: ClassSymbol, + pkgInternal: String, + containingClass: String, + sourceId: String, + ): JvmSymbol { + val packageName = pkgInternal.replace('/', '.') + return JvmSymbol( + key = "$containingClass#${symbol.name}", + sourceId = sourceId, + name = "$containingClass#${symbol.name}", + shortName = symbol.name, + packageName = packageName, + kind = JvmSymbolKind.ENUM_ENTRY, + language = JvmSourceLanguage.KOTLIN, + data = JvmEnumEntryInfo(containingClassName = containingClass), + ) + } + + private fun functionSymbol( + symbol: FunctionSymbol, + pkgInternal: String, + containingClass: String, + sourceId: String, + ): JvmSymbol? { + val visibility = mapVisibility(symbol.visibility) + if (visibility == JvmVisibility.PRIVATE) return null + + val kind = when { + symbol.isConstructor -> JvmSymbolKind.CONSTRUCTOR + symbol.isExtension -> JvmSymbolKind.EXTENSION_FUNCTION + else -> JvmSymbolKind.FUNCTION + } + + val packageName = pkgInternal.replace('/', '.') + val owner = containingClass.ifEmpty { pkgInternal } + + // For constructors, the short name is the class's simple name. + val shortName = if (symbol.isConstructor) { + containingClass.substringAfterLast('/').substringAfterLast('$') + } else { + symbol.name + } + val name = "$owner#$shortName" + + val parameters = symbol.parameters.map { param -> + JvmParameterInfo( + name = param.name, + typeName = param.type?.let { fqnToInternalName(it.name) } ?: "", + typeDisplayName = param.type?.render() ?: "", + hasDefaultValue = param.hasDefaultValue, + isVararg = param.isVararg, + isCrossinline = param.isCrossinline, + isNoinline = param.isNoinline, + ) + } + + val returnTypeInternal = + symbol.returnType?.let { fqnToInternalName(it.name) } ?: "kotlin/Unit" + val returnTypeDisplay = symbol.returnType?.render() ?: "Unit" + val receiverType = symbol.receiverType + + val key = "$name(${parameters.joinToString(",") { it.typeName }})" + + val signatureDisplay = buildString { + receiverType?.let { append(displayName(it)).append('.') } + if (symbol.typeParameters.isNotEmpty()) { + append('<') + append(symbol.typeParameters.joinToString { it.name }) + append('>') + } + append('(') + append(parameters.joinToString(", ") { "${it.name}: ${it.typeDisplayName}" }) + append(')') + if (!symbol.isConstructor) { + append(": ") + append(returnTypeDisplay) + } + } + + return JvmSymbol( + key = key, + sourceId = sourceId, + name = name, + shortName = shortName, + packageName = packageName, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmFunctionInfo( + containingClassName = containingClass, + returnTypeName = returnTypeInternal, + returnTypeDisplayName = returnTypeDisplay, + parameterCount = parameters.size, + parameters = parameters, + signatureDisplay = signatureDisplay, + typeParameters = symbol.typeParameters.map { it.name }, + isAbstract = symbol.modifiers.isAbstract, + isFinal = symbol.modifiers.isFinal, + kotlin = KotlinFunctionInfo( + receiverTypeName = receiverType?.let { fqnToInternalName(it.name) } ?: "", + receiverTypeDisplayName = receiverType?.let { displayName(it) } ?: "", + isSuspend = symbol.isSuspend, + isInline = symbol.isInline, + isInfix = symbol.isInfix, + isOperator = symbol.isOperator, + isTailrec = symbol.isTailrec, + isExternal = symbol.modifiers.isExternal, + isExpect = symbol.modifiers.isExpect, + isActual = symbol.modifiers.isActual, + isReturnTypeNullable = symbol.returnType?.isNullable ?: false, + ), + ), + ) + } + + private fun propertySymbol( + symbol: PropertySymbol, + pkgInternal: String, + containingClass: String, + sourceId: String, + ): JvmSymbol? { + val visibility = mapVisibility(symbol.visibility) + if (visibility == JvmVisibility.PRIVATE) return null + + val kind = + if (symbol.isExtension) JvmSymbolKind.EXTENSION_PROPERTY else JvmSymbolKind.PROPERTY + val packageName = pkgInternal.replace('/', '.') + val owner = containingClass.ifEmpty { pkgInternal } + val name = "$owner#${symbol.name}" + val receiverType = symbol.receiverType + + return JvmSymbol( + key = name, + sourceId = sourceId, + name = name, + shortName = symbol.name, + packageName = packageName, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmFieldInfo( + containingClassName = containingClass, + typeName = symbol.type?.let { fqnToInternalName(it.name) } ?: "", + typeDisplayName = symbol.type?.let { displayName(it) } ?: "", + isFinal = !symbol.isVar, + kotlin = KotlinPropertyInfo( + receiverTypeName = receiverType?.let { fqnToInternalName(it.name) } ?: "", + receiverTypeDisplayName = receiverType?.let { displayName(it) } ?: "", + isConst = symbol.isConst, + isLateinit = symbol.isLateInit, + hasGetter = symbol.hasCustomGetter, + hasSetter = symbol.hasCustomSetter, + isDelegated = symbol.isDelegated, + isExpect = symbol.modifiers.isExpect, + isActual = symbol.modifiers.isActual, + isExternal = symbol.modifiers.isExternal, + isTypeNullable = symbol.type?.isNullable ?: false, + ), + ), + ) + } + + private fun typeAliasSymbol( + symbol: TypeAliasSymbol, + pkgInternal: String, + containingClass: String, + sourceId: String, + ): JvmSymbol? { + val visibility = mapVisibility(symbol.visibility) + if (visibility == JvmVisibility.PRIVATE) return null + + val packageName = pkgInternal.replace('/', '.') + val internalName = buildClassInternalName(symbol.name, pkgInternal, containingClass) + + return JvmSymbol( + key = internalName, + sourceId = sourceId, + name = internalName, + shortName = symbol.name, + packageName = packageName, + kind = JvmSymbolKind.TYPE_ALIAS, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmTypeAliasInfo( + containingClassName = containingClass, + expandedTypeName = symbol.underlyingType?.let { fqnToInternalName(it.name) } ?: "", + expandedTypeDisplayName = symbol.underlyingType?.let { displayName(it) } ?: "", + typeParameters = symbol.typeParameters.map { it.name }, + ), + ) + } + + /** + * Builds the JVM internal name for a class. + * + * - Top-level: `com/example/MyClass` + * - Nested: `com/example/MyClass$Inner` + */ + private fun buildClassInternalName( + simpleName: String, + pkgInternal: String, + containingClass: String, + ): String = when { + containingClass.isNotEmpty() -> $$"$$containingClass$$$simpleName" + pkgInternal.isNotEmpty() -> "$pkgInternal/$simpleName" + else -> simpleName + } + + /** + * Converts a dot-separated FQN (as written in Kotlin source) to a JVM internal name. + * + * Applies standard Java naming conventions: lowercase-starting segments are + * package components (joined with `/`), uppercase-starting segments are class + * components (joined with `$`). + * + * Examples: + * ``` + * "String" → "String" + * "kotlin.String" → "kotlin/String" + * "java.util.Map" → "java/util/Map" + * "java.util.Map.Entry" → "java/util/Map$Entry" + * ``` + */ + internal fun fqnToInternalName(fqn: String): String { + if (fqn.isEmpty() || !fqn.contains('.')) return fqn + + val parts = fqn.split('.') + val pkg = mutableListOf() + val cls = mutableListOf() + + for (part in parts) { + if (cls.isEmpty() && part.isNotEmpty() && part[0].isLowerCase()) { + pkg += part + } else { + cls += part + } + } + + return when { + pkg.isEmpty() -> cls.joinToString("$") + cls.isEmpty() -> pkg.joinToString("/") + else -> "${pkg.joinToString("/")}/${cls.joinToString("$")}" + } + } + + /** + * Returns a short display name for a [TypeReference]: the simple class name + * with type arguments rendered, but without the package prefix. + * + * E.g. `java.util.Map.Entry` → `Entry`, `List` → `List`. + */ + private fun displayName(ref: TypeReference): String { + val baseName = fqnToInternalName(ref.name) + .substringAfterLast('/') + .substringAfterLast('$') + val args = ref.typeArguments + return buildString { + append(baseName) + if (args.isNotEmpty()) { + append('<') + append(args.joinToString(", ") { displayName(it) }) + append('>') + } + if (ref.isNullable) append('?') + } + } + + private fun mapClassKind(symbol: ClassSymbol): JvmSymbolKind = when (symbol.kind) { + ClassKind.CLASS -> when { + symbol.modifiers.isSealed -> JvmSymbolKind.SEALED_CLASS + else -> JvmSymbolKind.CLASS + } + + ClassKind.INTERFACE -> when { + symbol.modifiers.isSealed -> JvmSymbolKind.SEALED_INTERFACE + else -> JvmSymbolKind.INTERFACE + } + + ClassKind.OBJECT -> JvmSymbolKind.OBJECT + ClassKind.COMPANION_OBJECT -> JvmSymbolKind.COMPANION_OBJECT + ClassKind.ENUM_CLASS -> JvmSymbolKind.ENUM + ClassKind.ENUM_ENTRY -> JvmSymbolKind.ENUM_ENTRY + ClassKind.ANNOTATION_CLASS -> JvmSymbolKind.ANNOTATION_CLASS + ClassKind.DATA_CLASS -> JvmSymbolKind.DATA_CLASS + ClassKind.VALUE_CLASS -> JvmSymbolKind.VALUE_CLASS + } + + private fun mapVisibility(visibility: Visibility): JvmVisibility = when (visibility) { + Visibility.PUBLIC -> JvmVisibility.PUBLIC + Visibility.PROTECTED -> JvmVisibility.PROTECTED + Visibility.INTERNAL -> JvmVisibility.INTERNAL + Visibility.PRIVATE -> JvmVisibility.PRIVATE + } } From 49e7efe182b84d5c8451c9a2c0e07e1c19b13fb9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 13 Apr 2026 19:58:51 +0530 Subject: [PATCH 39/49] feat: add custom implementations of analysis API services Signed-off-by: Akash Yadav --- common/build.gradle.kts | 2 + .../itsaky/androidide/utils/Environment.java | 5 + .../javac/config/JavacConfigProvider.java | 6 +- .../logging/IDELoggingConfigurator.kt | 1 - .../codeonthego/indexing/FilteredIndex.kt | 12 +- .../codeonthego/indexing/InMemoryIndex.kt | 35 +- .../codeonthego/indexing/MergedIndex.kt | 49 +- .../codeonthego/indexing/SQLiteIndex.kt | 64 +-- .../codeonthego/indexing/api/Index.kt | 28 +- .../indexing/util/BackgroundIndexer.kt | 93 +--- .../androidide/lsp/java/JavaLanguageServer.kt | 6 +- .../indexing/jvm/CombinedJarScanner.kt | 11 +- ...ervice.kt => JvmLibraryIndexingService.kt} | 33 +- .../indexing/jvm/JvmLibrarySymbolIndex.kt | 166 ------- .../indexing/jvm/JvmSymbolIndex.kt | 143 ++++++ .../jvm/KotlinSourceIndexingService.kt | 162 ------ .../indexing/jvm/KotlinSourceScanner.kt | 469 ------------------ .../indexing/jvm/KotlinSourceSymbolIndex.kt | 173 ------- .../indexing/jvm/KtFileMetadata.kt | 47 ++ .../indexing/jvm/KtFileMetadataDescriptor.kt | 64 +++ .../indexing/jvm/KtFileMetadataIndex.kt | 141 ++++++ .../src/main/proto/jvm_symbol.proto | 9 + .../lsp/kotlin/KotlinLanguageServer.kt | 70 +-- .../androidide/lsp/kotlin/KtFileManager.kt | 208 -------- .../kotlin/compiler/CompilationEnvironment.kt | 385 +++++++++----- .../lsp/kotlin/compiler/Compiler.kt | 7 +- .../lsp/kotlin/compiler/KotlinProjectModel.kt | 138 +----- .../lsp/kotlin/compiler/ModuleResolver.kt | 36 -- .../lsp/kotlin/compiler/ReadWriteLock.kt | 24 + .../lsp/kotlin/compiler/WorkspaceExts.kt | 93 ++++ .../lsp/kotlin/compiler/index/IndexCommand.kt | 14 + .../lsp/kotlin/compiler/index/IndexWorker.kt | 120 +++++ .../kotlin/compiler/index/KtSymbolIndex.kt | 140 ++++++ .../kotlin/compiler/index/ScanningWorker.kt | 57 +++ .../compiler/index/SourceFileIndexer.kt | 414 ++++++++++++++++ .../lsp/kotlin/compiler/index/WorkerQueue.kt | 27 + .../compiler/modules/AbstractKtModule.kt | 29 ++ .../compiler/modules/KtLibraryModule.kt | 133 +++++ .../lsp/kotlin/compiler/modules/KtModule.kt | 39 ++ .../kotlin/compiler/modules/KtSourceModule.kt | 110 ++++ .../modules/NotUnderContentRootModule.kt | 40 ++ .../compiler/registrar/LspServiceRegistrar.kt | 122 +++++ .../services/AnalysisPermissionOptions.kt | 8 + .../compiler/services/AnnotationsResolver.kt | 165 ++++++ .../compiler/services/DeclarationsProvider.kt | 189 +++++++ .../services/DirectInheritorsProvider.kt | 172 +++++++ .../JavaModuleAccessibilityChecker.kt | 33 ++ .../services/JavaModuleAnnotationsProvider.kt | 16 + .../kotlin/compiler/services/KtLspService.kt | 16 + .../compiler/services/LanguageSettings.kt | 9 + .../services/ModificationTrackerFactory.kt | 6 + .../services/ModuleDependentsProvider.kt | 67 +++ .../compiler/services/PackageProvider.kt | 85 ++++ .../compiler/services/PlatformSettings.kt | 9 + .../services/ProjectStructureProvider.kt | 113 +++++ .../compiler/services/WriteAccessGuard.kt | 11 + .../completion/AdvancedKotlinEditHandler.kt | 6 +- .../KotlinClassImportEditHandler.kt | 4 +- .../kotlin/completion/KotlinCompletions.kt | 29 +- .../diagnostic/KotlinDiagnosticProvider.kt | 44 +- .../kotlin/utils/SymbolVisibilityChecker.kt | 6 +- .../main/resources/META-INF/kt-lsp/kt-lsp.xml | 12 + .../src/main/proto/android.proto | 4 + .../src/main/proto/common.proto | 9 + .../project-models/src/main/proto/java.proto | 3 + .../tooling/impl/serial/AndroidProjectExts.kt | 1 + .../tooling/impl/serial/JavaProjectExts.kt | 1 + 67 files changed, 3163 insertions(+), 1780 deletions(-) rename lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/{JvmIndexingService.kt => JvmLibraryIndexingService.kt} (82%) delete mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt delete mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt delete mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt delete mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadata.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataDescriptor.kt create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt delete mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KtFileManager.kt delete 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/compiler/ReadWriteLock.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AbstractKtModule.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtModule.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/NotUnderContentRootModule.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnalysisPermissionOptions.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnnotationsResolver.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DirectInheritorsProvider.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAccessibilityChecker.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAnnotationsProvider.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/KtLspService.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/LanguageSettings.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModificationTrackerFactory.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PackageProvider.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PlatformSettings.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/WriteAccessGuard.kt create mode 100644 lsp/kotlin/src/main/resources/META-INF/kt-lsp/kt-lsp.xml diff --git a/common/build.gradle.kts b/common/build.gradle.kts index 0c61e21fd9..ffb8278904 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -10,6 +10,8 @@ android { } dependencies { + compileOnly(libs.composite.javac) + api(platform(libs.sora.bom)) api(libs.common.editor) api(libs.common.lang3) diff --git a/common/src/main/java/com/itsaky/androidide/utils/Environment.java b/common/src/main/java/com/itsaky/androidide/utils/Environment.java index a3e1e64637..b6d69b1f28 100755 --- a/common/src/main/java/com/itsaky/androidide/utils/Environment.java +++ b/common/src/main/java/com/itsaky/androidide/utils/Environment.java @@ -25,6 +25,8 @@ import com.blankj.utilcode.util.FileUtils; import com.itsaky.androidide.app.configuration.IDEBuildConfigProvider; import com.itsaky.androidide.buildinfo.BuildInfo; +import com.itsaky.androidide.javac.config.JavacConfigProvider; + import java.io.File; import java.util.Map; import java.util.concurrent.atomic.AtomicBoolean; @@ -188,6 +190,9 @@ public static void init(Context context) { NDK_DIR = new File(ANDROID_HOME, "ndk"); + // required by Java and Kotlin LSP + System.setProperty(JavacConfigProvider.PROP_ANDROIDIDE_JAVA_HOME, JAVA_HOME.getAbsolutePath()); + isInitialized.set(true); } diff --git a/composite-builds/build-deps/java-compiler/src/main/java/com/itsaky/androidide/javac/config/JavacConfigProvider.java b/composite-builds/build-deps/java-compiler/src/main/java/com/itsaky/androidide/javac/config/JavacConfigProvider.java index 9d8539df0a..173b570c05 100644 --- a/composite-builds/build-deps/java-compiler/src/main/java/com/itsaky/androidide/javac/config/JavacConfigProvider.java +++ b/composite-builds/build-deps/java-compiler/src/main/java/com/itsaky/androidide/javac/config/JavacConfigProvider.java @@ -49,7 +49,11 @@ public class JavacConfigProvider { */ public static String getJavaHome() { String javaHome = System.getProperty(PROP_ANDROIDIDE_JAVA_HOME); - if (javaHome == null || javaHome.trim().length() == 0) { + if (javaHome == null || javaHome.trim().isEmpty()) { + System.err.println("!!!!!!!!!!!!!!!!!!!!!!!!"); + System.err.println(PROP_ANDROIDIDE_JAVA_HOME + " is not set. Falling back to java.home!"); + System.err.println("!!!!!!!!!!!!!!!!!!!!!!!!"); + javaHome = System.getProperty("java.home"); } return javaHome; diff --git a/logger/src/main/java/com/itsaky/androidide/logging/IDELoggingConfigurator.kt b/logger/src/main/java/com/itsaky/androidide/logging/IDELoggingConfigurator.kt index d497677881..ddbcbc7290 100644 --- a/logger/src/main/java/com/itsaky/androidide/logging/IDELoggingConfigurator.kt +++ b/logger/src/main/java/com/itsaky/androidide/logging/IDELoggingConfigurator.kt @@ -23,7 +23,6 @@ import ch.qos.logback.classic.spi.Configurator import ch.qos.logback.classic.spi.ConfiguratorRank import ch.qos.logback.core.spi.ContextAwareBase import com.google.auto.service.AutoService -import com.itsaky.androidide.logging.encoder.IDELogFormatEncoder /** * Default IDE logging configurator. 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 index d5a8527ca3..1aced73afb 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt @@ -1,7 +1,5 @@ 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 @@ -77,12 +75,10 @@ open class FilteredIndex( 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 + override fun query(query: IndexQuery): Sequence { if (query.sourceId != null && query.sourceId !in activeSources) { - return kotlinx.coroutines.flow.emptyFlow() + return emptySequence() } - return backing.query(query).filter { it.sourceId in activeSources } } @@ -95,7 +91,7 @@ open class FilteredIndex( return sourceId in activeSources && backing.containsSource(sourceId) } - override fun distinctValues(fieldName: String): Flow { + override fun distinctValues(fieldName: String): Sequence { // 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 @@ -109,4 +105,4 @@ open class FilteredIndex( 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 index 20067e998e..bccfb6f69d 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/InMemoryIndex.kt @@ -1,7 +1,5 @@ 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 @@ -54,17 +52,12 @@ class InMemoryIndex( } } - override fun query(query: IndexQuery): Flow = flow { + override fun query(query: IndexQuery): Sequence { 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++ - } + return keys + .mapNotNull { primaryMap[it] } + .take(limit) } override suspend fun get(key: String): T? = primaryMap[key] @@ -72,17 +65,9 @@ class InMemoryIndex( 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 fun distinctValues(fieldName: String): Sequence { + val fieldMap = fieldMaps[fieldName] ?: return emptySequence() + return lock.read { fieldMap.keys.toList() }.asSequence() } override suspend fun insertAll(entries: Sequence) { @@ -93,7 +78,7 @@ class InMemoryIndex( } } - override suspend fun insert(entry: T) = insertSingle(entry) + override suspend fun insert(entry: T) = lock.write { insertSingleLocked(entry) } override suspend fun removeBySource(sourceId: String) = lock.write { val keys = sourceMap.remove(sourceId) ?: return@write @@ -176,10 +161,6 @@ class InMemoryIndex( 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) { 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 index af39930033..05a9bab1e7 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/MergedIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/MergedIndex.kt @@ -1,17 +1,17 @@ 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. * + * Indexes are queried sequentially in the order they are provided. + * Duplicate keys (same entry present in more than one backing index) + * are deduplicated — the first occurrence wins. + * * @param T The indexed type. * @param indexes The indexes to merge, in priority order. */ @@ -21,28 +21,17 @@ class MergedIndex( constructor(vararg indexes: ReadableIndex) : this(indexes.toList()) - override fun query(query: IndexQuery): Flow = channelFlow { - val seen = ConcurrentHashMap.newKeySet() + override fun query(query: IndexQuery): Sequence = sequence { 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. + val seen = mutableSetOf() + var total = 0 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 - } - } + if (total >= limit) break + for (entry in index.query(query)) { + if (total >= limit) break + if (seen.add(entry.key)) { + yield(entry) + total++ } } } @@ -61,15 +50,11 @@ class MergedIndex( return indexes.any { it.containsSource(sourceId) } } - override fun distinctValues(fieldName: String): Flow = channelFlow { - val seen = ConcurrentHashMap.newKeySet() + override fun distinctValues(fieldName: String): Sequence = sequence { + val seen = mutableSetOf() for (index in indexes) { - launch { - index.distinctValues(fieldName).collect { value -> - if (seen.add(value)) { - send(value) - } - } + for (value in index.distinctValues(fieldName)) { + if (seen.add(value)) yield(value) } } } diff --git a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt index 8d885e533a..786dca5c03 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/SQLiteIndex.kt @@ -7,9 +7,6 @@ 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 @@ -41,6 +38,11 @@ import kotlin.collections.iterator * Uses WAL journal mode for concurrent read/write performance. * Inserts are batched inside transactions for throughput. * + * [query] and [distinctValues] eagerly collect results and return a + * [Sequence] backed by a list. The cursor is always closed before + * returning; callers are responsible for running on an appropriate + * thread (typically [Dispatchers.IO] via the suspend insert paths). + * * @param T The indexed entry type. * @param descriptor Defines fields and serialization. * @param context Android context (for database file location). @@ -53,7 +55,7 @@ class SQLiteIndex( override val descriptor: IndexDescriptor, context: Context, dbName: String?, - override val name: String = "persistent:${descriptor.name}", + override val name: String = "sqlite:${descriptor.name}", private val batchSize: Int = 500, ) : Index { @@ -100,18 +102,18 @@ class SQLiteIndex( createTable(db) } - override fun query(query: IndexQuery): Flow = flow { + override fun query(query: IndexQuery): Sequence { val (sql, args) = buildSelectQuery(query) val cursor = db.query(sql, args.toTypedArray()) - - cursor.use { + return cursor.use { val payloadIdx = it.getColumnIndexOrThrow("_payload") - while (it.moveToNext()) { - val bytes = it.getBlob(payloadIdx) - emit(descriptor.deserialize(bytes)) + buildList { + while (it.moveToNext()) { + add(descriptor.deserialize(it.getBlob(payloadIdx))) + } } - } - }.flowOn(Dispatchers.IO) + }.asSequence() + } override suspend fun get(key: String): T? = withContext(Dispatchers.IO) { val cursor = db.query( @@ -134,41 +136,17 @@ class SQLiteIndex( cursor.use { it.moveToFirst() } } - override fun distinctValues(fieldName: String): Flow = flow { + override fun distinctValues(fieldName: String): Sequence { 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() + return cursor.use { + buildList { + while (it.moveToNext()) { + add(it.getString(0)) + } } - } - // Flush remaining - if (batch.isNotEmpty()) { - insertBatch(batch) - } + }.asSequence() } override suspend fun insertAll(entries: Sequence) = withContext(Dispatchers.IO) { 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 index 39f9846494..222c74772e 100644 --- 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 @@ -1,28 +1,26 @@ 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. + * All query methods return [Sequence]s and results are produced lazily. + * The consumer decides how many to take and which thread to run on. * * @param T The indexed type. */ interface ReadableIndex { /** - * Query the index. Returns a lazy [Flow] of matching entries. + * Query the index. Returns a lazy [Sequence] 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 + fun query(query: IndexQuery): Sequence /** * Point lookup by key. Returns null if not found. @@ -43,32 +41,20 @@ interface ReadableIndex { * @param fieldName Must be one of the fields declared in the * [IndexDescriptor]. */ - fun distinctValues(fieldName: String): Flow + fun distinctValues(fieldName: String): Sequence } /** * 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]. + * Insert entries from a [Sequence]. * - * Entries are consumed lazily from the flow and batched + * Entries are consumed lazily from the sequence 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) 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 index 1ab75e4074..3d2e01a6ae 100644 --- 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 @@ -6,12 +6,6 @@ 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 @@ -52,12 +46,6 @@ class BackgroundIndexer( 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 { @@ -69,22 +57,22 @@ class BackgroundIndexer( private val activeJobs = ConcurrentHashMap() /** - * Index a single source. The [provider] returns a [Flow] that - * lazily produces entries so that it is NOT collected eagerly. + * Index a single source. The [provider] returns a [Sequence] that + * lazily produces entries — it is consumed on [Dispatchers.IO] by + * [Index.insertAll]. * * 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. + * @param provider Lambda returning a [Sequence] of entries. + * @return The launched job. */ fun indexSource( sourceId: String, skipIfExists: Boolean = true, - provider: (sourceId: String) -> Flow, + provider: (sourceId: String) -> Sequence, ): Job { // Cancel any in-flight job for this source activeJobs[sourceId]?.cancel() @@ -104,61 +92,28 @@ class BackgroundIndexer( 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) - ) - } + progressListener?.onProgress(sourceId, IndexingEvent.Started) - // 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) - ) - } + var count = 0 + val tracked = provider(sourceId).map { entry -> + count++ + if (count % 1000 == 0) { + progressListener?.onProgress(sourceId, IndexingEvent.Progress(count)) } + entry } - index.insert(counted) + index.insertAll(tracked) + + progressListener?.onProgress(sourceId, IndexingEvent.Completed(count)) + log.info("Indexed {} entries from {}", count, sourceId) } 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) - ) + progressListener?.onProgress(sourceId, IndexingEvent.Failed(e)) } finally { activeJobs.remove(sourceId) } @@ -169,23 +124,23 @@ class BackgroundIndexer( } /** - * Index multiple sources in parallel. + * Index multiple sources sequentially in the background. * * 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. + * @param mapper Maps each source to a (sourceId, Sequence) pair. */ fun indexSources( sources: Collection, skipIfExists: Boolean = true, - mapper: (S) -> Pair>, + mapper: (S) -> Pair>, ): List { return sources.map { source -> - val (sourceId, flow) = mapper(source) - indexSource(sourceId, skipIfExists) { flow } - }.filterNotNull() + val (sourceId, seq) = mapper(source) + indexSource(sourceId, skipIfExists) { seq } + } } /** 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 f5c1fb627c..995944e565 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 @@ -74,7 +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.appdevforall.codeonthego.indexing.jvm.JvmLibraryIndexingService import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -121,7 +121,7 @@ class JavaLanguageServer : ILanguageServer { val projectManager = ProjectManagerImpl.getInstance() projectManager.indexingServiceManager.register( - service = JvmIndexingService(context = BaseApplication.baseInstance) + service = JvmLibraryIndexingService(context = BaseApplication.baseInstance) ) JavaSnippetRepository.init() @@ -159,7 +159,7 @@ class JavaLanguageServer : ILanguageServer { (ProjectManagerImpl.getInstance() .indexingServiceManager - .getService(JvmIndexingService.ID) as? JvmIndexingService?) + .getService(JvmLibraryIndexingService.ID) as? JvmLibraryIndexingService?) ?.refresh() // Once we have project initialized 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 index f0bea84939..64af7ce982 100644 --- 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 @@ -1,9 +1,5 @@ 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 @@ -22,12 +18,12 @@ object CombinedJarScanner { private val log = LoggerFactory.getLogger(CombinedJarScanner::class.java) - fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Flow = flow { + fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Sequence = sequence { val jar = try { JarFile(jarPath.toFile()) } catch (e: Exception) { log.warn("Failed to open JAR: {}", jarPath, e) - return@flow + return@sequence } jar.use { @@ -50,14 +46,13 @@ object CombinedJarScanner { JarSymbolScanner.parseClassFile(bytes.inputStream(), sourceId) } - symbols?.forEach { emit(it) } + symbols?.forEach { yield(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 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/JvmLibraryIndexingService.kt similarity index 82% rename from lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt rename to lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibraryIndexingService.kt index a85c2c271e..8b53c393dd 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/JvmLibraryIndexingService.kt @@ -27,36 +27,41 @@ import kotlin.io.path.extension * Both the Kotlin and Java LSPs use this key to retrieve the * shared index from the [IndexRegistry]. */ -val JVM_LIBRARY_SYMBOL_INDEX = IndexKey("jvm-library-symbols") +val JVM_LIBRARY_SYMBOL_INDEX = IndexKey("jvm-library-symbols") /** * [IndexingService] that scans classpath JARs/AARs and builds - * a [JvmLibrarySymbolIndex]. + * a [JvmSymbolIndex]. * * Thread safety: all methods are called from the * [IndexingServiceManager][org.appdevforall.codeonthego.indexing.service.IndexingServiceManager]'s - * coroutine scope. The [JvmLibrarySymbolIndex] handles its own internal thread safety. + * coroutine scope. The [JvmSymbolIndex] handles its own internal thread safety. */ -class JvmIndexingService( +class JvmLibraryIndexingService( private val context: Context, ) : IndexingService { companion object { const val ID = "jvm-indexing-service" - private val log = LoggerFactory.getLogger(JvmIndexingService::class.java) + private val log = LoggerFactory.getLogger(JvmLibraryIndexingService::class.java) } override val id = ID override val providedKeys = listOf(JVM_LIBRARY_SYMBOL_INDEX) - private var index: JvmLibrarySymbolIndex? = null + private var libraryIndex: JvmSymbolIndex? = null private var indexingMutex = Mutex() private val coroutineScope = CoroutineScope(Dispatchers.Default) override suspend fun initialize(registry: IndexRegistry) { - val jvmIndex = JvmLibrarySymbolIndex.create(context) - this.index = jvmIndex + val jvmIndex = JvmSymbolIndex.createSqliteIndex( + context = context, + dbName = JvmSymbolIndex.DB_NAME_DEFAULT, + indexName = JvmSymbolIndex.INDEX_NAME_LIBRARY + ) + + this.libraryIndex = jvmIndex registry.register(JVM_LIBRARY_SYMBOL_INDEX, jvmIndex) log.info("JVM symbol index initialized") } @@ -76,7 +81,7 @@ class JvmIndexingService( } private suspend fun reindexLibraries() { - val index = this.index ?: run { + val index = this.libraryIndex ?: run { log.warn("Not indexing libraries. Index not initialized.") return } @@ -110,7 +115,7 @@ class JvmIndexingService( // JARs not in the set become invisible to queries. // JARs in the set that are already cached become // visible immediately. - index.setActiveLibraries(currentJars) + index.setActiveSources(currentJars) // Step 2: Index any JARs not yet in the cache. // Already-cached JARs are skipped (cheap existence check). @@ -118,9 +123,9 @@ class JvmIndexingService( // they're already in the active set. var newCount = 0 for (jarPath in currentJars) { - if (!index.isLibraryCached(jarPath)) { + if (!index.isCached(jarPath)) { newCount++ - index.indexLibrary(jarPath) { sourceId -> + index.indexSource(jarPath, skipIfExists = true) { sourceId -> CombinedJarScanner.scan(Paths.get(jarPath), sourceId) } } @@ -135,8 +140,8 @@ class JvmIndexingService( override fun close() { coroutineScope.cancelIfActive("indexing service closed") - index?.close() - index = null + libraryIndex?.close() + libraryIndex = null } private fun isIndexableJar(path: Path): Boolean { 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 deleted file mode 100644 index c961203f11..0000000000 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt +++ /dev/null @@ -1,166 +0,0 @@ -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.SQLiteIndex -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 - -/** - * An index of symbols from external Java libraries (JARs). - */ -class JvmLibrarySymbolIndex private constructor( - /** Persistent cache — stores every JAR ever indexed. */ - val libraryCache: SQLiteIndex, - - /** Filtered view — only shows JARs on the current classpath. */ - val libraryView: FilteredIndex, - - /** 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" - - fun create( - context: Context, - dbName: String = DB_NAME_DEFAULT, - ): JvmLibrarySymbolIndex { - val cache = SQLiteIndex( - descriptor = JvmSymbolDescriptor, - context = context, - dbName = dbName, - name = INDEX_NAME_LIBRARY, - ) - - val view = FilteredIndex(cache) - - val indexer = BackgroundIndexer(cache) - return JvmLibrarySymbolIndex( - libraryCache = cache, - libraryView = view, - 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) - - fun findByPrefix(prefix: String, limit: Int = 200): Flow = - libraryView.query(indexQuery { prefix(KEY_NAME, prefix); this.limit = limit }) - - fun findByPrefix( - prefix: String, kinds: Set, limit: Int = 200, - ): Flow = - 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(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(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(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(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(KEY_PACKAGE) - - suspend fun awaitLibraryIndexing() = libraryIndexer.awaitAll() - - override fun close() { - libraryCache.close() - libraryIndexer.close() - libraryView.close() - } -} \ No newline at end of file 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..2b3c044e9c --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolIndex.kt @@ -0,0 +1,143 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import android.content.Context +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import org.appdevforall.codeonthego.indexing.FilteredIndex +import org.appdevforall.codeonthego.indexing.SQLiteIndex +import org.appdevforall.codeonthego.indexing.api.Index +import org.appdevforall.codeonthego.indexing.api.WritableIndex +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.jvm.JvmSymbolIndex.Companion.DB_NAME_DEFAULT +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex.Companion.INDEX_NAME_LIBRARY +import org.appdevforall.codeonthego.indexing.util.BackgroundIndexer +import java.io.Closeable + +/** + * An index of symbols from JVM source and binary files. + */ +class JvmSymbolIndex( + private val backing: Index, + private val indexer: BackgroundIndexer, +) : FilteredIndex(backing), WritableIndex by backing, Closeable { + + companion object { + + const val DB_NAME_DEFAULT = "jvm_symbol_index.db" + const val INDEX_NAME_LIBRARY = "jvm-library-cache" + + /** + * Create (or get) a JVM symbol index backed by SQLite. + * + * @param context The context to use for accessing the SQLite database. + * @param dbName The name of the database. Defaults to [DB_NAME_DEFAULT]. + * @param indexName The name of the index. Defaults to [INDEX_NAME_LIBRARY]. + */ + fun createSqliteIndex( + context: Context, + dbName: String, + indexName: String, + ): JvmSymbolIndex { + val cache = SQLiteIndex( + descriptor = JvmSymbolDescriptor, + context = context, + dbName = dbName, + name = indexName, + ) + + val indexer = BackgroundIndexer(cache) + return JvmSymbolIndex(cache, indexer) + } + } + + /** + * Index a single source. The [provider] returns a [Sequence] that + * lazily produces entries — it is consumed on [Dispatchers.IO] by + * [Index.insertAll]. + * + * 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 [Sequence] of entries. + * @return The launched job. + */ + fun indexSource( + sourceId: String, + skipIfExists: Boolean = true, + provider: (sourceId: String) -> Sequence, + ): Job = indexer.indexSource(sourceId, skipIfExists, provider) + + /** + * Find symbols matching the given prefix. + * + * @param prefix The prefix to search for. + * @param limit The result limit. + * @see query + */ + fun findByPrefix(prefix: String, limit: Int = 200): Sequence = + query(indexQuery { prefix(KEY_NAME, prefix); this.limit = limit }) + + /** + * Find symbols having the given [receiver type][receiverTypeFqName]. + */ + fun findExtensionsFor( + receiverTypeFqName: String, + namePrefix: String = "", + limit: Int = 200, + ): Sequence = query(indexQuery { + eq(KEY_RECEIVER_TYPE, receiverTypeFqName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) + this.limit = limit + }) + + fun findTopLevelCallablesInPackage( + packageName: String, + namePrefix: String = "", + limit: Int = 200, + ): Sequence = query(indexQuery { + 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, + ): Sequence = query(indexQuery { + 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, + ): Sequence = query(indexQuery { + eq(KEY_CONTAINING_CLASS, classFqName) + if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) + this.limit = limit + }) + + suspend fun findByKey(key: String): JvmSymbol? = get(key) + + fun allPackages(): Sequence = distinctValues(KEY_PACKAGE) + + suspend fun awaitIndexing() = indexer.awaitAll() + + override fun close() { + super.close() + if (backing is AutoCloseable) { + backing.close() + } + + indexer.close() + } +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt deleted file mode 100644 index 2630d4ac2c..0000000000 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceIndexingService.kt +++ /dev/null @@ -1,162 +0,0 @@ -package org.appdevforall.codeonthego.indexing.jvm - -import android.content.Context -import com.itsaky.androidide.eventbus.events.editor.DocumentSaveEvent -import com.itsaky.androidide.eventbus.events.file.FileCreationEvent -import com.itsaky.androidide.eventbus.events.file.FileDeletionEvent -import com.itsaky.androidide.eventbus.events.file.FileRenameEvent -import com.itsaky.androidide.projects.ProjectManagerImpl -import com.itsaky.androidide.projects.api.ModuleProject -import com.itsaky.androidide.tasks.cancelIfActive -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -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.EventBus -import org.greenrobot.eventbus.Subscribe -import org.greenrobot.eventbus.ThreadMode -import org.slf4j.LoggerFactory -import java.io.File - -/** - * Well-known registry key for the Kotlin source symbol index. - */ -val KOTLIN_SOURCE_SYMBOL_INDEX = IndexKey("kotlin-source-symbols") - -/** - * [IndexingService] that scans all Kotlin source files in the open project and - * maintains an in-memory [KotlinSourceSymbolIndex]. - */ -class KotlinSourceIndexingService( - private val context: Context, -) : IndexingService { - - companion object { - const val ID = "kotlin-source-indexing-service" - private val log = LoggerFactory.getLogger(KotlinSourceIndexingService::class.java) - } - - override val id = ID - override val providedKeys = listOf(KOTLIN_SOURCE_SYMBOL_INDEX) - - private var index: KotlinSourceSymbolIndex? = null - private val refreshMutex = Mutex() - private val coroutineScope = CoroutineScope(Dispatchers.Default) - - override suspend fun initialize(registry: IndexRegistry) { - val sourceIndex = KotlinSourceSymbolIndex.create(context) - this.index = sourceIndex - registry.register(KOTLIN_SOURCE_SYMBOL_INDEX, sourceIndex) - - if (!EventBus.getDefault().isRegistered(this)) { - EventBus.getDefault().register(this) - } - - log.info("Kotlin source symbol index initialized") - } - - override fun close() { - EventBus.getDefault().unregister(this) - coroutineScope.cancelIfActive("Kotlin source indexing service closed") - index?.close() - index = null - } - - /** - * Scans all `.kt` source files across all project modules and indexes any - * file not yet present in the in-memory index. - */ - fun refresh() { - coroutineScope.launch { - refreshMutex.withLock { indexAllSourceFiles() } - } - } - - private suspend fun indexAllSourceFiles() { - val index = this.index ?: run { - log.warn("Kotlin source index not initialized; skipping refresh") - return - } - - val workspace = ProjectManagerImpl.getInstance().workspace ?: run { - log.warn("Workspace model not available; skipping Kotlin source scan") - return - } - - val sourceFiles = workspace.subProjects - .asSequence() - .filterIsInstance() - .flatMap { module -> module.getSourceDirectories().asSequence() } - .filter { it.exists() && it.isDirectory } - .flatMap { dir -> dir.walkTopDown().filter { it.isFile && it.extension == "kt" } } - .map { it.absolutePath } - .toList() - - log.info("Found {} Kotlin source files to index", sourceFiles.size) - - var submitted = 0 - for (filePath in sourceFiles) { - if (!index.isFileCached(filePath)) { - submitted++ - index.indexFile(filePath) - } - } - - if (submitted > 0) { - log.info("{} Kotlin source files submitted for background indexing", submitted) - } else { - log.info("All Kotlin source files already cached, nothing to index") - } - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("UNUSED") - fun onFileCreated(event: FileCreationEvent) { - if (!event.file.isKotlinSource) return - val filePath = event.file.absolutePath - log.debug("File created, indexing: {}", filePath) - index?.indexFile(filePath) - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("UNUSED") - fun onFileSaved(event: DocumentSaveEvent) { - val filePath = event.savedFile.toAbsolutePath().toString() - if (!filePath.endsWith(".kt")) return - log.debug("File saved, re-indexing: {}", filePath) - index?.reindexFile(filePath) - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("UNUSED") - fun onFileDeleted(event: FileDeletionEvent) { - if (!event.file.isKotlinSource) return - val filePath = event.file.absolutePath - log.debug("File deleted, removing from index: {}", filePath) - index?.removeFile(filePath) - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("UNUSED") - fun onFileRenamed(event: FileRenameEvent) { - val oldPath = event.file.absolutePath - val newPath = event.newFile.absolutePath - - if (event.file.isKotlinSource) { - log.debug("File renamed, removing old path from index: {}", oldPath) - index?.removeFile(oldPath) - } - - if (event.newFile.isKotlinSource) { - log.debug("File renamed, indexing new path: {}", newPath) - index?.indexFile(newPath) - } - } - - private val File.isKotlinSource: Boolean - get() = isFile && extension == "kt" -} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt deleted file mode 100644 index 3f5cb2725b..0000000000 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceScanner.kt +++ /dev/null @@ -1,469 +0,0 @@ -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.appdevforall.codeonthego.indexing.jvm.KotlinSourceScanner.fqnToInternalName -import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceScanner.scan -import org.appdevforall.codeonthego.lsp.kotlin.parser.KotlinParser -import org.appdevforall.codeonthego.lsp.kotlin.symbol.ClassKind -import org.appdevforall.codeonthego.lsp.kotlin.symbol.ClassSymbol -import org.appdevforall.codeonthego.lsp.kotlin.symbol.FunctionSymbol -import org.appdevforall.codeonthego.lsp.kotlin.symbol.PropertySymbol -import org.appdevforall.codeonthego.lsp.kotlin.symbol.Symbol -import org.appdevforall.codeonthego.lsp.kotlin.symbol.SymbolBuilder -import org.appdevforall.codeonthego.lsp.kotlin.symbol.TypeAliasSymbol -import org.appdevforall.codeonthego.lsp.kotlin.symbol.TypeReference -import org.appdevforall.codeonthego.lsp.kotlin.symbol.Visibility -import org.slf4j.LoggerFactory -import java.io.File - -/** - * Parses a Kotlin source file and produces [JvmSymbol] entries for indexing, - * working directly from the [SymbolBuilder] output. - * - * Type references coming from source (which are as-written, dot-separated) - * are converted to internal names via [fqnToInternalName], which applies the - * standard Java naming convention: lowercase segments are package components, - * uppercase-starting segments are class components. - * - * Thread safety: each call to [scan] creates its own [KotlinParser] instance. - */ -object KotlinSourceScanner { - - private val log = LoggerFactory.getLogger(KotlinSourceScanner::class.java) - - /** - * Parses the Kotlin source file at [filePath] and emits a [JvmSymbol] for - * each public/internal declaration found (classes and their members, - * top-level functions, properties, and type aliases). - */ - fun scan(filePath: String, sourceId: String): Flow = flow { - val file = File(filePath) - if (!file.exists() || !file.isFile) return@flow - - val content = try { - file.readText() - } catch (e: Exception) { - log.warn("Failed to read source file: {}", filePath, e) - return@flow - } - - KotlinParser().use { parser -> - val result = parser.parse(content, filePath) - result.tree.use { syntaxTree -> - val symbolTable = SymbolBuilder.build(syntaxTree, filePath) - // Internal prefix for this file's package: "com/example" - val pkgInternal = symbolTable.packageName.replace('.', '/') - - for (symbol in symbolTable.topLevelSymbols) { - for (jvmSymbol in toJvmSymbols( - symbol, - pkgInternal, - containingClass = "", - sourceId - )) { - emit(jvmSymbol) - } - } - } - } - }.flowOn(Dispatchers.IO) - - private fun toJvmSymbols( - symbol: Symbol, - pkgInternal: String, - containingClass: String, - sourceId: String, - ): List = when (symbol) { - is ClassSymbol -> classSymbols(symbol, pkgInternal, containingClass, sourceId) - is FunctionSymbol -> listOfNotNull( - functionSymbol( - symbol, - pkgInternal, - containingClass, - sourceId - ) - ) - - is PropertySymbol -> listOfNotNull( - propertySymbol( - symbol, - pkgInternal, - containingClass, - sourceId - ) - ) - - is TypeAliasSymbol -> listOfNotNull( - typeAliasSymbol( - symbol, - pkgInternal, - containingClass, - sourceId - ) - ) - - else -> emptyList() - } - - private fun classSymbols( - symbol: ClassSymbol, - pkgInternal: String, - containingClass: String, - sourceId: String, - ): List { - // Enum entries use a member-style key relative to their containing enum. - if (symbol.kind == ClassKind.ENUM_ENTRY) { - return listOf(enumEntrySymbol(symbol, pkgInternal, containingClass, sourceId)) - } - - val visibility = mapVisibility(symbol.visibility) - if (visibility == JvmVisibility.PRIVATE) return emptyList() - - // Internal name: "com/example/Outer$Inner" for nested, "com/example/Outer" for top-level. - val classInternalName = buildClassInternalName(symbol.name, pkgInternal, containingClass) - val packageName = pkgInternal.replace('/', '.') - - val kind = mapClassKind(symbol) - - val classSymbol = JvmSymbol( - key = classInternalName, - sourceId = sourceId, - name = classInternalName, - shortName = symbol.name, - packageName = packageName, - kind = kind, - language = JvmSourceLanguage.KOTLIN, - visibility = visibility, - data = JvmClassInfo( - internalName = classInternalName, - containingClassName = containingClass, - supertypeNames = symbol.superTypes.map { fqnToInternalName(it.name) }, - typeParameters = symbol.typeParameters.map { it.name }, - isAbstract = symbol.modifiers.isAbstract, - isFinal = symbol.modifiers.isFinal, - isInner = symbol.modifiers.isInner, - kotlin = KotlinClassInfo( - isData = symbol.modifiers.isData, - isValue = symbol.modifiers.isValue, - isSealed = symbol.modifiers.isSealed, - ), - ), - ) - - val result = mutableListOf(classSymbol) - - // Primary constructor (not always in the member scope, depending on SymbolBuilder). - symbol.primaryConstructor?.let { ctor -> - if (!ctor.isPrimaryConstructor || ctor !in (symbol.memberScope?.allSymbols - ?: emptyList()) - ) { - functionSymbol(ctor, pkgInternal, classInternalName, sourceId)?.let { result += it } - } - } - - // All members: nested classes, secondary constructors, functions, properties. - for (member in symbol.memberScope?.allSymbols ?: emptyList()) { - result += toJvmSymbols(member, pkgInternal, classInternalName, sourceId) - } - - return result - } - - private fun enumEntrySymbol( - symbol: ClassSymbol, - pkgInternal: String, - containingClass: String, - sourceId: String, - ): JvmSymbol { - val packageName = pkgInternal.replace('/', '.') - return JvmSymbol( - key = "$containingClass#${symbol.name}", - sourceId = sourceId, - name = "$containingClass#${symbol.name}", - shortName = symbol.name, - packageName = packageName, - kind = JvmSymbolKind.ENUM_ENTRY, - language = JvmSourceLanguage.KOTLIN, - data = JvmEnumEntryInfo(containingClassName = containingClass), - ) - } - - private fun functionSymbol( - symbol: FunctionSymbol, - pkgInternal: String, - containingClass: String, - sourceId: String, - ): JvmSymbol? { - val visibility = mapVisibility(symbol.visibility) - if (visibility == JvmVisibility.PRIVATE) return null - - val kind = when { - symbol.isConstructor -> JvmSymbolKind.CONSTRUCTOR - symbol.isExtension -> JvmSymbolKind.EXTENSION_FUNCTION - else -> JvmSymbolKind.FUNCTION - } - - val packageName = pkgInternal.replace('/', '.') - val owner = containingClass.ifEmpty { pkgInternal } - - // For constructors, the short name is the class's simple name. - val shortName = if (symbol.isConstructor) { - containingClass.substringAfterLast('/').substringAfterLast('$') - } else { - symbol.name - } - val name = "$owner#$shortName" - - val parameters = symbol.parameters.map { param -> - JvmParameterInfo( - name = param.name, - typeName = param.type?.let { fqnToInternalName(it.name) } ?: "", - typeDisplayName = param.type?.render() ?: "", - hasDefaultValue = param.hasDefaultValue, - isVararg = param.isVararg, - isCrossinline = param.isCrossinline, - isNoinline = param.isNoinline, - ) - } - - val returnTypeInternal = - symbol.returnType?.let { fqnToInternalName(it.name) } ?: "kotlin/Unit" - val returnTypeDisplay = symbol.returnType?.render() ?: "Unit" - val receiverType = symbol.receiverType - - val key = "$name(${parameters.joinToString(",") { it.typeName }})" - - val signatureDisplay = buildString { - receiverType?.let { append(displayName(it)).append('.') } - if (symbol.typeParameters.isNotEmpty()) { - append('<') - append(symbol.typeParameters.joinToString { it.name }) - append('>') - } - append('(') - append(parameters.joinToString(", ") { "${it.name}: ${it.typeDisplayName}" }) - append(')') - if (!symbol.isConstructor) { - append(": ") - append(returnTypeDisplay) - } - } - - return JvmSymbol( - key = key, - sourceId = sourceId, - name = name, - shortName = shortName, - packageName = packageName, - kind = kind, - language = JvmSourceLanguage.KOTLIN, - visibility = visibility, - data = JvmFunctionInfo( - containingClassName = containingClass, - returnTypeName = returnTypeInternal, - returnTypeDisplayName = returnTypeDisplay, - parameterCount = parameters.size, - parameters = parameters, - signatureDisplay = signatureDisplay, - typeParameters = symbol.typeParameters.map { it.name }, - isAbstract = symbol.modifiers.isAbstract, - isFinal = symbol.modifiers.isFinal, - kotlin = KotlinFunctionInfo( - receiverTypeName = receiverType?.let { fqnToInternalName(it.name) } ?: "", - receiverTypeDisplayName = receiverType?.let { displayName(it) } ?: "", - isSuspend = symbol.isSuspend, - isInline = symbol.isInline, - isInfix = symbol.isInfix, - isOperator = symbol.isOperator, - isTailrec = symbol.isTailrec, - isExternal = symbol.modifiers.isExternal, - isExpect = symbol.modifiers.isExpect, - isActual = symbol.modifiers.isActual, - isReturnTypeNullable = symbol.returnType?.isNullable ?: false, - ), - ), - ) - } - - private fun propertySymbol( - symbol: PropertySymbol, - pkgInternal: String, - containingClass: String, - sourceId: String, - ): JvmSymbol? { - val visibility = mapVisibility(symbol.visibility) - if (visibility == JvmVisibility.PRIVATE) return null - - val kind = - if (symbol.isExtension) JvmSymbolKind.EXTENSION_PROPERTY else JvmSymbolKind.PROPERTY - val packageName = pkgInternal.replace('/', '.') - val owner = containingClass.ifEmpty { pkgInternal } - val name = "$owner#${symbol.name}" - val receiverType = symbol.receiverType - - return JvmSymbol( - key = name, - sourceId = sourceId, - name = name, - shortName = symbol.name, - packageName = packageName, - kind = kind, - language = JvmSourceLanguage.KOTLIN, - visibility = visibility, - data = JvmFieldInfo( - containingClassName = containingClass, - typeName = symbol.type?.let { fqnToInternalName(it.name) } ?: "", - typeDisplayName = symbol.type?.let { displayName(it) } ?: "", - isFinal = !symbol.isVar, - kotlin = KotlinPropertyInfo( - receiverTypeName = receiverType?.let { fqnToInternalName(it.name) } ?: "", - receiverTypeDisplayName = receiverType?.let { displayName(it) } ?: "", - isConst = symbol.isConst, - isLateinit = symbol.isLateInit, - hasGetter = symbol.hasCustomGetter, - hasSetter = symbol.hasCustomSetter, - isDelegated = symbol.isDelegated, - isExpect = symbol.modifiers.isExpect, - isActual = symbol.modifiers.isActual, - isExternal = symbol.modifiers.isExternal, - isTypeNullable = symbol.type?.isNullable ?: false, - ), - ), - ) - } - - private fun typeAliasSymbol( - symbol: TypeAliasSymbol, - pkgInternal: String, - containingClass: String, - sourceId: String, - ): JvmSymbol? { - val visibility = mapVisibility(symbol.visibility) - if (visibility == JvmVisibility.PRIVATE) return null - - val packageName = pkgInternal.replace('/', '.') - val internalName = buildClassInternalName(symbol.name, pkgInternal, containingClass) - - return JvmSymbol( - key = internalName, - sourceId = sourceId, - name = internalName, - shortName = symbol.name, - packageName = packageName, - kind = JvmSymbolKind.TYPE_ALIAS, - language = JvmSourceLanguage.KOTLIN, - visibility = visibility, - data = JvmTypeAliasInfo( - containingClassName = containingClass, - expandedTypeName = symbol.underlyingType?.let { fqnToInternalName(it.name) } ?: "", - expandedTypeDisplayName = symbol.underlyingType?.let { displayName(it) } ?: "", - typeParameters = symbol.typeParameters.map { it.name }, - ), - ) - } - - /** - * Builds the JVM internal name for a class. - * - * - Top-level: `com/example/MyClass` - * - Nested: `com/example/MyClass$Inner` - */ - private fun buildClassInternalName( - simpleName: String, - pkgInternal: String, - containingClass: String, - ): String = when { - containingClass.isNotEmpty() -> $$"$$containingClass$$$simpleName" - pkgInternal.isNotEmpty() -> "$pkgInternal/$simpleName" - else -> simpleName - } - - /** - * Converts a dot-separated FQN (as written in Kotlin source) to a JVM internal name. - * - * Applies standard Java naming conventions: lowercase-starting segments are - * package components (joined with `/`), uppercase-starting segments are class - * components (joined with `$`). - * - * Examples: - * ``` - * "String" → "String" - * "kotlin.String" → "kotlin/String" - * "java.util.Map" → "java/util/Map" - * "java.util.Map.Entry" → "java/util/Map$Entry" - * ``` - */ - internal fun fqnToInternalName(fqn: String): String { - if (fqn.isEmpty() || !fqn.contains('.')) return fqn - - val parts = fqn.split('.') - val pkg = mutableListOf() - val cls = mutableListOf() - - for (part in parts) { - if (cls.isEmpty() && part.isNotEmpty() && part[0].isLowerCase()) { - pkg += part - } else { - cls += part - } - } - - return when { - pkg.isEmpty() -> cls.joinToString("$") - cls.isEmpty() -> pkg.joinToString("/") - else -> "${pkg.joinToString("/")}/${cls.joinToString("$")}" - } - } - - /** - * Returns a short display name for a [TypeReference]: the simple class name - * with type arguments rendered, but without the package prefix. - * - * E.g. `java.util.Map.Entry` → `Entry`, `List` → `List`. - */ - private fun displayName(ref: TypeReference): String { - val baseName = fqnToInternalName(ref.name) - .substringAfterLast('/') - .substringAfterLast('$') - val args = ref.typeArguments - return buildString { - append(baseName) - if (args.isNotEmpty()) { - append('<') - append(args.joinToString(", ") { displayName(it) }) - append('>') - } - if (ref.isNullable) append('?') - } - } - - private fun mapClassKind(symbol: ClassSymbol): JvmSymbolKind = when (symbol.kind) { - ClassKind.CLASS -> when { - symbol.modifiers.isSealed -> JvmSymbolKind.SEALED_CLASS - else -> JvmSymbolKind.CLASS - } - - ClassKind.INTERFACE -> when { - symbol.modifiers.isSealed -> JvmSymbolKind.SEALED_INTERFACE - else -> JvmSymbolKind.INTERFACE - } - - ClassKind.OBJECT -> JvmSymbolKind.OBJECT - ClassKind.COMPANION_OBJECT -> JvmSymbolKind.COMPANION_OBJECT - ClassKind.ENUM_CLASS -> JvmSymbolKind.ENUM - ClassKind.ENUM_ENTRY -> JvmSymbolKind.ENUM_ENTRY - ClassKind.ANNOTATION_CLASS -> JvmSymbolKind.ANNOTATION_CLASS - ClassKind.DATA_CLASS -> JvmSymbolKind.DATA_CLASS - ClassKind.VALUE_CLASS -> JvmSymbolKind.VALUE_CLASS - } - - private fun mapVisibility(visibility: Visibility): JvmVisibility = when (visibility) { - Visibility.PUBLIC -> JvmVisibility.PUBLIC - Visibility.PROTECTED -> JvmVisibility.PROTECTED - Visibility.INTERNAL -> JvmVisibility.INTERNAL - Visibility.PRIVATE -> JvmVisibility.PRIVATE - } -} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt deleted file mode 100644 index f888fdfed5..0000000000 --- a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinSourceSymbolIndex.kt +++ /dev/null @@ -1,173 +0,0 @@ -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.SQLiteIndex -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 - -/** - * An index of symbols extracted from Kotlin source files in the project. - * - * Unlike [JvmLibrarySymbolIndex], which accumulates a persistent on-disk cache - * of library JARs across IDE sessions, this index is deliberately **in-memory**: - * it is rebuilt from scratch on each project open and discarded when the project - * closes. This is correct because source files are cheap to re-parse (tree-sitter - * is fast) and the index must always reflect the current on-disk state. - */ -class KotlinSourceSymbolIndex private constructor( - val sourceIndex: SQLiteIndex, - val sourceIndexer: BackgroundIndexer, -) : Closeable { - - companion object { - - const val INDEX_NAME_SOURCE = "kotlin-source-index" - - /** - * Creates a [KotlinSourceSymbolIndex] backed by an in-memory SQLite database. - * - * The [context] is required by the AndroidX SQLite helpers even for in-memory - * databases; it is not used for any file I/O in this case. - */ - fun create(context: Context): KotlinSourceSymbolIndex { - // dbName = null → AndroidX SQLiteOpenHelper creates an in-memory database. - val index = SQLiteIndex( - descriptor = JvmSymbolDescriptor, - context = context, - dbName = null, - name = INDEX_NAME_SOURCE, - ) - val indexer = BackgroundIndexer(index) - return KotlinSourceSymbolIndex( - sourceIndex = index, - sourceIndexer = indexer, - ) - } - } - - /** - * Indexes the symbols in [filePath], skipping the file if it was already - * indexed in this session. - * - * Use [reindexFile] to force re-parsing (e.g. after a save event). - */ - fun indexFile( - filePath: String, - provider: (sourceId: String) -> Flow = { sourceId -> - KotlinSourceScanner.scan(filePath, sourceId) - }, - ) = sourceIndexer.indexSource(filePath, skipIfExists = true, provider) - - /** - * Re-indexes [filePath] unconditionally, removing any previously indexed - * symbols for that file first. - * - * Call this after the file is saved to disk. - */ - fun reindexFile( - filePath: String, - provider: (sourceId: String) -> Flow = { sourceId -> - KotlinSourceScanner.scan(filePath, sourceId) - }, - ) = sourceIndexer.indexSource(filePath, skipIfExists = false, provider) - - /** - * Removes all symbols that originate from [filePath] from the index. - * - * Implemented by scheduling an indexing job with an empty provider so that - * the [BackgroundIndexer] properly cancels any in-flight job for the same - * source before clearing the entries. - */ - fun removeFile(filePath: String) { - sourceIndexer.indexSource( - sourceId = filePath, - skipIfExists = false, - ) { kotlinx.coroutines.flow.emptyFlow() } - } - - /** - * Returns `true` if [filePath] has already been indexed in this session. - */ - suspend fun isFileCached(filePath: String): Boolean = - sourceIndex.containsSource(filePath) - - /** Prefix-based completion across all source symbols. */ - fun findByPrefix(prefix: String, limit: Int = 200): Flow = - sourceIndex.query(indexQuery { prefix(KEY_NAME, prefix); this.limit = limit }) - - /** Prefix-based completion filtered to specific [kinds]. */ - fun findByPrefix( - prefix: String, - kinds: Set, - limit: Int = 200, - ): Flow = - sourceIndex.query(indexQuery { prefix(KEY_NAME, prefix); this.limit = 0 }) - .filter { it.kind in kinds } - .take(limit) - - /** Find extension functions / properties declared for [receiverTypeFqName]. */ - fun findExtensionsFor( - receiverTypeFqName: String, - namePrefix: String = "", - limit: Int = 200, - ): Flow = sourceIndex.query(indexQuery { - eq(KEY_RECEIVER_TYPE, receiverTypeFqName) - if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) - this.limit = limit - }) - - /** Top-level callable symbols (functions, properties) in a package. */ - fun findTopLevelCallablesInPackage( - packageName: String, - namePrefix: String = "", - limit: Int = 200, - ): Flow = sourceIndex.query(indexQuery { - eq(KEY_PACKAGE, packageName) - if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) - this.limit = 0 - }).filter { it.kind.isCallable && it.isTopLevel }.take(limit) - - /** Top-level classifier symbols (classes, interfaces, objects…) in a package. */ - fun findClassifiersInPackage( - packageName: String, - namePrefix: String = "", - limit: Int = 200, - ): Flow = sourceIndex.query(indexQuery { - eq(KEY_PACKAGE, packageName) - if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) - this.limit = 0 - }).filter { it.kind.isClassifier }.take(limit) - - /** Members of a specific class (functions, properties). */ - fun findMembersOf( - classFqName: String, - namePrefix: String = "", - limit: Int = 200, - ): Flow = sourceIndex.query(indexQuery { - eq(KEY_CONTAINING_CLASS, classFqName) - if (namePrefix.isNotEmpty()) prefix(KEY_NAME, namePrefix) - this.limit = limit - }) - - /** Point lookup by fully-qualified name. */ - suspend fun findByFqName(fqName: String): JvmSymbol? = sourceIndex.get(fqName) - - /** All distinct package names present in the index. */ - fun allPackages(): Flow = sourceIndex.distinctValues(KEY_PACKAGE) - - /** Suspends until all in-flight indexing jobs complete. */ - suspend fun awaitIndexing() = sourceIndexer.awaitAll() - - override fun close() { - sourceIndexer.close() - sourceIndex.close() - } -} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadata.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadata.kt new file mode 100644 index 0000000000..754fbddcd3 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadata.kt @@ -0,0 +1,47 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import org.appdevforall.codeonthego.indexing.api.Indexable +import java.time.Instant + +/** + * Metadata for a single Kotlin source file. + * + * Stored in [KtFileMetadataIndex], one entry per `.kt` file discovered + * in the project source roots. The entry is keyed by [filePath] so that + * subsequent updates for the same file replace the previous record. + * + * @param filePath Absolute path to the `.kt` file. Acts as [key] and [sourceId]. + * @param packageFqName Fully-qualified package name declared in the file + * (empty string for the root / default package). + * @param lastModified Wall-clock time the file was last written to disk. + * @param modificationStamp Monotonically increasing stamp from the VFS or + * filesystem; used to detect stale cache entries + * without comparing file content. + * @param isIndexed Whether [symbolKeys] has been populated for this file. + * Files are inserted with `isIndexed = false` as a placeholder + * when first discovered; the indexer flips this to `true` + * after scanning and writing all symbols. + * @param symbolKeys The [Indexable.key] values of every [JvmSymbol] + * declared in this file that was written to the symbol + * index. Empty until [isIndexed] becomes `true`. + */ +data class KtFileMetadata( + val filePath: String, + val packageFqName: String, + val lastModified: Instant, + val modificationStamp: Long, + val isIndexed: Boolean = false, + val symbolKeys: List = emptyList(), +) : Indexable { + + companion object { + fun shouldBeSkipped(existing: KtFileMetadata? = null, new: KtFileMetadata): Boolean { + return existing != null && !existing.lastModified.isBefore(new.lastModified) && + existing.modificationStamp >= new.modificationStamp && + (new.modificationStamp != 0L || existing.modificationStamp == 0L) + } + } + + override val key: String get() = filePath + override val sourceId: String get() = filePath +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataDescriptor.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataDescriptor.kt new file mode 100644 index 0000000000..0453003f2b --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataDescriptor.kt @@ -0,0 +1,64 @@ +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 java.time.Instant + +/** + * [IndexDescriptor] for [KtFileMetadata]. + * + * Queryable fields: + * - `package` : exact match, for package → file path lookups and package + * existence checks used by the Kotlin LSP declaration/package + * providers. + * - `isIndexed` : exact match ("true"/"false"), to enumerate files that still + * need their declaration keys populated. + * + * Non-queryable data (`lastModified`, `modificationStamp`, `declarationKeys`) + * is stored opaquely in the protobuf payload blob. + * + * Serialization uses the `KtFileData` message from `jvm_symbol.proto`. + * `lastModified` is stored as epoch-milliseconds; `modificationStamp` is stored + * as-is (a raw long). + */ +object KtFileMetadataDescriptor : IndexDescriptor { + + const val KEY_PACKAGE = "package" + const val KEY_IS_INDEXED = "isIndexed" + + override val name: String = "kt_file_metadata" + + override val fields: List = listOf( + IndexField(name = KEY_PACKAGE), + IndexField(name = KEY_IS_INDEXED), + ) + + override fun fieldValues(entry: KtFileMetadata): Map = mapOf( + KEY_PACKAGE to entry.packageFqName, + KEY_IS_INDEXED to entry.isIndexed.toString(), + ) + + override fun serialize(entry: KtFileMetadata): ByteArray = + JvmSymbolProtos.KtFileData.newBuilder() + .setPath(entry.filePath) + .setPackageFqName(entry.packageFqName) + .setLastModified(entry.lastModified.toEpochMilli()) + .setModificationStamp(entry.modificationStamp) + .setIndexed(entry.isIndexed) + .addAllSymbolKeys(entry.symbolKeys) + .build() + .toByteArray() + + override fun deserialize(bytes: ByteArray): KtFileMetadata { + val proto = JvmSymbolProtos.KtFileData.parseFrom(bytes) + return KtFileMetadata( + filePath = proto.path, + packageFqName = proto.packageFqName, + lastModified = Instant.ofEpochMilli(proto.lastModified), + modificationStamp = proto.modificationStamp, + isIndexed = proto.indexed, + symbolKeys = proto.symbolKeysList.toList(), + ) + } +} diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt new file mode 100644 index 0000000000..ea39b293d9 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KtFileMetadataIndex.kt @@ -0,0 +1,141 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import android.content.Context +import org.appdevforall.codeonthego.indexing.SQLiteIndex +import org.appdevforall.codeonthego.indexing.api.indexQuery +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataDescriptor.KEY_IS_INDEXED +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataDescriptor.KEY_PACKAGE +import java.io.Closeable + +/** + * An index of [KtFileMetadata] entries, one per Kotlin source file. + */ +class KtFileMetadataIndex private constructor( + private val backing: SQLiteIndex, +) : Closeable { + + companion object { + + /** + * Creates a [KtFileMetadataIndex] backed by an in-memory SQLite database. + * + * The [context] is required by the AndroidX SQLite helpers even for in-memory + * databases; it is not used for any file I/O. + */ + fun create( + context: Context, + dbName: String? = null + ): KtFileMetadataIndex = + KtFileMetadataIndex( + SQLiteIndex( + descriptor = KtFileMetadataDescriptor, + context = context, + dbName = null, + name = "kt-file-metadata", + ) + ) + } + + /** + * Insert or replace the metadata record for a single file. + * + * Because [KtFileMetadata.key] == [KtFileMetadata.filePath], the + * underlying `CONFLICT_REPLACE` strategy ensures this is a true upsert. + */ + suspend fun upsert(metadata: KtFileMetadata) = backing.insert(metadata) + + /** + * Remove the metadata record for [filePath]. + * + * No-op if the file is not in the index. + */ + suspend fun remove(filePath: String) = backing.removeBySource(filePath) + + /** + * Return the [KtFileMetadata] for [filePath], or `null` if the file is + * not present in the index. + */ + suspend fun get(filePath: String): KtFileMetadata? = backing.get(filePath) + + /** + * Return `true` if [filePath] has a record in the index. + */ + suspend fun contains(filePath: String): Boolean = backing.containsSource(filePath) + + /** + * Returns a [Sequence] of files whose declared package exactly matches + * [packageFqName]. + */ + fun getFilesForPackage(packageFqName: String): Sequence = + backing.query( + indexQuery { + eq(KEY_PACKAGE, packageFqName) + limit = 0 + } + ) + + /** + * Returns a [Sequence] of absolute file paths whose declared package exactly + * matches [packageFqName]. + */ + fun getFilePathsForPackage(packageFqName: String): Sequence = + getFilesForPackage(packageFqName).map { it.filePath } + + /** + * Returns `true` if at least one file with package [packageFqName] is + * present in the index. + * + * Pass an empty string for the root (default) package. + */ + fun packageExists(packageFqName: String): Boolean = + backing.query(indexQuery { + eq(KEY_PACKAGE, packageFqName) + limit = 1 + }).firstOrNull() != null + + /** + * Returns the simple names of the direct child packages of [packageFqName]. + * + * For example, if the index contains `com.example.foo`, `com.example.bar`, + * and `com.example.foo.sub`, then `getSubpackageNames("com.example")` returns + * `{"foo", "bar"}`. Pass an empty string to enumerate top-level packages. + * + * Implemented by scanning all distinct package names and extracting the + * first component after [packageFqName]. This is fast for typical Android + * projects (dozens of packages) and avoids a secondary SQL schema. + */ + fun getSubpackageNames(packageFqName: String): Set { + val prefix = if (packageFqName.isEmpty()) "" else "$packageFqName." + val result = mutableSetOf() + for (pkg in backing.distinctValues(KEY_PACKAGE)) { + if (pkg == packageFqName) continue + if (prefix.isNotEmpty() && !pkg.startsWith(prefix)) continue + val remainder = if (prefix.isEmpty()) pkg else pkg.removePrefix(prefix) + val firstComponent = remainder.substringBefore('.') + if (firstComponent.isNotEmpty()) result.add(firstComponent) + } + return result + } + + /** + * Returns a [Sequence] of all distinct package names present in the index. + * + * Useful for building a complete package tree or bulk validity checks. + */ + fun allPackages(): Sequence = backing.distinctValues(KEY_PACKAGE) + + /** + * Returns a [Sequence] of file paths that have been discovered but whose + * symbols have not yet been extracted ([KtFileMetadata.isIndexed] is `false`). + */ + fun getUnindexedFiles(): Sequence = + backing.query(indexQuery { + eq(KEY_IS_INDEXED, false.toString()) + limit = 0 + }).map { it.filePath } + + /** Remove all records from the index. */ + suspend fun clear() = backing.clear() + + override fun close() = backing.close() +} diff --git a/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto b/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto index 0c92e0ab8e..7f7e5517e6 100644 --- a/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto +++ b/lsp/jvm-symbol-models/src/main/proto/jvm_symbol.proto @@ -208,3 +208,12 @@ enum JvmVisibility { VISIBILITY_PRIVATE = 4; VISIBILITY_PACKAGE_PRIVATE = 5; } + +message KtFileData { + string path = 1; + string packageFqName = 2; + int64 lastModified = 3; + int64 modificationStamp = 4; + bool indexed = 5; + repeated string symbolKeys = 6; +} 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 394d7cdcaf..61dc5c6f56 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.compiler.index.KT_SOURCE_FILE_INDEX_KEY +import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_META_INDEX_KEY import com.itsaky.androidide.lsp.kotlin.completion.complete import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor import com.itsaky.androidide.lsp.models.CompletionParams @@ -56,8 +57,9 @@ 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.appdevforall.codeonthego.indexing.jvm.KotlinSourceIndexingService +import org.appdevforall.codeonthego.indexing.jvm.JvmLibraryIndexingService +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode @@ -80,6 +82,8 @@ class KotlinLanguageServer : ILanguageServer { private val scope = CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!)) private var projectModel: KotlinProjectModel? = null + private val sourceIndex: JvmSymbolIndex? = null + private val fileIndex: KtFileMetadataIndex? = null private var compiler: Compiler? = null private var analyzeJob: Job? = null @@ -105,10 +109,6 @@ class KotlinLanguageServer : ILanguageServer { if (!EventBus.getDefault().isRegistered(this)) { EventBus.getDefault().register(this) } - - ProjectManagerImpl.getInstance().indexingServiceManager.register( - KotlinSourceIndexingService(context = BaseApplication.baseInstance) - ) } override fun shutdown() { @@ -129,22 +129,36 @@ class KotlinLanguageServer : ILanguageServer { override fun setupWithProject(workspace: Workspace) { logger.info("setupWithProject called, initialized={}", initialized) + val context = BaseApplication.baseInstance val indexingServiceManager = ProjectManagerImpl.getInstance() .indexingServiceManager - val jvmIndexingService = - indexingServiceManager.getService(JvmIndexingService.ID) as? JvmIndexingService? - val kotlinSourceIndexingService = - indexingServiceManager.getService(KotlinSourceIndexingService.ID) as? KotlinSourceIndexingService? - jvmIndexingService?.refresh() - kotlinSourceIndexingService?.refresh() + val indexingRegistry = indexingServiceManager.registry + indexingRegistry.register( + key = KT_SOURCE_FILE_INDEX_KEY, + index = JvmSymbolIndex.createSqliteIndex( + context = context, + dbName = KT_SOURCE_FILE_INDEX_KEY.name, + indexName = KT_SOURCE_FILE_INDEX_KEY.name, + ) + ) + + indexingRegistry.register( + key = KT_SOURCE_FILE_META_INDEX_KEY, + index = KtFileMetadataIndex.create( + context = context, + dbName = KT_SOURCE_FILE_META_INDEX_KEY.name + ) + ) + + val jvmLibraryIndexingService = + indexingServiceManager.getService(JvmLibraryIndexingService.ID) as? JvmLibraryIndexingService? + + jvmLibraryIndexingService?.refresh() val jdkHome = Environment.JAVA_HOME.toPath() val jdkRelease = IJdkDistributionProvider.DEFAULT_JAVA_RELEASE - val intellijPluginRoot = Paths.get( - BaseApplication - .baseInstance.applicationInfo.sourceDir - ) + val intellijPluginRoot = Paths.get(context.applicationInfo.sourceDir) val jvmTarget = JvmTarget.fromString(IJdkDistributionProvider.DEFAULT_JAVA_VERSION) ?: JvmTarget.JVM_21 @@ -159,6 +173,7 @@ class KotlinLanguageServer : ILanguageServer { this.projectModel = model val compiler = Compiler( + workspace = workspace, projectModel = model, intellijPluginRoot = intellijPluginRoot, jdkHome = jdkHome, @@ -256,8 +271,7 @@ class KotlinLanguageServer : ILanguageServer { } compiler?.compilationEnvironmentFor(event.openedFile)?.apply { - val content = FileManager.getDocumentContents(event.openedFile) - fileManager.onFileOpened(event.openedFile, content) + onFileOpen(event.openedFile) } selectedFile = event.openedFile @@ -292,8 +306,7 @@ class KotlinLanguageServer : ILanguageServer { } compiler?.compilationEnvironmentFor(event.changedFile)?.apply { - val content = FileManager.getDocumentContents(event.changedFile) - fileManager.onFileContentChanged(event.changedFile, content) + onFileContentChanged(event.changedFile) } debouncingAnalyze() @@ -307,8 +320,7 @@ class KotlinLanguageServer : ILanguageServer { } compiler?.compilationEnvironmentFor(event.closedFile)?.apply { - fileManager.onFileClosed(event.closedFile) - fileManager.clearAnalyzeTimestampOf(event.closedFile) + onFileClosed(event.closedFile) } if (FileManager.getActiveDocumentCount() == 0) { @@ -317,18 +329,6 @@ class KotlinLanguageServer : ILanguageServer { } } - @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 deleted file mode 100644 index c711534897..0000000000 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KtFileManager.kt +++ /dev/null @@ -1,208 +0,0 @@ -package com.itsaky.androidide.lsp.kotlin - -import com.itsaky.androidide.projects.FileManager -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) - } - - 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? { - val managed = entries[path] - if (managed != null) { - return managed - } - - val activeDocument = FileManager.getActiveDocument(path) - if (activeDocument != null) { - // document is active, but we were not notified - // open it now - onFileOpened(path, activeDocument.content) - return entries[path] - } - - return null - } - - 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 b747556854..a8bb55edba 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,32 +1,72 @@ package com.itsaky.androidide.lsp.kotlin.compiler -import com.itsaky.androidide.lsp.kotlin.KtFileManager +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence +import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule +import com.itsaky.androidide.lsp.kotlin.compiler.registrar.LspServiceRegistrar +import com.itsaky.androidide.lsp.kotlin.compiler.services.JavaModuleAccessibilityChecker +import com.itsaky.androidide.lsp.kotlin.compiler.services.JavaModuleAnnotationsProvider +import com.itsaky.androidide.lsp.kotlin.compiler.services.KtLspService +import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider +import com.itsaky.androidide.lsp.kotlin.compiler.services.WriteAccessGuard +import com.itsaky.androidide.lsp.kotlin.compiler.services.latestLanguageVersionSettings import com.itsaky.androidide.lsp.kotlin.utils.SymbolVisibilityChecker -import org.appdevforall.codeonthego.indexing.jvm.JvmLibrarySymbolIndex -import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceSymbolIndex -import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import com.itsaky.androidide.projects.FileManager +import com.itsaky.androidide.projects.api.Workspace +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex +import org.jetbrains.kotlin.K1Deprecation 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.declarations.KotlinDirectInheritorsProvider +import org.jetbrains.kotlin.analysis.api.platform.java.KotlinJavaModuleAccessibilityChecker +import org.jetbrains.kotlin.analysis.api.platform.java.KotlinJavaModuleAnnotationsProvider +import org.jetbrains.kotlin.analysis.api.platform.modification.KaElementModificationType +import org.jetbrains.kotlin.analysis.api.platform.modification.KaSourceModificationService +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackagePartProviderFactory import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderFactory -import org.jetbrains.kotlin.analysis.api.standalone.StandaloneAnalysisAPISession -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.analysis.api.platform.projectStructure.KotlinModuleDependentsProvider +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinProjectStructureProvider +import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.ApplicationServiceRegistration +import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectExtensionPoints +import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectModelServices +import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectServices 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.cli.jvm.compiler.CliMetadataFinderFactory +import org.jetbrains.kotlin.cli.jvm.compiler.CliVirtualFileFinderFactory +import org.jetbrains.kotlin.cli.jvm.compiler.JvmPackagePartProvider +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCliJavaFileManagerImpl +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreProjectEnvironment +import org.jetbrains.kotlin.cli.jvm.compiler.setupHighestLanguageLevel +import org.jetbrains.kotlin.cli.jvm.compiler.setupIdeaStandaloneExecution +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.cli.jvm.index.JvmDependenciesDynamicCompoundIndex +import org.jetbrains.kotlin.cli.jvm.index.JvmDependenciesIndexImpl +import org.jetbrains.kotlin.cli.jvm.index.SingleJavaFileRootsIndex +import org.jetbrains.kotlin.cli.jvm.modules.CliJavaModuleFinder +import org.jetbrains.kotlin.cli.jvm.modules.CliJavaModuleResolver +import org.jetbrains.kotlin.cli.jvm.modules.JavaModuleGraph import org.jetbrains.kotlin.com.intellij.core.CoreApplicationEnvironment +import org.jetbrains.kotlin.com.intellij.core.CorePackageIndex +import org.jetbrains.kotlin.com.intellij.mock.MockApplication import org.jetbrains.kotlin.com.intellij.mock.MockProject -import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager +import org.jetbrains.kotlin.com.intellij.openapi.command.CommandProcessor +import org.jetbrains.kotlin.com.intellij.openapi.editor.impl.DocumentWriteAccessGuard +import org.jetbrains.kotlin.com.intellij.openapi.roots.PackageIndex 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.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.com.intellij.psi.ClassTypePointerFactory import org.jetbrains.kotlin.com.intellij.psi.PsiDocumentManager import org.jetbrains.kotlin.com.intellij.psi.PsiManager +import org.jetbrains.kotlin.com.intellij.psi.impl.file.impl.JavaFileManager +import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.PsiClassReferenceTypePointerFactory +import org.jetbrains.kotlin.com.intellij.psi.search.ProjectScope import org.jetbrains.kotlin.config.ApiVersion import org.jetbrains.kotlin.config.CompilerConfiguration import org.jetbrains.kotlin.config.LanguageFeature @@ -38,6 +78,8 @@ 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.load.kotlin.MetadataFinderFactory +import org.jetbrains.kotlin.load.kotlin.VirtualFileFinderFactory import org.jetbrains.kotlin.metadata.jvm.deserialization.JvmProtoBufUtil import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtPsiFactory @@ -53,8 +95,11 @@ import kotlin.io.path.pathString * @param jdkHome Path to the JDK installation directory. * @param jdkRelease The JDK release version at [jdkHome]. */ +@Suppress("UnstableApiUsage") +@OptIn(K1Deprecation::class) internal class CompilationEnvironment( - val project: KotlinProjectModel, + workspace: Workspace, + val ktProject: KotlinProjectModel, val intellijPluginRoot: Path, val jdkHome: Path, val jdkRelease: Int, @@ -63,41 +108,53 @@ internal class CompilationEnvironment( ) : KotlinProjectModel.ProjectModelListener, AutoCloseable { private var disposable = Disposer.newDisposable() - var session: StandaloneAnalysisAPISession - private set - - var parser: KtPsiFactory - private set - - var fileManager: KtFileManager - private set + val application: MockApplication + val project: MockProject + val parser: KtPsiFactory + val commandProcessor: CommandProcessor + val modules: List val psiManager: PsiManager - get() = PsiManager.getInstance(session.project) + get() = PsiManager.getInstance(project) val psiDocumentManager: PsiDocumentManager - get() = PsiDocumentManager.getInstance(session.project) + get() = PsiDocumentManager.getInstance(project) + + val libraryIndex: JvmSymbolIndex? + get() = ktProject.libraryIndex - val modificationTrackerFactory: KotlinModificationTrackerFactory - get() = session.project.getService(KotlinModificationTrackerFactory::class.java) + val requireLibraryIndex: JvmSymbolIndex + get() = checkNotNull(libraryIndex) - val coreApplicationEnvironment: CoreApplicationEnvironment - get() = session.coreApplicationEnvironment + val sourceIndex: JvmSymbolIndex? + get() = ktProject.sourceIndex - val symbolVisibilityChecker: SymbolVisibilityChecker? - get() = project.symbolVisibilityChecker + val requireSourceIndex: JvmSymbolIndex + get() = checkNotNull(sourceIndex) - val requireSymbolVisibilityChecker: SymbolVisibilityChecker - get() = checkNotNull(symbolVisibilityChecker) + val fileIndex: KtFileMetadataIndex? + get() = ktProject.fileIndex - val libraryIndex: JvmLibrarySymbolIndex? - get() = project.libraryIndex + val requireFileIndex: KtFileMetadataIndex + get() = checkNotNull(fileIndex) - val requireLibraryIndex: JvmLibrarySymbolIndex - get() = checkNotNull(libraryIndex) + val symbolVisibilityChecker: SymbolVisibilityChecker by lazy { + val provider = + project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider + SymbolVisibilityChecker(provider) + } - val sourceIndex: KotlinSourceSymbolIndex? - get() = project.sourceIndex + val ktSymbolIndex by lazy { + KtSymbolIndex( + project = project, + modules = modules, + fileIndex = requireFileIndex, + sourceIndex = requireSourceIndex, + libraryIndex = requireLibraryIndex, + ) + } + + private val serviceRegistrars = listOf(LspServiceRegistrar) private val envMessageCollector = object : MessageCollector { override fun clear() { @@ -122,39 +179,152 @@ internal class CompilationEnvironment( } init { - session = buildSession() - parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) - fileManager = KtFileManager(parser, psiManager, psiDocumentManager) + System.setProperty("java.awt.headless", "true") + setupIdeaStandaloneExecution() - project.addListener(this) - } + val appEnv = KotlinCoreEnvironment.getOrCreateApplicationEnvironment( + projectDisposable = disposable, + configuration = createCompilerConfiguration(), + environmentMode = KotlinCoreApplicationEnvironmentMode.Production, + ) - private fun buildSession(): StandaloneAnalysisAPISession { - val configuration = createCompilerConfiguration() + val projectEnv = KotlinCoreProjectEnvironment( + disposable = disposable, + applicationEnvironment = appEnv + ) - val session = buildStandaloneAnalysisAPISession( - projectDisposable = disposable, - unitTestMode = false, - compilerConfiguration = configuration, + project = projectEnv.project + project.registerRWLock() + + application = appEnv.application + + ApplicationServiceRegistration.registerWithCustomRegistration( + application, + serviceRegistrars, ) { - buildKtModuleProvider { - this@CompilationEnvironment.project.configureModules(this) + registerApplicationServices(application, data = Unit) + } + + KotlinCoreEnvironment.registerProjectExtensionPoints(project.extensionArea) + + CoreApplicationEnvironment.registerExtensionPoint( + application.extensionArea, + ClassTypePointerFactory.EP_NAME, + ClassTypePointerFactory::class.java, + ) + + application.extensionArea.getExtensionPoint(ClassTypePointerFactory.EP_NAME) + .registerExtension(PsiClassReferenceTypePointerFactory(), application) + + CoreApplicationEnvironment.registerExtensionPoint( + application.extensionArea, + DocumentWriteAccessGuard.EP_NAME, + WriteAccessGuard::class.java, + ) + + serviceRegistrars.registerProjectExtensionPoints(project, data = Unit) + serviceRegistrars.registerProjectServices(project, data = Unit) + serviceRegistrars.registerProjectModelServices(project, disposable, data = Unit) + + modules = workspace.collectKtModules(project, appEnv) + + project.setupHighestLanguageLevel() + val librariesScope = ProjectScope.getLibrariesScope(project) + val libraryRoots = modules + .asFlatSequence() + .filterNot { it.isSourceModule } + .flatMap { + it.computeFiles(extended = true).map { JavaRoot(it, JavaRoot.RootType.BINARY) } } + .toList() + + val javaFileManager = + project.getService(JavaFileManager::class.java) as KotlinCliJavaFileManagerImpl + val javaModuleFinder = + CliJavaModuleFinder(jdkHome.toFile(), null, javaFileManager, project, jdkRelease) + val javaModuleGraph = JavaModuleGraph(javaModuleFinder) + val delegateJavaModuleResolver = + CliJavaModuleResolver(javaModuleGraph, emptyList(), emptyList(), project) + + val corePackageIndex = project.getService(PackageIndex::class.java) as CorePackageIndex + val packagePartProvider = JvmPackagePartProvider( + latestLanguageVersionSettings, + librariesScope + ).apply { + addRoots(libraryRoots, MessageCollector.NONE) } + val rootsIndex = + JvmDependenciesDynamicCompoundIndex(shouldOnlyFindFirstClass = false).apply { + addIndex( + JvmDependenciesIndexImpl( + libraryRoots, + shouldOnlyFindFirstClass = false + ) + ) // TODO Should receive all (sources + libraries) + + indexedRoots.forEach { javaRoot -> + if (javaRoot.file.isDirectory) { + if (javaRoot.type == JavaRoot.RootType.SOURCE) { + javaFileManager.addToClasspath(javaRoot.file) + corePackageIndex.addToClasspath(javaRoot.file) + } else { + projectEnv.addSourcesToClasspath(javaRoot.file) + } + } + } + } - return session - } + javaFileManager.initialize( + index = rootsIndex, + packagePartProviders = listOf(packagePartProvider), + singleJavaFileRootsIndex = SingleJavaFileRootsIndex(emptyList()), + usePsiClassFilesReading = true, + perfManager = null, + ) - private fun rebuildSession() { - logger.info("Rebuilding analysis session") + val fileFinderFactory = CliVirtualFileFinderFactory(rootsIndex, false, perfManager = null) - disposable.dispose() - disposable = Disposer.newDisposable() + with(project) { + registerService( + KotlinJavaModuleAccessibilityChecker::class.java, + JavaModuleAccessibilityChecker(delegateJavaModuleResolver) + ) + registerService( + KotlinJavaModuleAnnotationsProvider::class.java, + JavaModuleAnnotationsProvider(delegateJavaModuleResolver), + ) + registerService(VirtualFileFinderFactory::class.java, fileFinderFactory) + registerService( + MetadataFinderFactory::class.java, + CliMetadataFinderFactory(fileFinderFactory) + ) + } + + // Setup platform services + val lspServices = listOf( + KotlinModuleDependentsProvider::class.java, + KotlinProjectStructureProvider::class.java, + KotlinPackageProviderFactory::class.java, + KotlinDeclarationProviderFactory::class.java, + KotlinPackagePartProviderFactory::class.java, + KotlinAnnotationsResolverFactory::class.java, + KotlinDirectInheritorsProvider::class.java, + ) + + for (lspService in lspServices) { + (project.getService(lspService) as KtLspService).setupWith( + project = project, + index = ktSymbolIndex, + modules = modules, + libraryRoots = libraryRoots + ) + } - session = buildSession() - parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) + commandProcessor = application.getService(CommandProcessor::class.java) + parser = KtPsiFactory(project, eventSystemEnabled = enableParserEventSystem) - logger.info("Analysis session rebuilt") + // Sync the index in the background + ktSymbolIndex.syncIndexInBackground() } private fun createCompilerConfiguration(): CompilerConfiguration { @@ -176,81 +346,38 @@ internal class CompilationEnvironment( } } - private fun refreshSourceFiles() { - logger.info("Refreshing source files") - - 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() - } + fun onFileOpen(path: Path) { + val ktFile = loadKtFile(path) ?: return + ktSymbolIndex.openKtFile(path, ktFile) + } - logger.info("Refreshed: {} source KtFiles", sourceKtFiles.size) + fun onFileClosed(path: Path) { + ktSymbolIndex.closeKtFile(path) } - @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) - } + fun onFileContentChanged(path: Path) { + val ktFile = ktSymbolIndex.getOpenedKtFile(path) ?: return + val doc = project.read { psiDocumentManager.getDocument(ktFile) } ?: return + project.write { + commandProcessor.executeCommand(project, { + doc.setText(FileManager.getDocumentContents(path)) + psiDocumentManager.commitDocument(doc) + ktFile.onContentReload() + }, "onChangeFile", null) + + KaSourceModificationService.getInstance(project) + .handleElementModification(ktFile, KaElementModificationType.Unknown) } } - 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) - } - } - } + private fun loadKtFile(path: Path): KtFile? { + val virtualFile = + project.read { VirtualFileManager.getInstance().findFileByNioPath(path) } ?: return null + return project.read { psiManager.findFile(virtualFile) as? KtFile } } override fun close() { - fileManager.close() - project.removeListener(this) + ktProject.removeListener(this) disposable.dispose() } @@ -258,9 +385,5 @@ internal class CompilationEnvironment( 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 9d501b0d65..977e1e61aa 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,5 +1,6 @@ package com.itsaky.androidide.lsp.kotlin.compiler +import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.utils.DocumentUtils import org.jetbrains.kotlin.com.intellij.lang.Language import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems @@ -17,6 +18,7 @@ import java.nio.file.Paths import kotlin.io.path.pathString internal class Compiler( + workspace: Workspace, projectModel: KotlinProjectModel, intellijPluginRoot: Path, jdkHome: Path, @@ -35,7 +37,8 @@ internal class Compiler( init { defaultCompilationEnv = CompilationEnvironment( - project = projectModel, + workspace = workspace, + ktProject = projectModel, intellijPluginRoot = intellijPluginRoot, jdkHome = jdkHome, jdkRelease = jdkRelease, @@ -66,7 +69,7 @@ internal class Compiler( } fun psiFileFactoryFor(compilationKind: CompilationKind): PsiFileFactory = - PsiFileFactory.getInstance(compilationEnvironmentFor(compilationKind).session.project) + PsiFileFactory.getInstance(compilationEnvironmentFor(compilationKind).project) fun createPsiFileFor( content: String, 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 4069873445..04b6797d0c 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,25 +1,15 @@ package com.itsaky.androidide.lsp.kotlin.compiler -import com.itsaky.androidide.lsp.kotlin.utils.SymbolVisibilityChecker +import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_INDEX_KEY +import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_META_INDEX_KEY 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.api.Workspace -import com.itsaky.androidide.projects.models.bootClassPaths import org.appdevforall.codeonthego.indexing.jvm.JVM_LIBRARY_SYMBOL_INDEX -import org.appdevforall.codeonthego.indexing.jvm.JvmLibrarySymbolIndex -import org.appdevforall.codeonthego.indexing.jvm.KOTLIN_SOURCE_SYMBOL_INDEX -import org.appdevforall.codeonthego.indexing.jvm.KotlinSourceSymbolIndex -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.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex 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]. @@ -38,28 +28,28 @@ internal class KotlinProjectModel { 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 - - val libraryIndex: JvmLibrarySymbolIndex? + val libraryIndex: JvmSymbolIndex? get() = ProjectManagerImpl.getInstance() .indexingServiceManager .registry .get(JVM_LIBRARY_SYMBOL_INDEX) - val sourceIndex: KotlinSourceSymbolIndex? - get() = ProjectManagerImpl.getInstance() + val sourceIndex: JvmSymbolIndex? + get() = ProjectManagerImpl + .getInstance() + .indexingServiceManager + .registry + .get(KT_SOURCE_FILE_INDEX_KEY) + + val fileIndex: KtFileMetadataIndex? + get() = ProjectManagerImpl + .getInstance() .indexingServiceManager .registry - .get(KOTLIN_SOURCE_SYMBOL_INDEX) + .get(KT_SOURCE_FILE_META_INDEX_KEY) /** * The kind of change that occurred. @@ -106,102 +96,6 @@ internal class KotlinProjectModel { 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 - .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 { it.toPath() } - .map(::addLibrary) - } - - val libraryDependencies = moduleProjects - .flatMap { it.getCompileClasspaths() } - .filter { it.exists() } - .map { it.toPath() } - .associateWith(::addLibrary) - - val subprojectsAsModules = mutableMapOf() - val sourceRootToModuleMap = 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(sourceRoots) - - bootClassPaths.forEach { addRegularDependency(it) } - - project.getCompileClasspaths(excludeSourceGeneratedClassPath = true) - .forEach { classpath -> - val libDep = libraryDependencies[classpath.toPath()] - 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 - sourceRoots.forEach { root -> sourceRootToModuleMap[root] = module } - return module - } - - moduleProjects.forEach { addModule(getOrCreateModule(it)) } - - val moduleResolver = ModuleResolver( - jarMap = jarToModMap, - sourceRootMap = sourceRootToModuleMap, - ) - _moduleResolver = moduleResolver - _symbolVisibilityChecker = SymbolVisibilityChecker(moduleResolver) - } - } - private fun notifyListeners(changeKind: ChangeKind) { logger.info("Notifying project listeners for change: {}", changeKind) listeners.forEach { it.onProjectModelChanged(this, changeKind) } 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 deleted file mode 100644 index d1372a0852..0000000000 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ModuleResolver.kt +++ /dev/null @@ -1,36 +0,0 @@ -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.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule -import org.slf4j.LoggerFactory -import java.nio.file.Path -import java.nio.file.Paths - -internal class ModuleResolver( - private val jarMap: Map, - private val sourceRootMap: Map = emptyMap(), -) { - companion object { - private val logger = LoggerFactory.getLogger(ModuleResolver::class.java) - } - - /** - * Find the module that declares the given source ID. - * - * - For library JARs, the source ID is the JAR path — looked up directly. - * - For source files, the source ID is the `.kt` file path — resolved by - * finding the source root directory that is an ancestor of that path. - */ - fun findDeclaringModule(sourceId: String): KaModule? { - val path = Paths.get(sourceId) - jarMap[path]?.let { return it } - - // Walk source roots to find which module owns this file. - for ((root, module) in sourceRootMap) { - if (path.startsWith(root)) return module - } - - return null - } -} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ReadWriteLock.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ReadWriteLock.kt new file mode 100644 index 0000000000..bd9c000672 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/ReadWriteLock.kt @@ -0,0 +1,24 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.openapi.util.Key +import java.util.concurrent.locks.ReentrantReadWriteLock +import kotlin.concurrent.read +import kotlin.concurrent.write + +private val key = Key.create("org.adfa.cotg.rwlock") +private val lock = ReentrantReadWriteLock() + +fun Project.registerRWLock() { + putUserData(key, lock) +} + +fun Project.read(fn: () -> T): T { + val lock = getUserData(key)!! + return lock.read(fn) +} + +fun Project.write(fn: () -> T): T { + val lock = getUserData(key)!! + return lock.write(fn) +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt new file mode 100644 index 0000000000..7d07076842 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/WorkspaceExts.kt @@ -0,0 +1,93 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtLibraryModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtSourceModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.buildKtLibraryModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.buildKtSourceModule +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.com.intellij.core.CoreApplicationEnvironment +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.slf4j.LoggerFactory +import java.nio.file.Path +import kotlin.io.path.pathString + +private val logger = LoggerFactory.getLogger("WorkspaceExts") + +internal fun Workspace.collectKtModules( + project: Project, + appEnv: CoreApplicationEnvironment +): List = buildList { + fun addModule(module: KtModule) = add(module) + + val moduleProjects = subProjects + .asSequence() + .filterIsInstance() + .filter { it.path != rootProject.path } + + val jarToModMap = mutableMapOf() + + fun addLibrary(path: Path): KtLibraryModule { + val module = buildKtLibraryModule(project, appEnv) { + id = path.pathString + addContentRoot(path) + } + jarToModMap[path] = module + return module + } + + val bootClassPaths = moduleProjects + .filterIsInstance() + .flatMap { project -> + project.bootClassPaths + .asSequence() + .filter { it.exists() } + .map { it.toPath() } + .map(::addLibrary) + } + + val libraryDependencies = moduleProjects + .flatMap { it.getCompileClasspaths() } + .filter { it.exists() } + .map { it.toPath() } + .associateWith(::addLibrary) + + val subprojectsAsModules = mutableMapOf() + val sourceRootToModuleMap = mutableMapOf() + + fun getOrCreateModule(moduleProject: ModuleProject): KtSourceModule { + subprojectsAsModules[moduleProject]?.let { return it } + + val module = buildKtSourceModule(project) { + this.module = moduleProject + + bootClassPaths.forEach { addDependency(it) } + + moduleProject.getCompileClasspaths(excludeSourceGeneratedClassPath = true) + .forEach { classpath -> + val libDep = libraryDependencies[classpath.toPath()] + if (libDep == null) { + logger.error( + "Skipping non-existent classpath classpath: {}", + classpath + ) + return@forEach + } + addDependency(libDep) + } + + moduleProject.getCompileModuleProjects().forEach { dep -> + addDependency(getOrCreateModule(dep)) + } + } + + subprojectsAsModules[moduleProject] = module + module.contentRoots.forEach { root -> sourceRootToModuleMap[root] = module } + return module + } + + moduleProjects.forEach { addModule(getOrCreateModule(it)) } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt new file mode 100644 index 0000000000..3ac737e182 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt @@ -0,0 +1,14 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.psi.KtFile + +internal sealed interface IndexCommand { + data object Stop : IndexCommand + data object SourceScanningComplete: IndexCommand + data object IndexingComplete: IndexCommand + data class ScanSourceFile(val vf: VirtualFile): IndexCommand + data class IndexModifiedFile(val ktFile: KtFile): IndexCommand + data class IndexSourceFile(val vf: VirtualFile): IndexCommand + data class IndexLibraryFile(val vf: VirtualFile): IndexCommand +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt new file mode 100644 index 0000000000..31ae41ad33 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -0,0 +1,120 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.itsaky.androidide.lsp.kotlin.compiler.read +import org.appdevforall.codeonthego.indexing.api.Index +import org.appdevforall.codeonthego.indexing.jvm.CombinedJarScanner +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.psi.PsiManager +import org.jetbrains.kotlin.psi.KtFile +import org.slf4j.LoggerFactory + +internal class IndexWorker( + private val project: Project, + private val queue: WorkerQueue, + private val fileIndex: KtFileMetadataIndex, + private val sourceIndex: JvmSymbolIndex, + private val libraryIndex: JvmSymbolIndex, +) { + companion object { + private val logger = LoggerFactory.getLogger(IndexWorker::class.java) + } + + suspend fun start() { + var scanCount = 0 + var sourceIndexCount = 0 + var libraryIndexCount = 0 + + while (true) { + when (val command = queue.take()) { + is IndexCommand.IndexLibraryFile -> { + if (command.vf.fileSystem.protocol != "file") { + logger.warn("Unknown library file protocol: {}", command.vf.path) + continue + } + + if (command.vf.extension != "jar") { + logger.warn("Cannot index {} JVM library", command.vf.path) + continue + } + + libraryIndex.insertAll(CombinedJarScanner.scan(jarPath = command.vf.toNioPath())) + libraryIndexCount++ + } + + is IndexCommand.IndexSourceFile -> { + if (command.vf.fileSystem.protocol != "file") { + logger.warn("Unknown source file protocol: {}", command.vf.path) + continue + } + + val ktFile = project.read { + PsiManager.getInstance(project) + .findFile(command.vf) as? KtFile + } + + if (ktFile == null) { + // probably a non-kotlin file + continue + } + + indexSourceFile(project, ktFile, fileIndex, sourceIndex) + sourceIndexCount++ + } + + is IndexCommand.IndexModifiedFile -> { + indexSourceFile(project, command.ktFile, fileIndex, sourceIndex) + sourceIndexCount++ + } + + IndexCommand.IndexingComplete -> { + logger.info( + "Indexing complete: scanned={}, sourceIndexCount={}, libraryIndexCount={}", + scanCount, + sourceIndexCount, + libraryIndexCount + ) + } + + is IndexCommand.ScanSourceFile -> { + val ktFile = project.read { PsiManager.getInstance(project).findFile(command.vf) as? KtFile } + ?: continue + + val newFile = ktFile.toMetadata(project, isIndexed = false) + val existingFile = fileIndex.get(newFile.filePath) + if (KtFileMetadata.shouldBeSkipped(existingFile, newFile)) { + continue + } + + fileIndex.upsert(newFile) + scanCount++ + } + + IndexCommand.SourceScanningComplete -> { + logger.info("Scanning complete. Found {} files to index.", scanCount) + } + + IndexCommand.Stop -> break + } + } + } + + suspend fun submitCommand(cmd: IndexCommand) { + when (cmd) { + is IndexCommand.ScanSourceFile, IndexCommand.SourceScanningComplete -> { + queue.putScanQueue(cmd) + } + + is IndexCommand.IndexModifiedFile -> { + queue.putEditQueue(cmd) + } + + else -> { + queue.putIndexQueue(cmd) + } + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt new file mode 100644 index 0000000000..0c20061acb --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -0,0 +1,140 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.github.benmanes.caffeine.cache.Caffeine +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.read +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.launch +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex +import org.appdevforall.codeonthego.indexing.service.IndexKey +import org.checkerframework.checker.index.qual.NonNegative +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.com.intellij.psi.PsiManager +import org.jetbrains.kotlin.psi.KtFile +import java.nio.file.Path +import java.util.concurrent.ConcurrentHashMap + +val KT_SOURCE_FILE_INDEX_KEY = IndexKey("kt-source-file-index") +val KT_SOURCE_FILE_META_INDEX_KEY = IndexKey("kt-source-file-meta-index") + +/** + * An index of symbols from Kotlin source files and JARs. + * + * NOTE: This index does not own the provided [fileIndex], [sourceIndex] and [libraryIndex]. + * Callers are responsible for closing the provided indexes. + */ +internal class KtSymbolIndex( + val project: Project, + modules: List, + val fileIndex: KtFileMetadataIndex, + val sourceIndex: JvmSymbolIndex, + val libraryIndex: JvmSymbolIndex, + cacheSize: @NonNegative Long = DEFAULT_CACHE_SIZE, + private val scope: CoroutineScope = CoroutineScope( + Dispatchers.Default + SupervisorJob() + CoroutineName( + "KtSymbolIndex" + ) + ) +) { + companion object { + const val DEFAULT_CACHE_SIZE = 100L + } + + private val workerQueue = WorkerQueue() + private val indexWorker = IndexWorker( + project = project, + queue = workerQueue, + fileIndex = fileIndex, + sourceIndex = sourceIndex, + libraryIndex = libraryIndex, + ) + + private val scanningWorker = ScanningWorker( + indexWorker = indexWorker, + modules = modules, + ) + + private var scanningJob: Job? = null + private var indexingJob: Job? = null + + private val ktFileCache = Caffeine + .newBuilder() + .maximumSize(cacheSize) + .build() + + private val openedFiles = ConcurrentHashMap() + + val openedKtFiles: Sequence> + get() = openedFiles.asSequence() + + fun syncIndexInBackground() { + // TODO: Figure out how to handle already-running scanning/indexing jobs. + + indexingJob = scope.launch { + indexWorker.start() + } + + scanningJob = scope.launch(Dispatchers.IO) { + scanningWorker.start() + } + } + + fun queueOnFileChangedAsync(ktFile: KtFile) { + scope.launch { + queueOnFileChanged(ktFile) + } + } + + suspend fun queueOnFileChanged(ktFile: KtFile) { + indexWorker.submitCommand(IndexCommand.IndexModifiedFile(ktFile)) + } + + fun openKtFile(path: Path, ktFile: KtFile) { + openedFiles[path] = ktFile + } + + fun closeKtFile(path: Path) { + openedFiles.remove(path) + } + + fun getOpenedKtFile(path: Path) = openedFiles[path] + + fun getKtFile(vf: VirtualFile): KtFile { + val path = vf.toNioPath() + + openedFiles[path]?.also { return it } + ktFileCache.getIfPresent(path)?.also { return it } + + val ktFile = project.read { + PsiManager.getInstance(project) + .findFile(vf) as KtFile + } + + ktFileCache.put(path, ktFile) + return ktFile + } + + suspend fun close() { + scanningWorker.stop() + indexWorker.submitCommand(IndexCommand.Stop) + + scanningJob?.join() + indexingJob?.join() + } +} + +internal fun KtSymbolIndex.packageExistsInSource(packageFqn: String) = + fileIndex.packageExists(packageFqn) + +internal fun KtSymbolIndex.filesForPackage(packageFqn: String) = + fileIndex.getFilesForPackage(packageFqn) + +internal fun KtSymbolIndex.subpackageNames(packageFqn: String) = + fileIndex.getSubpackageNames(packageFqn) \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt new file mode 100644 index 0000000000..938631499f --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt @@ -0,0 +1,57 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence +import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule +import java.util.concurrent.atomic.AtomicBoolean + +internal class ScanningWorker( + private val indexWorker: IndexWorker, + private val modules: List, +) { + + private val isRunning = AtomicBoolean(false) + + suspend fun start() { + isRunning.set(true) + try { + scan() + } finally { + isRunning.set(false) + } + } + + private suspend fun scan() { + val allModules = modules.asFlatSequence() + val sourceFiles = allModules + .filter { it.isSourceModule } + .flatMap { it.computeFiles(extended = true) } + .takeWhile { isRunning.get() } + .toList() + + for (sourceFile in sourceFiles) { + if (!isRunning.get()) return + indexWorker.submitCommand(IndexCommand.ScanSourceFile(sourceFile)) + } + + indexWorker.submitCommand(IndexCommand.SourceScanningComplete) + + sourceFiles.asSequence() + .takeWhile { isRunning.get() } + .forEach { sourceFile -> + indexWorker.submitCommand(IndexCommand.IndexSourceFile(sourceFile)) + } + + allModules + .filterNot { it.isSourceModule } + .flatMap { it.computeFiles(extended = false) } + .takeWhile { isRunning.get() } + .forEach { indexWorker.submitCommand(IndexCommand.IndexLibraryFile(it)) } + + indexWorker.submitCommand(IndexCommand.IndexingComplete) + } + + fun stop() { + isRunning.set(false) + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt new file mode 100644 index 0000000000..95eaeb533f --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt @@ -0,0 +1,414 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.itsaky.androidide.lsp.kotlin.compiler.read +import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmFieldInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmParameterInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmSourceLanguage +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind +import org.appdevforall.codeonthego.indexing.jvm.JvmTypeAliasInfo +import org.appdevforall.codeonthego.indexing.jvm.JvmVisibility +import org.appdevforall.codeonthego.indexing.jvm.KotlinClassInfo +import org.appdevforall.codeonthego.indexing.jvm.KotlinFunctionInfo +import org.appdevforall.codeonthego.indexing.jvm.KotlinPropertyInfo +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata +import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.typeParameters +import org.jetbrains.kotlin.analysis.api.types.KaClassType +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtModifierListOwner +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtTypeAlias +import java.time.Instant +import kotlin.io.path.pathString + +internal fun KtFile.toMetadata(project: Project, isIndexed: Boolean = false): KtFileMetadata = + project.read { + KtFileMetadata( + filePath = virtualFile.toNioPath().pathString, + packageFqName = packageFqName.asString(), + lastModified = Instant.ofEpochMilli(virtualFile.timeStamp), + modificationStamp = modificationStamp, + isIndexed = isIndexed, + symbolKeys = emptyList() + ) + } + +internal suspend fun indexSourceFile( + project: Project, + ktFile: KtFile, + fileIndex: KtFileMetadataIndex, + symbolsIndex: JvmSymbolIndex, +) { + val newFile = ktFile.toMetadata(project, isIndexed = true) + val existingFile = fileIndex.get(newFile.filePath) + if (KtFileMetadata.shouldBeSkipped(existingFile, newFile) && existingFile?.isIndexed == true) { + return + } + + // Remove stale symbols written during the previous indexing pass. + if (existingFile?.isIndexed == true) { + symbolsIndex.removeBySource(newFile.filePath) + } + + val symbols = project.read { + val list = mutableListOf() + ktFile.accept(object : KtTreeVisitorVoid() { + override fun visitDeclaration(dcl: KtDeclaration) { + val symbol = analyze(dcl) { + analyzeDeclaration(newFile.filePath, dcl) + } + symbol?.let { list.add(it) } + super.visitDeclaration(dcl) + } + }) + list + } + + symbolsIndex.insertAll(symbols.asSequence()) + fileIndex.upsert(newFile.copy(symbolKeys = symbols.map { it.key })) +} + +private fun KaSession.analyzeDeclaration(filePath: String, dcl: KtDeclaration): JvmSymbol? { + dcl.name ?: return null + return when (dcl) { + is KtNamedFunction -> analyzeFunction(filePath, dcl) + is KtClassOrObject -> analyzeClassOrObject(filePath, dcl) + is KtParameter -> analyzeParameter(filePath, dcl) + is KtProperty -> analyzeProperty(filePath, dcl) + is KtTypeAlias -> analyzeTypeAlias(filePath, dcl) + else -> null + } +} + +/** + * Slash-package / dollar-nesting internal name for this class. + * Returns null for anonymous/local classes that have no stable FQ name. + */ +private fun KtClassOrObject.internalName(): String? { + val pkg = containingKtFile.packageFqName.asString() + val fqName = fqName?.asString() ?: return null + val relative = if (pkg.isEmpty()) fqName else fqName.removePrefix("$pkg.") + return if (pkg.isEmpty()) relative.replace('.', '$') + else "${pkg.replace('.', '/')}/${relative.replace('.', '$')}" +} + +/** + * Walk the PSI parent chain to find the internal name of the nearest + * enclosing class or object. Returns null for top-level declarations. + */ +private fun KtDeclaration.containingClassInternalName(): String? { + var p = parent + while (p != null) { + if (p is KtClassOrObject) return p.internalName() + p = p.parent + } + return null +} + +private fun KtModifierListOwner.jvmVisibility(): JvmVisibility = when { + hasModifier(KtTokens.PRIVATE_KEYWORD) -> JvmVisibility.PRIVATE + hasModifier(KtTokens.PROTECTED_KEYWORD) -> JvmVisibility.PROTECTED + hasModifier(KtTokens.INTERNAL_KEYWORD) -> JvmVisibility.INTERNAL + else -> JvmVisibility.PUBLIC +} + +/** + * Slash-package / dollar-nesting internal name for a resolved [KaType]. + * Mirrors [KotlinMetadataScanner]'s `kmTypeToName`. + * Returns an empty string for unresolvable types (type parameters, errors). + */ +private fun KaSession.kaTypeInternalName(type: KaType): String { + if (type !is KaClassType) return "" + val classId = type.classId + val pkg = classId.packageFqName.asString() + val rel = classId.relativeClassName.asString() + return if (pkg.isEmpty()) rel.replace('.', '$') + else "${pkg.replace('.', '/')}/${rel.replace('.', '$')}" +} + +/** + * Short display name (last segment after '/' and '$'), with generic arguments + * and a trailing '?' for nullable types. + * Mirrors [KotlinMetadataScanner]'s `kmTypeToDisplayName`. + */ +private fun KaSession.kaTypeDisplayName(type: KaType): String { + if (type !is KaClassType) return "" + val base = kaTypeInternalName(type).substringAfterLast('/').substringAfterLast('$') + val args = type.typeArguments.mapNotNull { it.type?.let { t -> kaTypeDisplayName(t) } } + return buildString { + append(base) + if (args.isNotEmpty()) append("<${args.joinToString(", ")}>") + if (type.isMarkedNullable) append("?") + } +} + +private fun KaSession.analyzeFunction(filePath: String, dcl: KtNamedFunction): JvmSymbol? { + val fnName = dcl.name ?: return null + val visibility = dcl.jvmVisibility() + if (visibility == JvmVisibility.PRIVATE) return null + + val pkg = dcl.containingKtFile.packageFqName.asString() + val containingClass = dcl.containingClassInternalName() + + val fnSymbol = dcl.symbol as? KaNamedFunctionSymbol ?: return null + + val parameters = fnSymbol.valueParameters.map { param -> + JvmParameterInfo( + name = param.name.asString(), + typeName = kaTypeInternalName(param.returnType), + typeDisplayName = kaTypeDisplayName(param.returnType), + hasDefaultValue = param.hasDefaultValue, + isVararg = param.isVararg, + ) + } + + val receiverType = fnSymbol.receiverParameter?.returnType + val returnType = fnSymbol.returnType + + // Mirrors KotlinMetadataScanner.extractFunction key / name conventions. + val qualifiedName = if (containingClass != null) "$containingClass#$fnName" + else "$pkg#$fnName" + val key = "$qualifiedName(${parameters.joinToString(",") { it.typeFqName }})" + + val signatureDisplay = buildString { + append("(") + append(parameters.joinToString(", ") { "${it.name}: ${it.typeDisplayName}" }) + append("): ") + append(kaTypeDisplayName(returnType)) + } + + return JvmSymbol( + key = key, + sourceId = filePath, + name = qualifiedName, + shortName = fnName, + packageName = pkg, + kind = if (receiverType != null) JvmSymbolKind.EXTENSION_FUNCTION else JvmSymbolKind.FUNCTION, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmFunctionInfo( + containingClassName = containingClass ?: "", + returnTypeName = kaTypeInternalName(returnType), + returnTypeDisplayName = kaTypeDisplayName(returnType), + parameterCount = parameters.size, + parameters = parameters, + signatureDisplay = signatureDisplay, + typeParameters = fnSymbol.typeParameters.map { it.name.asString() }, + kotlin = KotlinFunctionInfo( + receiverTypeName = receiverType?.let { kaTypeInternalName(it) } ?: "", + receiverTypeDisplayName = receiverType?.let { kaTypeDisplayName(it) } ?: "", + isSuspend = fnSymbol.isSuspend, + isInline = fnSymbol.isInline, + isInfix = fnSymbol.isInfix, + isOperator = fnSymbol.isOperator, + isTailrec = fnSymbol.isTailRec, + isExternal = fnSymbol.isExternal, + isExpect = fnSymbol.isExpect, + isReturnTypeNullable = returnType.isMarkedNullable, + ), + ), + ) +} + +@OptIn(KaExperimentalApi::class) +private fun KaSession.analyzeClassOrObject(filePath: String, dcl: KtClassOrObject): JvmSymbol? { + dcl.name ?: return null // anonymous objects have no stable name + val visibility = dcl.jvmVisibility() + if (visibility == JvmVisibility.PRIVATE) return null + + val internalName = dcl.internalName() ?: return null + val pkg = dcl.containingKtFile.packageFqName.asString() + val shortName = internalName.substringAfterLast('/').substringAfterLast('$') + val containingClass = dcl.containingClassInternalName() + + val clsSymbol = dcl.symbol as? KaClassSymbol ?: return null + + val kind = when (dcl) { + is KtObjectDeclaration if dcl.isCompanion() -> JvmSymbolKind.COMPANION_OBJECT + is KtObjectDeclaration -> JvmSymbolKind.OBJECT + is KtClass if dcl.isInterface() -> JvmSymbolKind.INTERFACE + is KtClass if dcl.isEnum() -> JvmSymbolKind.ENUM + is KtClass if dcl.isAnnotation() -> JvmSymbolKind.ANNOTATION_CLASS + is KtClass if dcl.isData() -> JvmSymbolKind.DATA_CLASS + is KtClass if dcl.hasModifier(KtTokens.VALUE_KEYWORD) -> JvmSymbolKind.VALUE_CLASS + is KtClass if dcl.hasModifier(KtTokens.SEALED_KEYWORD) -> JvmSymbolKind.SEALED_CLASS + else -> JvmSymbolKind.CLASS + } + + val supertypes = clsSymbol.superTypes.mapNotNull { st -> + if (st !is KaClassType) return@mapNotNull null + val sId = st.classId + val sPkg = sId.packageFqName.asString() + val sRel = sId.relativeClassName.asString() + val sInternal = if (sPkg.isEmpty()) sRel.replace('.', '$') + else "${sPkg.replace('.', '/')}/${sRel.replace('.', '$')}" + if (sInternal == "kotlin/Any") null else sInternal + } + + return JvmSymbol( + key = internalName, + sourceId = filePath, + name = internalName, + shortName = shortName, + packageName = pkg, + kind = kind, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmClassInfo( + internalName = internalName, + containingClassName = containingClass ?: "", + supertypeNames = supertypes, + typeParameters = clsSymbol.typeParameters.map { it.name.asString() }, + isAbstract = dcl.hasModifier(KtTokens.ABSTRACT_KEYWORD), + isFinal = dcl.hasModifier(KtTokens.FINAL_KEYWORD), + isInner = dcl is KtClass && dcl.isInner(), + isStatic = containingClass != null && !(dcl is KtClass && dcl.isInner()), + kotlin = KotlinClassInfo( + isData = dcl is KtClass && dcl.isData(), + isValue = dcl is KtClass && dcl.hasModifier(KtTokens.VALUE_KEYWORD), + isSealed = dcl is KtClass && dcl.hasModifier(KtTokens.SEALED_KEYWORD), + isFunInterface = dcl is KtClass && dcl.hasModifier(KtTokens.FUN_KEYWORD), + isExpect = dcl.hasModifier(KtTokens.EXPECT_KEYWORD), + isActual = dcl.hasModifier(KtTokens.ACTUAL_KEYWORD), + isExternal = dcl.hasModifier(KtTokens.EXTERNAL_KEYWORD), + ), + ), + ) +} + +private fun KaSession.analyzeProperty(filePath: String, dcl: KtProperty): JvmSymbol? { + val propName = dcl.name ?: return null + val visibility = dcl.jvmVisibility() + if (visibility == JvmVisibility.PRIVATE) return null + + val pkg = dcl.containingKtFile.packageFqName.asString() + val containingClass = dcl.containingClassInternalName() + + val propSymbol = dcl.symbol as? KaPropertySymbol ?: return null + val returnType = propSymbol.returnType + val receiverType = propSymbol.receiverParameter?.returnType + + val qualifiedName = if (containingClass != null) "$containingClass#$propName" + else "$pkg#$propName" + + return JvmSymbol( + key = qualifiedName, + sourceId = filePath, + name = qualifiedName, + shortName = propName, + packageName = pkg, + kind = if (receiverType != null) JvmSymbolKind.EXTENSION_PROPERTY else JvmSymbolKind.PROPERTY, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmFieldInfo( + containingClassName = containingClass ?: "", + typeName = kaTypeInternalName(returnType), + typeDisplayName = kaTypeDisplayName(returnType), + kotlin = KotlinPropertyInfo( + receiverTypeName = receiverType?.let { kaTypeInternalName(it) } ?: "", + receiverTypeDisplayName = receiverType?.let { kaTypeDisplayName(it) } ?: "", + isConst = dcl.hasModifier(KtTokens.CONST_KEYWORD), + isLateinit = dcl.hasModifier(KtTokens.LATEINIT_KEYWORD), + hasGetter = dcl.getter != null, + hasSetter = dcl.setter != null, + isDelegated = dcl.delegateExpression != null, + isTypeNullable = returnType.isMarkedNullable, + isExpect = dcl.hasModifier(KtTokens.EXPECT_KEYWORD), + isActual = dcl.hasModifier(KtTokens.ACTUAL_KEYWORD), + isExternal = dcl.hasModifier(KtTokens.EXTERNAL_KEYWORD), + ), + ), + ) +} + +/** + * Constructor `val`/`var` parameters are indexed as properties so that + * they appear in completion and navigation just like explicitly declared + * properties. Plain constructor or function parameters are skipped. + */ +private fun KaSession.analyzeParameter(filePath: String, dcl: KtParameter): JvmSymbol? { + if (!dcl.hasValOrVar()) return null + + val propName = dcl.name ?: return null + val visibility = dcl.jvmVisibility() + if (visibility == JvmVisibility.PRIVATE) return null + + val pkg = dcl.containingKtFile.packageFqName.asString() + val containingClass = dcl.containingClassInternalName() + + val paramSymbol = dcl.symbol as? KaValueParameterSymbol ?: return null + val returnType = paramSymbol.returnType + + val qualifiedName = if (containingClass != null) "$containingClass#$propName" + else "$pkg#$propName" + + return JvmSymbol( + key = qualifiedName, + sourceId = filePath, + name = qualifiedName, + shortName = propName, + packageName = pkg, + kind = JvmSymbolKind.PROPERTY, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmFieldInfo( + containingClassName = containingClass ?: "", + typeName = kaTypeInternalName(returnType), + typeDisplayName = kaTypeDisplayName(returnType), + kotlin = KotlinPropertyInfo( + isTypeNullable = returnType.isMarkedNullable, + ), + ), + ) +} + +private fun KaSession.analyzeTypeAlias(filePath: String, dcl: KtTypeAlias): JvmSymbol? { + val aliasName = dcl.name ?: return null + val visibility = dcl.jvmVisibility() + if (visibility == JvmVisibility.PRIVATE) return null + + val pkg = dcl.containingKtFile.packageFqName.asString() + + val aliasSymbol = dcl.symbol + val expandedType = aliasSymbol.expandedType + + // Key convention mirrors KotlinMetadataScanner: dot-notation FQ name. + val fqName = if (pkg.isEmpty()) aliasName else "$pkg.$aliasName" + + return JvmSymbol( + key = fqName, + sourceId = filePath, + name = fqName, + shortName = aliasName, + packageName = pkg, + kind = JvmSymbolKind.TYPE_ALIAS, + language = JvmSourceLanguage.KOTLIN, + visibility = visibility, + data = JvmTypeAliasInfo( + expandedTypeName = kaTypeInternalName(expandedType), + expandedTypeDisplayName = kaTypeDisplayName(expandedType), + typeParameters = aliasSymbol.typeParameters.map { it.name.asString() }, + ), + ) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt new file mode 100644 index 0000000000..55ce8374b9 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/WorkerQueue.kt @@ -0,0 +1,27 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.selects.select + +internal class WorkerQueue { + + private val scanChannel = Channel(capacity = 100) + private val editChannel = Channel(capacity = 20) + private val indexChannel = Channel(capacity = 100) + + suspend fun putScanQueue(item: T) = scanChannel.send(item) + suspend fun putEditQueue(item: T) = editChannel.send(item) + suspend fun putIndexQueue(item: T) = indexChannel.send(item) + + suspend fun take(): T { + scanChannel.tryReceive().getOrNull()?.let { return it } + editChannel.tryReceive().getOrNull()?.let { return it } + indexChannel.tryReceive().getOrNull()?.let { return it } + + return select { + scanChannel.onReceive { it } + editChannel.onReceive { it } + indexChannel.onReceive { it } + } + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AbstractKtModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AbstractKtModule.kt new file mode 100644 index 0000000000..b8bce90811 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/AbstractKtModule.kt @@ -0,0 +1,29 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.modules + +import org.jetbrains.kotlin.analysis.api.KaPlatformInterface +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KaModuleBase +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope + +@OptIn(KaPlatformInterface::class) +internal abstract class AbstractKtModule( + override val project: Project, + override val directRegularDependencies: List, +) : KtModule, KaModuleBase() { + + private val baseSearchScope by lazy { + val files = computeFiles(extended = true) + .toList() + + GlobalSearchScope.filesScope(project, files) + } + + override val baseContentScope: GlobalSearchScope + get() = baseSearchScope + + override val directDependsOnDependencies: List + get() = emptyList() + + override val directFriendDependencies: List + get() = emptyList() +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt new file mode 100644 index 0000000000..0e553cae32 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt @@ -0,0 +1,133 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.modules + +import com.itsaky.androidide.lsp.kotlin.compiler.DEFAULT_JVM_TARGET +import com.itsaky.androidide.lsp.kotlin.compiler.read +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaImplementationDetail +import org.jetbrains.kotlin.analysis.api.KaPlatformInterface +import org.jetbrains.kotlin.analysis.api.impl.base.util.LibraryUtils +import org.jetbrains.kotlin.analysis.api.projectStructure.KaLibraryModule +import org.jetbrains.kotlin.analysis.api.projectStructure.KaLibrarySourceModule +import org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem +import org.jetbrains.kotlin.com.intellij.core.CoreApplicationEnvironment +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems.JAR_PROTOCOL +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.config.JvmTarget +import org.jetbrains.kotlin.library.KLIB_FILE_EXTENSION +import org.jetbrains.kotlin.platform.TargetPlatform +import org.jetbrains.kotlin.platform.jvm.JvmPlatforms +import java.nio.file.Path +import kotlin.io.path.absolutePathString + +@OptIn(KaPlatformInterface::class) +internal class KtLibraryModule( + project: Project, + override val id: String, + override val contentRoots: Set, + dependencies: List, + private val applicationEnvironment: CoreApplicationEnvironment, + override val isSdk: Boolean = false, + private val jvmTarget: JvmTarget = DEFAULT_JVM_TARGET, + override val librarySources: KaLibrarySourceModule? = null, +) : KaLibraryModule, + AbstractKtModule( + project, + dependencies + ) { + + class Builder( + private val project: Project, + private val applicationEnvironment: CoreApplicationEnvironment, + ) { + lateinit var id: String + private val contentRoots = mutableSetOf() + private val dependencies = mutableListOf() + var isSdk: Boolean = false + var jvmTarget: JvmTarget = DEFAULT_JVM_TARGET + var librarySources: KaLibrarySourceModule? = null + + fun addContentRoot(root: Path) { + contentRoots.add(root) + } + + fun addDependency(dep: KtModule) { + dependencies.add(dep) + } + + fun build(): KtLibraryModule = KtLibraryModule( + project = project, + id = id, + contentRoots = contentRoots.toSet(), + dependencies = dependencies.toList(), + applicationEnvironment = applicationEnvironment, + isSdk = isSdk, + jvmTarget = jvmTarget, + librarySources = librarySources, + ) + } + + @OptIn(KaImplementationDetail::class) + override fun computeFiles(extended: Boolean): Sequence { + val roots = if (isSdk) project.read { + LibraryUtils.findClassesFromJdkHome( + contentRoots.first(), + isJre = false + ) + } + else contentRoots + + val notExtendedFiles = roots + .asSequence() + .mapNotNull { getVirtualFileForLibraryRoot(it, applicationEnvironment, project) } + + if (!extended) return notExtendedFiles + + return notExtendedFiles + .flatMap { LibraryUtils.getAllVirtualFilesFromRoot(it, includeRoot = true) } + } + + override val libraryName: String + get() = id + + override val binaryRoots: Collection + get() = contentRoots + + @KaExperimentalApi + override val binaryVirtualFiles: Collection + get() = emptyList() + + override val targetPlatform: TargetPlatform + get() = JvmPlatforms.jvmPlatformByTargetVersion(jvmTarget) +} + +internal fun buildKtLibraryModule( + project: Project, + applicationEnvironment: CoreApplicationEnvironment, + init: KtLibraryModule.Builder.() -> Unit, +): KtLibraryModule = KtLibraryModule.Builder(project, applicationEnvironment).apply(init).build() + +private const val JAR_SEPARATOR = "!/" +private fun getVirtualFileForLibraryRoot( + root: Path, + environment: CoreApplicationEnvironment, + project: Project, +): VirtualFile? { + val pathString = root.absolutePathString() + + // .jar or .klib files + if (pathString.endsWith(JAR_PROTOCOL) || pathString.endsWith(KLIB_FILE_EXTENSION)) { + return project.read { environment.jarFileSystem.findFileByPath(pathString + JAR_SEPARATOR) } + } + + // JDK classes + if (pathString.contains(JAR_SEPARATOR)) { + val (libHomePath, pathInImage) = CoreJrtFileSystem.splitPath(pathString) + val adjustedPath = libHomePath + JAR_SEPARATOR + "modules/$pathInImage" + return project.read { environment.jrtFileSystem?.findFileByPath(adjustedPath) } + } + + // Regular .class files + return project.read { VirtualFileManager.getInstance().findFileByNioPath(root) } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtModule.kt new file mode 100644 index 0000000000..b12b7d31bb --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtModule.kt @@ -0,0 +1,39 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.modules + +import org.jetbrains.kotlin.analysis.api.KaPlatformInterface +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import java.nio.file.Path + +@OptIn(KaPlatformInterface::class) +internal interface KtModule : KaModule { + + val id: String + + val contentRoots: Set + + override val directRegularDependencies: List + override val directDependsOnDependencies: List + override val directFriendDependencies: List + + fun computeFiles(extended: Boolean): Sequence +} + +internal val KtModule.isSourceModule: Boolean + get() = this is KtSourceModule + +internal fun List.asFlatSequence(): Sequence { + val processedModules = mutableSetOf() + return this.asSequence().flatMap { getModuleFlatSequence(it, processedModules) } +} + +private fun getModuleFlatSequence(ktModule: KtModule, processed: MutableSet): Sequence = sequence { + if (processed.contains(ktModule.id)) return@sequence + + yield(ktModule) + processed.add(ktModule.id) + + ktModule.directRegularDependencies.forEach { dependency -> + yieldAll(getModuleFlatSequence(dependency, processed)) + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt new file mode 100644 index 0000000000..44875b3ba0 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtSourceModule.kt @@ -0,0 +1,110 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.modules + +import com.itsaky.androidide.lsp.kotlin.compiler.DEFAULT_JVM_TARGET +import com.itsaky.androidide.lsp.kotlin.compiler.DEFAULT_LANGUAGE_VERSION +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.projects.api.ModuleProject +import org.jetbrains.kotlin.analysis.api.KaPlatformInterface +import org.jetbrains.kotlin.analysis.api.projectStructure.KaSourceModule +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.config.ApiVersion +import org.jetbrains.kotlin.config.JvmTarget +import org.jetbrains.kotlin.config.LanguageVersion +import org.jetbrains.kotlin.config.LanguageVersionSettings +import org.jetbrains.kotlin.config.LanguageVersionSettingsImpl +import org.jetbrains.kotlin.platform.TargetPlatform +import org.jetbrains.kotlin.platform.jvm.JvmPlatforms +import org.slf4j.LoggerFactory +import java.io.File +import java.nio.file.Paths +import kotlin.io.path.PathWalkOption +import kotlin.io.path.absolutePathString +import kotlin.io.path.extension +import kotlin.io.path.isDirectory +import kotlin.io.path.pathString +import kotlin.io.path.walk + +@OptIn(KaPlatformInterface::class) +internal class KtSourceModule( + project: Project, + val module: ModuleProject, + directRegularDependencies: List, +) : KaSourceModule, AbstractKtModule(project, directRegularDependencies) { + + private val logger = LoggerFactory.getLogger(KtSourceModule::class.java) + + class Builder(private val project: Project) { + lateinit var module: ModuleProject + private val dependencies = mutableListOf() + + fun addDependency(dep: KtModule) { + dependencies.add(dep) + } + + fun build(): KtSourceModule = KtSourceModule(project, module, dependencies.toList()) + } + + override val id: String + get() = module.path + + override val contentRoots by lazy { + module.getSourceDirectories() + .asSequence() + .map { it.toPath() } + .toSet() + } + + private val versions by lazy { + val kotlinCompilerSettings = when { + module.hasJavaProject() -> module.javaProject + .kotlinCompilerSettings + + module.hasAndroidProject() -> module.androidProject + .kotlinCompilerSettings + + else -> null + } + + if (kotlinCompilerSettings == null) { + return@lazy DEFAULT_LANGUAGE_VERSION to DEFAULT_JVM_TARGET + } + + val apiVersion = LanguageVersion.fromVersionString(kotlinCompilerSettings.apiVersion) + ?: LanguageVersion.fromFullVersionString(kotlinCompilerSettings.apiVersion) + + val jvmTarget = JvmTarget.fromString(kotlinCompilerSettings.jvmTarget) + + (apiVersion ?: DEFAULT_LANGUAGE_VERSION) to (jvmTarget ?: DEFAULT_JVM_TARGET) + } + + override val name: String + get() = module.name + + override val languageVersionSettings: LanguageVersionSettings + get() = LanguageVersionSettingsImpl( + languageVersion = versions.first, + apiVersion = ApiVersion.createByLanguageVersion(versions.first), + ) + + override val targetPlatform: TargetPlatform + get() = JvmPlatforms.jvmPlatformByTargetVersion(versions.second) + + override fun computeFiles(extended: Boolean): Sequence = + contentRoots + .asSequence() + .flatMap { it.walk() } + .filter { !it.isDirectory() && (it.extension == "kt" || it.extension == "java") } + .mapNotNull { + project.read { + VirtualFileManager.getInstance().findFileByNioPath(it) + } + } + +} + +internal fun buildKtSourceModule( + project: Project, + init: KtSourceModule.Builder.() -> Unit, +): KtSourceModule = KtSourceModule.Builder(project).apply(init).build() \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/NotUnderContentRootModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/NotUnderContentRootModule.kt new file mode 100644 index 0000000000..0de0b4282c --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/NotUnderContentRootModule.kt @@ -0,0 +1,40 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.modules + +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaPlatformInterface +import org.jetbrains.kotlin.analysis.api.projectStructure.KaNotUnderContentRootModule +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.com.intellij.psi.PsiFile +import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope +import org.jetbrains.kotlin.platform.TargetPlatform +import org.jetbrains.kotlin.platform.jvm.JvmPlatforms +import java.nio.file.Path + +@OptIn(KaPlatformInterface::class, KaExperimentalApi::class) +internal class NotUnderContentRootModule( + override val id: String, + project: Project, + override val moduleDescription: String, + directRegularDependencies: List = emptyList(), + override val targetPlatform: TargetPlatform = JvmPlatforms.defaultJvmPlatform, + override val file: PsiFile? = null, +) : KaNotUnderContentRootModule, AbstractKtModule( + project, directRegularDependencies +) { + override val name: String + get() = id + + override val baseContentScope: GlobalSearchScope + get() = if (file != null) GlobalSearchScope.fileScope(file) else GlobalSearchScope.EMPTY_SCOPE + + override val contentRoots: Set + get() = file?.virtualFile?.toNioPath()?.let(::setOf) ?: emptySet() + + override fun computeFiles(extended: Boolean): Sequence = sequence { + val vf = file?.virtualFile + if (vf != null) { + yield(vf) + } + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt new file mode 100644 index 0000000000..8d1d062955 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt @@ -0,0 +1,122 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.registrar + +import com.itsaky.androidide.lsp.kotlin.compiler.services.AnalysisPermissionOptions +import com.itsaky.androidide.lsp.kotlin.compiler.services.AnnotationsResolverFactory +import com.itsaky.androidide.lsp.kotlin.compiler.services.DeclarationProviderFactory +import com.itsaky.androidide.lsp.kotlin.compiler.services.DeclarationProviderMerger +import com.itsaky.androidide.lsp.kotlin.compiler.services.ModificationTrackerFactory +import com.itsaky.androidide.lsp.kotlin.compiler.services.ModuleDependentsProvider +import com.itsaky.androidide.lsp.kotlin.compiler.services.PackagePartProviderFactory +import com.itsaky.androidide.lsp.kotlin.compiler.services.PackageProviderFactory +import com.itsaky.androidide.lsp.kotlin.compiler.services.PackageProviderMerger +import com.itsaky.androidide.lsp.kotlin.compiler.services.PlatformSettings +import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider +import org.jetbrains.kotlin.analysis.api.KaImplementationDetail +import org.jetbrains.kotlin.analysis.api.platform.KotlinPlatformSettings +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.declarations.KotlinDeclarationProviderMerger +import org.jetbrains.kotlin.analysis.api.platform.lifetime.KotlinLifetimeTokenFactory +import org.jetbrains.kotlin.analysis.api.platform.lifetime.KotlinReadActionConfinementLifetimeTokenFactory +import org.jetbrains.kotlin.analysis.api.platform.modification.KotlinModificationTrackerFactory +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackagePartProviderFactory +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderFactory +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderMerger +import org.jetbrains.kotlin.analysis.api.platform.permissions.KotlinAnalysisPermissionOptions +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinModuleDependentsProvider +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinProjectStructureProvider +import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.AnalysisApiSimpleServiceRegistrar +import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.PluginStructureProvider +import org.jetbrains.kotlin.analysis.decompiler.stub.file.ClsKotlinBinaryClassCache +import org.jetbrains.kotlin.analysis.decompiler.stub.file.DummyFileAttributeService +import org.jetbrains.kotlin.analysis.decompiler.stub.file.FileAttributeService +import org.jetbrains.kotlin.cli.jvm.compiler.MockExternalAnnotationsManager +import org.jetbrains.kotlin.cli.jvm.compiler.MockInferredAnnotationsManager +import org.jetbrains.kotlin.com.intellij.codeInsight.ExternalAnnotationsManager +import org.jetbrains.kotlin.com.intellij.codeInsight.InferredAnnotationsManager +import org.jetbrains.kotlin.com.intellij.core.CoreJavaFileManager +import org.jetbrains.kotlin.com.intellij.mock.MockApplication +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.extensions.DefaultPluginDescriptor +import org.jetbrains.kotlin.com.intellij.psi.SmartPointerManager +import org.jetbrains.kotlin.com.intellij.psi.SmartTypePointerManager +import org.jetbrains.kotlin.com.intellij.psi.impl.file.impl.JavaFileManager +import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.SmartPointerManagerImpl +import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.SmartTypePointerManagerImpl + +@OptIn(KaImplementationDetail::class) +internal object LspServiceRegistrar : AnalysisApiSimpleServiceRegistrar() { + + private const val PLUGIN_RELATIVE_PATH = "/META-INF/kt-lsp/kt-lsp.xml" + private val pluginDescriptor = DefaultPluginDescriptor("kt-lsp-plugin-descriptor") + + override fun registerApplicationServices(application: MockApplication) { + PluginStructureProvider.registerApplicationServices(application, PLUGIN_RELATIVE_PATH) + + with(application) { + registerService(FileAttributeService::class.java, DummyFileAttributeService::class.java) + registerService( + KotlinAnalysisPermissionOptions::class.java, + AnalysisPermissionOptions::class.java + ) + registerService(ClsKotlinBinaryClassCache::class.java) + } + } + + override fun registerProjectServices(project: MockProject) { + PluginStructureProvider.registerProjectServices(project, PLUGIN_RELATIVE_PATH) + + + with(project) { + registerService( + CoreJavaFileManager::class.java, + project.getService(JavaFileManager::class.java) as CoreJavaFileManager + ) + registerService(ExternalAnnotationsManager::class.java, MockExternalAnnotationsManager()) + registerService(InferredAnnotationsManager::class.java, MockInferredAnnotationsManager()) + registerService( + KotlinLifetimeTokenFactory::class.java, + KotlinReadActionConfinementLifetimeTokenFactory::class.java + ) + registerService(KotlinPlatformSettings::class.java, PlatformSettings::class.java) + registerService( + SmartTypePointerManager::class.java, + SmartTypePointerManagerImpl::class.java + ) + registerService(SmartPointerManager::class.java, SmartPointerManagerImpl::class.java) + registerService( + KotlinProjectStructureProvider::class.java, + ProjectStructureProvider::class.java + ) + registerService( + KotlinModuleDependentsProvider::class.java, + ModuleDependentsProvider::class.java + ) + registerService( + KotlinModificationTrackerFactory::class.java, + ModificationTrackerFactory::class.java + ) + registerService( + KotlinAnnotationsResolverFactory::class.java, + AnnotationsResolverFactory::class.java + ) + registerService( + KotlinDeclarationProviderFactory::class.java, + DeclarationProviderFactory::class.java + ) + registerService( + KotlinDeclarationProviderMerger::class.java, + DeclarationProviderMerger::class.java + ) + registerService( + KotlinPackageProviderFactory::class.java, + PackageProviderFactory::class.java + ) + registerService(KotlinPackageProviderMerger::class.java, PackageProviderMerger::class.java) + registerService( + KotlinPackagePartProviderFactory::class.java, + PackagePartProviderFactory::class.java + ) + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnalysisPermissionOptions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnalysisPermissionOptions.kt new file mode 100644 index 0000000000..56ff769c31 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnalysisPermissionOptions.kt @@ -0,0 +1,8 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.analysis.api.platform.permissions.KotlinAnalysisPermissionOptions + +class AnalysisPermissionOptions : KotlinAnalysisPermissionOptions { + override val defaultIsAnalysisAllowedOnEdt: Boolean get() = false + override val defaultIsAnalysisAllowedInWriteAction: Boolean get() = true +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnnotationsResolver.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnnotationsResolver.kt new file mode 100644 index 0000000000..0d04ab5888 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/AnnotationsResolver.kt @@ -0,0 +1,165 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolver +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolverFactory +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProvider +import org.jetbrains.kotlin.analysis.api.platform.declarations.createDeclarationProvider +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope +import org.jetbrains.kotlin.com.intellij.psi.search.impl.VirtualFileEnumeration +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.parentOrNull +import org.jetbrains.kotlin.psi.KtAnnotated +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtFunction +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtTypeReference +import org.jetbrains.kotlin.psi.KtUserType +import org.jetbrains.kotlin.psi.declarationRecursiveVisitor +import org.jetbrains.kotlin.util.collectionUtils.filterIsInstanceAnd + +internal class AnnotationsResolverFactory : KtLspService, KotlinAnnotationsResolverFactory { + + private lateinit var project: Project + private lateinit var index: KtSymbolIndex + + override fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List + ) { + this.project = project + this.index = index + } + + override fun createAnnotationResolver(searchScope: GlobalSearchScope): KotlinAnnotationsResolver { + return AnnotationsResolver(project, searchScope, index) + } +} + +@Suppress("UnstableApiUsage") +internal class AnnotationsResolver( + project: Project, + private val scope: GlobalSearchScope, + private val index: KtSymbolIndex, +) : KotlinAnnotationsResolver { + + private val declarationProvider by lazy { + project.createDeclarationProvider(scope, contextualModule = null) + } + + private fun allDeclarations(): List { + val virtualFiles = VirtualFileEnumeration.extract(scope) ?: return emptyList() + + val filesInScope = virtualFiles + .filesIfCollection + .orEmpty() + .asSequence() + .filter { it in scope } + .mapNotNull { index.getKtFile(it) } + + return buildList { + val visitor = declarationRecursiveVisitor visit@{ + val isLocal = when (it) { + is KtClassOrObject -> it.isLocal + is KtFunction -> it.isLocal + is KtProperty -> it.isLocal + else -> return@visit + } + + if (!isLocal) { + add(it) + } + } + + filesInScope.forEach { it.accept(visitor) } + } + } + + override fun declarationsByAnnotation(annotationClassId: ClassId): Set { + return allDeclarations() + .asSequence() + .filter { annotationClassId in annotationsOnDeclaration(it) } + .toSet() + } + + override fun annotationsOnDeclaration(declaration: KtAnnotated): Set { + return declaration + .annotationEntries + .asSequence() + .flatMap { it.typeReference?.resolveAnnotationClassIds(declarationProvider).orEmpty() } + .toSet() + } +} + +private fun KtTypeReference.resolveAnnotationClassIds( + declarationProvider: KotlinDeclarationProvider, + candidates: MutableSet = mutableSetOf() +): Set { + val annotationTypeElement = typeElement as? KtUserType + val referencedName = annotationTypeElement?.referencedFqName ?: return emptySet() + if (referencedName.isRoot) return emptySet() + + if (!referencedName.parent().isRoot) { + return buildSet { referencedName.resolveToClassIds(this, declarationProvider) } + } + + val targetName = referencedName.shortName() + for (import in containingKtFile.importDirectives) { + val importedName = import.importedFqName ?: continue + when { + import.isAllUnder -> importedName.child(targetName).resolveToClassIds(candidates, declarationProvider) + importedName.shortName() == targetName -> importedName.resolveToClassIds(candidates, declarationProvider) + } + } + + containingKtFile.packageFqName.child(targetName).resolveToClassIds(candidates, declarationProvider) + return candidates +} + +private val KtUserType.referencedFqName: FqName? + get() { + val allTypes = generateSequence(this) { it.qualifier }.toList().asReversed() + val allQualifiers = allTypes.map { it.referencedName ?: return null } + return FqName.fromSegments(allQualifiers) + } + + +private fun FqName.resolveToClassIds(to: MutableSet, declarationProvider: KotlinDeclarationProvider) { + toClassIdSequence().mapNotNullTo(to) { classId -> + val classes = declarationProvider.getAllClassesByClassId(classId) + val typeAliases = declarationProvider.getAllTypeAliasesByClassId(classId) + typeAliases.singleOrNull()?.getTypeReference()?.resolveAnnotationClassIds(declarationProvider, to) + + val annotations = classes.filterIsInstanceAnd { it.isAnnotation() } + annotations.singleOrNull()?.let { + classId + } + } +} + +private fun FqName.toClassIdSequence(): Sequence { + var currentName = shortNameOrSpecial() + if (currentName.isSpecial) return emptySequence() + var currentParent = parentOrNull() ?: return emptySequence() + var currentRelativeName = currentName.asString() + + return sequence { + while (true) { + yield(ClassId(currentParent, FqName(currentRelativeName), isLocal = false)) + currentName = currentParent.shortNameOrSpecial() + if (currentName.isSpecial) break + currentParent = currentParent.parentOrNull() ?: break + currentRelativeName = "${currentName.asString()}.$currentRelativeName" + } + } +} + diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt new file mode 100644 index 0000000000..1b61343589 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DeclarationsProvider.kt @@ -0,0 +1,189 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.index.filesForPackage +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.read +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinCompositeDeclarationProvider +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProvider +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProviderFactory +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDeclarationProviderMerger +import org.jetbrains.kotlin.analysis.api.platform.declarations.createDeclarationProvider +import org.jetbrains.kotlin.analysis.api.platform.mergeSpecificProviders +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.fileClasses.javaFileFacadeFqName +import org.jetbrains.kotlin.name.CallableId +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtCallableDeclaration +import org.jetbrains.kotlin.psi.KtClassLikeDeclaration +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtScript +import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.psiUtil.isTopLevelKtOrJavaMember +import java.nio.file.Paths + +internal class DeclarationProviderFactory : KtLspService, KotlinDeclarationProviderFactory { + + private lateinit var project: Project + private lateinit var index: KtSymbolIndex + + override fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List + ) { + this.project = project + this.index = index + } + + override fun createDeclarationProvider( + scope: GlobalSearchScope, + contextualModule: KaModule? + ): KotlinDeclarationProvider { + return DeclarationProvider(scope, project, index) + } +} + +class DeclarationProviderMerger(private val project: Project) : KotlinDeclarationProviderMerger { + override fun merge(providers: List): KotlinDeclarationProvider = + providers.mergeSpecificProviders<_, DeclarationProvider>(KotlinCompositeDeclarationProvider.factory) { targetProviders -> + val combinedScope = GlobalSearchScope.union(targetProviders.map { it.scope }) + project.createDeclarationProvider(combinedScope, contextualModule = null).apply { + check(this is DeclarationProvider) { + "`DeclarationProvider` can only be merged into a combined declaration provider of the same type." + } + } + } +} + + +internal class DeclarationProvider( + val scope: GlobalSearchScope, + private val project: Project, + private val index: KtSymbolIndex +) : KotlinDeclarationProvider { + + private val KtElement.inScope: Boolean + get() = containingKtFile.virtualFile in scope + + override val hasSpecificCallablePackageNamesComputation: Boolean + get() = false + override val hasSpecificClassifierPackageNamesComputation: Boolean + get() = false + + override fun findFilesForFacade(facadeFqName: FqName): Collection { + if (facadeFqName.shortNameOrSpecial().isSpecial) return emptyList() + // According to standalone platform, this does not work with classes with @JvmPackageName + return findFilesForFacadeByPackage(facadeFqName.parent()) + .filter { it.javaFileFacadeFqName == facadeFqName } + } + + override fun findInternalFilesForFacade(facadeFqName: FqName): Collection = + // We don't deserialize libraries from stubs so we can return empty here safely + // We don't take the KaBuiltinsModule into account for simplicity, + // that means we expect the kotlin stdlib to be included on the project + emptyList() + + override fun findFilesForFacadeByPackage(packageFqName: FqName): Collection = + ktFilesForPackage(packageFqName).toList() + + override fun findFilesForScript(scriptFqName: FqName): Collection = + ktFilesForPackage(scriptFqName).mapNotNull { it.script }.toList() + + override fun getAllClassesByClassId(classId: ClassId): Collection = + ktFilesForPackage(classId.packageFqName) + .flatMap { + project.read { + PsiTreeUtil.collectElementsOfType(it, KtClassOrObject::class.java).asSequence() + } + } + .filter { it.getClassId() == classId } + .toList() + + override fun getAllTypeAliasesByClassId(classId: ClassId): Collection = + ktFilesForPackage(classId.packageFqName) + .flatMap { + project.read { + PsiTreeUtil.collectElementsOfType(it, KtTypeAlias::class.java).asSequence() + } + } + .filter { it.getClassId() == classId } + .toList() + + override fun getClassLikeDeclarationByClassId(classId: ClassId): KtClassLikeDeclaration? = + getAllClassesByClassId(classId).firstOrNull() + ?: getAllTypeAliasesByClassId(classId).firstOrNull() + + override fun getTopLevelCallableFiles(callableId: CallableId): Collection = + buildSet { + getTopLevelProperties(callableId).mapTo(this) { it.containingKtFile } + getTopLevelFunctions(callableId).mapTo(this) { it.containingKtFile } + } + + override fun getTopLevelFunctions(callableId: CallableId): Collection = + ktFilesForPackage(callableId.packageName) + .flatMap { + project.read { + PsiTreeUtil.collectElementsOfType(it, KtNamedFunction::class.java) + .asSequence() + } + } + .filter { it.isTopLevel } + .filter { it.nameAsName == callableId.callableName } + .toList() + + override fun getTopLevelKotlinClassLikeDeclarationNamesInPackage(packageFqName: FqName): Set = + ktFilesForPackage(packageFqName) + .flatMap { + project.read { + PsiTreeUtil.collectElementsOfType(it, KtClassLikeDeclaration::class.java) + .asSequence() + } + } + .filter { it.isTopLevelKtOrJavaMember() } + .mapNotNull { it.nameAsName } + .toSet() + + override fun getTopLevelCallableNamesInPackage(packageFqName: FqName): Set = + ktFilesForPackage(packageFqName) + .flatMap { + project.read { + PsiTreeUtil.collectElementsOfType(it, KtCallableDeclaration::class.java) + .asSequence() + } + } + .filter { it.isTopLevelKtOrJavaMember() } + .mapNotNull { it.nameAsName } + .toSet() + + override fun getTopLevelProperties(callableId: CallableId): Collection = + ktFilesForPackage(callableId.packageName) + .flatMap { + project.read { + PsiTreeUtil.collectElementsOfType(it, KtProperty::class.java).asSequence() + } + } + .filter { it.isTopLevel } + .filter { it.nameAsName == callableId.callableName } + .toList() + + private fun ktFilesForPackage(fqName: FqName): Sequence { + return index.filesForPackage(fqName.asString()) + .map { VirtualFileManager.getInstance().findFileByNioPath(Paths.get(it.filePath))!! } + .filter { it in scope } + .map { index.getKtFile(it) } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DirectInheritorsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DirectInheritorsProvider.kt new file mode 100644 index 0000000000..7036744413 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/DirectInheritorsProvider.kt @@ -0,0 +1,172 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence +import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule +import org.jetbrains.kotlin.analysis.api.KaImplementationDetail +import org.jetbrains.kotlin.analysis.api.fir.utils.isSubclassOf +import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinDirectInheritorsProvider +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinProjectStructureProvider +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.jetbrains.kotlin.analysis.low.level.api.fir.LLFirInternals +import org.jetbrains.kotlin.analysis.low.level.api.fir.sessions.LLFirSessionCache +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope +import org.jetbrains.kotlin.fir.declarations.FirClass +import org.jetbrains.kotlin.fir.resolve.providers.symbolProvider +import org.jetbrains.kotlin.fir.symbols.SymbolInternals +import org.jetbrains.kotlin.fir.symbols.impl.FirClassLikeSymbol +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtNullableType +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.KtTypeElement +import org.jetbrains.kotlin.psi.KtUserType +import org.jetbrains.kotlin.psi.psiUtil.contains +import org.jetbrains.kotlin.psi.psiUtil.getImportedSimpleNameByImportAlias +import org.jetbrains.kotlin.psi.psiUtil.getSuperNames + +internal class DirectInheritorsProvider: KtLspService, KotlinDirectInheritorsProvider { + private lateinit var index: KtSymbolIndex + private lateinit var modules: List + private lateinit var project: Project + + private val classesBySupertypeName = mutableMapOf>() + private val inheritableTypeAliasesByAliasedName = mutableMapOf>() + + override fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List + ) { + this.project = project + this.index = index + this.modules = modules + } + + @OptIn(SymbolInternals::class) + override fun getDirectKotlinInheritors( + ktClass: KtClass, + scope: GlobalSearchScope, + includeLocalInheritors: Boolean + ): Iterable { + computeIndex() + + val classId = ktClass.getClassId() ?: return emptyList() + val baseModule = KotlinProjectStructureProvider.getModule(project, ktClass, useSiteModule = null) + val baseFirClass = classId.toFirSymbol(baseModule)?.fir as? FirClass ?: return emptyList() + + val baseClassNames = mutableSetOf(classId.shortClassName) + calculateAliases(classId.shortClassName, baseClassNames) + + val possibleInheritors = baseClassNames.flatMap { classesBySupertypeName[it].orEmpty() } + if (possibleInheritors.isEmpty()) { + return emptyList() + } + + return possibleInheritors.filter { isValidInheritor(it, baseFirClass, scope, includeLocalInheritors) } + } + + // Let's say this operation is not frequently called, if we discover it's not the case we should cache it + private fun computeIndex() { + classesBySupertypeName.clear() + inheritableTypeAliasesByAliasedName.clear() + + modules + .asFlatSequence() + .filter { it.isSourceModule }.flatMap { it.computeFiles(extended = true) } + .map { index.getKtFile(it) } + .forEach { ktFile -> + ktFile.accept(object : KtTreeVisitorVoid() { + override fun visitClassOrObject(classOrObject: KtClassOrObject) { + classOrObject.getSuperNames().forEach { superName -> + classesBySupertypeName + .computeIfAbsent(Name.identifier(superName)) { mutableSetOf() } + .add(classOrObject) + } + super.visitClassOrObject(classOrObject) + } + + override fun visitTypeAlias(typeAlias: KtTypeAlias) { + val typeElement = typeAlias.getTypeReference()?.typeElement ?: return + + findInheritableSimpleNames(typeElement).forEach { expandedName -> + inheritableTypeAliasesByAliasedName + .computeIfAbsent(Name.identifier(expandedName)) { mutableSetOf() } + .add(typeAlias) + } + + super.visitTypeAlias(typeAlias) + } + }) + } + } + + private fun calculateAliases(aliasedName: Name, aliases: MutableSet) { + inheritableTypeAliasesByAliasedName[aliasedName].orEmpty().forEach { alias -> + val aliasName = alias.nameAsSafeName + val isNewAliasName = aliases.add(aliasName) + if (isNewAliasName) { + calculateAliases(aliasName, aliases) + } + } + } + + @OptIn(KaImplementationDetail::class, SymbolInternals::class) + private fun isValidInheritor( + candidate: KtClassOrObject, + baseFirClass: FirClass, + scope: GlobalSearchScope, + includeLocalInheritors: Boolean, + ): Boolean { + if (!includeLocalInheritors && candidate.isLocal) { + return false + } + + if (!scope.contains(candidate)) { + return false + } + + val candidateClassId = candidate.getClassId() ?: return false + val candidateModule = KotlinProjectStructureProvider.getModule(project, candidate, useSiteModule = null) + val candidateFirSymbol = candidateClassId.toFirSymbol(candidateModule) ?: return false + val candidateFirClass = candidateFirSymbol.fir as? FirClass ?: return false + + return isSubclassOf(candidateFirClass, baseFirClass, candidateFirClass.moduleData.session, allowIndirectSubtyping = false) + } + + @OptIn(LLFirInternals::class) + private fun ClassId.toFirSymbol(module: KaModule): FirClassLikeSymbol<*>? { + val session = LLFirSessionCache.getInstance(project).getSession(module, preferBinary = true) + return session.symbolProvider.getClassLikeSymbolByClassId(this) + } +} + +private fun findInheritableSimpleNames(typeElement: KtTypeElement): List { + return when (typeElement) { + is KtUserType -> { + val referenceName = typeElement.referencedName ?: return emptyList() + + buildList { + add(referenceName) + + val ktFile = typeElement.containingKtFile + if (!ktFile.isCompiled) { + val name = getImportedSimpleNameByImportAlias(typeElement.containingKtFile, referenceName) + if (name != null) { + add(name) + } + } + } + } + is KtNullableType -> typeElement.innerType?.let(::findInheritableSimpleNames) ?: emptyList() + else -> emptyList() + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAccessibilityChecker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAccessibilityChecker.kt new file mode 100644 index 0000000000..c6dff05a08 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAccessibilityChecker.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.analysis.api.platform.java.KotlinJavaModuleAccessibilityChecker +import org.jetbrains.kotlin.analysis.api.platform.java.KotlinJavaModuleAccessibilityError +import org.jetbrains.kotlin.cli.jvm.modules.CliJavaModuleResolver +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.resolve.jvm.modules.JavaModuleResolver + +class JavaModuleAccessibilityChecker( + private val javaModuleResolver: CliJavaModuleResolver, +): KotlinJavaModuleAccessibilityChecker { + override fun checkAccessibility( + useSiteFile: VirtualFile?, + referencedFile: VirtualFile, + referencedPackage: FqName? + ): KotlinJavaModuleAccessibilityError? { + val accessError = javaModuleResolver.checkAccessibility(useSiteFile, referencedFile, referencedPackage) + return accessError?.let(::convertAccessError) + } + + private fun convertAccessError(accessError: JavaModuleResolver.AccessError): KotlinJavaModuleAccessibilityError = + when (accessError) { + is JavaModuleResolver.AccessError.ModuleDoesNotReadUnnamedModule -> + KotlinJavaModuleAccessibilityError.ModuleDoesNotReadUnnamedModule + + is JavaModuleResolver.AccessError.ModuleDoesNotReadModule -> + KotlinJavaModuleAccessibilityError.ModuleDoesNotReadModule(accessError.dependencyModuleName) + + is JavaModuleResolver.AccessError.ModuleDoesNotExportPackage -> + KotlinJavaModuleAccessibilityError.ModuleDoesNotExportPackage(accessError.dependencyModuleName) + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAnnotationsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAnnotationsProvider.kt new file mode 100644 index 0000000000..c1a05127e3 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/JavaModuleAnnotationsProvider.kt @@ -0,0 +1,16 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.analysis.api.KaNonPublicApi +import org.jetbrains.kotlin.analysis.api.platform.java.KotlinJavaModuleJavaAnnotationsProvider +import org.jetbrains.kotlin.cli.jvm.modules.CliJavaModuleResolver +import org.jetbrains.kotlin.load.java.structure.JavaAnnotation +import org.jetbrains.kotlin.name.ClassId + +@OptIn(KaNonPublicApi::class) +class JavaModuleAnnotationsProvider( + private val javaModuleResolver: CliJavaModuleResolver, +): KotlinJavaModuleJavaAnnotationsProvider { + override fun getAnnotationsForModuleOwnerOfClass(classId: ClassId): List? { + return javaModuleResolver.getAnnotationsForModuleOwnerOfClass(classId) + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/KtLspService.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/KtLspService.kt new file mode 100644 index 0000000000..2ead27bcdb --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/KtLspService.kt @@ -0,0 +1,16 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.com.intellij.mock.MockProject + +internal interface KtLspService { + + fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List, + ) +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/LanguageSettings.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/LanguageSettings.kt new file mode 100644 index 0000000000..d661755914 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/LanguageSettings.kt @@ -0,0 +1,9 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.config.ApiVersion +import org.jetbrains.kotlin.config.LanguageVersion +import org.jetbrains.kotlin.config.LanguageVersionSettings +import org.jetbrains.kotlin.config.LanguageVersionSettingsImpl + +val latestLanguageVersionSettings: LanguageVersionSettings = + LanguageVersionSettingsImpl(LanguageVersion.LATEST_STABLE, ApiVersion.LATEST) \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModificationTrackerFactory.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModificationTrackerFactory.kt new file mode 100644 index 0000000000..167b968eb0 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModificationTrackerFactory.kt @@ -0,0 +1,6 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.analysis.api.platform.modification.KotlinModificationTrackerByEventFactoryBase +import org.jetbrains.kotlin.com.intellij.openapi.project.Project + +class ModificationTrackerFactory(project: Project): KotlinModificationTrackerByEventFactoryBase(project) \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt new file mode 100644 index 0000000000..5b057064c8 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ModuleDependentsProvider.kt @@ -0,0 +1,67 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinModuleDependentsProviderBase +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.jetbrains.kotlin.analysis.api.projectStructure.allDirectDependencies +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.util.containers.ContainerUtil.createConcurrentSoftMap + +internal class ModuleDependentsProvider : KtLspService, KotlinModuleDependentsProviderBase() { + + private lateinit var modules: List + + override fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List + ) { + this.modules = modules + } + + private val directDependentsByKtModule by lazy { + modules.asSequence() + .map { module -> + buildDependentsMap(module, module.allDirectDependencies()) + } + .reduce { acc, value -> acc + value } + } + + private val transitiveDependentsByKtModule = createConcurrentSoftMap>() + private val refinementDependentsByKtModule by lazy { + modules + .asSequence() + .map { buildDependentsMap(it, it.transitiveDependsOnDependencies.asSequence()) } + .reduce { acc, map -> acc + map } + } + + override fun getDirectDependents(module: KaModule): Set { + return directDependentsByKtModule[module].orEmpty() + } + + override fun getRefinementDependents(module: KaModule): Set { + return refinementDependentsByKtModule[module].orEmpty() + } + + override fun getTransitiveDependents(module: KaModule): Set { + return transitiveDependentsByKtModule.computeIfAbsent(module) { key -> + computeTransitiveDependents( + key + ) + } + } +} + +private fun buildDependentsMap( + module: KaModule, + dependencies: Sequence, +): Map> = buildMap { + dependencies.forEach { dependency -> + if (dependency == module) return@forEach + val dependents = computeIfAbsent(dependency) { mutableSetOf() } + dependents.add(module) + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PackageProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PackageProvider.kt new file mode 100644 index 0000000000..0a65305983 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PackageProvider.kt @@ -0,0 +1,85 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.index.packageExistsInSource +import com.itsaky.androidide.lsp.kotlin.compiler.index.subpackageNames +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import org.jetbrains.kotlin.analysis.api.platform.mergeSpecificProviders +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinCompositePackageProvider +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackagePartProviderFactory +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProvider +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderBase +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderFactory +import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProviderMerger +import org.jetbrains.kotlin.analysis.api.platform.packages.createPackageProvider +import org.jetbrains.kotlin.cli.common.messages.MessageCollector +import org.jetbrains.kotlin.cli.jvm.compiler.JvmPackagePartProvider +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope +import org.jetbrains.kotlin.load.kotlin.PackagePartProvider +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name + +internal class PackageProviderFactory: KtLspService, KotlinPackageProviderFactory { + private lateinit var project: Project + private lateinit var index: KtSymbolIndex + + override fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List + ) { + this.project = project + this.index = index + } + + override fun createPackageProvider(searchScope: GlobalSearchScope): KotlinPackageProvider = PackageProvider(project, searchScope, index) +} + +private class PackageProvider( + project: Project, + searchScope: GlobalSearchScope, + private val index: KtSymbolIndex +): KotlinPackageProviderBase(project, searchScope) { + override fun doesKotlinOnlyPackageExist(packageFqName: FqName): Boolean { + return packageFqName.isRoot || index.packageExistsInSource(packageFqName.asString()) + } + + override fun getKotlinOnlySubpackageNames(packageFqName: FqName): Set { + return index.subpackageNames(packageFqName.asString()).map { Name.identifier(it) }.toSet() + } +} + +internal class PackageProviderMerger(private val project: Project) : KotlinPackageProviderMerger { + override fun merge(providers: List): KotlinPackageProvider = + providers.mergeSpecificProviders<_, PackageProvider>(KotlinCompositePackageProvider.factory) { targetProviders -> + val combinedScope = GlobalSearchScope.union(targetProviders.map { it.searchScope }) + project.createPackageProvider(combinedScope).apply { + check(this is PackageProvider) { + "`${PackageProvider::class.simpleName}` can only be merged into a combined package provider of the same type." + } + } + } +} + +internal class PackagePartProviderFactory: KtLspService, KotlinPackagePartProviderFactory { + private lateinit var allLibraryRoots: List + + override fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List + ) { + this.allLibraryRoots = libraryRoots + } + + override fun createPackagePartProvider(scope: GlobalSearchScope): PackagePartProvider { + return JvmPackagePartProvider(latestLanguageVersionSettings, scope).apply { + addRoots(allLibraryRoots, MessageCollector.NONE) + } + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PlatformSettings.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PlatformSettings.kt new file mode 100644 index 0000000000..d297e756a1 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/PlatformSettings.kt @@ -0,0 +1,9 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.analysis.api.platform.KotlinDeserializedDeclarationsOrigin +import org.jetbrains.kotlin.analysis.api.platform.KotlinPlatformSettings + +class PlatformSettings : KotlinPlatformSettings { + override val deserializedDeclarationsOrigin: KotlinDeserializedDeclarationsOrigin + get() = KotlinDeserializedDeclarationsOrigin.BINARIES +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt new file mode 100644 index 0000000000..3f09e6253b --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt @@ -0,0 +1,113 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex +import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.NotUnderContentRootModule +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaPlatformInterface +import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinProjectStructureProviderBase +import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule +import org.jetbrains.kotlin.analysis.api.projectStructure.KaNotUnderContentRootModule +import org.jetbrains.kotlin.cli.jvm.index.JavaRoot +import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import java.nio.file.Paths + +internal class ProjectStructureProvider : KtLspService, KotlinProjectStructureProviderBase() { + + private lateinit var modules: List + private lateinit var project: Project + + private val notUnderContentRootModuleWithoutPsiFile by lazy { + NotUnderContentRootModule( + id = "unnamed-outside-content-root", + moduleDescription = "unnamed-outside-content-root", + project = project, + ) + } + + override fun setupWith( + project: MockProject, + index: KtSymbolIndex, + modules: List, + libraryRoots: List + ) { + this.modules = modules + this.project = project + } + + override fun getModule( + element: PsiElement, + useSiteModule: KaModule? + ): KaModule { + val virtualFile = element.containingFile.virtualFile + val visited = mutableSetOf() + + modules.forEach { module -> + val foundModule = searchVirtualFileInModule(virtualFile, useSiteModule ?: module, visited) + if (foundModule != null) return foundModule + } + + return NotUnderContentRootModule( + id = "unnamed-outside-content-root", + moduleDescription = "unnamed-outside-content-root module with a PSI file.", + project = project, + file = element.containingFile, + ) + } + + /** + * Find the [KaModule] that owns the given [sourceId]. + * + * - For library JARs, [sourceId] is the JAR path — matched against [KtModule.contentRoots] exactly. + * - For source files, [sourceId] is the `.kt` file path — matched by checking whether the path + * falls under any source root in [KtModule.contentRoots]. + * + * The search is recursive: if the top-level modules do not match, their transitive dependencies + * are checked as well. + * + * @return The declaring [KaModule], or `null` if none is found. + */ + @OptIn(KaExperimentalApi::class) + fun findModuleForSourceId(sourceId: String): KaModule? { + val path = Paths.get(sourceId) + val visited = mutableSetOf() + + fun search(module: KaModule): KaModule? { + if (!visited.add(module.moduleDescription)) return null + if (module is KtModule) { + val roots = module.contentRoots + if (roots.contains(path) || roots.any { path.startsWith(it) }) return module + } + return module.directRegularDependencies.firstNotNullOfOrNull { search(it) } + } + + return modules.firstNotNullOfOrNull { search(it) } + } + + override fun getImplementingModules(module: KaModule): List { + // TODO: needs to be implemented when we want to support KMP + return emptyList() + } + + @OptIn(KaPlatformInterface::class) + override fun getNotUnderContentRootModule(project: Project): KaNotUnderContentRootModule { + return notUnderContentRootModuleWithoutPsiFile + } + + private fun searchVirtualFileInModule(vf: VirtualFile, module: KaModule, visited: MutableSet): KaModule? { + if (visited.contains(module)) return null + if (module.contentScope.contains(vf)) return module + + visited.add(module) + module.directRegularDependencies + .forEach { dependency -> + val submodule = searchVirtualFileInModule(vf, dependency, visited) + if (submodule != null) return submodule + } + + return null + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/WriteAccessGuard.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/WriteAccessGuard.kt new file mode 100644 index 0000000000..bc9bfd9a48 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/WriteAccessGuard.kt @@ -0,0 +1,11 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.kotlin.com.intellij.openapi.editor.Document +import org.jetbrains.kotlin.com.intellij.openapi.editor.impl.DocumentWriteAccessGuard + +@Suppress("UnstableApiUsage") +class WriteAccessGuard: DocumentWriteAccessGuard() { + override fun isWritable(p0: Document): Result { + return success() + } +} \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt index c63a908733..d3a32d82d6 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt @@ -1,10 +1,10 @@ package com.itsaky.androidide.lsp.kotlin.completion -import com.itsaky.androidide.lsp.kotlin.KtFileManager import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext import com.itsaky.androidide.lsp.models.CompletionItem import io.github.rosemoe.sora.text.Content import io.github.rosemoe.sora.widget.CodeEditor +import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory internal abstract class AdvancedKotlinEditHandler( @@ -23,7 +23,7 @@ internal abstract class AdvancedKotlinEditHandler( column: Int, index: Int ) { - val managedFile = analysisContext.env.fileManager.getOpenFile(analysisContext.file) + val managedFile = analysisContext.env.ktSymbolIndex.getOpenedKtFile(analysisContext.file) if (managedFile == null) { logger.error("Unable to perform edit. File not open.") return @@ -36,7 +36,7 @@ internal abstract class AdvancedKotlinEditHandler( } abstract fun performEdits( - managedFile: KtFileManager.ManagedFile, + ktFile: KtFile, editor: CodeEditor, item: CompletionItem ) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinClassImportEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinClassImportEditHandler.kt index d58b2950ee..66d3b3fb93 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinClassImportEditHandler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinClassImportEditHandler.kt @@ -1,18 +1,18 @@ package com.itsaky.androidide.lsp.kotlin.completion -import com.itsaky.androidide.lsp.kotlin.KtFileManager import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext import com.itsaky.androidide.lsp.kotlin.utils.insertImport import com.itsaky.androidide.lsp.models.ClassCompletionData import com.itsaky.androidide.lsp.models.CompletionItem import com.itsaky.androidide.lsp.util.RewriteHelper import io.github.rosemoe.sora.widget.CodeEditor +import org.jetbrains.kotlin.psi.KtFile internal class KotlinClassImportEditHandler( analysisContext: AnalysisContext, ) : AdvancedKotlinEditHandler(analysisContext) { override fun performEdits( - managedFile: KtFileManager.ManagedFile, + ktFile: KtFile, editor: CodeEditor, item: CompletionItem ) { 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 dd564eb93b..113efbbaf7 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 @@ -22,7 +22,6 @@ import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind import org.appdevforall.codeonthego.indexing.jvm.JvmTypeAliasInfo -import org.appdevforall.codeonthego.indexing.jvm.JvmVisibility import org.jetbrains.kotlin.analysis.api.KaContextParameterApi import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaIdeApi @@ -61,6 +60,7 @@ import org.jetbrains.kotlin.psi.psiUtil.getParentOfType import org.jetbrains.kotlin.psi.psiUtil.startOffset import org.jetbrains.kotlin.types.Variance import org.slf4j.LoggerFactory +import kotlin.io.path.name private const val KT_COMPLETION_PLACEHOLDER = "KT_COMPLETION_PLACEHOLDER" @@ -74,9 +74,9 @@ private val logger = LoggerFactory.getLogger("KotlinCompletions") * @return The completion result. */ internal fun CompilationEnvironment.complete(params: CompletionParams): CompletionResult { - val managedFile = fileManager.getOpenFile(params.file) - if (managedFile == null) { - logger.warn("No managed file for {}", params.file) + val ktFile = ktSymbolIndex.getOpenedKtFile(params.file) + if (ktFile == null) { + logger.warn("File {} is not open", params.file) return CompletionResult.EMPTY } @@ -96,9 +96,9 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi } val completionKtFile = - managedFile.createInMemoryFileWithContent( - psiFactory = parser, - content = textWithPlaceholder + parser.createFile( + fileName = params.file.name, + text = textWithPlaceholder ) return try { @@ -282,26 +282,21 @@ private suspend fun KaSession.collectUnimportedSymbols( // Library symbols: JAR-based, use full SymbolVisibilityChecker val visibilityChecker = env.symbolVisibilityChecker - if (visibilityChecker == null) { - logger.warn("No visibility checker found") - return - } - env.libraryIndex?.findByPrefix(ctx.partial) - ?.collect { symbol -> + ?.forEach { symbol -> val isVisible = visibilityChecker.isVisible( symbol = symbol, useSiteModule = useSiteModule, useSitePackage = currentPackage, ) - if (!isVisible) return@collect + if (!isVisible) return@forEach buildUnimportedSymbolItem(symbol)?.let { to += it } } // Source symbols: project .kt files — skip private and same-package symbols env.sourceIndex?.findByPrefix(ctx.partial) - ?.collect { symbol -> - if (symbol.packageName == currentPackage) return@collect + ?.forEach { symbol -> + if (symbol.packageName == currentPackage) return@forEach val isVisible = visibilityChecker.isVisible( symbol = symbol, @@ -309,7 +304,7 @@ private suspend fun KaSession.collectUnimportedSymbols( useSitePackage = currentPackage ) - if (!isVisible) return@collect + if (!isVisible) return@forEach buildUnimportedSymbolItem(symbol)?.let { to += it } } 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 fce0b8c721..afca38be9f 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,25 +1,20 @@ package com.itsaky.androidide.lsp.kotlin.diagnostic import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.utils.toRange 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 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 -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.slf4j.LoggerFactory import java.nio.file.Path -import kotlin.time.Clock -import kotlin.time.toKotlinInstant +import kotlin.math.log private val logger = LoggerFactory.getLogger("KotlinDiagnosticProvider") @@ -37,31 +32,32 @@ internal fun CompilationEnvironment.collectDiagnosticsFor(file: Path): Diagnosti @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 + var ktFile = ktSymbolIndex.getOpenedKtFile(file) + if (ktFile == null) { + onFileOpen(file) + ktFile = ktSymbolIndex.getOpenedKtFile(file) } - val analyzedAt = managed.analyzeTimestamp - val modifiedAt = FileManager.getLastModified(file) - if (analyzedAt > modifiedAt.toKotlinInstant()) { - logger.debug("Skipping analysis. File unmodified.") + if (ktFile == null) { + logger.warn("File {} is not accessible", file) return DiagnosticResult.NO_UPDATE } - val rawDiagnostics = managed.analyze { ktFile -> - ktFile.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) + logger.debug("Analyzing ktFile: {}", ktFile.text) + + val diagnostics = project.read { + analyze(ktFile) { + ktFile.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS) + .map { it.toDiagnosticItem() } + } } - logger.info("Found {} diagnostics", rawDiagnostics.size) + logger.info("Found {} diagnostics", diagnostics.size) return DiagnosticResult( - file = file, diagnostics = rawDiagnostics.map { rawDiagnostic -> - rawDiagnostic.toDiagnosticItem() - }).also { - managed.analyzeTimestamp = Clock.System.now() - } + file = file, + diagnostics = diagnostics + ) } private fun KaDiagnosticWithPsi<*>.toDiagnosticItem(): DiagnosticItem { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt index c8d0398ff6..db61fb038f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt @@ -1,6 +1,6 @@ package com.itsaky.androidide.lsp.kotlin.utils -import com.itsaky.androidide.lsp.kotlin.compiler.ModuleResolver +import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol import org.appdevforall.codeonthego.indexing.jvm.JvmVisibility import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule @@ -8,7 +8,7 @@ import org.jetbrains.kotlin.analysis.api.projectStructure.allDirectDependencies import java.util.concurrent.ConcurrentHashMap internal class SymbolVisibilityChecker( - private val moduleResolver: ModuleResolver, + private val structureProvider: ProjectStructureProvider, ) { // visibility check cache, for memoization // useSiteModule -> list of modules visible from useSiteModule @@ -19,7 +19,7 @@ internal class SymbolVisibilityChecker( useSiteModule: KaModule, useSitePackage: String? = null, ): Boolean { - val declaringModule = moduleResolver.findDeclaringModule(symbol.sourceId) + val declaringModule = structureProvider.findModuleForSourceId(symbol.sourceId) ?: return false if (!isReachable(useSiteModule, declaringModule)) return false diff --git a/lsp/kotlin/src/main/resources/META-INF/kt-lsp/kt-lsp.xml b/lsp/kotlin/src/main/resources/META-INF/kt-lsp/kt-lsp.xml new file mode 100644 index 0000000000..ae91693bae --- /dev/null +++ b/lsp/kotlin/src/main/resources/META-INF/kt-lsp/kt-lsp.xml @@ -0,0 +1,12 @@ + + + + + + + + \ No newline at end of file diff --git a/subprojects/project-models/src/main/proto/android.proto b/subprojects/project-models/src/main/proto/android.proto index b2b8f71da4..8f3e9f2600 100644 --- a/subprojects/project-models/src/main/proto/android.proto +++ b/subprojects/project-models/src/main/proto/android.proto @@ -276,4 +276,8 @@ message AndroidProject { // The variant dependencies of this project. VariantDependencies variantDependencies = 10; + + // The compiler settings for Kotlin sources. + optional KotlinCompilerSettings kotlinCompilerSettings = 11; + } \ No newline at end of file diff --git a/subprojects/project-models/src/main/proto/common.proto b/subprojects/project-models/src/main/proto/common.proto index e150991e30..f01d284264 100644 --- a/subprojects/project-models/src/main/proto/common.proto +++ b/subprojects/project-models/src/main/proto/common.proto @@ -13,6 +13,15 @@ message JavaCompilerSettings { string targetCompatibility = 2; } +// Kotlin compiler settings +message KotlinCompilerSettings { + // The Kotlin API version. + string apiVersion = 1; + + // The target JVM version. + string jvmTarget = 2; +} + // Info about an external library. message LibraryInfo { diff --git a/subprojects/project-models/src/main/proto/java.proto b/subprojects/project-models/src/main/proto/java.proto index 196e47b0f6..ec23a375b2 100644 --- a/subprojects/project-models/src/main/proto/java.proto +++ b/subprojects/project-models/src/main/proto/java.proto @@ -71,4 +71,7 @@ message JavaProject { // The Java compiler settings for this project. JavaCompilerSettings javaCompilerSettings = 3; + + // The Kotlin compiler settings. + optional KotlinCompilerSettings kotlinCompilerSettings = 4; } \ No newline at end of file diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/AndroidProjectExts.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/AndroidProjectExts.kt index 95d03336db..389bc92309 100644 --- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/AndroidProjectExts.kt +++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/AndroidProjectExts.kt @@ -61,6 +61,7 @@ fun createAndroidProjectProtoModel( }, mainSourceSet = basicAndroidProject.mainSourceSet?.asProtoModel(), javaCompilerSettings = androidProject.javaCompileOptions?.asProtoModel(), + kotlinCompilerSettings = null, // TODO: Read kotlin compiler settings viewBindingOptions = androidProject.viewBindingOptions?.asProtoModel(), bootClassPathsList = basicAndroidProject.bootClasspath.map { file -> file.absolutePath }, variantList = diff --git a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/JavaProjectExts.kt b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/JavaProjectExts.kt index ec708bc867..be15aab5ab 100644 --- a/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/JavaProjectExts.kt +++ b/subprojects/tooling-api-impl/src/main/java/com/itsaky/androidide/tooling/impl/serial/JavaProjectExts.kt @@ -26,6 +26,7 @@ fun createJavaProjectProtoModel( contentRootList = ideaModule.contentRoots.map { it.asProtoModel() }, dependencyList = ideaModule.dependencies.map { it.asProtoModel(moduleNameToPath) }, javaCompilerSettings = createCompilerSettings(ideaProject, ideaModule), + kotlinCompilerSettings = null, // TODO: read kotlin compiler settings ) private fun createCompilerSettings( From a33427d080ee2bd3b5c1a1ee848cab401d88294f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Apr 2026 01:17:01 +0530 Subject: [PATCH 40/49] fix: use StandaloneProjectFactory to create MockProject The StandaloneProjectFactory takes care of setting up a special MockProject instance which allows us to use Intellij's MessageBus to notify the analysis API about file changes. Signed-off-by: Akash Yadav --- .../kotlin/compiler/CompilationEnvironment.kt | 34 +++++++++++-------- .../compiler/registrar/LspServiceRegistrar.kt | 14 -------- 2 files changed, 19 insertions(+), 29 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 a8bb55edba..d632317b72 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 @@ -29,6 +29,7 @@ import org.jetbrains.kotlin.analysis.api.platform.packages.KotlinPackageProvider import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinModuleDependentsProvider import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinProjectStructureProvider import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.ApplicationServiceRegistration +import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.StandaloneProjectFactory import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectExtensionPoints import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectModelServices import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.registerProjectServices @@ -40,6 +41,7 @@ import org.jetbrains.kotlin.cli.jvm.compiler.CliMetadataFinderFactory import org.jetbrains.kotlin.cli.jvm.compiler.CliVirtualFileFinderFactory import org.jetbrains.kotlin.cli.jvm.compiler.JvmPackagePartProvider import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCliJavaFileManagerImpl +import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironment import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreProjectEnvironment @@ -108,8 +110,17 @@ internal class CompilationEnvironment( ) : KotlinProjectModel.ProjectModelListener, AutoCloseable { private var disposable = Disposer.newDisposable() + val projectEnv: KotlinCoreProjectEnvironment + + val applicationEnv: KotlinCoreApplicationEnvironment + get() = projectEnv.environment as KotlinCoreApplicationEnvironment + val application: MockApplication + get() = applicationEnv.application + val project: MockProject + get() = projectEnv.project + val parser: KtPsiFactory val commandProcessor: CommandProcessor val modules: List @@ -182,22 +193,15 @@ internal class CompilationEnvironment( System.setProperty("java.awt.headless", "true") setupIdeaStandaloneExecution() - val appEnv = KotlinCoreEnvironment.getOrCreateApplicationEnvironment( - projectDisposable = disposable, - configuration = createCompilerConfiguration(), - environmentMode = KotlinCoreApplicationEnvironmentMode.Production, - ) - - val projectEnv = KotlinCoreProjectEnvironment( - disposable = disposable, - applicationEnvironment = appEnv - ) + projectEnv = StandaloneProjectFactory + .createProjectEnvironment( + projectDisposable = disposable, + applicationEnvironmentMode = KotlinCoreApplicationEnvironmentMode.Production, + compilerConfiguration = createCompilerConfiguration(), + ) - project = projectEnv.project project.registerRWLock() - application = appEnv.application - ApplicationServiceRegistration.registerWithCustomRegistration( application, serviceRegistrars, @@ -226,9 +230,8 @@ internal class CompilationEnvironment( serviceRegistrars.registerProjectServices(project, data = Unit) serviceRegistrars.registerProjectModelServices(project, disposable, data = Unit) - modules = workspace.collectKtModules(project, appEnv) + modules = workspace.collectKtModules(project, applicationEnv) - project.setupHighestLanguageLevel() val librariesScope = ProjectScope.getLibrariesScope(project) val libraryRoots = modules .asFlatSequence() @@ -253,6 +256,7 @@ internal class CompilationEnvironment( ).apply { addRoots(libraryRoots, MessageCollector.NONE) } + val rootsIndex = JvmDependenciesDynamicCompoundIndex(shouldOnlyFindFirstClass = false).apply { addIndex( diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt index 8d1d062955..b2ea451387 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt @@ -30,17 +30,10 @@ import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.Plugin import org.jetbrains.kotlin.analysis.decompiler.stub.file.ClsKotlinBinaryClassCache import org.jetbrains.kotlin.analysis.decompiler.stub.file.DummyFileAttributeService import org.jetbrains.kotlin.analysis.decompiler.stub.file.FileAttributeService -import org.jetbrains.kotlin.cli.jvm.compiler.MockExternalAnnotationsManager -import org.jetbrains.kotlin.cli.jvm.compiler.MockInferredAnnotationsManager -import org.jetbrains.kotlin.com.intellij.codeInsight.ExternalAnnotationsManager -import org.jetbrains.kotlin.com.intellij.codeInsight.InferredAnnotationsManager -import org.jetbrains.kotlin.com.intellij.core.CoreJavaFileManager import org.jetbrains.kotlin.com.intellij.mock.MockApplication import org.jetbrains.kotlin.com.intellij.mock.MockProject -import org.jetbrains.kotlin.com.intellij.openapi.extensions.DefaultPluginDescriptor import org.jetbrains.kotlin.com.intellij.psi.SmartPointerManager import org.jetbrains.kotlin.com.intellij.psi.SmartTypePointerManager -import org.jetbrains.kotlin.com.intellij.psi.impl.file.impl.JavaFileManager import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.SmartPointerManagerImpl import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.SmartTypePointerManagerImpl @@ -48,7 +41,6 @@ import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.SmartTypePointer internal object LspServiceRegistrar : AnalysisApiSimpleServiceRegistrar() { private const val PLUGIN_RELATIVE_PATH = "/META-INF/kt-lsp/kt-lsp.xml" - private val pluginDescriptor = DefaultPluginDescriptor("kt-lsp-plugin-descriptor") override fun registerApplicationServices(application: MockApplication) { PluginStructureProvider.registerApplicationServices(application, PLUGIN_RELATIVE_PATH) @@ -68,12 +60,6 @@ internal object LspServiceRegistrar : AnalysisApiSimpleServiceRegistrar() { with(project) { - registerService( - CoreJavaFileManager::class.java, - project.getService(JavaFileManager::class.java) as CoreJavaFileManager - ) - registerService(ExternalAnnotationsManager::class.java, MockExternalAnnotationsManager()) - registerService(InferredAnnotationsManager::class.java, MockInferredAnnotationsManager()) registerService( KotlinLifetimeTokenFactory::class.java, KotlinReadActionConfinementLifetimeTokenFactory::class.java From 51d068640f6323bce696e756f4ee085fb68b196b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Apr 2026 01:53:28 +0530 Subject: [PATCH 41/49] feat: add support for indexing classes using VirtualFile Signed-off-by: Akash Yadav --- .../indexing/jvm/CombinedJarScanner.kt | 23 + .../indexing/jvm/JarSymbolScanner.kt | 513 +++++++++--------- .../indexing/jvm/KotlinMetadataScanner.kt | 21 + .../lsp/kotlin/compiler/index/IndexWorker.kt | 15 +- .../kotlin/compiler/index/ScanningWorker.kt | 9 +- .../compiler/modules/KtLibraryModule.kt | 22 + 6 files changed, 345 insertions(+), 258 deletions(-) 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 index 64af7ce982..33256609df 100644 --- 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 @@ -1,5 +1,8 @@ package org.appdevforall.codeonthego.indexing.jvm +import org.jetbrains.kotlin.analysis.api.KaImplementationDetail +import org.jetbrains.kotlin.analysis.api.impl.base.util.LibraryUtils +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.org.objectweb.asm.AnnotationVisitor import org.jetbrains.org.objectweb.asm.ClassReader import org.jetbrains.org.objectweb.asm.ClassVisitor @@ -18,6 +21,26 @@ object CombinedJarScanner { private val log = LoggerFactory.getLogger(CombinedJarScanner::class.java) + @OptIn(KaImplementationDetail::class) + fun scan(rootVf: VirtualFile, sourceId: String = rootVf.path): Sequence = sequence { + val allFiles = LibraryUtils.getAllVirtualFilesFromRoot(rootVf, includeRoot = true) + for (vf in allFiles) { + if (!vf.name.endsWith(".class")) continue + if (vf.name == "module-info.class" || vf.name == "package-info.class") continue + try { + val bytes = vf.contentsToByteArray() + val symbols = if (hasKotlinMetadata(bytes)) { + KotlinMetadataScanner.parseKotlinClass(bytes.inputStream(), sourceId) + } else { + JarSymbolScanner.parseClassFile(bytes.inputStream(), sourceId) + } + symbols?.forEach { yield(it) } + } catch (e: Exception) { + log.debug("Failed to parse {}: {}", vf.path, e.message) + } + } + } + fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Sequence = sequence { val jar = try { JarFile(jarPath.toFile()) 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 index 19a8422d69..2c30a93372 100644 --- 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 @@ -4,6 +4,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import org.jetbrains.kotlin.analysis.api.KaImplementationDetail +import org.jetbrains.kotlin.analysis.api.impl.base.util.LibraryUtils +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.org.objectweb.asm.AnnotationVisitor import org.jetbrains.org.objectweb.asm.ClassReader import org.jetbrains.org.objectweb.asm.ClassVisitor @@ -27,9 +30,28 @@ import kotlin.io.path.pathString */ object JarSymbolScanner { - private val log = LoggerFactory.getLogger(JarSymbolScanner::class.java) + private val log = LoggerFactory.getLogger(JarSymbolScanner::class.java) + + @OptIn(KaImplementationDetail::class) + fun scan(rootVf: VirtualFile, sourceId: String = rootVf.path): Flow = flow { + val allFiles = LibraryUtils.getAllVirtualFilesFromRoot(rootVf, includeRoot = true) + for (vf in allFiles) { + if (!vf.name.endsWith(".class")) continue + if (vf.name == "module-info.class" || vf.name == "package-info.class") continue + try { + vf.contentsToByteArray().inputStream().use { input -> + for (symbol in parseClassFile(input, sourceId)) { + emit(symbol) + } + } + } catch (e: Exception) { + log.debug("Failed to parse {}: {}", vf.path, e.message) + } + } + } + .flowOn(Dispatchers.IO) - fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Flow = flow { + fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Flow = flow { val jar = try { JarFile(jarPath.toFile()) } catch (e: Exception) { @@ -59,244 +81,247 @@ object JarSymbolScanner { } .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) - } - interfaces?.forEach { add(it) } - } - - val containingClass = if (isInnerClass) { + 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) + } + interfaces?.forEach { add(it) } + } + + val containingClass = if (isInnerClass) { className.substringBeforeLast('$') - } else "" - - symbols.add( - JvmSymbol( - key = className, - sourceId = sourceId, - name = classFqName, - shortName = shortClassName.split('.').last(), - packageName = packageName, - kind = kind, - language = language, - visibility = visibilityFromAccess(classAccess), - isDeprecated = classDeprecated, - data = JvmClassInfo( + } else "" + + symbols.add( + JvmSymbol( + key = className, + sourceId = sourceId, + name = classFqName, + shortName = shortClassName.split('.').last(), + packageName = packageName, + kind = kind, + language = language, + visibility = visibilityFromAccess(classAccess), + isDeprecated = classDeprecated, + data = JvmClassInfo( internalName = className, - containingClassName = containingClass, - supertypeNames = 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 - typeName = typeToName(type), - typeDisplayName = typeToDisplayName(type), - ) - } - - val fqName = "$className#$methodName" - val key = "$fqName(${parameters.joinToString(",") { it.typeName }})" - - val signatureDisplay = buildString { - append("(") - append(parameters.joinToString(", ") { it.typeDisplayName }) - append(")") - if (!isConstructor) { - append(": ") - append(typeToDisplayName(returnType)) - } - } - - symbols.add( - JvmSymbol( - key = key, - sourceId = sourceId, - name = fqName, - shortName = methodName, - packageName = packageName, - kind = kind, - language = language, - visibility = visibilityFromAccess(access), - isDeprecated = classDeprecated, - data = JvmFunctionInfo( - containingClassName = className, - returnTypeName = typeToName(returnType), - returnTypeDisplayName = typeToDisplayName(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 iName = "$className#$name" - - symbols.add( - JvmSymbol( - key = iName, - sourceId = sourceId, - name = iName, - shortName = name, - packageName = packageName, - kind = kind, - language = language, - visibility = visibilityFromAccess(access), - isDeprecated = classDeprecated, - data = JvmFieldInfo( - containingClassName = className, - typeName = typeToName(fieldType), - typeDisplayName = typeToDisplayName(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 typeToName(type: Type): String = when (type.sort) { - Type.VOID -> "V" - Type.BOOLEAN -> "Z" - Type.BYTE -> "B" - Type.CHAR -> "C" - Type.SHORT -> "S" - Type.INT -> "I" - Type.LONG -> "J" - Type.FLOAT -> "F" - Type.DOUBLE -> "D" - Type.ARRAY -> "[".repeat(type.dimensions) + typeToName(type.elementType) - Type.OBJECT -> type.internalName - else -> type.internalName - } - - private fun typeToDisplayName(type: Type): String = when (type.sort) { + containingClassName = containingClass, + supertypeNames = 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 + typeName = typeToName(type), + typeDisplayName = typeToDisplayName(type), + ) + } + + val fqName = "$className#$methodName" + val key = "$fqName(${parameters.joinToString(",") { it.typeName }})" + + val signatureDisplay = buildString { + append("(") + append(parameters.joinToString(", ") { it.typeDisplayName }) + append(")") + if (!isConstructor) { + append(": ") + append(typeToDisplayName(returnType)) + } + } + + symbols.add( + JvmSymbol( + key = key, + sourceId = sourceId, + name = fqName, + shortName = methodName, + packageName = packageName, + kind = kind, + language = language, + visibility = visibilityFromAccess(access), + isDeprecated = classDeprecated, + data = JvmFunctionInfo( + containingClassName = className, + returnTypeName = typeToName(returnType), + returnTypeDisplayName = typeToDisplayName(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 iName = "$className#$name" + + symbols.add( + JvmSymbol( + key = iName, + sourceId = sourceId, + name = iName, + shortName = name, + packageName = packageName, + kind = kind, + language = language, + visibility = visibilityFromAccess(access), + isDeprecated = classDeprecated, + data = JvmFieldInfo( + containingClassName = className, + typeName = typeToName(fieldType), + typeDisplayName = typeToDisplayName(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 typeToName(type: Type): String = when (type.sort) { + Type.VOID -> "V" + Type.BOOLEAN -> "Z" + Type.BYTE -> "B" + Type.CHAR -> "C" + Type.SHORT -> "S" + Type.INT -> "I" + Type.LONG -> "J" + Type.FLOAT -> "F" + Type.DOUBLE -> "D" + Type.ARRAY -> "[".repeat(type.dimensions) + typeToName(type.elementType) + Type.OBJECT -> type.internalName + else -> type.internalName + } + + private fun typeToDisplayName(type: Type): String = when (type.sort) { Type.BOOLEAN -> "boolean" Type.BYTE -> "byte" Type.CHAR -> "char" @@ -305,10 +330,10 @@ object JarSymbolScanner { Type.LONG -> "long" Type.FLOAT -> "float" Type.DOUBLE -> "double" - Type.VOID -> "void" - Type.ARRAY -> typeToDisplayName(type.elementType) + "[]".repeat(type.dimensions) - Type.OBJECT -> type.className.substringAfterLast('.') - else -> typeToName(type) - } - } + Type.VOID -> "void" + Type.ARRAY -> typeToDisplayName(type.elementType) + "[]".repeat(type.dimensions) + Type.OBJECT -> type.className.substringAfterLast('.') + else -> typeToName(type) + } + } } 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 a0c6580600..a230093803 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 @@ -4,6 +4,9 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flowOn +import org.jetbrains.kotlin.analysis.api.KaImplementationDetail +import org.jetbrains.kotlin.analysis.api.impl.base.util.LibraryUtils +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.org.objectweb.asm.AnnotationVisitor import org.jetbrains.org.objectweb.asm.ClassReader import org.jetbrains.org.objectweb.asm.ClassVisitor @@ -50,6 +53,23 @@ object KotlinMetadataScanner { private val log = LoggerFactory.getLogger(KotlinMetadataScanner::class.java) + @OptIn(KaImplementationDetail::class) + fun scan(rootVf: VirtualFile, sourceId: String = rootVf.path): Flow = flow { + val allFiles = LibraryUtils.getAllVirtualFilesFromRoot(rootVf, includeRoot = true) + for (vf in allFiles) { + if (!vf.name.endsWith(".class")) continue + if (vf.name == "module-info.class") continue + try { + vf.contentsToByteArray().inputStream().use { input -> + parseKotlinClass(input, sourceId)?.forEach { emit(it) } + } + } catch (e: Exception) { + log.debug("Failed to parse {}: {}", vf.path, e.message) + } + } + } + .flowOn(Dispatchers.IO) + fun scan(jarPath: Path, sourceId: String = jarPath.pathString): Flow = flow { val jar = try { JarFile(jarPath.toFile()) @@ -393,6 +413,7 @@ object KotlinMetadataScanner { metadataVersion = value.copyOf() } } + "k" -> metadataKind = value as? Int "xi" -> extraInt = value as? Int "xs" -> extraString = value as? String diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt index 31ae41ad33..629f483dce 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -1,9 +1,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index import com.itsaky.androidide.lsp.kotlin.compiler.read -import org.appdevforall.codeonthego.indexing.api.Index import org.appdevforall.codeonthego.indexing.jvm.CombinedJarScanner -import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex @@ -31,17 +29,8 @@ internal class IndexWorker( while (true) { when (val command = queue.take()) { is IndexCommand.IndexLibraryFile -> { - if (command.vf.fileSystem.protocol != "file") { - logger.warn("Unknown library file protocol: {}", command.vf.path) - continue - } - - if (command.vf.extension != "jar") { - logger.warn("Cannot index {} JVM library", command.vf.path) - continue - } - - libraryIndex.insertAll(CombinedJarScanner.scan(jarPath = command.vf.toNioPath())) + logger.debug("index library: {}", command.vf.path) + libraryIndex.insertAll(CombinedJarScanner.scan(rootVf = command.vf)) libraryIndexCount++ } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt index 938631499f..5cdc16cbc5 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule +import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicBoolean internal class ScanningWorker( @@ -10,6 +11,10 @@ internal class ScanningWorker( private val modules: List, ) { + companion object { + private val logger = LoggerFactory.getLogger(ScanningWorker::class.java) + } + private val isRunning = AtomicBoolean(false) suspend fun start() { @@ -22,8 +27,9 @@ internal class ScanningWorker( } private suspend fun scan() { - val allModules = modules.asFlatSequence() + val allModules = modules.asFlatSequence().toList() val sourceFiles = allModules + .asSequence() .filter { it.isSourceModule } .flatMap { it.computeFiles(extended = true) } .takeWhile { isRunning.get() } @@ -43,6 +49,7 @@ internal class ScanningWorker( } allModules + .asSequence() .filterNot { it.isSourceModule } .flatMap { it.computeFiles(extended = false) } .takeWhile { isRunning.get() } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt index 0e553cae32..ba2a273f92 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt @@ -10,17 +10,22 @@ import org.jetbrains.kotlin.analysis.api.projectStructure.KaLibraryModule import org.jetbrains.kotlin.analysis.api.projectStructure.KaLibrarySourceModule import org.jetbrains.kotlin.cli.jvm.modules.CoreJrtFileSystem import org.jetbrains.kotlin.com.intellij.core.CoreApplicationEnvironment +import org.jetbrains.kotlin.com.intellij.openapi.module.Module import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.StandardFileSystems.JAR_PROTOCOL import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager +import org.jetbrains.kotlin.com.intellij.psi.search.GlobalSearchScope import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.library.KLIB_FILE_EXTENSION 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.absolutePathString +private val logger = LoggerFactory.getLogger("KtLibraryModule") + @OptIn(KaPlatformInterface::class) internal class KtLibraryModule( project: Project, @@ -88,6 +93,23 @@ internal class KtLibraryModule( .flatMap { LibraryUtils.getAllVirtualFilesFromRoot(it, includeRoot = true) } } + override val baseContentScope: GlobalSearchScope by lazy { + val virtualFileUrls = computeFiles(extended = true).map { it.url }.toSet() + object : GlobalSearchScope(project) { + override fun contains(p0: VirtualFile): Boolean { + return p0.url in virtualFileUrls + } + + override fun isSearchInModuleContent(p0: Module): Boolean { + return false + } + + override fun isSearchInLibraries(): Boolean { + return true + } + } + } + override val libraryName: String get() = id From e77df4897662a6636a014b13665243f67c9363f0 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Apr 2026 19:37:23 +0530 Subject: [PATCH 42/49] feat: create in-memory KtFile instances for modified files Signed-off-by: Akash Yadav --- .../itsaky/androidide/app/IDEApplication.kt | 4 + .../service/IndexingServiceManager.kt | 5 - .../lsp/kotlin/KotlinLanguageServer.kt | 3 - .../kotlin/compiler/CompilationEnvironment.kt | 26 +-- .../lsp/kotlin/compiler/index/IndexWorker.kt | 1 - .../kotlin/compiler/index/KtSymbolIndex.kt | 11 +- .../compiler/modules/KtLibraryModule.kt | 7 +- .../compiler/registrar/LspServiceRegistrar.kt | 22 +++ .../services/NoOpAsyncExecutionService.kt | 157 ++++++++++++++++++ .../services/ProjectStructureProvider.kt | 27 +++ .../kotlin/completion/KotlinCompletions.kt | 86 +++++----- .../diagnostic/KotlinDiagnosticProvider.kt | 2 - .../kotlin-analysis-api/build.gradle.kts | 4 +- 13 files changed, 282 insertions(+), 73 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/NoOpAsyncExecutionService.kt diff --git a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt index e22fe84a2b..1253cdf342 100755 --- a/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt +++ b/app/src/main/java/com/itsaky/androidide/app/IDEApplication.kt @@ -47,6 +47,7 @@ import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch import kotlinx.coroutines.plus import org.appdevforall.codeonthego.computervision.di.computerVisionModule +import org.jetbrains.kotlin.cli.jvm.compiler.setupIdeaStandaloneExecution import org.koin.android.ext.koin.androidContext import org.koin.core.context.GlobalContext import org.koin.core.context.startKoin @@ -103,6 +104,9 @@ class IDEApplication : private set init { + System.setProperty("java.awt.headless", "true") + setupIdeaStandaloneExecution() + @Suppress("Deprecation") Shell.setDefaultBuilder( Shell.Builder 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 index 6575c5df63..724804e7a5 100644 --- 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 @@ -40,11 +40,6 @@ class IndexingServiceManager( * @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 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 61dc5c6f56..46d1062f19 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 @@ -82,8 +82,6 @@ class KotlinLanguageServer : ILanguageServer { private val scope = CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!)) private var projectModel: KotlinProjectModel? = null - private val sourceIndex: JvmSymbolIndex? = null - private val fileIndex: KtFileMetadataIndex? = null private var compiler: Compiler? = null private var analyzeJob: Job? = null @@ -96,7 +94,6 @@ 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" 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 d632317b72..8eefb9bbb8 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 @@ -190,8 +190,6 @@ internal class CompilationEnvironment( } init { - System.setProperty("java.awt.headless", "true") - setupIdeaStandaloneExecution() projectEnv = StandaloneProjectFactory .createProjectEnvironment( @@ -236,8 +234,8 @@ internal class CompilationEnvironment( val libraryRoots = modules .asFlatSequence() .filterNot { it.isSourceModule } - .flatMap { - it.computeFiles(extended = true).map { JavaRoot(it, JavaRoot.RootType.BINARY) } + .flatMap { libMod -> + libMod.computeFiles(extended = false).map { file -> JavaRoot(file, JavaRoot.RootType.BINARY) } } .toList() @@ -357,20 +355,22 @@ internal class CompilationEnvironment( fun onFileClosed(path: Path) { ktSymbolIndex.closeKtFile(path) + (project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider) + .unregisterInMemoryFile(path.pathString) } fun onFileContentChanged(path: Path) { - val ktFile = ktSymbolIndex.getOpenedKtFile(path) ?: return - val doc = project.read { psiDocumentManager.getDocument(ktFile) } ?: return - project.write { - commandProcessor.executeCommand(project, { - doc.setText(FileManager.getDocumentContents(path)) - psiDocumentManager.commitDocument(doc) - ktFile.onContentReload() - }, "onChangeFile", null) + val newContent = FileManager.getDocumentContents(path) + val newKtFile = project.read { parser.createFile(path.pathString, newContent) } + + // Tell ProjectStructureProvider which module owns this LightVirtualFile. + val provider = project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider + provider.registerInMemoryFile(path.pathString, newKtFile.virtualFile) + ktSymbolIndex.openKtFile(path, newKtFile) + project.write { KaSourceModificationService.getInstance(project) - .handleElementModification(ktFile, KaElementModificationType.Unknown) + .handleElementModification(newKtFile, KaElementModificationType.Unknown) } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt index 629f483dce..bd91a7109e 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -29,7 +29,6 @@ internal class IndexWorker( while (true) { when (val command = queue.take()) { is IndexCommand.IndexLibraryFile -> { - logger.debug("index library: {}", command.vf.path) libraryIndex.insertAll(CombinedJarScanner.scan(rootVf = command.vf)) libraryIndexCount++ } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 0c20061acb..2a388b9447 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -15,7 +15,6 @@ import org.appdevforall.codeonthego.indexing.service.IndexKey import org.checkerframework.checker.index.qual.NonNegative import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile -import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.com.intellij.psi.PsiManager import org.jetbrains.kotlin.psi.KtFile import java.nio.file.Path @@ -112,15 +111,17 @@ internal class KtSymbolIndex( openedFiles[path]?.also { return it } ktFileCache.getIfPresent(path)?.also { return it } - val ktFile = project.read { - PsiManager.getInstance(project) - .findFile(vf) as KtFile - } + val ktFile = loadKtFile(vf) ktFileCache.put(path, ktFile) return ktFile } + private fun loadKtFile(vf: VirtualFile): KtFile = project.read { + PsiManager.getInstance(project) + .findFile(vf) as KtFile + } + suspend fun close() { scanningWorker.stop() indexWorker.submitCommand(IndexCommand.Stop) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt index ba2a273f92..85b7de142c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtLibraryModule.kt @@ -93,14 +93,15 @@ internal class KtLibraryModule( .flatMap { LibraryUtils.getAllVirtualFilesFromRoot(it, includeRoot = true) } } + @OptIn(KaExperimentalApi::class) override val baseContentScope: GlobalSearchScope by lazy { val virtualFileUrls = computeFiles(extended = true).map { it.url }.toSet() object : GlobalSearchScope(project) { - override fun contains(p0: VirtualFile): Boolean { - return p0.url in virtualFileUrls + override fun contains(vf: VirtualFile): Boolean { + return vf.url in virtualFileUrls } - override fun isSearchInModuleContent(p0: Module): Boolean { + override fun isSearchInModuleContent(module: Module): Boolean { return false } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt index b2ea451387..f3b0edbfa3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/registrar/LspServiceRegistrar.kt @@ -2,6 +2,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler.registrar import com.itsaky.androidide.lsp.kotlin.compiler.services.AnalysisPermissionOptions import com.itsaky.androidide.lsp.kotlin.compiler.services.AnnotationsResolverFactory +import com.itsaky.androidide.lsp.kotlin.compiler.services.NoOpAsyncExecutionService import com.itsaky.androidide.lsp.kotlin.compiler.services.DeclarationProviderFactory import com.itsaky.androidide.lsp.kotlin.compiler.services.DeclarationProviderMerger import com.itsaky.androidide.lsp.kotlin.compiler.services.ModificationTrackerFactory @@ -11,6 +12,7 @@ import com.itsaky.androidide.lsp.kotlin.compiler.services.PackageProviderFactory import com.itsaky.androidide.lsp.kotlin.compiler.services.PackageProviderMerger import com.itsaky.androidide.lsp.kotlin.compiler.services.PlatformSettings import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaImplementationDetail import org.jetbrains.kotlin.analysis.api.platform.KotlinPlatformSettings import org.jetbrains.kotlin.analysis.api.platform.declarations.KotlinAnnotationsResolverFactory @@ -30,12 +32,22 @@ import org.jetbrains.kotlin.analysis.api.standalone.base.projectStructure.Plugin import org.jetbrains.kotlin.analysis.decompiler.stub.file.ClsKotlinBinaryClassCache import org.jetbrains.kotlin.analysis.decompiler.stub.file.DummyFileAttributeService import org.jetbrains.kotlin.analysis.decompiler.stub.file.FileAttributeService +import org.jetbrains.kotlin.asJava.finder.JavaElementFinder +import org.jetbrains.kotlin.com.intellij.lang.ASTNode import org.jetbrains.kotlin.com.intellij.mock.MockApplication import org.jetbrains.kotlin.com.intellij.mock.MockProject +import org.jetbrains.kotlin.com.intellij.openapi.Disposable +import org.jetbrains.kotlin.com.intellij.openapi.application.AsyncExecutionService +import org.jetbrains.kotlin.com.intellij.psi.PsiElementFinder +import org.jetbrains.kotlin.com.intellij.psi.PsiFile +import org.jetbrains.kotlin.com.intellij.psi.PsiTreeChangeEvent +import org.jetbrains.kotlin.com.intellij.psi.PsiTreeChangeListener import org.jetbrains.kotlin.com.intellij.psi.SmartPointerManager import org.jetbrains.kotlin.com.intellij.psi.SmartTypePointerManager +import org.jetbrains.kotlin.com.intellij.psi.impl.PsiElementFinderImpl import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.SmartPointerManagerImpl import org.jetbrains.kotlin.com.intellij.psi.impl.smartPointers.SmartTypePointerManagerImpl +import org.jetbrains.kotlin.com.intellij.psi.impl.source.codeStyle.IndentHelper @OptIn(KaImplementationDetail::class) internal object LspServiceRegistrar : AnalysisApiSimpleServiceRegistrar() { @@ -52,6 +64,7 @@ internal object LspServiceRegistrar : AnalysisApiSimpleServiceRegistrar() { AnalysisPermissionOptions::class.java ) registerService(ClsKotlinBinaryClassCache::class.java) + registerService(AsyncExecutionService::class.java, NoOpAsyncExecutionService::class.java) } } @@ -105,4 +118,13 @@ internal object LspServiceRegistrar : AnalysisApiSimpleServiceRegistrar() { ) } } + + @OptIn(KaExperimentalApi::class) + @Suppress("TestOnlyProblems") + override fun registerProjectModelServices(project: MockProject, disposable: Disposable) { + with(PsiElementFinder.EP.getPoint(project)) { + registerExtension(JavaElementFinder(project), disposable) + registerExtension(PsiElementFinderImpl(project), disposable) + } + } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/NoOpAsyncExecutionService.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/NoOpAsyncExecutionService.kt new file mode 100644 index 0000000000..bc88cd8739 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/NoOpAsyncExecutionService.kt @@ -0,0 +1,157 @@ +@file:Suppress("UnstableApiUsage") + +package com.itsaky.androidide.lsp.kotlin.compiler.services + +import org.jetbrains.concurrency.CancellablePromise +import org.jetbrains.concurrency.Promise +import org.jetbrains.kotlin.com.intellij.openapi.Disposable +import org.jetbrains.kotlin.com.intellij.openapi.application.AppUIExecutor +import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager +import org.jetbrains.kotlin.com.intellij.openapi.application.AsyncExecutionService +import org.jetbrains.kotlin.com.intellij.openapi.application.ExpirableExecutor +import org.jetbrains.kotlin.com.intellij.openapi.application.ModalityState +import org.jetbrains.kotlin.com.intellij.openapi.application.NonBlockingReadAction +import org.jetbrains.kotlin.com.intellij.openapi.progress.ProgressIndicator +import org.jetbrains.kotlin.com.intellij.openapi.project.Project +import org.jetbrains.kotlin.com.intellij.util.Function +import java.util.concurrent.Callable +import java.util.concurrent.CompletableFuture +import java.util.concurrent.Executor +import java.util.concurrent.ForkJoinPool +import java.util.concurrent.TimeUnit +import java.util.function.BooleanSupplier +import java.util.function.Consumer + +/** + * No-op [AsyncExecutionService] for standalone (non-IDE) environments. + * + * The real implementation requires an IDE event-dispatch / write-thread infrastructure that does + * not exist in our standalone setup. Submitted tasks are run asynchronously on the common + * ForkJoin pool so that stub rebuilds triggered by structural PSI changes don't block the + * analysis thread or deadlock against the project read/write lock. + */ +internal class NoOpAsyncExecutionService : AsyncExecutionService() { + + private val executor: AppUIExecutor = NoOpAppUIExecutor() + + override fun createExecutor(backgroundExecutor: Executor): ExpirableExecutor = + NoOpExpirableExecutor(backgroundExecutor) + + override fun createUIExecutor(modalityState: ModalityState): AppUIExecutor = executor + + override fun createWriteThreadExecutor(modalityState: ModalityState): AppUIExecutor = executor + + override fun buildNonBlockingReadAction(callable: Callable): NonBlockingReadAction = + NoOpNonBlockingReadAction(callable) +} + +private class NoOpAppUIExecutor : AppUIExecutor { + override fun later(): AppUIExecutor = this + override fun withDocumentsCommitted(project: Project): AppUIExecutor = this + override fun inSmartMode(project: Project): AppUIExecutor = this + override fun expireWith(disposable: Disposable): AppUIExecutor = this + + override fun execute(runnable: Runnable) { + ApplicationManager.getApplication().invokeLater(runnable) + } + + override fun submit(callable: Callable): CancellablePromise { + val future = CompletableFuture() + ApplicationManager.getApplication().invokeLater { + try { future.complete(callable.call()) } + catch (e: Throwable) { future.completeExceptionally(e) } + } + return future.asCancellablePromise() + } + + override fun submit(runnable: Runnable): CancellablePromise<*> { + val future = CompletableFuture() + ApplicationManager.getApplication().invokeLater { + try { runnable.run(); future.complete(null) } + catch (e: Throwable) { future.completeExceptionally(e) } + } + return future.asCancellablePromise() + } +} + +private class NoOpExpirableExecutor(private val exec: Executor) : ExpirableExecutor { + override fun expireWith(disposable: Disposable): ExpirableExecutor = this + + override fun execute(runnable: Runnable) = exec.execute(runnable) + + override fun submit(callable: Callable): CancellablePromise = + CompletableFuture.supplyAsync({ callable.call() }, exec).asCancellablePromise() + + override fun submit(runnable: Runnable): CancellablePromise<*> = + CompletableFuture.runAsync(runnable, exec).thenApply { null }.asCancellablePromise() +} + +private class NoOpNonBlockingReadAction(private val callable: Callable) : NonBlockingReadAction { + override fun inSmartMode(project: Project): NonBlockingReadAction = this + override fun withDocumentsCommitted(project: Project): NonBlockingReadAction = this + override fun expireWhen(condition: BooleanSupplier): NonBlockingReadAction = this + override fun wrapProgress(indicator: ProgressIndicator): NonBlockingReadAction = this + override fun expireWith(disposable: Disposable): NonBlockingReadAction = this + override fun finishOnUiThread(modalityState: ModalityState, uiThreadAction: Consumer): NonBlockingReadAction = this + override fun coalesceBy(vararg equality: Any): NonBlockingReadAction = this + + override fun submit(backgroundThreadExecutor: Executor): CancellablePromise = + CompletableFuture.supplyAsync({ callable.call() }, backgroundThreadExecutor) + .asCancellablePromise() + + override fun executeSynchronously(): T = callable.call() +} + +private fun CompletableFuture.asCancellablePromise(): CancellablePromise = + CompletableFutureCancellablePromise(this) + +private class CompletableFutureCancellablePromise( + private val future: CompletableFuture +) : CancellablePromise { + + // Future + override fun cancel(mayInterruptIfRunning: Boolean): Boolean = future.cancel(mayInterruptIfRunning) + override fun isCancelled(): Boolean = future.isCancelled + override fun isDone(): Boolean = future.isDone + override fun get(): T = future.get() + override fun get(timeout: Long, unit: TimeUnit): T = future.get(timeout, unit) + + // CancellablePromise + override fun cancel() { future.cancel(true) } + + override fun onSuccess(handler: Consumer): CancellablePromise { + future.thenAccept(handler) + return this + } + + override fun onError(handler: Consumer): CancellablePromise { + future.exceptionally { e -> handler.accept(e); null } + return this + } + + override fun onProcessed(handler: Consumer): CancellablePromise { + future.whenComplete { value, _ -> handler.accept(value) } + return this + } + + // Promise + override fun getState(): Promise.State = when { + future.isCancelled || future.isCompletedExceptionally -> Promise.State.REJECTED + future.isDone -> Promise.State.SUCCEEDED + else -> Promise.State.PENDING + } + + override fun processed(child: Promise): Promise = this + + override fun blockingGet(timeout: Int, unit: TimeUnit): T = future.get(timeout.toLong(), unit) + + override fun then(handler: Function): Promise = + future.thenApply { handler.`fun`(it) }.asCancellablePromise() + + override fun thenAsync( + handler: Function> + ): Promise = future.thenCompose { value -> + @Suppress("UNCHECKED_CAST") + (handler.`fun`(value) as CompletableFutureCancellablePromise).future + }.asCancellablePromise() +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt index 3f09e6253b..7e63ee2494 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt @@ -13,13 +13,34 @@ import org.jetbrains.kotlin.com.intellij.mock.MockProject import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.slf4j.LoggerFactory import java.nio.file.Paths +import java.util.concurrent.ConcurrentHashMap internal class ProjectStructureProvider : KtLspService, KotlinProjectStructureProviderBase() { + companion object { + private val logger = LoggerFactory.getLogger(ProjectStructureProvider::class.java) + } + private lateinit var modules: List private lateinit var project: Project + private val inMemoryVfToModule = ConcurrentHashMap() + private val pathToInMemoryVf = ConcurrentHashMap() + + fun registerInMemoryFile(sourcePath: String, vf: VirtualFile) { + pathToInMemoryVf.remove(sourcePath)?.let { inMemoryVfToModule.remove(it) } + + val module = findModuleForSourceId(sourcePath) ?: return + inMemoryVfToModule[vf] = module + pathToInMemoryVf[sourcePath] = vf + } + + fun unregisterInMemoryFile(sourcePath: String) { + pathToInMemoryVf.remove(sourcePath)?.let { inMemoryVfToModule.remove(it) } + } + private val notUnderContentRootModuleWithoutPsiFile by lazy { NotUnderContentRootModule( id = "unnamed-outside-content-root", @@ -43,6 +64,9 @@ internal class ProjectStructureProvider : KtLspService, KotlinProjectStructurePr useSiteModule: KaModule? ): KaModule { val virtualFile = element.containingFile.virtualFile + + inMemoryVfToModule[virtualFile]?.let { return it } + val visited = mutableSetOf() modules.forEach { module -> @@ -50,6 +74,9 @@ internal class ProjectStructureProvider : KtLspService, KotlinProjectStructurePr if (foundModule != null) return foundModule } + // fallback: path-based lookup + findModuleForSourceId(virtualFile.path)?.let { return it } + return NotUnderContentRootModule( id = "unnamed-outside-content-root", moduleDescription = "unnamed-outside-content-root module with a PSI 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 113efbbaf7..c76d3d5b0f 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 @@ -2,6 +2,7 @@ package com.itsaky.androidide.lsp.kotlin.completion import com.itsaky.androidide.lsp.api.describeSnippet import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext import com.itsaky.androidide.lsp.kotlin.utils.ContextKeywords import com.itsaky.androidide.lsp.kotlin.utils.ModifierFilter @@ -49,6 +50,7 @@ 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.analysis.low.level.api.fir.util.originalKtFile import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName @@ -95,49 +97,54 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi append(originalText, completionOffset, originalText.length) } - val completionKtFile = + val completionKtFile = project.read { parser.createFile( fileName = params.file.name, text = textWithPlaceholder - ) + ).apply { + originalFile = ktFile + } + } return try { - analyzeCopy( - useSiteElement = completionKtFile, - resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, - ) { - val ctx = - resolveAnalysisContext( - env = this@complete, - file = params.file, - ktFile = completionKtFile, - offset = completionOffset, - partial = partial - ) - - if (ctx == null) { - logger.error( - "Unable to determine context at offset {} in file {}", - completionOffset, - params.file - ) - return@analyzeCopy CompletionResult.EMPTY - } + project.read { + analyzeCopy( + useSiteElement = completionKtFile, + resolutionMode = KaDanglingFileResolutionMode.PREFER_SELF, + ) { + val ctx = + resolveAnalysisContext( + env = this@complete, + file = params.file, + ktFile = completionKtFile, + offset = completionOffset, + partial = partial + ) + + if (ctx == null) { + logger.error( + "Unable to determine context at offset {} in file {}", + completionOffset, + params.file + ) + return@analyzeCopy CompletionResult.EMPTY + } - context(ctx) { - runBlocking { - val items = mutableListOf() - val completionContext = determineCompletionContext(ctx.psiElement) - when (completionContext) { - CompletionContext.Scope -> - collectScopeCompletions(to = items) + context(ctx) { + runBlocking { + val items = mutableListOf() + val completionContext = determineCompletionContext(ctx.psiElement) + when (completionContext) { + CompletionContext.Scope -> + collectScopeCompletions(to = items) - CompletionContext.Member -> - collectMemberCompletions(to = items) - } + CompletionContext.Member -> + collectMemberCompletions(to = items) + } - collectKeywordCompletions(to = items) - CompletionResult(items) + collectKeywordCompletions(to = items) + CompletionResult(items) + } } } } @@ -146,7 +153,7 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi throw e } - logger.warn("An error occurred while computing completions for {}", params.file) + logger.warn("An error occurred while computing completions for {}", params.file, e) return CompletionResult.EMPTY } } @@ -324,9 +331,10 @@ private fun KaSession.buildUnimportedSymbolItem(symbol: JvmSymbol): CompletionIt val receiverClassId = internalNameToClassId(receiverTypeName) val receiverType = findClass(receiverClassId) if (receiverType != null) { - val satisfiesImplicitReceivers = ctx.scopeContext.implicitReceivers.any { receiver -> - receiver.type.isSubtypeOf(receiverType) - } + val satisfiesImplicitReceivers = + ctx.scopeContext.implicitReceivers.any { receiver -> + receiver.type.isSubtypeOf(receiverType) + } // the extension property/function's receiver type // is not available in current context, so ignore this sym if (!satisfiesImplicitReceivers) return null 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 afca38be9f..72a3ff2aba 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 @@ -43,8 +43,6 @@ private fun CompilationEnvironment.doAnalyze(file: Path): DiagnosticResult { return DiagnosticResult.NO_UPDATE } - logger.debug("Analyzing ktFile: {}", ktFile.text) - val diagnostics = project.read { analyze(ktFile) { ktFile.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS) diff --git a/subprojects/kotlin-analysis-api/build.gradle.kts b/subprojects/kotlin-analysis-api/build.gradle.kts index 2e4f08710f..5b6a20674c 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}-f047b07" +val ktAndroidTag = "v${ktAndroidVersion}-1e59a8b" 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 = "c9897c94ae1431fadeb4fa5b05dd4d478a60c4589f38f801e07c72405a7b34b1", + sha256Checksum = "9d7d60f30169da932f21c130f3955016b165d45215564a1fb883021e59528835", ) } } From 340b08b73965e2a15572a8be8f32dbcd1bfd1aae Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Apr 2026 20:39:21 +0530 Subject: [PATCH 43/49] fix: IndexWorker always re-index libraries Signed-off-by: Akash Yadav --- .../codeonthego/indexing/jvm/KotlinMetadataScanner.kt | 3 ++- .../androidide/lsp/kotlin/compiler/index/IndexWorker.kt | 6 ++++-- 2 files changed, 6 insertions(+), 3 deletions(-) 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 a230093803..ab6d9c7f46 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 @@ -129,7 +129,8 @@ object KotlinMetadataScanner { } private fun extractFromClass( - klass: KmClass, sourceId: String, + klass: KmClass, + sourceId: String, ): List { val symbols = mutableListOf() val className = klass.name diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt index bd91a7109e..266f80f3d4 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -29,7 +29,7 @@ internal class IndexWorker( while (true) { when (val command = queue.take()) { is IndexCommand.IndexLibraryFile -> { - libraryIndex.insertAll(CombinedJarScanner.scan(rootVf = command.vf)) + libraryIndex.indexSource(command.vf.path) { CombinedJarScanner.scan(rootVf = command.vf) } libraryIndexCount++ } @@ -68,7 +68,9 @@ internal class IndexWorker( } is IndexCommand.ScanSourceFile -> { - val ktFile = project.read { PsiManager.getInstance(project).findFile(command.vf) as? KtFile } + val ktFile = project.read { + PsiManager.getInstance(project).findFile(command.vf) as? KtFile + } ?: continue val newFile = ktFile.toMetadata(project, isIndexed = false) From a1d999ef2b209d08db7b652952f0c1afd1f16161 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Apr 2026 22:22:39 +0530 Subject: [PATCH 44/49] feat: add KtFile.backingFilePath for better resolution of declaring modules Signed-off-by: Akash Yadav --- .../codeonthego/indexing/FilteredIndex.kt | 3 +- .../kotlin/compiler/CompilationEnvironment.kt | 4 +- .../lsp/kotlin/compiler/index/IndexWorker.kt | 9 ++++- .../kotlin/compiler/index/KtSymbolIndex.kt | 1 + .../kotlin/compiler/index/ScanningWorker.kt | 4 ++ .../lsp/kotlin/compiler/modules/KtFileExts.kt | 9 +++++ .../services/ProjectStructureProvider.kt | 40 ++++++++++++++++--- .../kotlin/completion/KotlinCompletions.kt | 33 ++++++++------- .../kotlin/utils/SymbolVisibilityChecker.kt | 5 +++ .../lsp/kotlin/utils/VirtualFileExts.kt | 7 ++++ 10 files changed, 88 insertions(+), 27 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtFileExts.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/VirtualFileExts.kt 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 index 1aced73afb..a110f276dd 100644 --- a/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt +++ b/lsp/indexing/src/main/kotlin/org/appdevforall/codeonthego/indexing/FilteredIndex.kt @@ -79,7 +79,8 @@ open class FilteredIndex( if (query.sourceId != null && query.sourceId !in activeSources) { return emptySequence() } - return backing.query(query).filter { it.sourceId in activeSources } + val original = backing.query(query) + return original.filter { it.sourceId in activeSources } } override suspend fun get(key: String): T? { 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 8eefb9bbb8..2389525252 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 @@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence +import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule import com.itsaky.androidide.lsp.kotlin.compiler.registrar.LspServiceRegistrar import com.itsaky.androidide.lsp.kotlin.compiler.services.JavaModuleAccessibilityChecker @@ -45,8 +46,6 @@ import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironment import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreEnvironment import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreProjectEnvironment -import org.jetbrains.kotlin.cli.jvm.compiler.setupHighestLanguageLevel -import org.jetbrains.kotlin.cli.jvm.compiler.setupIdeaStandaloneExecution import org.jetbrains.kotlin.cli.jvm.index.JavaRoot import org.jetbrains.kotlin.cli.jvm.index.JvmDependenciesDynamicCompoundIndex import org.jetbrains.kotlin.cli.jvm.index.JvmDependenciesIndexImpl @@ -362,6 +361,7 @@ internal class CompilationEnvironment( fun onFileContentChanged(path: Path) { val newContent = FileManager.getDocumentContents(path) val newKtFile = project.read { parser.createFile(path.pathString, newContent) } + newKtFile.backingFilePath = path // Tell ProjectStructureProvider which module owns this LightVirtualFile. val provider = project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt index 266f80f3d4..3195eb17c6 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -1,14 +1,17 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.utils.toNioPathOrNull import org.appdevforall.codeonthego.indexing.jvm.CombinedJarScanner import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.psi.PsiManager +import org.jetbrains.kotlin.com.intellij.util.io.URLUtil.JAR_SEPARATOR import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory +import kotlin.io.path.pathString internal class IndexWorker( private val project: Project, @@ -29,7 +32,11 @@ internal class IndexWorker( while (true) { when (val command = queue.take()) { is IndexCommand.IndexLibraryFile -> { - libraryIndex.indexSource(command.vf.path) { CombinedJarScanner.scan(rootVf = command.vf) } + var sourceId = command.vf.toNioPathOrNull()?.pathString ?: command.vf.path + if (sourceId.endsWith(JAR_SEPARATOR)) { + sourceId = sourceId.substringBeforeLast(JAR_SEPARATOR) + } + libraryIndex.indexSource(sourceId) { CombinedJarScanner.scan(rootVf = command.vf) } libraryIndexCount++ } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 2a388b9447..6ee8113725 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -56,6 +56,7 @@ internal class KtSymbolIndex( ) private val scanningWorker = ScanningWorker( + sourceIndex = sourceIndex, indexWorker = indexWorker, modules = modules, ) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt index 5cdc16cbc5..4e1c592c2a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt @@ -3,10 +3,12 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence import com.itsaky.androidide.lsp.kotlin.compiler.modules.isSourceModule +import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.slf4j.LoggerFactory import java.util.concurrent.atomic.AtomicBoolean internal class ScanningWorker( + private val sourceIndex: JvmSymbolIndex, private val indexWorker: IndexWorker, private val modules: List, ) { @@ -35,6 +37,8 @@ internal class ScanningWorker( .takeWhile { isRunning.get() } .toList() + sourceIndex.setActiveSources(sourceFiles.asSequence().map { it.path }.toSet()) + for (sourceFile in sourceFiles) { if (!isRunning.get()) return indexWorker.submitCommand(IndexCommand.ScanSourceFile(sourceFile)) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtFileExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtFileExts.kt new file mode 100644 index 0000000000..0560662d8d --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/modules/KtFileExts.kt @@ -0,0 +1,9 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.modules + +import org.jetbrains.kotlin.com.intellij.openapi.util.Key +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.UserDataProperty +import java.nio.file.Path + +private val KT_LSP_COMPLETION_BACKING_FILE = Key("KT_LSP_COMPLETION_BACKING_FILE") +var KtFile.backingFilePath by UserDataProperty(KT_LSP_COMPLETION_BACKING_FILE) \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt index 7e63ee2494..f663cc0e57 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/services/ProjectStructureProvider.kt @@ -3,19 +3,23 @@ package com.itsaky.androidide.lsp.kotlin.compiler.services import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.NotUnderContentRootModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaPlatformInterface import org.jetbrains.kotlin.analysis.api.platform.projectStructure.KotlinProjectStructureProviderBase import org.jetbrains.kotlin.analysis.api.projectStructure.KaModule import org.jetbrains.kotlin.analysis.api.projectStructure.KaNotUnderContentRootModule +import org.jetbrains.kotlin.analysis.low.level.api.fir.util.originalKtFile import org.jetbrains.kotlin.cli.jvm.index.JavaRoot import org.jetbrains.kotlin.com.intellij.mock.MockProject import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory import java.nio.file.Paths import java.util.concurrent.ConcurrentHashMap +import kotlin.io.path.pathString internal class ProjectStructureProvider : KtLspService, KotlinProjectStructureProviderBase() { @@ -27,7 +31,7 @@ internal class ProjectStructureProvider : KtLspService, KotlinProjectStructurePr private lateinit var project: Project private val inMemoryVfToModule = ConcurrentHashMap() - private val pathToInMemoryVf = ConcurrentHashMap() + private val pathToInMemoryVf = ConcurrentHashMap() fun registerInMemoryFile(sourcePath: String, vf: VirtualFile) { pathToInMemoryVf.remove(sourcePath)?.let { inMemoryVfToModule.remove(it) } @@ -63,18 +67,38 @@ internal class ProjectStructureProvider : KtLspService, KotlinProjectStructurePr element: PsiElement, useSiteModule: KaModule? ): KaModule { - val virtualFile = element.containingFile.virtualFile + val virtualFile = element.containingFile?.virtualFile + ?: return notUnderContentRootModuleWithoutPsiFile + // Fast path: in-memory file registered by onFileContentChanged. inMemoryVfToModule[virtualFile]?.let { return it } val visited = mutableSetOf() + val backingFilePath = (element.containingFile as? KtFile)?.let { + it.backingFilePath ?: it.originalKtFile?.backingFilePath + } + + if (backingFilePath != null) { + findModuleForSourceId(backingFilePath.pathString)?.let { return it } + } + + // If the caller supplies a use-site module, search its dependency tree first. + // This covers the common case (element is in the same module or one of its direct + // library dependencies) without scanning every top-level module. + if (useSiteModule != null) { + searchVirtualFileInModule(virtualFile, useSiteModule, visited)?.let { return it } + } + + // Full scan: search every top-level module and their transitive dependencies. + // The shared `visited` set avoids re-visiting what we already searched above, + // but still reaches modules that are NOT in useSiteModule's dependency tree + // (e.g. a library module that is a sibling of useSiteModule, not a child of it). modules.forEach { module -> - val foundModule = searchVirtualFileInModule(virtualFile, useSiteModule ?: module, visited) - if (foundModule != null) return foundModule + searchVirtualFileInModule(virtualFile, module, visited)?.let { return it } } - // fallback: path-based lookup + // Path-based fallback for in-memory LightVirtualFiles created by onFileContentChanged. findModuleForSourceId(virtualFile.path)?.let { return it } return NotUnderContentRootModule( @@ -124,7 +148,11 @@ internal class ProjectStructureProvider : KtLspService, KotlinProjectStructurePr return notUnderContentRootModuleWithoutPsiFile } - private fun searchVirtualFileInModule(vf: VirtualFile, module: KaModule, visited: MutableSet): KaModule? { + private fun searchVirtualFileInModule( + vf: VirtualFile, + module: KaModule, + visited: MutableSet + ): KaModule? { if (visited.contains(module)) return null if (module.contentScope.contains(vf)) return module 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 c76d3d5b0f..57ced1e5e5 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 @@ -103,6 +103,7 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi text = textWithPlaceholder ).apply { originalFile = ktFile + originalKtFile = ktFile } } @@ -131,20 +132,18 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi } context(ctx) { - runBlocking { - val items = mutableListOf() - val completionContext = determineCompletionContext(ctx.psiElement) - when (completionContext) { - CompletionContext.Scope -> - collectScopeCompletions(to = items) - - CompletionContext.Member -> - collectMemberCompletions(to = items) - } - - collectKeywordCompletions(to = items) - CompletionResult(items) + val items = mutableListOf() + val completionContext = determineCompletionContext(ctx.psiElement) + when (completionContext) { + CompletionContext.Scope -> + collectScopeCompletions(to = items) + + CompletionContext.Member -> + collectMemberCompletions(to = items) } + + collectKeywordCompletions(to = items) + CompletionResult(items) } } } @@ -244,7 +243,7 @@ private fun KaSession.collectExtensionFunctions( } context(env: CompilationEnvironment, ctx: AnalysisContext) -private suspend fun KaSession.collectScopeCompletions( +private fun KaSession.collectScopeCompletions( to: MutableList, ) { val ktElement = ctx.ktElement @@ -281,7 +280,7 @@ private suspend fun KaSession.collectScopeCompletions( } context(env: CompilationEnvironment, ctx: AnalysisContext) -private suspend fun KaSession.collectUnimportedSymbols( +private fun KaSession.collectUnimportedSymbols( to: MutableList ) { val currentPackage = ctx.ktElement.containingKtFile.packageDirective?.name @@ -289,7 +288,7 @@ private suspend fun KaSession.collectUnimportedSymbols( // Library symbols: JAR-based, use full SymbolVisibilityChecker val visibilityChecker = env.symbolVisibilityChecker - env.libraryIndex?.findByPrefix(ctx.partial) + env.libraryIndex?.findByPrefix(ctx.partial, limit = 0) ?.forEach { symbol -> val isVisible = visibilityChecker.isVisible( symbol = symbol, @@ -301,7 +300,7 @@ private suspend fun KaSession.collectUnimportedSymbols( } // Source symbols: project .kt files — skip private and same-package symbols - env.sourceIndex?.findByPrefix(ctx.partial) + env.sourceIndex?.findByPrefix(ctx.partial, limit = 0) ?.forEach { symbol -> if (symbol.packageName == currentPackage) return@forEach diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt index db61fb038f..69c4c612c2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/SymbolVisibilityChecker.kt @@ -5,11 +5,16 @@ 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 org.slf4j.LoggerFactory import java.util.concurrent.ConcurrentHashMap internal class SymbolVisibilityChecker( private val structureProvider: ProjectStructureProvider, ) { + companion object { + private val logger = LoggerFactory.getLogger(SymbolVisibilityChecker::class.java) + } + // visibility check cache, for memoization // useSiteModule -> list of modules visible from useSiteModule private val moduleVisibilityCache = ConcurrentHashMap>() diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/VirtualFileExts.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/VirtualFileExts.kt new file mode 100644 index 0000000000..2232db0561 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/VirtualFileExts.kt @@ -0,0 +1,7 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile +import java.nio.file.Path + +fun VirtualFile.toNioPathOrNull(): Path? = + runCatching { toNioPath() }.getOrNull() \ No newline at end of file From 49542ad6b36a4fd01e79d97cacf92fcba5e8f89c Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Apr 2026 22:28:41 +0530 Subject: [PATCH 45/49] fix: remove library indexing logic for kt index worker Libraries are already indexed by JvmLibraryIndexingService Signed-off-by: Akash Yadav --- .../lsp/kotlin/compiler/index/IndexCommand.kt | 1 - .../lsp/kotlin/compiler/index/IndexWorker.kt | 18 +----------------- .../lsp/kotlin/compiler/index/KtSymbolIndex.kt | 1 - .../kotlin/compiler/index/ScanningWorker.kt | 11 +---------- 4 files changed, 2 insertions(+), 29 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt index 3ac737e182..1d890426c1 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt @@ -10,5 +10,4 @@ internal sealed interface IndexCommand { data class ScanSourceFile(val vf: VirtualFile): IndexCommand data class IndexModifiedFile(val ktFile: KtFile): IndexCommand data class IndexSourceFile(val vf: VirtualFile): IndexCommand - data class IndexLibraryFile(val vf: VirtualFile): IndexCommand } \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt index 3195eb17c6..3be16fdecc 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -1,24 +1,19 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index import com.itsaky.androidide.lsp.kotlin.compiler.read -import com.itsaky.androidide.lsp.kotlin.utils.toNioPathOrNull -import org.appdevforall.codeonthego.indexing.jvm.CombinedJarScanner import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.psi.PsiManager -import org.jetbrains.kotlin.com.intellij.util.io.URLUtil.JAR_SEPARATOR import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory -import kotlin.io.path.pathString internal class IndexWorker( private val project: Project, private val queue: WorkerQueue, private val fileIndex: KtFileMetadataIndex, private val sourceIndex: JvmSymbolIndex, - private val libraryIndex: JvmSymbolIndex, ) { companion object { private val logger = LoggerFactory.getLogger(IndexWorker::class.java) @@ -27,19 +22,9 @@ internal class IndexWorker( suspend fun start() { var scanCount = 0 var sourceIndexCount = 0 - var libraryIndexCount = 0 while (true) { when (val command = queue.take()) { - is IndexCommand.IndexLibraryFile -> { - var sourceId = command.vf.toNioPathOrNull()?.pathString ?: command.vf.path - if (sourceId.endsWith(JAR_SEPARATOR)) { - sourceId = sourceId.substringBeforeLast(JAR_SEPARATOR) - } - libraryIndex.indexSource(sourceId) { CombinedJarScanner.scan(rootVf = command.vf) } - libraryIndexCount++ - } - is IndexCommand.IndexSourceFile -> { if (command.vf.fileSystem.protocol != "file") { logger.warn("Unknown source file protocol: {}", command.vf.path) @@ -67,10 +52,9 @@ internal class IndexWorker( IndexCommand.IndexingComplete -> { logger.info( - "Indexing complete: scanned={}, sourceIndexCount={}, libraryIndexCount={}", + "Indexing complete: scanned={}, sourceIndexCount={}", scanCount, sourceIndexCount, - libraryIndexCount ) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 6ee8113725..822cc0c296 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -52,7 +52,6 @@ internal class KtSymbolIndex( queue = workerQueue, fileIndex = fileIndex, sourceIndex = sourceIndex, - libraryIndex = libraryIndex, ) private val scanningWorker = ScanningWorker( diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt index 4e1c592c2a..0f5a7c7db3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/ScanningWorker.kt @@ -29,9 +29,7 @@ internal class ScanningWorker( } private suspend fun scan() { - val allModules = modules.asFlatSequence().toList() - val sourceFiles = allModules - .asSequence() + val sourceFiles = modules.asFlatSequence() .filter { it.isSourceModule } .flatMap { it.computeFiles(extended = true) } .takeWhile { isRunning.get() } @@ -52,13 +50,6 @@ internal class ScanningWorker( indexWorker.submitCommand(IndexCommand.IndexSourceFile(sourceFile)) } - allModules - .asSequence() - .filterNot { it.isSourceModule } - .flatMap { it.computeFiles(extended = false) } - .takeWhile { isRunning.get() } - .forEach { indexWorker.submitCommand(IndexCommand.IndexLibraryFile(it)) } - indexWorker.submitCommand(IndexCommand.IndexingComplete) } From 888193fcd69b19bd4a40d4411fe38146b708e3c9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Apr 2026 22:47:26 +0530 Subject: [PATCH 46/49] feat: add index for generated JARs This indexed is *always* refreshed after every successful build Signed-off-by: Akash Yadav --- .../indexing/service/IndexingService.kt | 8 ++ .../service/IndexingServiceManager.kt | 14 ++ .../androidide/lsp/java/JavaLanguageServer.kt | 4 + .../jvm/JvmGeneratedIndexingService.kt | 132 ++++++++++++++++++ .../kotlin/compiler/CompilationEnvironment.kt | 3 + .../lsp/kotlin/compiler/KotlinProjectModel.kt | 8 ++ .../kotlin/completion/KotlinCompletions.kt | 7 + 7 files changed, 176 insertions(+) create mode 100644 lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmGeneratedIndexingService.kt 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 index cbf074cce2..33f010fbd5 100644 --- 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 @@ -33,6 +33,14 @@ interface IndexingService : Closeable { */ suspend fun initialize(registry: IndexRegistry) + /** + * Called after a build completes. + * + * Implementations should re-index any build outputs that may have changed + * (e.g. generated JARs). The default is a no-op. + */ + suspend fun onBuildCompleted() {} + /** * Called when the project is closed or the IDE shuts down. * Release all resources. 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 index 724804e7a5..3a5b98de66 100644 --- 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 @@ -69,12 +69,26 @@ class IndexingServiceManager( /** * Called after a build completes. + * + * Forwards the event to all registered services concurrently. + * Failures in one service don't affect others (SupervisorJob). */ fun onBuildCompleted() { if (!initialized) { log.warn("onBuildCompleted called before initialization, ignoring") return } + scope.launch { + services.values.forEach { service -> + launch { + try { + service.onBuildCompleted() + } catch (e: Exception) { + log.error("Service '{}' failed in onBuildCompleted", service.id, e) + } + } + } + } } /** 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 995944e565..2bdb29845b 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 @@ -74,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.JvmGeneratedIndexingService import org.appdevforall.codeonthego.indexing.jvm.JvmLibraryIndexingService import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe @@ -123,6 +124,9 @@ class JavaLanguageServer : ILanguageServer { projectManager.indexingServiceManager.register( service = JvmLibraryIndexingService(context = BaseApplication.baseInstance) ) + projectManager.indexingServiceManager.register( + service = JvmGeneratedIndexingService(context = BaseApplication.baseInstance) + ) JavaSnippetRepository.init() } diff --git a/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmGeneratedIndexingService.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmGeneratedIndexingService.kt new file mode 100644 index 0000000000..5a8c5ee4eb --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmGeneratedIndexingService.kt @@ -0,0 +1,132 @@ +package org.appdevforall.codeonthego.indexing.jvm + +import android.content.Context +import com.itsaky.androidide.projects.ProjectManagerImpl +import com.itsaky.androidide.projects.api.ModuleProject +import com.itsaky.androidide.tasks.cancelIfActive +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +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.slf4j.LoggerFactory +import java.nio.file.Paths +import kotlin.io.path.extension + +/** + * Well-known key for the JVM generated-symbol index. + * + * Covers build-time-generated JARs such as R.jar that are excluded + * from the main library index. Both the Kotlin and Java LSPs can + * retrieve this index from the [IndexRegistry]. + */ +val JVM_GENERATED_SYMBOL_INDEX = IndexKey("jvm-generated-symbols") + +/** + * [IndexingService] that scans build-generated JARs (R.jar, etc.) and + * maintains a dedicated [JvmSymbolIndex] for them. + * + * Generated JARs are re-indexed unconditionally on every build completion + * because their contents change (new R-field values, new resource IDs) even + * when the set of JARs doesn't change. + */ +class JvmGeneratedIndexingService( + private val context: Context, +) : IndexingService { + + companion object { + const val ID = "jvm-generated-indexing-service" + private const val DB_NAME = "jvm_generated_symbol_index.db" + private const val INDEX_NAME = "jvm-generated-cache" + private val log = LoggerFactory.getLogger(JvmGeneratedIndexingService::class.java) + } + + override val id = ID + + override val providedKeys = listOf(JVM_GENERATED_SYMBOL_INDEX) + + private var generatedIndex: JvmSymbolIndex? = null + private val indexingMutex = Mutex() + private val coroutineScope = CoroutineScope(Dispatchers.Default) + + override suspend fun initialize(registry: IndexRegistry) { + val index = JvmSymbolIndex.createSqliteIndex( + context = context, + dbName = DB_NAME, + indexName = INDEX_NAME, + ) + + this.generatedIndex = index + registry.register(JVM_GENERATED_SYMBOL_INDEX, index) + log.info("JVM generated symbol index initialized") + + // Kick off an initial index pass for any already-built JARs. + coroutineScope.launch { + indexingMutex.withLock { + reindexGeneratedJars(forceReindex = false) + } + } + } + + override suspend fun onBuildCompleted() { + // Generated JARs (especially R.jar) always change after a build — + // their field values are regenerated. Force a full re-index. + coroutineScope.launch { + indexingMutex.withLock { + reindexGeneratedJars(forceReindex = true) + } + } + } + + private suspend fun reindexGeneratedJars(forceReindex: Boolean) { + val index = this.generatedIndex ?: run { + log.warn("Not indexing generated JARs — index not initialized.") + return + } + + val workspace = ProjectManagerImpl.getInstance().workspace ?: run { + log.warn("Not indexing generated JARs — workspace model not available.") + return + } + + val generatedJars = + workspace.subProjects + .asSequence() + .filterIsInstance() + .filter { it.path != workspace.rootProject.path } + .flatMap { project -> project.getIntermediateClasspaths() } + .filter { jar -> jar.exists() && jar.toPath().extension.lowercase() == "jar" } + .map { jar -> jar.absolutePath } + .toSet() + + log.info("{} generated JARs found", generatedJars.size) + + // Make exactly these JARs visible; remove stale ones from scope. + index.setActiveSources(generatedJars) + + var submitted = 0 + for (jarPath in generatedJars) { + if (forceReindex || !index.isCached(jarPath)) { + submitted++ + index.indexSource(jarPath, skipIfExists = false) { sourceId -> + CombinedJarScanner.scan(Paths.get(jarPath), sourceId) + } + } + } + + if (submitted > 0) { + log.info("{} generated JARs submitted for background indexing (force={})", submitted, forceReindex) + } else { + log.info("All generated JARs already cached, nothing to index") + } + } + + override fun close() { + coroutineScope.cancelIfActive("generated indexing service closed") + generatedIndex?.close() + generatedIndex = null + } +} 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 2389525252..78ff370699 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 @@ -148,6 +148,9 @@ internal class CompilationEnvironment( val requireFileIndex: KtFileMetadataIndex get() = checkNotNull(fileIndex) + val generatedIndex: JvmSymbolIndex? + get() = ktProject.generatedIndex + val symbolVisibilityChecker: SymbolVisibilityChecker by lazy { val provider = project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider 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 04b6797d0c..f109360c3e 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 @@ -4,6 +4,7 @@ import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_INDEX_KEY import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_META_INDEX_KEY import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.api.Workspace +import org.appdevforall.codeonthego.indexing.jvm.JVM_GENERATED_SYMBOL_INDEX import org.appdevforall.codeonthego.indexing.jvm.JVM_LIBRARY_SYMBOL_INDEX import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex @@ -51,6 +52,13 @@ internal class KotlinProjectModel { .registry .get(KT_SOURCE_FILE_META_INDEX_KEY) + val generatedIndex: JvmSymbolIndex? + get() = ProjectManagerImpl + .getInstance() + .indexingServiceManager + .registry + .get(JVM_GENERATED_SYMBOL_INDEX) + /** * The kind of change that occurred. */ 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 57ced1e5e5..be58063f7b 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 @@ -314,6 +314,13 @@ private fun KaSession.collectUnimportedSymbols( buildUnimportedSymbolItem(symbol)?.let { to += it } } + + // Generated symbols: R.jar etc. — all public by construction, no visibility check needed. + env.generatedIndex?.findByPrefix(ctx.partial, limit = 0) + ?.forEach { symbol -> + if (symbol.packageName == currentPackage) return@forEach + buildUnimportedSymbolItem(symbol)?.let { to += it } + } } context(ctx: AnalysisContext) From f3997a5ccb0f40ec3bef155a22d1ff90139f6b1f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 15 Apr 2026 00:44:03 +0530 Subject: [PATCH 47/49] fix: java source files are not recognized by analysis API Signed-off-by: Akash Yadav --- .../kotlin/compiler/CompilationEnvironment.kt | 31 ++++++++++++++----- 1 file changed, 24 insertions(+), 7 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 78ff370699..3c59cdf099 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 @@ -55,6 +55,7 @@ import org.jetbrains.kotlin.cli.jvm.modules.CliJavaModuleResolver import org.jetbrains.kotlin.cli.jvm.modules.JavaModuleGraph import org.jetbrains.kotlin.com.intellij.core.CoreApplicationEnvironment import org.jetbrains.kotlin.com.intellij.core.CorePackageIndex +import org.jetbrains.kotlin.com.intellij.ide.highlighter.JavaFileType import org.jetbrains.kotlin.com.intellij.mock.MockApplication import org.jetbrains.kotlin.com.intellij.mock.MockProject import org.jetbrains.kotlin.com.intellij.openapi.command.CommandProcessor @@ -86,6 +87,8 @@ import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path +import kotlin.io.path.extension +import kotlin.io.path.isDirectory import kotlin.io.path.pathString /** @@ -237,7 +240,8 @@ internal class CompilationEnvironment( .asFlatSequence() .filterNot { it.isSourceModule } .flatMap { libMod -> - libMod.computeFiles(extended = false).map { file -> JavaRoot(file, JavaRoot.RootType.BINARY) } + libMod.computeFiles(extended = false) + .map { file -> JavaRoot(file, JavaRoot.RootType.BINARY) } } .toList() @@ -257,14 +261,21 @@ internal class CompilationEnvironment( addRoots(libraryRoots, MessageCollector.NONE) } + val (javaRoots, singleJavaFileRoots) = modules + .asFlatSequence() + .filter { it.isSourceModule } + .flatMap { it.contentRoots } + .mapNotNull { VirtualFileManager.getInstance().findFileByNioPath(it) } + .partition { it.isDirectory || it.extension != JavaFileType.DEFAULT_EXTENSION } + val rootsIndex = - JvmDependenciesDynamicCompoundIndex(shouldOnlyFindFirstClass = false).apply { + JvmDependenciesDynamicCompoundIndex(shouldOnlyFindFirstClass = true).apply { addIndex( JvmDependenciesIndexImpl( - libraryRoots, - shouldOnlyFindFirstClass = false + libraryRoots + javaRoots.map { JavaRoot(it, JavaRoot.RootType.SOURCE) }, + shouldOnlyFindFirstClass = true ) - ) // TODO Should receive all (sources + libraries) + ) indexedRoots.forEach { javaRoot -> if (javaRoot.file.isDirectory) { @@ -281,7 +292,12 @@ internal class CompilationEnvironment( javaFileManager.initialize( index = rootsIndex, packagePartProviders = listOf(packagePartProvider), - singleJavaFileRootsIndex = SingleJavaFileRootsIndex(emptyList()), + singleJavaFileRootsIndex = SingleJavaFileRootsIndex(singleJavaFileRoots.map { + JavaRoot( + it, + JavaRoot.RootType.SOURCE + ) + }), usePsiClassFilesReading = true, perfManager = null, ) @@ -367,7 +383,8 @@ internal class CompilationEnvironment( newKtFile.backingFilePath = path // Tell ProjectStructureProvider which module owns this LightVirtualFile. - val provider = project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider + val provider = + project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider provider.registerInMemoryFile(path.pathString, newKtFile.virtualFile) ktSymbolIndex.openKtFile(path, newKtFile) From bcbbba32e4a485b13499ff7b8352f03128d528ea Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 16 Apr 2026 00:54:41 +0530 Subject: [PATCH 48/49] chore: trigger ci Signed-off-by: Akash Yadav From 81f403d9182b9ca0077342009de0998c895515dc Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 21 Apr 2026 02:17:16 +0530 Subject: [PATCH 49/49] fix: remove unused duplicate file Signed-off-by: Akash Yadav --- .../completion/SymbolVisibilityChecker.kt | 87 ------------------- 1 file changed, 87 deletions(-) delete mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/SymbolVisibilityChecker.kt 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 deleted file mode 100644 index 010b187e41..0000000000 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/SymbolVisibilityChecker.kt +++ /dev/null @@ -1,87 +0,0 @@ -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 - } - } -}