diff --git a/annotation-processors/build.gradle.kts b/annotation-processors/build.gradle.kts index de7d778ddc..da709dc2ca 100644 --- a/annotation-processors/build.gradle.kts +++ b/annotation-processors/build.gradle.kts @@ -16,6 +16,7 @@ */ import com.itsaky.androidide.build.config.BuildConfig +import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.tasks.KotlinCompile plugins { @@ -45,5 +46,5 @@ dependencies { } tasks.withType { - kotlinOptions.jvmTarget = "17" + compilerOptions.jvmTarget.set(JvmTarget.JVM_17) } diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 34538dfb6b..d9dcb121a5 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 @@ -197,6 +212,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) @@ -278,6 +297,7 @@ dependencies { implementation(projects.gradlePluginConfig) implementation(projects.subprojects.aaptcompiler) implementation(projects.subprojects.javacServices) + implementation(projects.subprojects.kotlinAnalysisApi) implementation(projects.subprojects.shizukuApi) implementation(projects.subprojects.shizukuManager) implementation(projects.subprojects.shizukuProvider) 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..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 @@ -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,44 @@ 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) + + buildServiceScope.launch { + ProjectManagerImpl.getInstance() + .indexingServiceManager + .onBuildCompleted() + } + + EventBus.getDefault() + .post( + BuildCompletedEvent( + result = result, + ) + ) } override fun onProgressEvent(event: ProgressEvent) { @@ -574,7 +596,7 @@ class GradleBuildService : message = TaskExecutionMessage( tasks = tasks, - buildId = nextBuildId(), + buildId = nextBuildId(BuildRunType.TaskRun), ), ) @@ -610,9 +632,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/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/composite-builds/build-deps/java-compiler/build.gradle.kts b/composite-builds/build-deps/java-compiler/build.gradle.kts index 3a7714abdb..03743a2d50 100644 --- a/composite-builds/build-deps/java-compiler/build.gradle.kts +++ b/composite-builds/build-deps/java-compiler/build.gradle.kts @@ -16,10 +16,15 @@ */ plugins { - id("java-library") + id("java-library") } java { - sourceCompatibility = JavaVersion.VERSION_1_8 - targetCompatibility = JavaVersion.VERSION_1_8 -} \ No newline at end of file + sourceCompatibility = JavaVersion.VERSION_1_8 + targetCompatibility = JavaVersion.VERSION_1_8 +} + +dependencies { + annotationProcessor(libs.google.auto.service) + implementation(libs.google.auto.service.annotations) +} diff --git a/composite-builds/build-deps/java-compiler/src/main/java/javac/internal/jrtfs/JrtFileSystemProvider.java b/composite-builds/build-deps/java-compiler/src/main/java/javac/internal/jrtfs/JrtFileSystemProvider.java index d122fbb0af..d48a738e5e 100644 --- a/composite-builds/build-deps/java-compiler/src/main/java/javac/internal/jrtfs/JrtFileSystemProvider.java +++ b/composite-builds/build-deps/java-compiler/src/main/java/javac/internal/jrtfs/JrtFileSystemProvider.java @@ -25,6 +25,8 @@ package javac.internal.jrtfs; +import com.google.auto.service.AutoService; + import java.io.*; import java.net.MalformedURLException; import java.net.URL; @@ -53,6 +55,7 @@ * but also compiled and delivered as part of the jrtfs.jar to support access * to the jimage file provided by the shipped JDK by tools running on JDK 8. */ +@AutoService(FileSystemProvider.class) public final class JrtFileSystemProvider extends FileSystemProvider { private volatile FileSystem theFileSystem; diff --git a/composite-builds/build-logic/desugaring/build.gradle.kts b/composite-builds/build-logic/desugaring/build.gradle.kts index bb17148415..1d5bf29482 100644 --- a/composite-builds/build-logic/desugaring/build.gradle.kts +++ b/composite-builds/build-logic/desugaring/build.gradle.kts @@ -1,3 +1,6 @@ +import org.jetbrains.kotlin.gradle.dsl.KotlinVersion +import org.jetbrains.kotlin.gradle.tasks.KotlinCompile + /* * This file is part of AndroidIDE. * @@ -16,26 +19,34 @@ */ plugins { - `kotlin-dsl` + `kotlin-dsl` } dependencies { - implementation(gradleApi()) - implementation(libs.composite.desugaringCore) + implementation(gradleApi()) + implementation(libs.composite.desugaringCore) - compileOnly(libs.android.gradle.plugin) + compileOnly(libs.android.gradle.plugin) - testImplementation(libs.tests.junit) - testImplementation(libs.tests.google.truth) + testImplementation(libs.tests.junit) + testImplementation(libs.tests.google.truth) } gradlePlugin { - plugins { - create("desugaring") { - id = "com.itsaky.androidide.desugaring" - implementationClass = "com.itsaky.androidide.desugaring.DesugarGradlePlugin" - displayName = "AndroidIDE Method Desugaring Plugin" - description = "Gradle plugin for method desugaring in Android projects." - } - } + plugins { + create("desugaring") { + id = "com.itsaky.androidide.desugaring" + implementationClass = "com.itsaky.androidide.desugaring.DesugarGradlePlugin" + displayName = "AndroidIDE Method Desugaring Plugin" + description = "Gradle plugin for method desugaring in Android projects." + } + } +} + +tasks.withType { + compilerOptions { + apiVersion.set(KotlinVersion.KOTLIN_2_1) + languageVersion.set(KotlinVersion.KOTLIN_2_1) + } } + 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 } diff --git a/composite-builds/build-logic/plugins/build.gradle.kts b/composite-builds/build-logic/plugins/build.gradle.kts index 409aac8403..f0a9d2e804 100644 --- a/composite-builds/build-logic/plugins/build.gradle.kts +++ b/composite-builds/build-logic/plugins/build.gradle.kts @@ -97,3 +97,12 @@ gradlePlugin { } } } + +tasks.withType { + compilerOptions { + apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) + + compilerOptions.freeCompilerArgs.add("-Xuse-fir-lt=false") + } +} 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/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/git-core/build.gradle.kts b/git-core/build.gradle.kts index 37bab43c9b..d3ae586bb8 100644 --- a/git-core/build.gradle.kts +++ b/git-core/build.gradle.kts @@ -1,49 +1,23 @@ plugins { - alias(libs.plugins.android.library) - alias(libs.plugins.kotlin.android) + alias(libs.plugins.android.library) + alias(libs.plugins.kotlin.android) } android { - namespace = "com.itsaky.androidide.git.core" - compileSdk = 35 - - defaultConfig { - minSdk = 27 - - testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner" - consumerProguardFiles("consumer-rules.pro") - } - - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - isCoreLibraryDesugaringEnabled = true - } - kotlinOptions { - jvmTarget = "17" - } + namespace = "com.itsaky.androidide.git.core" } dependencies { - implementation(libs.androidx.core.ktx.v1120) - implementation(libs.androidx.appcompat.v171) - implementation(libs.google.material) - implementation(libs.git.jgit) - coreLibraryDesugaring(libs.desugar.jdk.libs.v215) - implementation(libs.common.kotlin.coroutines.core) - implementation(libs.common.kotlin.coroutines.android) - implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.core.ktx.v1120) + implementation(libs.androidx.appcompat.v171) + implementation(libs.google.material) + implementation(libs.git.jgit) + coreLibraryDesugaring(libs.desugar.jdk.libs.v215) + implementation(libs.common.kotlin.coroutines.core) + implementation(libs.common.kotlin.coroutines.android) + implementation(libs.androidx.lifecycle.viewmodel.ktx) - testImplementation(libs.tests.junit) - androidTestImplementation(libs.tests.androidx.junit) - androidTestImplementation(libs.tests.androidx.espresso.core) + testImplementation(libs.tests.junit) + androidTestImplementation(libs.tests.androidx.junit) + androidTestImplementation(libs.tests.androidx.espresso.core) } diff --git a/gradle.properties b/gradle.properties index 925c52d5d5..a398853a74 100755 --- a/gradle.properties +++ b/gradle.properties @@ -1,31 +1,24 @@ -## For more details on how to configure your build environment visit -# http://www.gradle.org/docs/current/userguide/build_environment.html -# -# Specifies the JVM arguments used for the daemon process. -# The setting is particularly useful for tweaking memory settings. -# Default value: -Xmx1024m -XX:MaxPermSize=256m -# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 -# -# When configured, Gradle will run in incubating parallel mode. -# This option should only be used with decoupled projects. More details, visit -# http://www.gradle.org/docs/current/userguide/multi_project_builds.html#sec:decoupled_projects -# org.gradle.parallel=true -#Wed Feb 02 13:50:55 IST 2022 - -org.gradle.jvmargs=-Xmx8G -Dkotlin.daemon.jvm.options="-Xmx4096M" -XX:+HeapDumpOnOutOfMemoryError --add-opens java.base/java.lang=ALL-UNNAMED --add-opens java.base/java.util=ALL-UNNAMED --add-opens java.base/java.io=ALL-UNNAMED - -# Increase memory for aapt2 to prevent Java heap space issues during asset compression -android.aapt2.daemonHeapSize=8192M -# For CI builds, set worker max to limit memory usage -org.gradle.workers.max=2 -# Use less memory per worker during asset compression -org.gradle.vfs.watch=true -org.gradle.parallel=false -org.gradle.configureondemand=true -org.gradle.caching=true -android.useAndroidX=true -android.enableJetifier=false -android.jetifier.ignorelist=common-30.2.2.jar - -# TODO : Migrate -android.nonTransitiveRClass=false +## For more details on how to configure your build environment visit +# http://www.gradle.org/docs/current/userguide/build_environment.html +# +# Specifies the JVM arguments used for the daemon process. +# The setting is particularly useful for tweaking memory settings. +# Default value: -Xmx1024m -XX:MaxPermSize=256m +# org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8 +# +# When configured, Gradle will run in incubating parallel mode. +# This option should only be used with decoupled projects. For more details, visit +# https://developer.android.com/r/tools/gradle-multi-project-decoupled-projects +# org.gradle.parallel=true +#Fri Mar 13 16:37:43 IST 2026 +android.aapt2.daemonHeapSize=8192M +android.enableJetifier=false +android.jetifier.ignorelist=common-30.2.2.jar +android.nonTransitiveRClass=false +android.useAndroidX=true +org.gradle.caching=true +org.gradle.configureondemand=true +org.gradle.jvmargs=-Xmx8192M -Dkotlin.daemon.jvm.options\="-Xmx8192M" -XX\:+HeapDumpOnOutOfMemoryError --add-opens java.base/java.lang\=ALL-UNNAMED --add-opens java.base/java.util\=ALL-UNNAMED --add-opens java.base/java.io\=ALL-UNNAMED +org.gradle.parallel=true +org.gradle.vfs.watch=true +org.gradle.workers.max=30 diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 433a98d96f..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" @@ -20,7 +20,7 @@ gson = "2.10.1" junit-jupiter = "5.10.2" anroidx-test-core = "2.2.0" koinAndroid = "4.1.1" -kotlin = "2.1.21" +kotlin = "2.3.0" kotlin-coroutines = "1.9.0" kotlinxCoroutinesCore = "1.10.2" kotlinxSerializationJson = "1.9.0" @@ -35,7 +35,7 @@ editor = "0.23.6" glide = "4.16.0" androidx-vectordrawable = "1.2.0" androidx-navigation = "2.7.7" -ksp = "2.1.21-2.0.2" +ksp = "2.3.6" antlr4 = "4.13.1" androidx-work = "2.10.0" androidx-espresso = "3.5.1" @@ -43,7 +43,7 @@ retrofit = "2.11.0" markwon = "4.6.2" maven-publish-plugin = "0.27.0" logback = "1.5.3" -room = "2.7.2" +room = "2.8.4" utilcodex = "1.31.1" viewpager2 = "1.1.0-beta02" zoomage = "1.3.1" @@ -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" } @@ -239,8 +239,8 @@ androidx-work-ktx = { module = "androidx.work:work-runtime-ktx", version.ref = " google-material = { module = "com.google.android.material:material", version = "1.12.0" } google-gson = { module = "com.google.code.gson:gson", version = "2.12.1" } google-guava = { module = "com.google.guava:guava", version = "33.4.0-android" } -google-auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version = "1.10.4" } -google-auto-value-ap = { module = "com.google.auto.value:auto-value", version = "1.10.4" } +google-auto-value-annotations = { module = "com.google.auto.value:auto-value-annotations", version = "1.11.0" } +google-auto-value-ap = { module = "com.google.auto.value:auto-value", version = "1.11.0" } google-auto-service-annotations = { module = "com.google.auto.service:auto-service-annotations", version = "1.1.1" } google-auto-service = { module = "com.google.auto.service:auto-service", version = "1.1.1" } google-protobuf-java = { module = "com.google.protobuf:protobuf-javalite", version.ref = "protobuf" } @@ -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/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index c0cb293e89..692c2dc230 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.10.2-all.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.4-all.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/llama-impl/build.gradle.kts b/llama-impl/build.gradle.kts index 6a36589b76..0b1439bcc4 100644 --- a/llama-impl/build.gradle.kts +++ b/llama-impl/build.gradle.kts @@ -1,68 +1,58 @@ plugins { - id("com.android.library") - id("org.jetbrains.kotlin.android") + id("com.android.library") + id("org.jetbrains.kotlin.android") } android { - namespace = "android.llama.cpp" - compileSdk = 35 + namespace = "android.llama.cpp" - defaultConfig { - minSdk = 33 - consumerProguardFiles("proguard-rules.pro") - ndk { - // Add NDK properties if wanted, e.g. - // abiFilters += listOf("arm64-v8a") - } - externalNativeBuild { - cmake { - arguments += "-DLLAMA_CURL=OFF" - arguments += "-DLLAMA_BUILD_COMMON=ON" - arguments += "-DGGML_LLAMAFILE=OFF" - arguments += "-DCMAKE_BUILD_TYPE=Release" - cppFlags += listOf() - arguments += listOf() + defaultConfig { + minSdk = 33 + consumerProguardFiles("proguard-rules.pro") + ndk { + // Add NDK properties if wanted, e.g. + // abiFilters += listOf("arm64-v8a") + } + externalNativeBuild { + cmake { + arguments += "-DLLAMA_CURL=OFF" + arguments += "-DLLAMA_BUILD_COMMON=ON" + arguments += "-DGGML_LLAMAFILE=OFF" + arguments += "-DCMAKE_BUILD_TYPE=Release" + cppFlags += listOf() + arguments += listOf() - cppFlags("") - } - } - } + cppFlags("") + } + } + } - buildTypes { - release { - isMinifyEnabled = false - proguardFiles( - getDefaultProguardFile("proguard-android-optimize.txt"), - "proguard-rules.pro" - ) - } - } - externalNativeBuild { - cmake { - path("src/main/cpp/CMakeLists.txt") - version = "3.22.1" - } - } - compileOptions { - // It's fine for the library to be compiled with modern features - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - kotlinOptions { - jvmTarget = "1.8" - } + buildTypes { + release { + isMinifyEnabled = false + proguardFiles( + getDefaultProguardFile("proguard-android-optimize.txt"), + "proguard-rules.pro", + ) + } + } + externalNativeBuild { + cmake { + path("src/main/cpp/CMakeLists.txt") + version = "3.22.1" + } + } - packaging { - resources { - excludes += "/META-INF/{AL2.0,LGPL2.1}" - } - } + packaging { + resources { + excludes += "/META-INF/{AL2.0,LGPL2.1}" + } + } } dependencies { - implementation(project(":llama-api")) - implementation(libs.androidx.core.ktx.v1120) - implementation(libs.androidx.appcompat.v171) - implementation(libs.tooling.slf4j) - + implementation(project(":llama-api")) + implementation(libs.androidx.core.ktx.v1120) + implementation(libs.androidx.appcompat.v171) + implementation(libs.tooling.slf4j) } 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/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/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/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..a85c2c271e --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmIndexingService.kt @@ -0,0 +1,146 @@ +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.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.extension + +/** + * 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_LIBRARY_SYMBOL_INDEX = IndexKey("jvm-library-symbols") + +/** + * [IndexingService] that scans classpath JARs/AARs and builds + * a [JvmLibrarySymbolIndex]. + * + * 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. + */ +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_LIBRARY_SYMBOL_INDEX) + + private var index: JvmLibrarySymbolIndex? = 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 + registry.register(JVM_LIBRARY_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/JvmLibrarySymbolIndex.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt new file mode 100644 index 0000000000..ec52e5d633 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmLibrarySymbolIndex.kt @@ -0,0 +1,166 @@ +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.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 + +/** + * An index of symbols from external Java libraries (JARs). + */ +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, + + /** 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 = PersistentIndex( + 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/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..4d34d1b55d --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/JvmSymbolDescriptor.kt @@ -0,0 +1,416 @@ +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 { + + 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 = 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( + 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 = + 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/KotlinMetadataScanner.kt b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt new file mode 100644 index 0000000000..691d50dd25 --- /dev/null +++ b/lsp/jvm-symbol-index/src/main/kotlin/org/appdevforall/codeonthego/indexing/jvm/KotlinMetadataScanner.kt @@ -0,0 +1,433 @@ +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) { + "mv" -> { + if (value is IntArray) { + metadataVersion = value.copyOf() + } + } + "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-core/build.gradle.kts b/lsp/kotlin-core/build.gradle.kts index df2bb81208..29489689fc 100644 --- a/lsp/kotlin-core/build.gradle.kts +++ b/lsp/kotlin-core/build.gradle.kts @@ -23,11 +23,11 @@ plugins { android { namespace = "org.appdevforall.codeonthego.lsp.kotlin" +} - kotlinOptions { - freeCompilerArgs += listOf( - "-opt-in=kotlin.contracts.ExperimentalContracts" - ) +kotlin { + compilerOptions { + freeCompilerArgs.add("-opt-in=kotlin.contracts.ExperimentalContracts") } } diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 9de97e6c31..54c0fa6206 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,15 +36,16 @@ kapt { dependencies { kapt(projects.annotationProcessors) - implementation(projects.lsp.kotlinCore) implementation(projects.lsp.api) + implementation(projects.lsp.jvmSymbolIndex) implementation(projects.lsp.models) + implementation(projects.editorApi) implementation(projects.eventbusEvents) + implementation(projects.subprojects.kotlinAnalysisApi) implementation(projects.shared) 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/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/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..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 @@ -17,17 +17,20 @@ package com.itsaky.androidide.lsp.kotlin -import androidx.core.net.toUri -import com.itsaky.androidide.eventbus.events.editor.ChangeType +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 +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.adapters.toIde -import com.itsaky.androidide.lsp.kotlin.adapters.toLsp4j +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 import com.itsaky.androidide.lsp.models.DefinitionParams @@ -39,440 +42,293 @@ 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.FileManager +import com.itsaky.androidide.projects.ProjectManagerImpl 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 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.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.appdevforall.codeonthego.indexing.jvm.JvmIndexingService import org.greenrobot.eventbus.EventBus import org.greenrobot.eventbus.Subscribe import org.greenrobot.eventbus.ThreadMode +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 { - private val ktLspServer = KtLspServer() - private var clientBridge: KotlinLanguageClientBridge? = null - private var _client: ILanguageClient? = null - private var _settings: IServerSettings? = null - private var selectedFile: Path? = null - private var initialized = false - - override val serverId: String = SERVER_ID - - override val client: ILanguageClient? - get() = _client - - val settings: IServerSettings - get() = _settings ?: KotlinServerSettings.getInstance().also { _settings = it } - - 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) - } - } - - 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?) { - 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 - } - } - - override suspend fun findReferences(params: ReferenceParams): ReferenceResult { - if (!settings.referencesEnabled()) { - return ReferenceResult(emptyList()) - } - - 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 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()) - } - } - - override suspend fun findDefinition(params: DefinitionParams): DefinitionResult { - if (!settings.definitionsEnabled()) { - return DefinitionResult(emptyList()) - } - - 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 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()) - } - } - - override suspend fun expandSelection(params: ExpandSelectionParams): Range { - return params.selection - } - - override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp { - if (!settings.signatureHelpEnabled()) { - return SignatureHelp(emptyList(), -1, -1) - } - - 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 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) - } - } - - override suspend fun analyze(file: Path): DiagnosticResult { - log.debug("analyze() called for 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 - } - - 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) }) - } - - @Subscribe(threadMode = ThreadMode.ASYNC) - @Suppress("unused") - fun onDocumentOpen(event: DocumentOpenEvent) { - if (!DocumentUtils.isKotlinFile(event.openedFile)) { - return - } - - 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") - fun onDocumentChange(event: DocumentChangeEvent) { - 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) - @Suppress("unused") - fun onDocumentClose(event: DocumentCloseEvent) { - 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) - @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) - - 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) - } - } - } - } + private var _client: ILanguageClient? = null + private var _settings: IServerSettings? = null + private var selectedFile: Path? = null + private var initialized = false + + private val scope = + CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!)) + private var projectModel: KotlinProjectModel? = null + private var compiler: Compiler? = null + private var analyzeJob: Job? = null + + override val serverId: String = SERVER_ID + + override val client: ILanguageClient? + get() = _client + + val settings: IServerSettings + 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 logger = LoggerFactory.getLogger(KotlinLanguageServer::class.java) + } + + init { + applySettings(KotlinServerSettings.getInstance()) + + if (!EventBus.getDefault().isRegistered(this)) { + EventBus.getDefault().register(this) + } + } + + override fun shutdown() { + EventBus.getDefault().unregister(this) + scope.cancel("LSP is being shut down") + compiler?.close() + initialized = false + } + + override fun connectClient(client: ILanguageClient?) { + this._client = client + } + + override fun applySettings(settings: IServerSettings?) { + this._settings = settings + } + + 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( + BaseApplication + .baseInstance.applicationInfo.sourceDir + ) + + val jvmTarget = JvmTarget.fromString(IJdkDistributionProvider.DEFAULT_JAVA_VERSION) + ?: JvmTarget.JVM_21 + + val jvmPlatform = JvmPlatforms.jvmPlatformByTargetVersion(jvmTarget) + + 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 + } else { + logger.info("Updating project model") + + projectModel?.update(workspace, jvmPlatform) + } + + initialized = true + logger.info("Kotlin project initialized") + } + + override fun complete(params: CompletionParams?): CompletionResult { + 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 { + if (!settings.referencesEnabled()) { + return ReferenceResult.empty() + } + + if (!DocumentUtils.isKotlinFile(params.file)) { + return ReferenceResult.empty() + } + + return ReferenceResult.empty() + } + + override suspend fun findDefinition(params: DefinitionParams): DefinitionResult { + if (!settings.definitionsEnabled()) { + 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 signatureHelp(params: SignatureHelpParams): SignatureHelp { + if (!settings.signatureHelpEnabled()) { + return SignatureHelp.empty() + } + + if (!DocumentUtils.isKotlinFile(params.file)) { + return SignatureHelp.empty() + } + + return SignatureHelp.empty() + } + + override suspend fun analyze(file: Path): DiagnosticResult { + logger.debug("analyze(file={})", file) + + if (!settings.diagnosticsEnabled() || !settings.codeAnalysisEnabled()) { + logger.debug( + "analyze() skipped: diagnosticsEnabled={}, codeAnalysisEnabled={}", + settings.diagnosticsEnabled(), settings.codeAnalysisEnabled() + ) + return DiagnosticResult.NO_UPDATE + } + + if (!DocumentUtils.isKotlinFile(file)) { + logger.debug("analyze() skipped: not a Kotlin file") + return DiagnosticResult.NO_UPDATE + } + + return compiler?.compilationEnvironmentFor(file)?.collectDiagnosticsFor(file) + ?: DiagnosticResult.NO_UPDATE + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("unused") + fun onDocumentOpen(event: DocumentOpenEvent) { + if (!DocumentUtils.isKotlinFile(event.openedFile)) { + return + } + + compiler?.compilationEnvironmentFor(event.openedFile)?.apply { + val content = FileManager.getDocumentContents(event.openedFile) + fileManager.onFileOpened(event.openedFile, content) + } + + 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) + @Suppress("unused") + fun onDocumentChange(event: DocumentChangeEvent) { + if (!DocumentUtils.isKotlinFile(event.changedFile)) { + return + } + + compiler?.compilationEnvironmentFor(event.changedFile)?.apply { + val content = FileManager.getDocumentContents(event.changedFile) + fileManager.onFileContentChanged(event.changedFile, content) + } + + debouncingAnalyze() + } + + @Subscribe(threadMode = ThreadMode.ASYNC) + @Suppress("unused") + fun onDocumentClose(event: DocumentCloseEvent) { + if (!DocumentUtils.isKotlinFile(event.closedFile)) { + return + } + + 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) { + if (!DocumentUtils.isKotlinFile(event.selectedFile)) { + return + } + + selectedFile = event.selectedFile + val uri = event.selectedFile.toUri().toString() + + logger.debug("onDocumentSelected: uri={}", uri) + } } 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/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/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/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..6bb19535d9 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -0,0 +1,255 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +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 +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.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 +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.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 +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]. + */ +internal class CompilationEnvironment( + 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 + + var fileManager: KtFileManager + private set + + val psiManager: PsiManager + get() = PsiManager.getInstance(session.project) + + val psiDocumentManager: PsiDocumentManager + get() = PsiDocumentManager.getInstance(session.project) + + val modificationTrackerFactory: KotlinModificationTrackerFactory + get() = session.project.getService(KotlinModificationTrackerFactory::class.java) + + 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() { + } + + 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 { + session = buildSession() + parser = KtPsiFactory(session.project, eventSystemEnabled = enableParserEventSystem) + fileManager = KtFileManager(parser, psiManager, psiDocumentManager) + + 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 = this@CompilationEnvironment.intellijPluginRoot.pathString + this.languageVersionSettings = LanguageVersionSettingsImpl( + languageVersion = this@CompilationEnvironment.languageVersion, + apiVersion = ApiVersion.createByLanguageVersion(this@CompilationEnvironment.languageVersion), + analysisFlags = emptyMap(), + specificFeatures = LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED } + ) + + this.jdkHome = this@CompilationEnvironment.jdkHome.toFile() + this.jdkRelease = this@CompilationEnvironment.jdkRelease + + this.messageCollector = this@CompilationEnvironment.envMessageCollector + } + } + + 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() + } + + 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() { + fileManager.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/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..48bde185b6 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -0,0 +1,98 @@ +package com.itsaky.androidide.lsp.kotlin.compiler + +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 +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 +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 +import kotlin.io.path.pathString + +internal class Compiler( + projectModel: KotlinProjectModel, + intellijPluginRoot: Path, + jdkHome: Path, + jdkRelease: Int, + languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, +) : AutoCloseable { + private val logger = LoggerFactory.getLogger(Compiler::class.java) + + @Suppress("JoinDeclarationAndAssignment") + private val defaultCompilationEnv: CompilationEnvironment + + val fileSystem: VirtualFileSystem + + val defaultKotlinParser: KtPsiFactory + get() = defaultCompilationEnv.parser + + init { + defaultCompilationEnv = CompilationEnvironment( + projectModel = projectModel, + intellijPluginRoot = intellijPluginRoot, + jdkHome = jdkHome, + jdkRelease = jdkRelease, + languageVersion = languageVersion, + enableParserEventSystem = true, + ) + + // must be initialized AFTER the compilation env has been initialized + 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 = + 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/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..06bd635704 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/KotlinProjectModel.kt @@ -0,0 +1,187 @@ +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.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]. + * + * 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. + */ +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. + */ + 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 + .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() + + 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 + return module + } + + moduleProjects.forEach { addModule(getOrCreateModule(it)) } + + val moduleResolver = ModuleResolver(jarMap = jarToModMap) + _moduleResolver = moduleResolver + _symbolVisibilityChecker = SymbolVisibilityChecker(moduleResolver) + } + } + + 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/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/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/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/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..ca003ee1c8 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -0,0 +1,456 @@ +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.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 +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.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 + +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. + */ +internal 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( + psiFactory = parser, + content = textWithPlaceholder + ) + + return try { + analyzeCopy( + 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( + "Unable to determine context at offset {} in file {}", + completionOffset, + params.file + ) + return@analyzeCopy CompletionResult.EMPTY + } + + val ( + psiElement, + _, + ktElement, + scopeContext, + compositeScope, + completionContext + ) = cursorContext + + val items = mutableListOf() + + when (completionContext) { + CompletionContext.Scope -> + collectScopeCompletions( + scopeContext = scopeContext, + scope = compositeScope, + symbolVisibilityChecker = symbolVisibilityChecker, + ktElement = ktElement, + partial = partial, + to = items + ) + + CompletionContext.Member -> + collectMemberCompletions( + scope = compositeScope, + element = psiElement, + partial = partial, + to = items + ) + } + + collectKeywordCompletions( + ctx = cursorContext, + 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( + scope: KaScope, + 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 + } + + 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) +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) } + .map { it.symbol } + + val classifiers = + typeScope.getClassifierSymbols { name -> matchesPrefix(name, partial) } + + to += toCompletionItems(callables, partial) + to += toCompletionItems(classifiers, 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 + + 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.collectExtensionFunctions( + scope: KaScope, + partial: String, + receiverType: KaType, + to: MutableList +) { + 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( + scopeContext: KaScopeContext, + scope: KaScope, + symbolVisibilityChecker: SymbolVisibilityChecker, + ktElement: KtElement, + partial: String, + to: MutableList, +) { + logger.info( + "Complete scope members of {}: [{}] matching '{}'", + ktElement, + ktElement.text, + 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) +} + +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, + partial: String +): Sequence = + callables.mapNotNull { + callableSymbolToCompletionItem(it, partial) + } + +@JvmName("classifiersToCompletionItems") +private fun KaSession.toCompletionItems( + classifiers: Sequence, + partial: String +): Sequence = + classifiers.mapNotNull { + classifierSymbolToCompletionItem(it, partial) + } + +@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? { + 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 = kind + item.matchLevel = CompletionItem.matchLevel(item.ideLabel, 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 { + 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/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 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 new file mode 100644 index 0000000000..4472c1a652 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -0,0 +1,104 @@ +package com.itsaky.androidide.lsp.kotlin.diagnostic + +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 kotlinx.coroutines.CancellationException +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.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 + +private val logger = LoggerFactory.getLogger("KotlinDiagnosticProvider") + +internal 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 +} + +@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 + } + + val analyzedAt = managed.analyzeTimestamp + val modifiedAt = FileManager.getLastModified(file) + if (analyzedAt > modifiedAt.toKotlinInstant()) { + logger.debug("Skipping analysis. File unmodified.") + return DiagnosticResult.NO_UPDATE + } + + val rawDiagnostics = managed.analyze { ktFile -> + ktFile.collectDiagnostics(filter = KaDiagnosticCheckerFilter.ONLY_COMMON_CHECKERS) + } + + logger.info("Found {} diagnostics", rawDiagnostics.size) + + return DiagnosticResult( + file = file, diagnostics = rawDiagnostics.map { rawDiagnostic -> + rawDiagnostic.toDiagnosticItem() + }).also { + managed.analyzeTimestamp = Clock.System.now() + } +} + +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, + ) + ) +} 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, diff --git a/plugin-api/build.gradle.kts b/plugin-api/build.gradle.kts index 7adab325d9..878e7d7f44 100644 --- a/plugin-api/build.gradle.kts +++ b/plugin-api/build.gradle.kts @@ -1,39 +1,23 @@ - - plugins { - id("com.android.library") - id("kotlin-android") - id("kotlin-parcelize") + id("com.android.library") + id("kotlin-android") + id("kotlin-parcelize") } android { - namespace = "com.itsaky.androidide.plugins.api" - compileSdk = 35 - - defaultConfig { - minSdk = 28 - } - - compileOptions { - sourceCompatibility = JavaVersion.VERSION_17 - targetCompatibility = JavaVersion.VERSION_17 - } - - kotlinOptions { - jvmTarget = "17" - } + namespace = "com.itsaky.androidide.plugins.api" } dependencies { - // Only include Android context for basic Android functionality - compileOnly("androidx.appcompat:appcompat:1.6.1") - compileOnly("androidx.fragment:fragment-ktx:1.6.2") - compileOnly("com.google.android.material:material:1.11.0") + // Only include Android context for basic Android functionality + compileOnly("androidx.appcompat:appcompat:1.6.1") + compileOnly("androidx.fragment:fragment-ktx:1.6.2") + compileOnly("com.google.android.material:material:1.11.0") } tasks.register("createPluginApiJar") { - dependsOn("assembleRelease") - from(layout.buildDirectory.file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar")) - into(layout.buildDirectory.dir("libs")) - rename { "plugin-api-1.0.0.jar" } -} \ No newline at end of file + dependsOn("assembleRelease") + from(layout.buildDirectory.file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar")) + into(layout.buildDirectory.dir("libs")) + rename { "plugin-api-1.0.0.jar" } +} diff --git a/plugin-manager/build.gradle.kts b/plugin-manager/build.gradle.kts index 4def49e6da..278fd96d41 100644 --- a/plugin-manager/build.gradle.kts +++ b/plugin-manager/build.gradle.kts @@ -1,48 +1,31 @@ - - import com.itsaky.androidide.build.config.BuildConfig plugins { - id("com.android.library") - id("kotlin-android") + id("com.android.library") + id("kotlin-android") } android { - namespace = "${BuildConfig.PACKAGE_NAME}.plugins.manager" - - compileSdk = BuildConfig.COMPILE_SDK - - defaultConfig { - minSdk = BuildConfig.MIN_SDK - } - - compileOptions { - sourceCompatibility = BuildConfig.JAVA_VERSION - targetCompatibility = BuildConfig.JAVA_VERSION - } + namespace = "${BuildConfig.PACKAGE_NAME}.plugins.manager" - kotlinOptions { - jvmTarget = BuildConfig.JAVA_VERSION.toString() - } - - lint { - abortOnError = false - } + lint { + abortOnError = false + } } dependencies { - api(projects.pluginApi) - - implementation(projects.actions) - implementation(projects.common) - implementation(projects.logger) - implementation(projects.lookup) - implementation(projects.preferences) - implementation(projects.resources) - implementation(projects.idetooltips) - implementation(projects.shared) - implementation(projects.subprojects.projects) - - implementation(libs.androidx.appcompat) - implementation(libs.gson.v2101) -} \ No newline at end of file + api(projects.pluginApi) + + implementation(projects.actions) + implementation(projects.common) + implementation(projects.logger) + implementation(projects.lookup) + implementation(projects.preferences) + implementation(projects.resources) + implementation(projects.idetooltips) + implementation(projects.shared) + implementation(projects.subprojects.projects) + + implementation(libs.androidx.appcompat) + implementation(libs.gson.v2101) +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 247903307d..6165b7a722 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", @@ -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", @@ -155,6 +158,7 @@ include( ":subprojects:flashbar", ":subprojects:framework-stubs", ":subprojects:javac-services", + ":subprojects:kotlin-analysis-api", ":subprojects:libjdwp", ":subprojects:projects", ":subprojects:project-models", @@ -185,12 +189,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..2e4f08710f --- /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}-f047b07" +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 = "c9897c94ae1431fadeb4fa5b05dd4d478a60c4589f38f801e07c72405a7b34b1", + ) + } +} 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 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() } 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. 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() + } } 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"),