From 437d25122ada84399523fa7717cd6008c6fb0169 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 4 Oct 2026 15:10:33 +0100 Subject: [PATCH 1/8] style: spotless reformat, no functional change --- .../itsaky/androidide/handlers/LspHandler.kt | 4 +- .../language/treesitter/TSLanguageRegistry.kt | 21 +- .../treesitter/TreeSitterLanguageSpec.kt | 61 +-- .../internal/TSLanguageRegistryImpl.kt | 18 +- .../editor/schemes/LanguageSpecProvider.kt | 93 ++-- .../schemes/LocalCaptureSpecProvider.kt | 60 +-- .../lsp/api/DefaultLanguageServerRegistry.kt | 18 +- .../manager/services/IdeCommandServiceImpl.kt | 466 +++++++++--------- 8 files changed, 372 insertions(+), 369 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt index 4c8df0ccea..5633691432 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt @@ -30,7 +30,6 @@ import com.itsaky.androidide.utils.FeatureFlags * @author Akash Yadav */ object LspHandler { - fun registerLanguageServers() { ILanguageServerRegistry.default.apply { getServer(JavaLanguageServer.SERVER_ID) ?: register(JavaLanguageServer()) @@ -44,8 +43,7 @@ object LspHandler { } @Throws(Throwable::class) - suspend fun connectDebugClient(client: IDebugClient) = - ILanguageServerRegistry.default.connectDebugClient(client) + suspend fun connectDebugClient(client: IDebugClient) = ILanguageServerRegistry.default.connectDebugClient(client) fun destroyLanguageServers(isConfigurationChange: Boolean) { if (isConfigurationChange) { diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt index 6753f53002..655d2c7d16 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt @@ -25,9 +25,7 @@ import com.itsaky.androidide.editor.language.treesitter.internal.TSLanguageRegis * @author Akash Yadav */ interface TSLanguageRegistry { - companion object { - @JvmStatic val instance by lazy { TSLanguageRegistryImpl() } } @@ -41,7 +39,7 @@ interface TSLanguageRegistry { */ fun registerIfNeeded( fileType: String, - factory: TreeSitterLanguage.Factory + factory: TreeSitterLanguage.Factory, ): Boolean /** @@ -52,7 +50,10 @@ interface TSLanguageRegistry { * @throws AlreadyRegisteredException If an instance of [TreeSitterLanguage.Factory] is already * registered for the given file type. */ - fun register(fileType: String, factory: TreeSitterLanguage.Factory) + fun register( + fileType: String, + factory: TreeSitterLanguage.Factory, + ) /** * Checks whether a [TreeSitterLanguage] has been registered for the given [file type][fileType]. @@ -77,11 +78,13 @@ interface TSLanguageRegistry { */ fun destroy() - class AlreadyRegisteredException(type: String) : - IllegalStateException( - "An instance of TreeSitterLanguage.Factory is already registered for file type '$type'" + class AlreadyRegisteredException( + type: String, + ) : IllegalStateException( + "An instance of TreeSitterLanguage.Factory is already registered for file type '$type'", ) - class NotRegisteredException(type: String) : - RuntimeException("No TreeSitterLanguage.Factory registered for file type '$type'") + class NotRegisteredException( + type: String, + ) : RuntimeException("No TreeSitterLanguage.Factory registered for file type '$type'") } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt index dc89e349d5..1ef0a3f1c9 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt @@ -29,39 +29,40 @@ import java.io.Closeable * @author Akash Yadav */ class TreeSitterLanguageSpec -@JvmOverloads constructor( - val spec: TsLanguageSpec, - indentsQueryScm: String = "" -) : Closeable { + @JvmOverloads + constructor( + val spec: TsLanguageSpec, + indentsQueryScm: String = "", + ) : Closeable { + // + val language: TSLanguage + get() = spec.language + // - // - val language: TSLanguage - get() = spec.language - // + val indentsQuery: TSQuery? = + if (indentsQueryScm.isBlank()) { + TSQuery.EMPTY + } else { + TSQuery + .create(language, indentsQueryScm) + .let { if (it.canAccess()) it else null } + } - val indentsQuery: TSQuery? = if (indentsQueryScm.isBlank()) { - TSQuery.EMPTY - } else { - TSQuery.create(language, indentsQueryScm) - .let { if (it.canAccess()) it else null } - } + init { + indentsQuery?.validateOrThrow(name = "indents") + } - init { - indentsQuery?.validateOrThrow(name = "indents") - } - - override fun close() { - indentsQuery?.close() - if (spec.language.isExternal) { - spec.language.close() - } - spec.close() - } -} + override fun close() { + indentsQuery?.close() + if (spec.language.isExternal) { + spec.language.close() + } + spec.close() + } + } private fun TSQuery.validateOrThrow(name: String) { - if (errorType != TSQueryError.None) { - throw IllegalArgumentException( - "query(name:$name) parsing failed: ${errorType.name} at text offset $errorOffset") - } + if (errorType != TSQueryError.None) { + throw IllegalArgumentException("query(name:$name) parsing failed: ${errorType.name} at text offset $errorOffset") + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt index 56e1f9ce01..70c77a523e 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt @@ -27,13 +27,12 @@ import java.util.concurrent.ConcurrentHashMap * @author Akash Yadav */ class TSLanguageRegistryImpl : TSLanguageRegistry { - private val registry = ConcurrentHashMap>() override fun registerIfNeeded( fileType: String, - factory: TreeSitterLanguage.Factory + factory: TreeSitterLanguage.Factory, ): Boolean { if (hasLanguage(fileType)) return false register(fileType, factory) @@ -42,7 +41,7 @@ class TSLanguageRegistryImpl : TSLanguageRegistry { override fun register( fileType: String, - factory: TreeSitterLanguage.Factory + factory: TreeSitterLanguage.Factory, ) { val older = registry.put(fileType, factory) if (older != null) { @@ -51,17 +50,12 @@ class TSLanguageRegistryImpl : TSLanguageRegistry { } } - override fun hasLanguage(fileType: String): Boolean { - return registry.containsKey(fileType) - } + override fun hasLanguage(fileType: String): Boolean = registry.containsKey(fileType) @Suppress("UNCHECKED_CAST") - override fun getFactory( - fileType: String - ): TreeSitterLanguage.Factory { - return (registry[fileType] ?: throw TSLanguageRegistry.NotRegisteredException(fileType)) - as TreeSitterLanguage.Factory - } + override fun getFactory(fileType: String): TreeSitterLanguage.Factory = + (registry[fileType] ?: throw TSLanguageRegistry.NotRegisteredException(fileType)) + as TreeSitterLanguage.Factory override fun destroy() { registry.clear() diff --git a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt index 79aadfe4fa..ba6e6ce6c1 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt @@ -36,50 +36,55 @@ import java.io.FileNotFoundException * @author Akash Yadav */ object LanguageSpecProvider { + private const val BASE_SPEC_PATH = "editor/treesitter" + private val log = LoggerFactory.getLogger(LanguageSpecProvider::class.java) - private const val BASE_SPEC_PATH = "editor/treesitter" - private val log = LoggerFactory.getLogger(LanguageSpecProvider::class.java) + @JvmStatic + @JvmOverloads + fun getLanguageSpec( + context: Context, + type: String, + lang: TSLanguage, + localsCaptureSpec: LocalsCaptureSpec = LocalsCaptureSpec.DEFAULT, + ): TreeSitterLanguageSpec { + val editorLangSpec = + TsLanguageSpec( + language = lang, + highlightScmSource = readScheme(context, type, "highlights"), + localsScmSource = readScheme(context, type, "locals"), + codeBlocksScmSource = readScheme(context, type, "blocks"), + bracketsScmSource = readScheme(context, type, "brackets"), + localsCaptureSpec = localsCaptureSpec, + predicates = + listOf( + MatchPredicate, + NotMatchPredicate, + EqualPredicate, + NotEqualPredicate, + AnyOfPredicate, + ), + ) + return TreeSitterLanguageSpec( + spec = editorLangSpec, + indentsQueryScm = readScheme(context, type, "indents"), + ) + } - @JvmStatic - @JvmOverloads - fun getLanguageSpec( - context: Context, - type: String, - lang: TSLanguage, - localsCaptureSpec: LocalsCaptureSpec = LocalsCaptureSpec.DEFAULT - ): TreeSitterLanguageSpec { - val editorLangSpec = - TsLanguageSpec( - language = lang, - highlightScmSource = readScheme(context, type, "highlights"), - localsScmSource = readScheme(context, type, "locals"), - codeBlocksScmSource = readScheme(context, type, "blocks"), - bracketsScmSource = readScheme(context, type, "brackets"), - localsCaptureSpec = localsCaptureSpec, - predicates = - listOf( - MatchPredicate, - NotMatchPredicate, - EqualPredicate, - NotEqualPredicate, - AnyOfPredicate - ) - ) - return TreeSitterLanguageSpec( - spec = editorLangSpec, - indentsQueryScm = readScheme(context, type, "indents") - ) - } - - private fun readScheme(context: Context, type: String, name: String): String { - return try { - context.assets.open("${BASE_SPEC_PATH}/${type}/${name}.scm").reader().readText() - } catch (e: Exception) { - if (e !is FileNotFoundException) { - // log everything except FileNotFoundException - log.error("Failed to read scheme file {} for type {}", name, type, e) - } - "" - } - } + private fun readScheme( + context: Context, + type: String, + name: String, + ): String = + try { + context.assets + .open("${BASE_SPEC_PATH}/$type/$name.scm") + .reader() + .readText() + } catch (e: Exception) { + if (e !is FileNotFoundException) { + // log everything except FileNotFoundException + log.error("Failed to read scheme file {} for type {}", name, type, e) + } + "" + } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt index d9e9c49150..d594539b6d 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt @@ -26,40 +26,28 @@ import org.slf4j.LoggerFactory * @author Akash Yadav */ object LocalCaptureSpecProvider { - - private val log = LoggerFactory.getLogger(LocalCaptureSpecProvider::class.java) - - @JvmStatic - fun newLocalCaptureSpec(type: String): LocalsCaptureSpec { - val lang = - IDEColorSchemeProvider.getColorSchemeForType(type)?.languages?.get(type) - ?: run { - log.error( - "Cannot create LocalsCaptureSpec. Failed to load current color scheme. Falling back to default implementation" - ) - return LocalsCaptureSpec.DEFAULT - } - return object : LocalsCaptureSpec() { - - override fun isDefinitionCapture(captureName: String): Boolean { - return lang.isLocalDef(captureName) - } - - override fun isDefinitionValueCapture(captureName: String): Boolean { - return lang.isLocalDefVal(captureName) - } - - override fun isReferenceCapture(captureName: String): Boolean { - return lang.isLocalRef(captureName) - } - - override fun isScopeCapture(captureName: String): Boolean { - return lang.isLocalScope(captureName) - } - - override fun isMembersScopeCapture(captureName: String): Boolean { - return lang.isMembersScope(captureName) - } - } - } + private val log = LoggerFactory.getLogger(LocalCaptureSpecProvider::class.java) + + @JvmStatic + fun newLocalCaptureSpec(type: String): LocalsCaptureSpec { + val lang = + IDEColorSchemeProvider.getColorSchemeForType(type)?.languages?.get(type) + ?: run { + log.error( + "Cannot create LocalsCaptureSpec. Failed to load current color scheme. Falling back to default implementation", + ) + return LocalsCaptureSpec.DEFAULT + } + return object : LocalsCaptureSpec() { + override fun isDefinitionCapture(captureName: String): Boolean = lang.isLocalDef(captureName) + + override fun isDefinitionValueCapture(captureName: String): Boolean = lang.isLocalDefVal(captureName) + + override fun isReferenceCapture(captureName: String): Boolean = lang.isLocalRef(captureName) + + override fun isScopeCapture(captureName: String): Boolean = lang.isLocalScope(captureName) + + override fun isMembersScopeCapture(captureName: String): Boolean = lang.isMembersScope(captureName) + } + } } diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt index 159ecb5e5c..279886428b 100644 --- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt @@ -33,7 +33,7 @@ import kotlin.concurrent.withLock /** * Thread-safe implementation of [ILanguageServerRegistry]. - * + * * @author Akash Yadav */ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { @@ -55,9 +55,10 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { @Throws(Throwable::class) override suspend fun connectDebugClient(client: IDebugClient): Map { Objects.requireNonNull(client) - val servers = lock.readLock().withLock { - mRegister.values.toList() - } + val servers = + lock.readLock().withLock { + mRegister.values.toList() + } return buildMap { for (server in servers) { @@ -71,7 +72,7 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { sLogger.error( "Unable to connect LSP server '{}' to debug client", server.serverId, - e + e, ) this[server.serverId] = DebugClientConnectionResult.Failure(cause = e) @@ -136,9 +137,10 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { } override fun unregister(serverId: String) { - val registered = lock.writeLock().withLock { - mRegister.remove(serverId) - } + val registered = + lock.writeLock().withLock { + mRegister.remove(serverId) + } checkNotNull(registered) { "No server found for the given server ID" } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt index 3d8cbbb9ff..2beb041cbf 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt @@ -25,235 +25,247 @@ import java.util.UUID import java.util.concurrent.ConcurrentHashMap class IdeCommandServiceImpl( - private val pluginId: String, - private val permissions: Set, - private val projectRootProvider: () -> File?, - private val appFilesDir: File + private val pluginId: String, + private val permissions: Set, + private val projectRootProvider: () -> File?, + private val appFilesDir: File, ) : IdeCommandService { - - private val runningCommands = ConcurrentHashMap() - - override fun executeCommand(spec: CommandSpec, timeoutMs: Long): CommandExecution { - requirePermission() - requireConcurrencyLimit() - - val executionId = "$pluginId-${UUID.randomUUID()}" - val projectRoot = projectRootProvider() - - val processBuilder = when (spec) { - is CommandSpec.ShellCommand -> { - val workDir = when { - spec.workingDirectory == null -> projectRoot - Paths.get(spec.workingDirectory).isAbsolute -> File(spec.workingDirectory) - else -> projectRoot?.let { File(it, spec.workingDirectory).canonicalFile } - } - validateWorkingDirectory(workDir) - ProcessBuilder(listOf(spec.executable) + spec.arguments).apply { - workDir?.let { directory(it) } - environment().putAll(spec.environment) - } - } - is CommandSpec.GradleTask -> { - val gradleWrapper = projectRoot?.let { File(it, "gradlew") } - ?: throw IllegalStateException("No project root available for Gradle task execution") - if (!gradleWrapper.exists()) { - throw IllegalStateException("Gradle wrapper not found at ${gradleWrapper.absolutePath}") - } - if (!gradleWrapper.canExecute()) { - throw IllegalStateException("Gradle wrapper is not executable: ${gradleWrapper.absolutePath}") - } - ProcessBuilder(listOf(gradleWrapper.absolutePath, spec.taskPath) + spec.arguments).apply { - directory(projectRoot) - } - } - } - - processBuilder.redirectErrorStream(false) - injectTermuxEnvironment(processBuilder) - - val execution = CommandExecutionImpl( - executionId = executionId, - processBuilder = processBuilder, - timeoutMs = timeoutMs - ) - runningCommands[executionId] = execution - execution.start { runningCommands.remove(executionId) } - return execution - } - - override fun isCommandRunning(executionId: String): Boolean { - return runningCommands[executionId]?.isRunning() == true - } - - override fun cancelCommand(executionId: String): Boolean { - return runningCommands[executionId]?.let { - it.cancel() - true - } ?: false - } - - override fun getRunningCommandCount(): Int = runningCommands.size - - fun cancelAllCommands() { - runningCommands.entries.removeAll { (_, execution) -> - execution.cancel() - true - } - } - - private fun requirePermission() { - if (PluginPermission.SYSTEM_COMMANDS !in permissions) { - throw SecurityException( - "Plugin $pluginId does not have SYSTEM_COMMANDS permission" - ) - } - } - - private fun requireConcurrencyLimit() { - if (runningCommands.size >= MAX_CONCURRENT_COMMANDS) { - throw IllegalStateException( - "Plugin $pluginId has reached the maximum of $MAX_CONCURRENT_COMMANDS concurrent commands" - ) - } - } - - private fun validateWorkingDirectory(dir: File?) { - if (dir == null) return - val projectRoot = projectRootProvider() ?: return - val normalizedDir = dir.canonicalFile.toPath() - val normalizedRoot = projectRoot.canonicalFile.toPath() - if (normalizedDir != normalizedRoot && !normalizedDir.startsWith(normalizedRoot)) { - throw SecurityException( - "Plugin $pluginId attempted to execute in directory outside project root: $normalizedDir" - ) - } - } - - private fun injectTermuxEnvironment(processBuilder: ProcessBuilder) { - val termuxBase = appFilesDir.absolutePath - val termuxBin = "$termuxBase/usr/bin" - val termuxLib = "$termuxBase/usr/lib" - val env = processBuilder.environment() - - val existingPath = env["PATH"] ?: "" - if (!existingPath.contains(termuxBin)) { - env["PATH"] = "$termuxBin:$existingPath" - } - - val existingLdPath = env["LD_LIBRARY_PATH"] ?: "" - if (!existingLdPath.contains(termuxLib)) { - env["LD_LIBRARY_PATH"] = "$termuxLib:$existingLdPath" - } - - env.putIfAbsent("HOME", "$termuxBase/home") - env.putIfAbsent("TMPDIR", "$termuxBase/usr/tmp") - env.putIfAbsent("LANG", "en_US.UTF-8") - env.putIfAbsent("PREFIX", "$termuxBase/usr") - } - - companion object { - private const val MAX_CONCURRENT_COMMANDS = 3 - } + private val runningCommands = ConcurrentHashMap() + + override fun executeCommand( + spec: CommandSpec, + timeoutMs: Long, + ): CommandExecution { + requirePermission() + requireConcurrencyLimit() + + val executionId = "$pluginId-${UUID.randomUUID()}" + val projectRoot = projectRootProvider() + + val processBuilder = + when (spec) { + is CommandSpec.ShellCommand -> { + val workDir = + when { + spec.workingDirectory == null -> projectRoot + Paths.get(spec.workingDirectory).isAbsolute -> File(spec.workingDirectory) + else -> projectRoot?.let { File(it, spec.workingDirectory).canonicalFile } + } + validateWorkingDirectory(workDir) + ProcessBuilder(listOf(spec.executable) + spec.arguments).apply { + workDir?.let { directory(it) } + environment().putAll(spec.environment) + } + } + + is CommandSpec.GradleTask -> { + val gradleWrapper = + projectRoot?.let { File(it, "gradlew") } + ?: throw IllegalStateException("No project root available for Gradle task execution") + if (!gradleWrapper.exists()) { + throw IllegalStateException("Gradle wrapper not found at ${gradleWrapper.absolutePath}") + } + if (!gradleWrapper.canExecute()) { + throw IllegalStateException("Gradle wrapper is not executable: ${gradleWrapper.absolutePath}") + } + ProcessBuilder(listOf(gradleWrapper.absolutePath, spec.taskPath) + spec.arguments).apply { + directory(projectRoot) + } + } + } + + processBuilder.redirectErrorStream(false) + injectTermuxEnvironment(processBuilder) + + val execution = + CommandExecutionImpl( + executionId = executionId, + processBuilder = processBuilder, + timeoutMs = timeoutMs, + ) + runningCommands[executionId] = execution + execution.start { runningCommands.remove(executionId) } + return execution + } + + override fun isCommandRunning(executionId: String): Boolean = runningCommands[executionId]?.isRunning() == true + + override fun cancelCommand(executionId: String): Boolean = + runningCommands[executionId]?.let { + it.cancel() + true + } ?: false + + override fun getRunningCommandCount(): Int = runningCommands.size + + fun cancelAllCommands() { + runningCommands.entries.removeAll { (_, execution) -> + execution.cancel() + true + } + } + + private fun requirePermission() { + if (PluginPermission.SYSTEM_COMMANDS !in permissions) { + throw SecurityException( + "Plugin $pluginId does not have SYSTEM_COMMANDS permission", + ) + } + } + + private fun requireConcurrencyLimit() { + if (runningCommands.size >= MAX_CONCURRENT_COMMANDS) { + throw IllegalStateException( + "Plugin $pluginId has reached the maximum of $MAX_CONCURRENT_COMMANDS concurrent commands", + ) + } + } + + private fun validateWorkingDirectory(dir: File?) { + if (dir == null) return + val projectRoot = projectRootProvider() ?: return + val normalizedDir = dir.canonicalFile.toPath() + val normalizedRoot = projectRoot.canonicalFile.toPath() + if (normalizedDir != normalizedRoot && !normalizedDir.startsWith(normalizedRoot)) { + throw SecurityException( + "Plugin $pluginId attempted to execute in directory outside project root: $normalizedDir", + ) + } + } + + private fun injectTermuxEnvironment(processBuilder: ProcessBuilder) { + val termuxBase = appFilesDir.absolutePath + val termuxBin = "$termuxBase/usr/bin" + val termuxLib = "$termuxBase/usr/lib" + val env = processBuilder.environment() + + val existingPath = env["PATH"] ?: "" + if (!existingPath.contains(termuxBin)) { + env["PATH"] = "$termuxBin:$existingPath" + } + + val existingLdPath = env["LD_LIBRARY_PATH"] ?: "" + if (!existingLdPath.contains(termuxLib)) { + env["LD_LIBRARY_PATH"] = "$termuxLib:$existingLdPath" + } + + env.putIfAbsent("HOME", "$termuxBase/home") + env.putIfAbsent("TMPDIR", "$termuxBase/usr/tmp") + env.putIfAbsent("LANG", "en_US.UTF-8") + env.putIfAbsent("PREFIX", "$termuxBase/usr") + } + + companion object { + private const val MAX_CONCURRENT_COMMANDS = 3 + } } private class CommandExecutionImpl( - override val executionId: String, - private val processBuilder: ProcessBuilder, - private val timeoutMs: Long + override val executionId: String, + private val processBuilder: ProcessBuilder, + private val timeoutMs: Long, ) : CommandExecution { - - private val outputChannel = Channel(capacity = Channel.UNLIMITED) - private val resultDeferred = CompletableDeferred() - private val scope = CoroutineScope(Dispatchers.IO + Job()) - private var process: Process? = null - private val stdoutBuilder = StringBuilder() - private val stderrBuilder = StringBuilder() - - override val output: Flow = outputChannel.receiveAsFlow() - - fun start(onComplete: () -> Unit) { - scope.launch { - val startTime = System.currentTimeMillis() - - runCatching { - withTimeout(timeoutMs) { - process = processBuilder.start() - val proc = process!! - - val stdoutJob = launch { readStream(proc, isStdErr = false) } - val stderrJob = launch { readStream(proc, isStdErr = true) } - - val exitCode = proc.waitFor() - stdoutJob.join() - stderrJob.join() - - outputChannel.send(CommandOutput.ExitCode(exitCode)) - outputChannel.close() - - val duration = System.currentTimeMillis() - startTime - if (exitCode == 0) { - CommandResult.Success(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), duration) - } else { - CommandResult.Failure(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), null, duration) - } - } - }.onSuccess { result -> - resultDeferred.complete(result) - }.onFailure { e -> - process?.destroyForcibly() - outputChannel.close() - val stdout = stdoutBuilder.toString() - val stderr = stderrBuilder.toString() - val duration = System.currentTimeMillis() - startTime - val failureResult = when (e) { - is kotlinx.coroutines.TimeoutCancellationException -> - CommandResult.Failure(-1, stdout, stderr, "Command timed out after ${timeoutMs}ms: ${e.message}", duration) - is kotlinx.coroutines.CancellationException -> - CommandResult.Cancelled(stdout, stderr) - else -> - CommandResult.Failure(-1, stdout, stderr, "Unexpected error: ${e.message}", duration) - } - if (resultDeferred.isActive) { - resultDeferred.complete(failureResult) - } - } - - onComplete() - } - } - - private suspend fun readStream(process: Process, isStdErr: Boolean) { - val stream = if (isStdErr) process.errorStream else process.inputStream - val builder = if (isStdErr) stderrBuilder else stdoutBuilder - BufferedReader(InputStreamReader(stream)).use { reader -> - var line = reader.readLine() - while (line != null) { - if (builder.length + line.length <= MAX_OUTPUT_BYTES) { - builder.appendLine(line) - } - val output = if (isStdErr) CommandOutput.StdErr(line) else CommandOutput.StdOut(line) - outputChannel.send(output) - line = reader.readLine() - } - } - } - - override suspend fun await(): CommandResult = resultDeferred.await() - - override fun cancel() { - process?.destroyForcibly() - outputChannel.close() - if (resultDeferred.isActive) { - resultDeferred.complete( - CommandResult.Cancelled(stdoutBuilder.toString(), stderrBuilder.toString()) - ) - } - scope.cancel() - } - - fun isRunning(): Boolean = process?.isAlive == true - - companion object { - private const val MAX_OUTPUT_BYTES = 10 * 1024 * 1024 - } -} \ No newline at end of file + private val outputChannel = Channel(capacity = Channel.UNLIMITED) + private val resultDeferred = CompletableDeferred() + private val scope = CoroutineScope(Dispatchers.IO + Job()) + private var process: Process? = null + private val stdoutBuilder = StringBuilder() + private val stderrBuilder = StringBuilder() + + override val output: Flow = outputChannel.receiveAsFlow() + + fun start(onComplete: () -> Unit) { + scope.launch { + val startTime = System.currentTimeMillis() + + runCatching { + withTimeout(timeoutMs) { + process = processBuilder.start() + val proc = process!! + + val stdoutJob = launch { readStream(proc, isStdErr = false) } + val stderrJob = launch { readStream(proc, isStdErr = true) } + + val exitCode = proc.waitFor() + stdoutJob.join() + stderrJob.join() + + outputChannel.send(CommandOutput.ExitCode(exitCode)) + outputChannel.close() + + val duration = System.currentTimeMillis() - startTime + if (exitCode == 0) { + CommandResult.Success(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), duration) + } else { + CommandResult.Failure(exitCode, stdoutBuilder.toString(), stderrBuilder.toString(), null, duration) + } + } + }.onSuccess { result -> + resultDeferred.complete(result) + }.onFailure { e -> + process?.destroyForcibly() + outputChannel.close() + val stdout = stdoutBuilder.toString() + val stderr = stderrBuilder.toString() + val duration = System.currentTimeMillis() - startTime + val failureResult = + when (e) { + is kotlinx.coroutines.TimeoutCancellationException -> { + CommandResult.Failure(-1, stdout, stderr, "Command timed out after ${timeoutMs}ms: ${e.message}", duration) + } + + is kotlinx.coroutines.CancellationException -> { + CommandResult.Cancelled(stdout, stderr) + } + + else -> { + CommandResult.Failure(-1, stdout, stderr, "Unexpected error: ${e.message}", duration) + } + } + if (resultDeferred.isActive) { + resultDeferred.complete(failureResult) + } + } + + onComplete() + } + } + + private suspend fun readStream( + process: Process, + isStdErr: Boolean, + ) { + val stream = if (isStdErr) process.errorStream else process.inputStream + val builder = if (isStdErr) stderrBuilder else stdoutBuilder + BufferedReader(InputStreamReader(stream)).use { reader -> + var line = reader.readLine() + while (line != null) { + if (builder.length + line.length <= MAX_OUTPUT_BYTES) { + builder.appendLine(line) + } + val output = if (isStdErr) CommandOutput.StdErr(line) else CommandOutput.StdOut(line) + outputChannel.send(output) + line = reader.readLine() + } + } + } + + override suspend fun await(): CommandResult = resultDeferred.await() + + override fun cancel() { + process?.destroyForcibly() + outputChannel.close() + if (resultDeferred.isActive) { + resultDeferred.complete( + CommandResult.Cancelled(stdoutBuilder.toString(), stderrBuilder.toString()), + ) + } + scope.cancel() + } + + fun isRunning(): Boolean = process?.isAlive == true + + companion object { + private const val MAX_OUTPUT_BYTES = 10 * 1024 * 1024 + } +} From b527ed431155bde105ae82d6a13ef7a99bc4512f Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 4 Oct 2026 15:13:38 +0100 Subject: [PATCH 2/8] ADFA-4851: Move the Termux process environment into common, no functional change IdeCommandServiceImpl's private injectTermuxEnvironment becomes TermuxProcessEnvironment.applyTo in :common, unchanged, so the plugin language server launcher can start its process with the same PATH, LD_LIBRARY_PATH, HOME, TMPDIR, LANG and PREFIX. --- .../utils/TermuxProcessEnvironment.kt | 29 +++++++++++++++++++ .../manager/services/IdeCommandServiceImpl.kt | 25 ++-------------- 2 files changed, 31 insertions(+), 23 deletions(-) create mode 100644 common/src/main/java/com/itsaky/androidide/utils/TermuxProcessEnvironment.kt diff --git a/common/src/main/java/com/itsaky/androidide/utils/TermuxProcessEnvironment.kt b/common/src/main/java/com/itsaky/androidide/utils/TermuxProcessEnvironment.kt new file mode 100644 index 0000000000..1011bedef0 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/TermuxProcessEnvironment.kt @@ -0,0 +1,29 @@ +package com.itsaky.androidide.utils + +import java.io.File + +object TermuxProcessEnvironment { + fun applyTo( + env: MutableMap, + termuxRoot: File, + ) { + val termuxBase = termuxRoot.absolutePath + val termuxBin = "$termuxBase/usr/bin" + val termuxLib = "$termuxBase/usr/lib" + + val existingPath = env["PATH"] ?: "" + if (!existingPath.contains(termuxBin)) { + env["PATH"] = "$termuxBin:$existingPath" + } + + val existingLdPath = env["LD_LIBRARY_PATH"] ?: "" + if (!existingLdPath.contains(termuxLib)) { + env["LD_LIBRARY_PATH"] = "$termuxLib:$existingLdPath" + } + + env.putIfAbsent("HOME", "$termuxBase/home") + env.putIfAbsent("TMPDIR", "$termuxBase/usr/tmp") + env.putIfAbsent("LANG", "en_US.UTF-8") + env.putIfAbsent("PREFIX", "$termuxBase/usr") + } +} diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt index 2beb041cbf..a334b1b2a4 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/services/IdeCommandServiceImpl.kt @@ -6,6 +6,7 @@ import com.itsaky.androidide.plugins.extensions.CommandResult import com.itsaky.androidide.plugins.extensions.CommandSpec import com.itsaky.androidide.plugins.services.CommandExecution import com.itsaky.androidide.plugins.services.IdeCommandService +import com.itsaky.androidide.utils.TermuxProcessEnvironment import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -75,7 +76,7 @@ class IdeCommandServiceImpl( } processBuilder.redirectErrorStream(false) - injectTermuxEnvironment(processBuilder) + TermuxProcessEnvironment.applyTo(processBuilder.environment(), appFilesDir) val execution = CommandExecutionImpl( @@ -133,28 +134,6 @@ class IdeCommandServiceImpl( } } - private fun injectTermuxEnvironment(processBuilder: ProcessBuilder) { - val termuxBase = appFilesDir.absolutePath - val termuxBin = "$termuxBase/usr/bin" - val termuxLib = "$termuxBase/usr/lib" - val env = processBuilder.environment() - - val existingPath = env["PATH"] ?: "" - if (!existingPath.contains(termuxBin)) { - env["PATH"] = "$termuxBin:$existingPath" - } - - val existingLdPath = env["LD_LIBRARY_PATH"] ?: "" - if (!existingLdPath.contains(termuxLib)) { - env["LD_LIBRARY_PATH"] = "$termuxLib:$existingLdPath" - } - - env.putIfAbsent("HOME", "$termuxBase/home") - env.putIfAbsent("TMPDIR", "$termuxBase/usr/tmp") - env.putIfAbsent("LANG", "en_US.UTF-8") - env.putIfAbsent("PREFIX", "$termuxBase/usr") - } - companion object { private const val MAX_CONCURRENT_COMMANDS = 3 } From 4b3aea1c6cd9c9cf26d1d369ffc46e648a54f45b Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 4 Oct 2026 15:13:53 +0100 Subject: [PATCH 3/8] ADFA-4851: Give late-registered language servers the connected client and open project The registry connected the client and dispatched the open project only to servers already registered at that moment. A plugin's language server registers whenever its plugin loads, which can be after both, so it never received a client to publish diagnostics to. register now hands a new server the connected client and current workspace, and destroy forgets both. --- .../lsp/api/DefaultLanguageServerRegistry.kt | 25 +++-- .../api/DefaultLanguageServerRegistryTest.kt | 95 +++++++++++++++++++ 2 files changed, 112 insertions(+), 8 deletions(-) create mode 100644 lsp/api/src/test/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistryTest.kt diff --git a/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt b/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt index 279886428b..f9ddbb2e20 100644 --- a/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt +++ b/lsp/api/src/main/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistry.kt @@ -40,8 +40,15 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { private val mRegister = HashMap() private val lock: ReadWriteLock = ReentrantReadWriteLock() + @Volatile + private var connectedClient: ILanguageClient? = null + + @Volatile + private var currentWorkspace: Workspace? = null + override fun connectClient(client: ILanguageClient) { Objects.requireNonNull(client) + connectedClient = client lock.readLock().lock() try { for (server in mRegister.values) { @@ -97,6 +104,8 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { lock.writeLock().withLock { mRegister.clear() } + connectedClient = null + currentWorkspace = null } override fun getServer(serverId: String): ILanguageServer? { @@ -112,6 +121,7 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { @Suppress("unused") fun onProjectInitialized(event: ProjectInitializedEvent) { val project = event.get(Workspace::class.java) ?: return + currentWorkspace = project sLogger.debug("Dispatching ProjectInitializedEvent to language servers...") val servers = lock.readLock().withLock { mRegister.values.toList() } @@ -125,15 +135,14 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { EventBus.getDefault().register(this) } - lock.writeLock().lock() - try { - val old = mRegister.putIfAbsent(server.serverId, server) - if (old != null) { - sLogger.warn("Attempt to re-register LSP server with ID '{}'", server.serverId) - } - } finally { - lock.writeLock().unlock() + val old = lock.writeLock().withLock { mRegister.putIfAbsent(server.serverId, server) } + if (old != null) { + sLogger.warn("Attempt to re-register LSP server with ID '{}'", server.serverId) + return } + + connectedClient?.let(server::connectClient) + currentWorkspace?.let(server::setupWithProject) } override fun unregister(serverId: String) { diff --git a/lsp/api/src/test/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistryTest.kt b/lsp/api/src/test/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistryTest.kt new file mode 100644 index 0000000000..18f8e9f075 --- /dev/null +++ b/lsp/api/src/test/java/com/itsaky/androidide/lsp/api/DefaultLanguageServerRegistryTest.kt @@ -0,0 +1,95 @@ +package com.itsaky.androidide.lsp.api + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.models.CompletionParams +import com.itsaky.androidide.lsp.models.CompletionResult +import com.itsaky.androidide.lsp.models.DefinitionParams +import com.itsaky.androidide.lsp.models.DefinitionResult +import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.lsp.models.DiagnosticResult +import com.itsaky.androidide.lsp.models.ExpandSelectionParams +import com.itsaky.androidide.lsp.models.PerformCodeActionParams +import com.itsaky.androidide.lsp.models.ReferenceParams +import com.itsaky.androidide.lsp.models.ReferenceResult +import com.itsaky.androidide.lsp.models.ShowDocumentParams +import com.itsaky.androidide.lsp.models.ShowDocumentResult +import com.itsaky.androidide.lsp.models.SignatureHelp +import com.itsaky.androidide.lsp.models.SignatureHelpParams +import com.itsaky.androidide.models.Location +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.projects.api.Workspace +import org.junit.Test +import java.io.File +import java.nio.file.Path + +class DefaultLanguageServerRegistryTest { + @Test + fun `a server registered after the client connects receives the client`() { + val registry = DefaultLanguageServerRegistry() + val client = NoOpClient() + registry.connectClient(client) + + val late = RecordingServer("late") + registry.register(late) + + assertThat(late.client).isSameInstanceAs(client) + registry.destroy() + } + + @Test + fun `a destroyed registry does not hand its old client to new servers`() { + val registry = DefaultLanguageServerRegistry() + registry.connectClient(NoOpClient()) + registry.destroy() + + val server = RecordingServer("fresh") + registry.register(server) + + assertThat(server.client).isNull() + registry.destroy() + } + + private class RecordingServer( + override val serverId: String, + ) : ILanguageServer { + override var client: ILanguageClient? = null + + override fun shutdown() = Unit + + override fun connectClient(client: ILanguageClient?) { + this.client = client + } + + override fun applySettings(settings: IServerSettings?) = Unit + + override fun setupWithProject(workspace: Workspace) = Unit + + override fun complete(params: CompletionParams?): CompletionResult = CompletionResult.EMPTY + + override suspend fun findReferences(params: ReferenceParams): ReferenceResult = ReferenceResult.empty() + + override suspend fun findDefinition(params: DefinitionParams): DefinitionResult = DefinitionResult.empty() + + override suspend fun expandSelection(params: ExpandSelectionParams): Range = params.selection + + override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp = SignatureHelp.empty() + + override suspend fun analyze(file: Path): DiagnosticResult = DiagnosticResult.NO_UPDATE + } + + private class NoOpClient : ILanguageClient { + override fun getDiagnosticAt( + file: File, + line: Int, + column: Int, + ): DiagnosticItem? = null + + override fun performCodeAction(params: PerformCodeActionParams) = Unit + + override fun publishDiagnostics(result: DiagnosticResult) = Unit + + override fun showDocument(params: ShowDocumentParams): ShowDocumentResult = ShowDocumentResult(false) + + override fun showLocations(locations: List) = Unit + } +} From b3da3754089194f1d6254223a0ec8b60e77f085f Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 4 Oct 2026 15:14:10 +0100 Subject: [PATCH 4/8] ADFA-4851: Let plugins contribute languages: tree-sitter highlighting and a language server A plugin implementing the new LanguageExtension returns LanguageDefinitions that claim file extensions and may carry a TreeSitterGrammar and a LanguageServerDefinition. - Grammar: the plugin ships lib//libtree-sitter-.so and its query files. PluginTreeSitterLanguage loads it through TSLanguage.loadLanguage, and each colour scheme maps the standard capture names through a new generic.json, so a language with no scheme of its own is still coloured. - Server: :lsp:external runs any stdio language server under the Termux environment and adapts it to ILanguageServer over LSP4J (completion, diagnostics, definition, references, formatting). - PluginLanguageSupport wires both into the editor, re-registering grammars after every project close, since preDestroy destroys the tree-sitter registry. Extensions the IDE already handles cannot be claimed. The API is additive. --- ARCHITECTURE.md | 2 +- app/build.gradle.kts | 1 + .../editor/EditorHandlerActivity.kt | 2 + .../CredentialProtectedApplicationLoader.kt | 3 + .../itsaky/androidide/handlers/LspHandler.kt | 2 + .../androidide/lsp/PluginLanguageSupport.kt | 162 +++++ .../itsaky/androidide/ui/CodeEditorView.kt | 3 +- docs/PLUGIN_API_CHANGELOG.md | 13 + docs/PLUGIN_AUTHORING.md | 20 + docs/plugin-api.md | 5 +- .../schemes/default-dark/default-dark.json | 3 +- .../editor/schemes/default-dark/generic.json | 56 ++ .../editor/schemes/default-dark/scheme.prop | 2 +- .../editor/schemes/default/default.json | 3 +- .../editor/schemes/default/generic.json | 56 ++ .../assets/editor/schemes/default/scheme.prop | 2 +- .../treesitter/PluginTreeSitterLanguage.kt | 51 ++ .../language/treesitter/TSLanguageRegistry.kt | 5 + .../language/treesitter/TreeSitterLanguage.kt | 88 ++- .../treesitter/TreeSitterLanguageSpec.kt | 3 - .../internal/TSLanguageRegistryImpl.kt | 5 + .../editor/schemes/IDEColorScheme.kt | 6 +- .../editor/schemes/LanguageSpecProvider.kt | 31 +- .../schemes/LocalCaptureSpecProvider.kt | 2 +- lsp/external/.gitignore | 1 + lsp/external/build.gradle.kts | 27 + .../lsp/external/ExternalLanguageServer.kt | 557 ++++++++++++++++++ .../lsp/external/Lsp4jConversions.kt | 179 ++++++ .../external/ExternalLanguageServerTest.kt | 366 ++++++++++++ plugin-api/api/plugin-api.api | 51 ++ .../plugins/extensions/LanguageExtension.kt | 25 + .../plugins/manager/core/PluginManager.kt | 64 ++ .../language/PluginLanguageContribution.kt | 12 + settings.gradle.kts | 1 + 34 files changed, 1732 insertions(+), 77 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt create mode 100644 editor/src/main/assets/editor/schemes/default-dark/generic.json create mode 100644 editor/src/main/assets/editor/schemes/default/generic.json create mode 100644 editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt create mode 100644 lsp/external/.gitignore create mode 100644 lsp/external/build.gradle.kts create mode 100644 lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt create mode 100644 lsp/external/src/main/java/com/itsaky/androidide/lsp/external/Lsp4jConversions.kt create mode 100644 lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt create mode 100644 plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/LanguageExtension.kt create mode 100644 plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/language/PluginLanguageContribution.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 0f14c2f68b..45de5d5356 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -72,7 +72,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil | Application | `app` | The IDE itself — activities, fragments, services, DI, agent, web server. Wires everything together. | | Build engine | `subprojects:tooling-api*`, `gradle-plugin*`, `subprojects:projects`, `subprojects:builder-model-impl` | Runs a real Gradle build of the user's project out-of-process and streams events back. | | Quick Build (experimental, ADFA-4128) | `quickbuild:core`, `quickbuild:daemon`, `quickbuild:protocol`, `quickbuild:runtime` | Live-reloads the user's app on every save in seconds, by running it as a generated proxy app instead of doing a full Gradle rebuild. | -| Language tooling | `lsp:{api,java,kotlin,xml,indexing,refactor-core,ui,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting, and the tree-sitter document outline (`editor/.../language/outline`, rendered by `app`'s sidebar `OutlineFragment`). `lsp:refactor-core` holds the language-agnostic half of the refactorings (offset spans, block geometry, rewrite composition, name primitives) so `lsp:java` and `lsp:kotlin` share one copy; `lsp:ui` holds the Compose sheets they share. Neither depends on a language server. | +| Language tooling | `lsp:{api,java,kotlin,xml,external,indexing,refactor-core,ui,…}`, `lexers`, `editor*`, `editor-treesitter` | Language servers, indexing, the Sora-based editor and highlighting, and the tree-sitter document outline (`editor/.../language/outline`, rendered by `app`'s sidebar `OutlineFragment`). `lsp:refactor-core` holds the language-agnostic half of the refactorings (offset spans, block geometry, rewrite composition, name primitives) so `lsp:java` and `lsp:kotlin` share one copy; `lsp:ui` holds the Compose sheets they share. Neither depends on a language server. `lsp:external` adapts a plugin's stdio language server to `ILanguageServer` over LSP4J; `app`'s `PluginLanguageSupport` installs plugin grammars and servers. | | UI design tooling | `layouteditor`, `uidesigner`, `xml-inflater`, `vectormaster`, `compose-preview` | Visual/XML design surfaces for the *user's* app. | | Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. | | Plugin system | `plugin-api`, `plugin-api:plugin-builder`, `plugin-manager` | In-app plugin SDK + manager — `AndroidManifest.xml` `` contract, permissions, extensions. See [plugin-api.md](docs/plugin-api.md) for the API surface & compatibility policy. | diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 070c41a292..d0e1bfc9b5 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -426,6 +426,7 @@ dependencies { implementation(projects.subprojects.toolingApi) implementation(projects.logsender) implementation(projects.lsp.api) + implementation(projects.lsp.external) implementation(projects.lsp.java) implementation(projects.lsp.kotlin) implementation(projects.lsp.xml) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 42aecc8a1e..899b672a0b 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -87,6 +87,7 @@ import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.interfaces.IEditorHandler +import com.itsaky.androidide.lsp.PluginLanguageSupport import com.itsaky.androidide.models.DeepLinkOpenRequest import com.itsaky.androidide.models.DeepLinkRequest import com.itsaky.androidide.models.EditorIntentExtras @@ -381,6 +382,7 @@ open class EditorHandlerActivity : TSLanguageRegistry.instance.registerIfNeeded(LogLanguage.TS_TYPE, LogLanguage.FACTORY) TSLanguageRegistry.instance.registerIfNeeded(JsonLanguage.TS_TYPE, JsonLanguage.FACTORY) TSLanguageRegistry.instance.registerIfNeeded(XMLLanguage.TS_TYPE, XMLLanguage.FACTORY) + PluginLanguageSupport.registerGrammars() IDEColorSchemeProvider.initIfNeeded() } diff --git a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt index e41e39f09f..f0680aee59 100644 --- a/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt +++ b/app/src/main/java/com/itsaky/androidide/app/CredentialProtectedApplicationLoader.kt @@ -16,6 +16,7 @@ import com.itsaky.androidide.editor.schemes.IDEColorSchemeProvider import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent import com.itsaky.androidide.lookup.Lookup +import com.itsaky.androidide.lsp.PluginLanguageSupport import com.itsaky.androidide.managers.ToolsManager import com.itsaky.androidide.plugins.PluginLogger import com.itsaky.androidide.plugins.base.PluginFragmentHelper @@ -361,6 +362,8 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader { logger = pluginLogger, ) + pluginManager?.setLanguageContributionsListener(PluginLanguageSupport::refresh) + // Set up plugin service providers setupPluginServices() setupPluginInflationErrorHandler() diff --git a/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt b/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt index 5633691432..79f3428544 100644 --- a/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt +++ b/app/src/main/java/com/itsaky/androidide/handlers/LspHandler.kt @@ -17,6 +17,7 @@ package com.itsaky.androidide.handlers +import com.itsaky.androidide.lsp.PluginLanguageSupport import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServerRegistry import com.itsaky.androidide.lsp.debug.IDebugClient @@ -36,6 +37,7 @@ object LspHandler { getServer(KotlinLanguageServer.SERVER_ID) ?: register(KotlinLanguageServer()) getServer(XMLLanguageServer.SERVER_ID) ?: register(XMLLanguageServer()) } + PluginLanguageSupport.registerServers() } fun connectClient(client: ILanguageClient) { diff --git a/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt b/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt new file mode 100644 index 0000000000..85bb628e1c --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt @@ -0,0 +1,162 @@ +package com.itsaky.androidide.lsp + +import android.os.Handler +import android.os.Looper +import com.itsaky.androidide.app.IDEApplication +import com.itsaky.androidide.editor.language.treesitter.PluginTreeSitterLanguage +import com.itsaky.androidide.editor.language.treesitter.TSLanguageRegistry +import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguage +import com.itsaky.androidide.lsp.api.ILanguageServerRegistry +import com.itsaky.androidide.lsp.external.ExternalLanguageServer +import com.itsaky.androidide.plugins.extensions.LanguageServerDefinition +import com.itsaky.androidide.plugins.manager.language.PluginLanguageContribution +import com.itsaky.androidide.preferences.internal.EditorPreferences +import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.TermuxProcessEnvironment +import org.slf4j.LoggerFactory +import java.io.File +import java.util.concurrent.Executor +import java.util.concurrent.Executors + +object PluginLanguageSupport { + private val log = LoggerFactory.getLogger(PluginLanguageSupport::class.java) + private val hostFileTypes = setOf("java", "kt", "kts", "xml", "json", "log", "gradle", "c", "h", "cc", "cpp", "cxx") + private val mainExecutor = Handler(Looper.getMainLooper()).let { handler -> Executor { handler.post(it) } } + private val refreshExecutor = Executors.newSingleThreadExecutor { Thread(it, "plugin-languages") } + private val lock = Any() + private var installed: List = emptyList() + + fun serverIdFor(file: File): String? { + val type = file.extension.lowercase() + return synchronized(lock) { installed.firstOrNull { type in it.fileTypes }?.serverId } + } + + fun refresh() { + refreshExecutor.execute(::reconcile) + } + + fun registerServers() { + synchronized(lock) { installed.forEach(::registerServer) } + } + + fun registerGrammars() { + synchronized(lock) { installed.forEach(::registerGrammar) } + } + + private fun registerGrammar(language: InstalledLanguage) { + language.grammarFactories.forEach { (type, factory) -> TSLanguageRegistry.instance.registerIfNeeded(type, factory) } + } + + private fun reconcile() { + val contributions = IDEApplication.getPluginManager()?.getEnabledLanguageContributions() ?: emptyList() + synchronized(lock) { + val keep = installed.filter { it.contribution in contributions } + installed.filterNot { it in keep }.forEach(::uninstall) + + val claimed = keep.flatMapTo(mutableSetOf()) { it.fileTypes } + val added = + contributions + .filter { contribution -> keep.none { it.contribution == contribution } } + .mapNotNull { install(it, claimed) } + installed = keep + added + installed.forEach(::registerGrammar) + installed.forEach(::registerServer) + } + } + + private fun install( + contribution: PluginLanguageContribution, + claimed: MutableSet, + ): InstalledLanguage? { + val definition = contribution.definition + val fileTypes = + definition.fileExtensions + .map { it.lowercase() } + .filter { type -> + val free = type !in hostFileTypes && type !in claimed + if (!free) { + log.warn("Plugin {} cannot claim '.{}' for {}: already handled", contribution.pluginId, type, definition.languageId) + } + free + }.toSet() + if (fileTypes.isEmpty()) return null + claimed += fileTypes + + val serverId = definition.server?.let { "plugin.${contribution.pluginId}.${definition.languageId}" } + val factories = grammarFactories(contribution, fileTypes, serverId) + log.info("Installed {} from plugin {} for {}", definition.languageId, contribution.pluginId, fileTypes) + return InstalledLanguage(contribution, fileTypes, serverId, factories) + } + + private fun grammarFactories( + contribution: PluginLanguageContribution, + fileTypes: Set, + serverId: String?, + ): Map> { + val grammar = contribution.definition.grammar ?: return emptyMap() + val library = File(contribution.nativeLibraryDir, "libtree-sitter-${grammar.name}.so") + if (!library.isFile) { + log.error("Plugin {} grammar library not found: {}", contribution.pluginId, library) + return emptyMap() + } + return fileTypes + .associateWith { type -> + PluginTreeSitterLanguage.Factory(type, library, grammar.name, contribution.assets, grammar.queriesAssetPath, serverId) + } + } + + private fun uninstall(language: InstalledLanguage) { + language.grammarFactories.forEach { (type, factory) -> TSLanguageRegistry.instance.unregister(type, factory) } + val serverId = language.serverId ?: return + val registry = ILanguageServerRegistry.default + if (registry.getServer(serverId) != null) { + registry.unregister(serverId) + } + log.info("Uninstalled {} from plugin {}", language.contribution.definition.languageId, language.contribution.pluginId) + } + + private fun registerServer(language: InstalledLanguage) { + val serverId = language.serverId ?: return + val server = language.contribution.definition.server ?: return + val registry = ILanguageServerRegistry.default + if (registry.getServer(serverId) != null) return + registry.register( + ExternalLanguageServer( + serverId = serverId, + languageId = language.contribution.definition.languageId, + fileExtensions = language.fileTypes, + initializationOptions = server.initializationOptions, + processFactory = { workingDirectory -> startProcess(server, workingDirectory) }, + indentation = { ExternalLanguageServer.Indentation(EditorPreferences.tabSize, EditorPreferences.useSoftTab) }, + uiExecutor = mainExecutor, + ), + ) + } + + private fun startProcess( + server: LanguageServerDefinition, + workingDirectory: File?, + ): Process { + val command = server.command.toMutableList() + val executable = command.first() + if ('/' !in executable) { + val termuxExecutable = File(Environment.BIN_DIR, executable) + if (termuxExecutable.canExecute()) { + command[0] = termuxExecutable.absolutePath + } + } + return ProcessBuilder(command) + .apply { + workingDirectory?.let(::directory) + environment().putAll(server.environment) + TermuxProcessEnvironment.applyTo(environment(), Environment.PREFIX.parentFile) + }.start() + } + + private data class InstalledLanguage( + val contribution: PluginLanguageContribution, + val fileTypes: Set, + val serverId: String?, + val grammarFactories: Map>, + ) +} diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 09b97a7314..125bce8982 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -45,6 +45,7 @@ import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent import com.itsaky.androidide.lsp.BreakpointHandler import com.itsaky.androidide.lsp.IDEDebugClientImpl import com.itsaky.androidide.lsp.IDELanguageClientImpl +import com.itsaky.androidide.lsp.PluginLanguageSupport import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.ILanguageServerRegistry import com.itsaky.androidide.lsp.java.JavaLanguageServer @@ -619,7 +620,7 @@ class CodeEditorView( "java" -> JavaLanguageServer.SERVER_ID "kt", "kts" -> KotlinLanguageServer.SERVER_ID "xml" -> XMLLanguageServer.SERVER_ID - else -> return null + else -> PluginLanguageSupport.serverIdFor(file) ?: return null } return ILanguageServerRegistry.default.getServer(serverID) diff --git a/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 708b80164b..8667f9e49e 100644 --- a/docs/PLUGIN_API_CHANGELOG.md +++ b/docs/PLUGIN_API_CHANGELOG.md @@ -36,6 +36,19 @@ milestone. **[verified]** = read from the checked-in ABI dump. **[reconstructed] = diffed from `plugin-api/src` history (predates the dump; symbol-accurate). ### 26.41 — unreleased +- **added — Plugin languages: tree-sitter highlighting and a language server** _(ADFA-4851)_ **[verified]** + A plugin implementing `LanguageExtension` returns `LanguageDefinition`s, each claiming file + extensions and optionally carrying a `TreeSitterGrammar` and a `LanguageServerDefinition`. + The grammar is `lib//libtree-sitter-.so` exporting `tree_sitter_`, built + at tree-sitter language ABI 13 or 14, with `highlights.scm` (and optionally `locals`, + `blocks`, `brackets`, `indents`) under `queriesAssetPath` in the plugin's assets. Captures + use the standard names (`keyword`, `string`, `function`, `type`, ...); every colour scheme + maps them through its `generic.json`. The server is any stdio LSP process: a bare command + name resolves against the Termux `bin` directory, and it runs with the Termux environment + plus `environment`. A grammar needs `native.code`; a server needs `system.commands`. + Extensions the IDE already handles (`java`, `kt`, `kts`, `xml`, `json`, `log`, `gradle`, + C/C++) cannot be claimed. Purely additive. Floor `plugin.min_ide_version` at `26.41`: an + older IDE cannot load a plugin class that implements `LanguageExtension`. - **added — The AI prompt config engine and settings-pane helpers** _(ADFA-6281)_ **[verified]** Every AI plugin carried its own copy of the code that reads and renders its prompt config, and the credential screens their own copy of the reveal toggle and pane diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 4a2bbeca71..44332bc83f 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -344,6 +344,26 @@ Defined by `PluginPermission` in `plugin-api/src/main/kotlin/com/itsaky/androidi | `native.code` | Execute native machine code | | `ide.environment.write` | Write to IDE-managed directories (Android SDK, NDK, cache) | +## Contributing a language + +Implement `LanguageExtension` to give a file type syntax highlighting, a language server, or both: + +```kotlin +override fun getLanguages() = listOf( + LanguageDefinition( + languageId = "python", + fileExtensions = setOf("py"), + grammar = TreeSitterGrammar(name = "python", queriesAssetPath = "treesitter/python"), + server = LanguageServerDefinition(command = listOf("pylsp")), + ), +) +``` + +- **Grammar.** Ship `lib//libtree-sitter-.so` for `arm64-v8a` and `armeabi-v7a`, exporting `tree_sitter_`, generated at tree-sitter language ABI 13 or 14 (grammar releases up to 0.23.x). Build it from the grammar's `src/parser.c` and `src/scanner.c` with `externalNativeBuild`; `Python-Tools` and `Go-Tools` in `plugin-examples` do this. Needs `native.code`. +- **Queries.** Put `highlights.scm` (optionally `locals.scm`, `blocks.scm`, `brackets.scm`, `indents.scm`) in `assets//`. Use standard capture names so every colour scheme can style them. When two patterns capture the same node at the same position, the one listed **first** wins, so put catch-all patterns such as `(identifier) @variable` last. +- **Server.** Any stdio language server. A bare command resolves against the Termux `bin` directory; an absolute path runs as is. It starts when the first matching file opens, with the Termux environment plus `environment`, and is restarted up to twice if it crashes. Needs `system.commands`. Install the server yourself, for example with `IdeCommandService`. +- File extensions the IDE already handles cannot be claimed. Set `plugin.min_ide_version` to `26.41` or later. + ## Troubleshooting **Install fails with "Missing icon_day and icon_night for debug plugin"** diff --git a/docs/plugin-api.md b/docs/plugin-api.md index 3d8aae5450..f5dacc5abb 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -10,14 +10,15 @@ The surface a plugin binds to is broader than one module. All of the following a - **The `:plugin-api` module** — package `com.itsaky.androidide.plugins.*`: - Core: `IPlugin` (lifecycle), `PluginContext`, `PluginLogger`, `ServiceRegistry`, `ResourceManager`. - - Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`. + - Extension interfaces plugins **implement**: `UIExtension`, `EditorExtension`, `EditorTabExtension`, `DocumentationExtension`, `BuildActionExtension`, `SnippetExtension`, `ProjectExtension`, `FileOpenExtension`, `SettingsExtension`, `LanguageExtension`. - IDE service interfaces plugins **call** (via `ServiceRegistry.get(X::class.java)`): `IdeProjectService`, `IdeEditorService`, `IdeFileService`, `IdeEnvironmentService`, `IdeArchiveService`, `IdeBuildService`, `IdeLogService`, `IdeUIService`, `IdeEditorTabService`, `IdeTooltipService`, `IdeThemeService`, `IdeFeatureFlagService`, `IdeCommandService`, `IdeTemplateService`, `IdeSnippetService`, `IdeSidebarService`. - Cross-plugin service interfaces, where **one plugin implements what another calls** (via `SharedServices`): `LlmInferenceService` — implemented by ai-core, called by every AI plugin — together with the types nested in it that a *backend* plugin implements (`LlmBackend`, `HistoryCapableBackend`, `ToolCallingBackend`, `CancellableBackend`, `ConfigurableBackend`, `EmbeddingBackend`) and the value types either side constructs (`ChatMessage`, `LlmConfig`, `LlmResponse`, `SystemPromptRequest`, `ToolDefinition`, `ToolCallRequest`). Also `ToolSourceRegistry` — implemented by ai-core, called by any plugin contributing tools to the agent — with `ToolSource` and `ToolSpec`, which a *contributing* plugin implements, `ToolInvocation`, which ai-core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns. - Utility classes plugins **instantiate**: `KeystoreSecretStore` (AES/GCM over the Android Keystore, alias supplied by the caller); the AI prompt config engine in `ai.prompt` (`PromptTemplateEngine`, `PromptConfigLoader`, `PromptConfigStore`, `PromptConfigDocument`, `PromptConfigObject`, `AssetPromptConfigSource`), generic over a plugin's own config type through `PromptConfigParser` and `PromptConfigProvider`, which a plugin implements; and the settings-pane helpers in `ai.ui` (`SecretRevealController` with `RevealToggle`, `applyPaneStyling` with `PaneStyle`, `ButtonColors` and `FieldColors`). Host-side implementations rather than interfaces, so plugins share one copy in the process instead of compiling their own. - - Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`). + - Data classes plugins **construct** (e.g. `MenuItem`, `TabItem`, `EditorTabItem`, `NavigationItem`, `ToolbarAction`, `FabAction`, `PluginBuildAction`, `SnippetContribution`, `PluginTooltipEntry`, `PluginSettingsEntry`, `LanguageDefinition`, `TreeSitterGrammar`, `LanguageServerDefinition`). - Enums / sealed types plugins **reference**: `PluginPermission`, `ShowAsAction`, `ArchiveFormat`, `BuildActionCategory`, `ToolbarActionIds`, `CommandSpec`, `CommandResult`, `ExtractResult`, `KeystoreSecretStore.Stored`. Sealed, so a plugin `when`s over the cases exhaustively — adding one is a **breaking** change, not an additive one. - **Wire/format contracts outside the module:** - Manifest `` keys — `plugin.id`, `plugin.name`, `plugin.version`, `plugin.description`, `plugin.author`, `plugin.main_class`, `plugin.min_ide_version`, `plugin.max_ide_version`, `plugin.permissions`, `plugin.sidebar_items`, `plugin.icon_day`, `plugin.icon_night`, `plugin.vcs_revision`, `plugin.build_timestamp`. Matched **by string** — a rename silently breaks every plugin. + - Plugin language assets: `lib//libtree-sitter-.so` exporting `tree_sitter_`, the query file names under `TreeSitterGrammar.queriesAssetPath`, and the standard capture names mapped by each colour scheme's `generic.json`. - Permission **key strings** (`filesystem.read`, `filesystem.write`, `network.access`, `system.commands`, `ide.settings`, `project.structure`, `native.code`, `ide.environment.write`) — also matched by string. - The path allowlist and per-plugin data directories enforced by `IdeFileService` / `IdeArchiveService`. - File/format contracts: the `.cgp` package format (including `assets/cgp-build.properties`, the provenance record the builder writes into every artifact — see [PLUGIN_AUTHORING.md](PLUGIN_AUTHORING.md#provenance)), the `.cgt` template format, the `plugin_documentation.db` schema, `.codeonthego/scripts.json`, the TextMate snippet syntax, and the `http://localhost:6174/` help-server URL scheme with the `plugin//` namespace. diff --git a/editor/src/main/assets/editor/schemes/default-dark/default-dark.json b/editor/src/main/assets/editor/schemes/default-dark/default-dark.json index 2414835acb..f31c50c521 100644 --- a/editor/src/main/assets/editor/schemes/default-dark/default-dark.json +++ b/editor/src/main/assets/editor/schemes/default-dark/default-dark.json @@ -71,6 +71,7 @@ "@json.json", "@kotlin.json", "@xml.json", - "@log.json" + "@log.json", + "@generic.json" ] } \ No newline at end of file diff --git a/editor/src/main/assets/editor/schemes/default-dark/generic.json b/editor/src/main/assets/editor/schemes/default-dark/generic.json new file mode 100644 index 0000000000..0a1fa7efa9 --- /dev/null +++ b/editor/src/main/assets/editor/schemes/default-dark/generic.json @@ -0,0 +1,56 @@ +{ + "types": [ + "*" + ], + "styles": { + "comment": { + "fg": "@comment", + "italic": true + }, + "keyword": { + "fg": "@keyword", + "bold": true + }, + "string": { + "fg": "@string", + "completion": false, + "maybeHexColor": true + }, + "escape": "@keyword", + "string.escape": "@keyword", + "number": "@number", + "boolean": "@keyword", + "constant": { + "fg": "@constant", + "bold": true + }, + "constant.builtin": "@keyword", + "type": "@type", + "type.builtin": { + "fg": "@keyword", + "bold": true + }, + "constructor": "@type", + "module": "@type", + "namespace": "@type", + "function": "@func.decl", + "function.call": "@func.call", + "function.method": "@func.call", + "function.builtin": { + "fg": "@keyword", + "bold": true + }, + "variable": "@onSurface", + "variable.builtin": { + "fg": "@keyword", + "bold": true + }, + "variable.parameter": "@variable", + "parameter": "@variable", + "property": "@field", + "field": "@field", + "attribute": "@attribute", + "label": "@attribute", + "operator": "@operator" + } +} diff --git a/editor/src/main/assets/editor/schemes/default-dark/scheme.prop b/editor/src/main/assets/editor/schemes/default-dark/scheme.prop index cee9e44e0d..9764c6b6ff 100644 --- a/editor/src/main/assets/editor/schemes/default-dark/scheme.prop +++ b/editor/src/main/assets/editor/schemes/default-dark/scheme.prop @@ -2,7 +2,7 @@ scheme.name=AndroidIDE Default - Dark # The version code of the color scheme -scheme.version=14 +scheme.version=15 # Whether the scheme is dark or light scheme.isDark=true diff --git a/editor/src/main/assets/editor/schemes/default/default.json b/editor/src/main/assets/editor/schemes/default/default.json index 703afce508..c22feb2e1d 100644 --- a/editor/src/main/assets/editor/schemes/default/default.json +++ b/editor/src/main/assets/editor/schemes/default/default.json @@ -72,6 +72,7 @@ "@json.json", "@kotlin.json", "@xml.json", - "@log.json" + "@log.json", + "@generic.json" ] } \ No newline at end of file diff --git a/editor/src/main/assets/editor/schemes/default/generic.json b/editor/src/main/assets/editor/schemes/default/generic.json new file mode 100644 index 0000000000..0a1fa7efa9 --- /dev/null +++ b/editor/src/main/assets/editor/schemes/default/generic.json @@ -0,0 +1,56 @@ +{ + "types": [ + "*" + ], + "styles": { + "comment": { + "fg": "@comment", + "italic": true + }, + "keyword": { + "fg": "@keyword", + "bold": true + }, + "string": { + "fg": "@string", + "completion": false, + "maybeHexColor": true + }, + "escape": "@keyword", + "string.escape": "@keyword", + "number": "@number", + "boolean": "@keyword", + "constant": { + "fg": "@constant", + "bold": true + }, + "constant.builtin": "@keyword", + "type": "@type", + "type.builtin": { + "fg": "@keyword", + "bold": true + }, + "constructor": "@type", + "module": "@type", + "namespace": "@type", + "function": "@func.decl", + "function.call": "@func.call", + "function.method": "@func.call", + "function.builtin": { + "fg": "@keyword", + "bold": true + }, + "variable": "@onSurface", + "variable.builtin": { + "fg": "@keyword", + "bold": true + }, + "variable.parameter": "@variable", + "parameter": "@variable", + "property": "@field", + "field": "@field", + "attribute": "@attribute", + "label": "@attribute", + "operator": "@operator" + } +} diff --git a/editor/src/main/assets/editor/schemes/default/scheme.prop b/editor/src/main/assets/editor/schemes/default/scheme.prop index c39635bdc2..6edc0f518d 100644 --- a/editor/src/main/assets/editor/schemes/default/scheme.prop +++ b/editor/src/main/assets/editor/schemes/default/scheme.prop @@ -2,7 +2,7 @@ scheme.name=AndroidIDE Default # The version code of the color scheme -scheme.version=14 +scheme.version=15 # Whether the scheme is dark or light scheme.isDark=false diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt new file mode 100644 index 0000000000..0599480b16 --- /dev/null +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt @@ -0,0 +1,51 @@ +package com.itsaky.androidide.editor.language.treesitter + +import android.content.Context +import android.content.res.AssetManager +import com.itsaky.androidide.editor.language.newline.TSBracketsHandler +import com.itsaky.androidide.editor.language.newline.TSCStyleBracketsHandler +import com.itsaky.androidide.editor.schemes.LanguageSpecProvider +import com.itsaky.androidide.editor.schemes.LocalCaptureSpecProvider.newLocalCaptureSpec +import com.itsaky.androidide.lsp.api.ILanguageServer +import com.itsaky.androidide.lsp.api.ILanguageServerRegistry +import com.itsaky.androidide.treesitter.TSLanguage +import io.github.rosemoe.sora.util.MyCharacter +import java.io.File + +class PluginTreeSitterLanguage( + langType: String, + languageSpec: TreeSitterLanguageSpec, + private val languageServerId: String?, +) : TreeSitterLanguage(langType, languageSpec) { + override val languageServer: ILanguageServer? + get() = languageServerId?.let { ILanguageServerRegistry.default.getServer(it) } + + override fun checkIsCompletionChar(c: Char): Boolean = MyCharacter.isJavaIdentifierPart(c) || c == '.' + + override fun createNewlineHandlers(): Array = arrayOf(TSCStyleBracketsHandler(this)) + + class Factory( + private val langType: String, + private val grammarLibrary: File, + private val grammarName: String, + private val queriesAssets: AssetManager, + private val queriesDir: String, + private val languageServerId: String?, + ) : TreeSitterLanguage.Factory { + override fun create(context: Context): PluginTreeSitterLanguage { + val grammar = + TSLanguage.loadLanguage(grammarLibrary.absolutePath, grammarName) + ?: throw IllegalStateException( + "Unable to load tree_sitter_$grammarName from ${grammarLibrary.absolutePath}", + ) + val spec = + LanguageSpecProvider.getLanguageSpec( + queriesAssets, + queriesDir, + grammar, + newLocalCaptureSpec(langType), + ) + return PluginTreeSitterLanguage(langType, spec, languageServerId) + } + } +} diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt index 655d2c7d16..fae7383481 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TSLanguageRegistry.kt @@ -72,6 +72,11 @@ interface TSLanguageRegistry { */ fun getFactory(fileType: String): TreeSitterLanguage.Factory + fun unregister( + fileType: String, + factory: TreeSitterLanguage.Factory<*>, + ): Boolean + /** * Destroys the language registry, removing all the registered language factory. This must be * called only when the application is exiting. 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 9cf6790144..6544138684 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 @@ -42,35 +42,26 @@ import java.io.File * @author Akash Yadav */ abstract class TreeSitterLanguage( - context: Context, - lang: TSLanguage, - private val langType: String + private val langType: String, + private val languageSpec: TreeSitterLanguageSpec, ) : IDELanguage() { + constructor( + context: Context, + lang: TSLanguage, + langType: String, + ) : this(langType, getLanguageSpec(context, langType, lang, newLocalCaptureSpec(langType))) - 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 - } + private val indentProvider by lazy { + TreeSitterIndentProvider(languageSpec, analyzer.analyzeWorker!!, getTabSize()) + } companion object { - init { TreeSitter.loadLibrary() } @@ -80,7 +71,7 @@ abstract class TreeSitterLanguage( } fun setupWith(scheme: IDEColorScheme?) { - val langScheme = scheme?.languages?.get(langType) + val langScheme = scheme?.getLanguageScheme(langType) this.languageScheme = langScheme this.analyzer.langScheme = languageScheme langScheme?.styles?.forEach { tsTheme.putStyleRule(it.key, it.value.makeStyle()) } @@ -110,32 +101,22 @@ abstract class TreeSitterLanguage( this.analyzer.unhighlightLines() } - override fun getAnalyzeManager(): AnalyzeManager { - return this.analyzer - } + override fun getAnalyzeManager(): AnalyzeManager = this.analyzer - override fun getSymbolPairs(): SymbolPairMatch { - return CommonSymbolPairs() - } + override fun getSymbolPairs(): SymbolPairMatch = CommonSymbolPairs() - open fun createNewlineHandlers(): Array { - return emptyArray() - } + open fun createNewlineHandlers(): Array = emptyArray() - override fun getNewlineHandlers(): Array { - return newlineHandlersLazy - } + override fun getNewlineHandlers(): Array = newlineHandlersLazy - override fun getInterruptionLevel(): Int { - return INTERRUPTION_LEVEL_STRONG - } + override fun getInterruptionLevel(): Int = INTERRUPTION_LEVEL_STRONG override fun getIndentAdvance( content: ContentReference, line: Int, column: Int, spaceCountOnLine: Int, - tabCountOnLine: Int + tabCountOnLine: Int, ): Int { return try { if (line == content.reference.lineCount - 1) { @@ -145,26 +126,31 @@ abstract class TreeSitterLanguage( } // Request both lines so the advance is a relative delta, cancelling any mismatch between the editor's indent size and the file's actual indentation width. - val linesToReq = longArrayOf( - IntPair.pack(line, column), - IntPair.pack(line + 1, 0) - ) + val linesToReq = + longArrayOf( + IntPair.pack(line, column), + IntPair.pack(line + 1, 0), + ) - val indents = this.indentProvider.getIndentsForLines( - content = content.reference, - positions = linesToReq, - ) + val indents = + this.indentProvider.getIndentsForLines( + content = content.reference, + positions = linesToReq, + ) val (indentLine, indentNxtLine) = indents // A sentinel indent (e.g. INDENT_AUTO == Int.MAX_VALUE) would overflow the advance into a huge whitespace string (OOM), so fall back to the default advance. - if (indentLine == TreeSitterIndentProvider.INDENTATION_ERR - || indentNxtLine == TreeSitterIndentProvider.INDENTATION_ERR - || indentLine == TreeSitterIndentProvider.INDENT_AUTO - || indentNxtLine == TreeSitterIndentProvider.INDENT_AUTO + if (indentLine == TreeSitterIndentProvider.INDENTATION_ERR || + indentNxtLine == TreeSitterIndentProvider.INDENTATION_ERR || + indentLine == TreeSitterIndentProvider.INDENT_AUTO || + indentNxtLine == TreeSitterIndentProvider.INDENT_AUTO ) { log.debug( "expectedIndent[{}]={}, expectedIndentNextLine[{}]={}, returning default indent advance", - line, indentLine, line + 1, indentNxtLine + line, + indentLine, + line + 1, + indentNxtLine, ) return DEF_IDENT_ADV } @@ -175,11 +161,10 @@ abstract class TreeSitterLanguage( "An error occurred computing indentation at line:column::{}:{}", line, column, - e + e, ) DEF_IDENT_ADV } - } override fun destroy() { @@ -189,7 +174,6 @@ abstract class TreeSitterLanguage( /** A [Factory] creates instance of a specific [TreeSitterLanguage] implementation. */ fun interface Factory { - /** * Create the instance of the [TreeSitterLanguage] implementation. * diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt index 1ef0a3f1c9..12ea71c32a 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt @@ -54,9 +54,6 @@ class TreeSitterLanguageSpec override fun close() { indentsQuery?.close() - if (spec.language.isExternal) { - spec.language.close() - } spec.close() } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt index 70c77a523e..4e3580950f 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/internal/TSLanguageRegistryImpl.kt @@ -57,6 +57,11 @@ class TSLanguageRegistryImpl : TSLanguageRegistry { (registry[fileType] ?: throw TSLanguageRegistry.NotRegisteredException(fileType)) as TreeSitterLanguage.Factory + override fun unregister( + fileType: String, + factory: TreeSitterLanguage.Factory<*>, + ): Boolean = registry.remove(fileType, factory) + override fun destroy() { registry.clear() } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/schemes/IDEColorScheme.kt b/editor/src/main/java/com/itsaky/androidide/editor/schemes/IDEColorScheme.kt index 47442a61b6..b242172975 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/schemes/IDEColorScheme.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/schemes/IDEColorScheme.kt @@ -55,7 +55,7 @@ class IDEColorScheme( SchemeParser { name -> File(this.file.parentFile, name) }.load(this) } - fun getLanguageScheme(type: String): LanguageScheme? = this.languages[type] + fun getLanguageScheme(type: String): LanguageScheme? = this.languages[type] ?: this.languages[GENERIC_LANGUAGE_TYPE] internal fun putColor(color: Int): Int { this.colorIds[++colorId] = color @@ -76,6 +76,10 @@ class IDEColorScheme( } override fun isDark(): Boolean = this.isDarkScheme + + companion object { + const val GENERIC_LANGUAGE_TYPE = "*" + } } /** diff --git a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt index ba6e6ce6c1..c1f41b4dcb 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LanguageSpecProvider.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.editor.schemes import android.content.Context +import android.content.res.AssetManager import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguageSpec import com.itsaky.androidide.editor.language.treesitter.predicates.AnyOfPredicate import com.itsaky.androidide.editor.language.treesitter.predicates.EqualPredicate @@ -46,14 +47,23 @@ object LanguageSpecProvider { type: String, lang: TSLanguage, localsCaptureSpec: LocalsCaptureSpec = LocalsCaptureSpec.DEFAULT, + ): TreeSitterLanguageSpec = getLanguageSpec(context.assets, "${BASE_SPEC_PATH}/$type", lang, localsCaptureSpec) + + @JvmStatic + @JvmOverloads + fun getLanguageSpec( + assets: AssetManager, + queriesDir: String, + lang: TSLanguage, + localsCaptureSpec: LocalsCaptureSpec = LocalsCaptureSpec.DEFAULT, ): TreeSitterLanguageSpec { val editorLangSpec = TsLanguageSpec( language = lang, - highlightScmSource = readScheme(context, type, "highlights"), - localsScmSource = readScheme(context, type, "locals"), - codeBlocksScmSource = readScheme(context, type, "blocks"), - bracketsScmSource = readScheme(context, type, "brackets"), + highlightScmSource = readScheme(assets, queriesDir, "highlights"), + localsScmSource = readScheme(assets, queriesDir, "locals"), + codeBlocksScmSource = readScheme(assets, queriesDir, "blocks"), + bracketsScmSource = readScheme(assets, queriesDir, "brackets"), localsCaptureSpec = localsCaptureSpec, predicates = listOf( @@ -66,24 +76,21 @@ object LanguageSpecProvider { ) return TreeSitterLanguageSpec( spec = editorLangSpec, - indentsQueryScm = readScheme(context, type, "indents"), + indentsQueryScm = readScheme(assets, queriesDir, "indents"), ) } private fun readScheme( - context: Context, - type: String, + assets: AssetManager, + queriesDir: String, name: String, ): String = try { - context.assets - .open("${BASE_SPEC_PATH}/$type/$name.scm") - .reader() - .readText() + assets.open("$queriesDir/$name.scm").reader().use { it.readText() } } catch (e: Exception) { if (e !is FileNotFoundException) { // log everything except FileNotFoundException - log.error("Failed to read scheme file {} for type {}", name, type, e) + log.error("Failed to read scheme file {} in {}", name, queriesDir, e) } "" } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt index d594539b6d..8a2f6dfb9a 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt @@ -31,7 +31,7 @@ object LocalCaptureSpecProvider { @JvmStatic fun newLocalCaptureSpec(type: String): LocalsCaptureSpec { val lang = - IDEColorSchemeProvider.getColorSchemeForType(type)?.languages?.get(type) + IDEColorSchemeProvider.getColorSchemeForType(type)?.getLanguageScheme(type) ?: run { log.error( "Cannot create LocalsCaptureSpec. Failed to load current color scheme. Falling back to default implementation", diff --git a/lsp/external/.gitignore b/lsp/external/.gitignore new file mode 100644 index 0000000000..796b96d1c4 --- /dev/null +++ b/lsp/external/.gitignore @@ -0,0 +1 @@ +/build diff --git a/lsp/external/build.gradle.kts b/lsp/external/build.gradle.kts new file mode 100644 index 0000000000..2eaea613d3 --- /dev/null +++ b/lsp/external/build.gradle.kts @@ -0,0 +1,27 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + id("com.android.library") + id("kotlin-android") +} + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.lsp.external" +} + +dependencies { + implementation(projects.lsp.api) + implementation(projects.eventbusEvents) + implementation(projects.shared) + + implementation(libs.common.lsp4j) + implementation(libs.common.jsonrpc) + implementation(libs.common.kotlin) + implementation(libs.common.kotlin.coroutines.android) + + compileOnly(projects.common) + + testImplementation(libs.tests.junit) + testImplementation(libs.tests.google.truth) + testImplementation(projects.common) +} diff --git a/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt new file mode 100644 index 0000000000..224b120260 --- /dev/null +++ b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt @@ -0,0 +1,557 @@ +package com.itsaky.androidide.lsp.external + +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.lsp.api.ILanguageClient +import com.itsaky.androidide.lsp.api.ILanguageServer +import com.itsaky.androidide.lsp.api.IServerSettings +import com.itsaky.androidide.lsp.models.CodeFormatResult +import com.itsaky.androidide.lsp.models.CompletionParams +import com.itsaky.androidide.lsp.models.CompletionResult +import com.itsaky.androidide.lsp.models.DefinitionParams +import com.itsaky.androidide.lsp.models.DefinitionResult +import com.itsaky.androidide.lsp.models.DiagnosticResult +import com.itsaky.androidide.lsp.models.ExpandSelectionParams +import com.itsaky.androidide.lsp.models.FormatCodeParams +import com.itsaky.androidide.lsp.models.MatchLevel +import com.itsaky.androidide.lsp.models.ReferenceParams +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.progress.ICancelChecker +import com.itsaky.androidide.projects.FileManager +import com.itsaky.androidide.projects.api.Workspace +import com.itsaky.androidide.projects.models.projectDir +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.eclipse.lsp4j.ClientCapabilities +import org.eclipse.lsp4j.CompletionCapabilities +import org.eclipse.lsp4j.CompletionItemCapabilities +import org.eclipse.lsp4j.DefinitionCapabilities +import org.eclipse.lsp4j.Diagnostic +import org.eclipse.lsp4j.DidChangeTextDocumentParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DidSaveTextDocumentParams +import org.eclipse.lsp4j.DocumentFormattingParams +import org.eclipse.lsp4j.DocumentRangeFormattingParams +import org.eclipse.lsp4j.FormattingCapabilities +import org.eclipse.lsp4j.FormattingOptions +import org.eclipse.lsp4j.InitializeParams +import org.eclipse.lsp4j.InitializedParams +import org.eclipse.lsp4j.MessageActionItem +import org.eclipse.lsp4j.MessageParams +import org.eclipse.lsp4j.MessageType +import org.eclipse.lsp4j.PublishDiagnosticsCapabilities +import org.eclipse.lsp4j.PublishDiagnosticsParams +import org.eclipse.lsp4j.RangeFormattingCapabilities +import org.eclipse.lsp4j.ReferenceContext +import org.eclipse.lsp4j.ReferencesCapabilities +import org.eclipse.lsp4j.SelectionRangeCapabilities +import org.eclipse.lsp4j.SelectionRangeParams +import org.eclipse.lsp4j.ServerCapabilities +import org.eclipse.lsp4j.ShowMessageRequestParams +import org.eclipse.lsp4j.SignatureHelpCapabilities +import org.eclipse.lsp4j.SynchronizationCapabilities +import org.eclipse.lsp4j.TextDocumentClientCapabilities +import org.eclipse.lsp4j.TextDocumentContentChangeEvent +import org.eclipse.lsp4j.TextDocumentIdentifier +import org.eclipse.lsp4j.TextDocumentItem +import org.eclipse.lsp4j.TextDocumentSyncKind +import org.eclipse.lsp4j.VersionedTextDocumentIdentifier +import org.eclipse.lsp4j.WorkspaceFolder +import org.eclipse.lsp4j.launch.LSPLauncher +import org.eclipse.lsp4j.services.LanguageClient +import org.eclipse.lsp4j.services.LanguageServer +import org.greenrobot.eventbus.EventBus +import org.greenrobot.eventbus.Subscribe +import org.greenrobot.eventbus.ThreadMode +import org.slf4j.LoggerFactory +import java.io.File +import java.io.IOException +import java.nio.file.Path +import java.util.concurrent.CancellationException +import java.util.concurrent.CompletableFuture +import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutionException +import java.util.concurrent.Executor +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import java.util.concurrent.Future +import java.util.concurrent.TimeUnit +import java.util.concurrent.TimeoutException +import org.eclipse.lsp4j.CompletionParams as LspCompletionParams +import org.eclipse.lsp4j.DefinitionParams as LspDefinitionParams +import org.eclipse.lsp4j.ReferenceParams as LspReferenceParams +import org.eclipse.lsp4j.SignatureHelpParams as LspSignatureHelpParams + +class ExternalLanguageServer( + override val serverId: String, + private val languageId: String, + fileExtensions: Set, + private val initializationOptions: Map, + private val processFactory: (File?) -> Process, + private val indentation: () -> Indentation, + private val uiExecutor: Executor, +) : ILanguageServer { + private val extensions = fileExtensions.map { it.lowercase() }.toSet() + private val messageExecutor: ExecutorService = Executors.newSingleThreadExecutor { Thread(it, "lsp-$serverId") } + private val lifecycleLock = Any() + private val openDocuments = ConcurrentHashMap() + private val diagnostics = ConcurrentHashMap>() + + private var closed = false + private var crashCount = 0 + + @Volatile + private var session: Session? = null + + @Volatile + private var languageClient: ILanguageClient? = null + + @Volatile + private var workspaceRoot: File? = null + + override val client: ILanguageClient? + get() = languageClient + + init { + EventBus.getDefault().register(this) + } + + override fun connectClient(client: ILanguageClient?) { + languageClient = client + } + + override fun applySettings(settings: IServerSettings?) = Unit + + override fun setupWithProject(workspace: Workspace) { + workspaceRoot = workspace.rootProject.projectDir + } + + override fun shutdown() { + synchronized(lifecycleLock) { + if (closed) return + closed = true + } + EventBus.getDefault().unregister(this) + messageExecutor.execute { + val ending = session ?: return@execute + session = null + stopProcess(ending) + } + messageExecutor.shutdown() + } + + override fun complete(params: CompletionParams?): CompletionResult { + if (params == null || !handles(params.file) || params.prefix.isNullOrEmpty()) return CompletionResult.EMPTY + val lspParams = + LspCompletionParams().apply { + textDocument = TextDocumentIdentifier(params.file.toLspUri()) + position = params.position.toLsp() + } + val content = params.content?.toString() + val result = + request(params.cancelChecker, REQUEST_TIMEOUT_SECONDS, { it.completionProvider != null }) { + if (content != null) syncDocument(params.file, content) + it.textDocumentService.completion(lspParams) + } ?: return CompletionResult.EMPTY + val items = if (result.isLeft) result.left else result.right.items + val prefix = (params.prefix ?: "").substringAfterLast('.') + return CompletionResult(items.map { it.toIde(prefix) }.filter { it.matchLevel != MatchLevel.NO_MATCH }) + } + + override suspend fun findDefinition(params: DefinitionParams): DefinitionResult { + if (!handles(params.file)) return DefinitionResult.empty() + val lspParams = LspDefinitionParams(TextDocumentIdentifier(params.file.toLspUri()), params.position.toLsp()) + val result = + withContext(Dispatchers.IO) { + request(params.cancelChecker, REQUEST_TIMEOUT_SECONDS, { it.definitionProvider.isEnabled() }) { + it.textDocumentService.definition(lspParams) + } + } + return DefinitionResult(result.toIdeLocations()) + } + + override suspend fun findReferences(params: ReferenceParams): ReferenceResult { + if (!handles(params.file)) return ReferenceResult.empty() + val lspParams = + LspReferenceParams( + TextDocumentIdentifier(params.file.toLspUri()), + params.position.toLsp(), + ReferenceContext(params.includeDeclaration), + ) + val result = + withContext(Dispatchers.IO) { + request(params.cancelChecker, REQUEST_TIMEOUT_SECONDS, { it.referencesProvider.isEnabled() }) { + it.textDocumentService.references(lspParams) + } + } + return ReferenceResult(result?.map { it.toIde() } ?: emptyList()) + } + + override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp { + if (!handles(params.file)) return SignatureHelp.empty() + val lspParams = LspSignatureHelpParams(TextDocumentIdentifier(params.file.toLspUri()), params.position.toLsp()) + val result = + withContext(Dispatchers.IO) { + request(params.cancelChecker, REQUEST_TIMEOUT_SECONDS, { it.signatureHelpProvider != null }) { + it.textDocumentService.signatureHelp(lspParams) + } + } + return result?.toIde() ?: SignatureHelp.empty() + } + + override suspend fun expandSelection(params: ExpandSelectionParams): Range { + if (!handles(params.file)) return params.selection + val lspParams = + SelectionRangeParams(TextDocumentIdentifier(params.file.toLspUri()), listOf(params.selection.start.toLsp())) + val result = + withContext(Dispatchers.IO) { + request(ICancelChecker.NOOP, REQUEST_TIMEOUT_SECONDS, { it.selectionRangeProvider.isEnabled() }) { + it.textDocumentService.selectionRange(lspParams) + } + } + var candidate = result?.firstOrNull() + while (candidate != null) { + val range = candidate.range.toIde() + if (range.start <= params.selection.start && range.end >= params.selection.end && range != params.selection) { + return range + } + candidate = candidate.parent + } + return params.selection + } + + override suspend fun analyze(file: Path): DiagnosticResult { + if (!handles(file)) return DiagnosticResult.NO_UPDATE + val raw = diagnostics[file] ?: return DiagnosticResult.NO_UPDATE + val text = openDocuments[file]?.text ?: withContext(Dispatchers.IO) { file.toFile().readText() } + return raw.toDiagnosticResult(file, serverId, text) + } + + override fun formatCode(params: FormatCodeParams?): CodeFormatResult { + if (params == null) return CodeFormatResult.NONE + val content = params.content.toString() + val options = indentation().let { FormattingOptions(it.tabSize, it.insertSpaces) } + val wholeDocument = params.range.start.index <= 0 && params.range.end.index >= content.length + val edits = + request( + ICancelChecker.NOOP, + FORMAT_TIMEOUT_SECONDS, + { it.documentFormattingProvider.isEnabled() || it.documentRangeFormattingProvider.isEnabled() }, + ) { server -> + val file = + openDocuments.entries.firstOrNull { it.value.text == content }?.key + ?: return@request CompletableFuture.completedFuture(null) + val document = TextDocumentIdentifier(file.toLspUri()) + val capabilities = session?.capabilities + if (!wholeDocument && capabilities?.documentRangeFormattingProvider.isEnabled()) { + server.textDocumentService.rangeFormatting( + DocumentRangeFormattingParams(document, options, params.range.toLsp()), + ) + } else { + server.textDocumentService.formatting(DocumentFormattingParams(document, options)) + } + } + if (edits.isNullOrEmpty()) return CodeFormatResult.NONE + return CodeFormatResult(true, indexedTextEdits = edits.toIndexedEdits(content)) + } + + @Subscribe(threadMode = ThreadMode.POSTING) + @Suppress("unused") + fun onDocumentOpen(event: DocumentOpenEvent) { + val file = event.openedFile + if (!handles(file)) return + submit { + openDocuments[file] = OpenDocument(event.version, event.text) + val current = session + if (current == null) { + start() + } else { + current.server.textDocumentService.didOpen(openParams(file, openDocuments.getValue(file))) + } + } + } + + @Subscribe(threadMode = ThreadMode.POSTING) + @Suppress("unused") + fun onDocumentChange(event: DocumentChangeEvent) { + val file = event.changedFile + if (!handles(file)) return + submit { syncDocument(file, FileManager.getDocumentContents(file)) } + } + + @Subscribe(threadMode = ThreadMode.POSTING) + @Suppress("unused") + fun onDocumentSave(event: DocumentSaveEvent) { + val file = event.savedFile + if (!handles(file)) return + submit { + if (!openDocuments.containsKey(file)) return@submit + session?.server?.textDocumentService?.didSave(DidSaveTextDocumentParams(TextDocumentIdentifier(file.toLspUri()))) + } + } + + @Subscribe(threadMode = ThreadMode.POSTING) + @Suppress("unused") + fun onDocumentClose(event: DocumentCloseEvent) { + val file = event.closedFile + if (!handles(file)) return + submit { + openDocuments.remove(file) ?: return@submit + session?.server?.textDocumentService?.didClose(DidCloseTextDocumentParams(TextDocumentIdentifier(file.toLspUri()))) + } + } + + private fun syncDocument( + file: Path, + text: String, + ) { + val document = openDocuments[file] ?: return + if (document.text == text) return + val synced = OpenDocument(document.version + 1, text) + openDocuments[file] = synced + val current = session ?: return + if (current.syncKind == TextDocumentSyncKind.None) return + current.server.textDocumentService.didChange( + DidChangeTextDocumentParams( + VersionedTextDocumentIdentifier(file.toLspUri(), synced.version), + listOf(TextDocumentContentChangeEvent(text)), + ), + ) + } + + private fun handles(file: Path): Boolean = file.toFile().extension.lowercase() in extensions + + private fun submit(task: () -> Unit): Boolean = + synchronized(lifecycleLock) { + if (closed) return false + messageExecutor.execute(task) + true + } + + private fun request( + cancelChecker: ICancelChecker, + timeoutSeconds: Long, + supported: (ServerCapabilities) -> Boolean, + send: (LanguageServer) -> CompletableFuture, + ): T? { + val current = session ?: return null + if (!supported(current.capabilities)) return null + + val sent = CompletableFuture>() + val submitted = + submit { + runCatching { send(current.server) }.fold(sent::complete, sent::completeExceptionally) + } + if (!submitted) return null + + val onCancel: () -> Unit = { sent.thenAccept { it.cancel(true) } } + cancelChecker.invokeOnCancel(onCancel) + return try { + sent.get(timeoutSeconds, TimeUnit.SECONDS).get(timeoutSeconds, TimeUnit.SECONDS) + } catch (e: CancellationException) { + null + } catch (e: InterruptedException) { + sent.thenAccept { it.cancel(true) } + Thread.currentThread().interrupt() + null + } catch (e: TimeoutException) { + sent.thenAccept { it.cancel(true) } + log.warn("Request to language server {} timed out after {}s", serverId, timeoutSeconds) + null + } catch (e: ExecutionException) { + log.error("Request to language server {} failed", serverId, e.cause ?: e) + null + } finally { + cancelChecker.removeOnCancel(onCancel) + } + } + + private fun start(): Session? { + val process = + try { + processFactory(workspaceRoot) + } catch (e: IOException) { + log.error("Unable to start language server {}", serverId, e) + return null + } + drainErrors(process) + + val launcher = LSPLauncher.createClientLauncher(ClientBridge(), process.inputStream, process.outputStream) + val listening = launcher.startListening() + val server = launcher.remoteProxy + val capabilities = + try { + server.initialize(initializeParams()).get(INITIALIZE_TIMEOUT_SECONDS, TimeUnit.SECONDS).capabilities + } catch (e: ExecutionException) { + log.error("Language server {} failed to initialize", serverId, e.cause ?: e) + process.destroy() + return null + } catch (e: TimeoutException) { + log.error("Language server {} did not initialize within {}s", serverId, INITIALIZE_TIMEOUT_SECONDS) + process.destroy() + return null + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + process.destroy() + return null + } + server.initialized(InitializedParams()) + + val started = Session(process, server, capabilities) + session = started + openDocuments.forEach { (file, document) -> server.textDocumentService.didOpen(openParams(file, document)) } + watchForExit(started, listening) + log.info("Language server {} started", serverId) + return started + } + + private fun watchForExit( + started: Session, + listening: Future, + ) { + Thread({ + try { + listening.get() + } catch (e: ExecutionException) { + log.warn("Language server {} connection failed", serverId, e.cause ?: e) + } catch (e: CancellationException) { + log.debug("Language server {} connection cancelled", serverId) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + submit { onSessionEnded(started) } + }, "lsp-$serverId-exit").apply { + isDaemon = true + start() + } + } + + private fun onSessionEnded(ended: Session) { + if (session !== ended) return + session = null + ended.process.destroy() + crashCount++ + log.warn("Language server {} exited unexpectedly ({}/{})", serverId, crashCount, MAX_RESTARTS) + if (crashCount < MAX_RESTARTS && openDocuments.isNotEmpty()) { + start() + } + } + + private fun stopProcess(ending: Session) { + try { + ending.server.shutdown().get(SHUTDOWN_TIMEOUT_SECONDS, TimeUnit.SECONDS) + ending.server.exit() + } catch (e: ExecutionException) { + log.debug("Language server {} did not shut down cleanly", serverId, e.cause ?: e) + } catch (e: TimeoutException) { + log.debug("Language server {} did not acknowledge shutdown", serverId) + } catch (e: InterruptedException) { + Thread.currentThread().interrupt() + } + ending.process.destroy() + } + + private fun drainErrors(process: Process) { + Thread({ + try { + process.errorStream.bufferedReader().forEachLine { log.debug("[{}] {}", serverId, it) } + } catch (e: IOException) { + log.debug("Language server {} stderr closed", serverId) + } + }, "lsp-$serverId-stderr").apply { + isDaemon = true + start() + } + } + + private fun initializeParams(): InitializeParams = + InitializeParams().apply { + val root = workspaceRoot?.toPath() + rootUri = root?.toLspUri() + workspaceFolders = root?.let { listOf(WorkspaceFolder(it.toLspUri(), it.fileName.toString())) } + initializationOptions = this@ExternalLanguageServer.initializationOptions.takeIf { it.isNotEmpty() } + capabilities = + ClientCapabilities().apply { + textDocument = + TextDocumentClientCapabilities().apply { + synchronization = SynchronizationCapabilities(false, false, true) + completion = CompletionCapabilities(CompletionItemCapabilities(false)) + publishDiagnostics = PublishDiagnosticsCapabilities() + definition = DefinitionCapabilities() + references = ReferencesCapabilities() + signatureHelp = SignatureHelpCapabilities() + formatting = FormattingCapabilities() + rangeFormatting = RangeFormattingCapabilities() + selectionRange = SelectionRangeCapabilities() + } + } + } + + private fun openParams( + file: Path, + document: OpenDocument, + ): DidOpenTextDocumentParams = DidOpenTextDocumentParams(TextDocumentItem(file.toLspUri(), languageId, document.version, document.text)) + + private inner class ClientBridge : LanguageClient { + override fun publishDiagnostics(params: PublishDiagnosticsParams) { + val file = params.uri.toPath() + diagnostics[file] = params.diagnostics + val result = params.diagnostics.toDiagnosticResult(file, serverId, openDocuments[file]?.text) + uiExecutor.execute { languageClient?.publishDiagnostics(result) } + } + + override fun telemetryEvent(value: Any?) = Unit + + override fun showMessage(params: MessageParams) = logMessage(params) + + override fun showMessageRequest(params: ShowMessageRequestParams): CompletableFuture { + log.info("[{}] {}", serverId, params.message) + return CompletableFuture.completedFuture(null) + } + + override fun logMessage(params: MessageParams) { + when (params.type) { + MessageType.Error -> log.error("[{}] {}", serverId, params.message) + MessageType.Warning -> log.warn("[{}] {}", serverId, params.message) + MessageType.Info -> log.info("[{}] {}", serverId, params.message) + MessageType.Log, null -> log.debug("[{}] {}", serverId, params.message) + } + } + } + + private class Session( + val process: Process, + val server: LanguageServer, + val capabilities: ServerCapabilities, + ) { + val syncKind: TextDocumentSyncKind = + capabilities.textDocumentSync?.let { if (it.isLeft) it.left else it.right.change } ?: TextDocumentSyncKind.None + } + + data class Indentation( + val tabSize: Int, + val insertSpaces: Boolean, + ) + + private data class OpenDocument( + val version: Int, + val text: String, + ) + + companion object { + private val log = LoggerFactory.getLogger(ExternalLanguageServer::class.java) + private const val INITIALIZE_TIMEOUT_SECONDS = 60L + private const val REQUEST_TIMEOUT_SECONDS = 10L + private const val FORMAT_TIMEOUT_SECONDS = 20L + private const val SHUTDOWN_TIMEOUT_SECONDS = 3L + private const val MAX_RESTARTS = 3 + } +} + +private fun org.eclipse.lsp4j.jsonrpc.messages.Either?.isEnabled(): Boolean = this != null && (isRight || left == true) diff --git a/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/Lsp4jConversions.kt b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/Lsp4jConversions.kt new file mode 100644 index 0000000000..a3c2becb0b --- /dev/null +++ b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/Lsp4jConversions.kt @@ -0,0 +1,179 @@ +package com.itsaky.androidide.lsp.external + +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.DiagnosticResult +import com.itsaky.androidide.lsp.models.DiagnosticSeverity +import com.itsaky.androidide.lsp.models.IndexedTextEdit +import com.itsaky.androidide.lsp.models.MarkupContent +import com.itsaky.androidide.lsp.models.MarkupKind +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.eclipse.lsp4j.jsonrpc.messages.Either +import java.net.URI +import java.nio.file.Path +import java.nio.file.Paths +import org.eclipse.lsp4j.CompletionItem as LspCompletionItem +import org.eclipse.lsp4j.CompletionItemKind as LspCompletionItemKind +import org.eclipse.lsp4j.Diagnostic as LspDiagnostic +import org.eclipse.lsp4j.DiagnosticSeverity as LspDiagnosticSeverity +import org.eclipse.lsp4j.Location as LspLocation +import org.eclipse.lsp4j.LocationLink as LspLocationLink +import org.eclipse.lsp4j.MarkupContent as LspMarkupContent +import org.eclipse.lsp4j.ParameterInformation as LspParameterInformation +import org.eclipse.lsp4j.Position as LspPosition +import org.eclipse.lsp4j.Range as LspRange +import org.eclipse.lsp4j.SignatureHelp as LspSignatureHelp +import org.eclipse.lsp4j.SignatureInformation as LspSignatureInformation +import org.eclipse.lsp4j.TextEdit as LspTextEdit + +internal fun Path.toLspUri(): String = toUri().toString() + +internal fun String.toPath(): Path = Paths.get(URI(this)) + +internal fun Position.toLsp(): LspPosition = LspPosition(line, column) + +internal fun Range.toLsp(): LspRange = LspRange(start.toLsp(), end.toLsp()) + +internal fun LspRange.toIde(): Range = Range(Position(start.line, start.character), Position(end.line, end.character)) + +internal fun LspRange.toIde(lines: LineOffsets): Range = + Range( + Position(start.line, start.character, lines.indexOf(start)), + Position(end.line, end.character, lines.indexOf(end)), + ) + +internal fun LspLocation.toIde(): Location = Location(uri.toPath(), range.toIde()) + +internal fun LspLocationLink.toIde(): Location = Location(targetUri.toPath(), targetSelectionRange.toIde()) + +internal fun Either, out List>?.toIdeLocations(): List = + when { + this == null -> emptyList() + isLeft -> left.map { it.toIde() } + else -> right.map { it.toIde() } + } + +internal fun LspCompletionItem.toIde(prefix: String): CompletionItem { + val edit = textEdit + val insertion = + when { + edit == null -> insertText + edit.isLeft -> edit.left.newText + else -> edit.right.newText + } + return CompletionItem( + label, + detail ?: "", + insertion, + null, + sortText, + null, + kind.toIde(), + CompletionItem.matchLevel(filterText ?: label, prefix), + null, + null, + ) +} + +internal fun LspCompletionItemKind?.toIde(): CompletionItemKind = + when (this) { + LspCompletionItemKind.Method -> CompletionItemKind.METHOD + LspCompletionItemKind.Function -> CompletionItemKind.FUNCTION + LspCompletionItemKind.Constructor -> CompletionItemKind.CONSTRUCTOR + LspCompletionItemKind.Field -> CompletionItemKind.FIELD + LspCompletionItemKind.Variable, LspCompletionItemKind.Constant -> CompletionItemKind.VARIABLE + LspCompletionItemKind.Class, LspCompletionItemKind.Struct -> CompletionItemKind.CLASS + LspCompletionItemKind.Interface -> CompletionItemKind.INTERFACE + LspCompletionItemKind.Module -> CompletionItemKind.MODULE + LspCompletionItemKind.Property -> CompletionItemKind.PROPERTY + LspCompletionItemKind.Value -> CompletionItemKind.VALUE + LspCompletionItemKind.Enum -> CompletionItemKind.ENUM + LspCompletionItemKind.EnumMember -> CompletionItemKind.ENUM_MEMBER + LspCompletionItemKind.Keyword -> CompletionItemKind.KEYWORD + LspCompletionItemKind.Snippet -> CompletionItemKind.SNIPPET + LspCompletionItemKind.TypeParameter -> CompletionItemKind.TYPE_PARAMETER + else -> CompletionItemKind.NONE + } + +internal fun LspSignatureHelp.toIde(): SignatureHelp = + SignatureHelp( + signatures.map { it.toIde() }, + activeSignature ?: 0, + activeParameter ?: 0, + ) + +private fun LspSignatureInformation.toIde(): SignatureInformation = + SignatureInformation( + label, + documentation.toIde(), + parameters?.map { it.toIde() } ?: emptyList(), + ) + +private fun LspParameterInformation.toIde(): ParameterInformation { + val text = if (label.isLeft) label.left else "${label.right.first}-${label.right.second}" + return ParameterInformation(text, documentation.toIde()) +} + +private fun Either?.toIde(): MarkupContent = + when { + this == null -> MarkupContent() + isLeft -> MarkupContent(left, MarkupKind.PLAIN) + else -> MarkupContent(right.value, if (right.kind == "markdown") MarkupKind.MARKDOWN else MarkupKind.PLAIN) + } + +internal fun List.toDiagnosticResult( + file: Path, + source: String, + text: String?, +): DiagnosticResult { + val lines = text?.let(::LineOffsets) + return DiagnosticResult( + file, + map { diagnostic -> + DiagnosticItem( + diagnostic.message, + diagnostic.code?.let { if (it.isLeft) it.left else it.right.toString() } ?: "", + if (lines == null) diagnostic.range.toIde() else diagnostic.range.toIde(lines), + diagnostic.source ?: source, + diagnostic.severity.toIde(), + ) + }, + ) +} + +private fun LspDiagnosticSeverity?.toIde(): DiagnosticSeverity = + when (this) { + LspDiagnosticSeverity.Error -> DiagnosticSeverity.ERROR + LspDiagnosticSeverity.Warning -> DiagnosticSeverity.WARNING + LspDiagnosticSeverity.Hint -> DiagnosticSeverity.HINT + LspDiagnosticSeverity.Information, null -> DiagnosticSeverity.INFO + } + +internal fun List.toIndexedEdits(text: String): MutableList { + val lines = LineOffsets(text) + return map { IndexedTextEdit(lines.indexOf(it.range.start), lines.indexOf(it.range.end), it.newText) } + .sortedByDescending { it.start } + .toMutableList() +} + +internal class LineOffsets( + private val text: String, +) { + private val starts: IntArray = + buildList { + add(0) + text.forEachIndexed { index, c -> if (c == '\n') add(index + 1) } + }.toIntArray() + + fun indexOf(position: LspPosition): Int { + if (position.line >= starts.size) return text.length + val lineEnd = if (position.line + 1 < starts.size) starts[position.line + 1] - 1 else text.length + return minOf(starts[position.line] + position.character, lineEnd) + } +} diff --git a/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt b/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt new file mode 100644 index 0000000000..e123e674a6 --- /dev/null +++ b/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt @@ -0,0 +1,366 @@ +package com.itsaky.androidide.lsp.external + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.eventbus.events.editor.ChangeType +import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent +import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent +import com.itsaky.androidide.lsp.api.ILanguageClient +import com.itsaky.androidide.lsp.models.CompletionParams +import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.lsp.models.DiagnosticResult +import com.itsaky.androidide.lsp.models.FormatCodeParams +import com.itsaky.androidide.lsp.models.PerformCodeActionParams +import com.itsaky.androidide.lsp.models.ShowDocumentParams +import com.itsaky.androidide.lsp.models.ShowDocumentResult +import com.itsaky.androidide.models.Location +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.progress.ICancelChecker +import com.itsaky.androidide.projects.FileManager +import org.eclipse.lsp4j.CompletionItem +import org.eclipse.lsp4j.CompletionList +import org.eclipse.lsp4j.CompletionOptions +import org.eclipse.lsp4j.Diagnostic +import org.eclipse.lsp4j.DidChangeTextDocumentParams +import org.eclipse.lsp4j.DidCloseTextDocumentParams +import org.eclipse.lsp4j.DidOpenTextDocumentParams +import org.eclipse.lsp4j.DidSaveTextDocumentParams +import org.eclipse.lsp4j.DocumentFormattingParams +import org.eclipse.lsp4j.InitializeParams +import org.eclipse.lsp4j.InitializeResult +import org.eclipse.lsp4j.PublishDiagnosticsParams +import org.eclipse.lsp4j.ServerCapabilities +import org.eclipse.lsp4j.TextDocumentSyncKind +import org.eclipse.lsp4j.TextEdit +import org.eclipse.lsp4j.jsonrpc.messages.Either +import org.eclipse.lsp4j.launch.LSPLauncher +import org.eclipse.lsp4j.services.LanguageClient +import org.eclipse.lsp4j.services.LanguageClientAware +import org.eclipse.lsp4j.services.LanguageServer +import org.eclipse.lsp4j.services.TextDocumentService +import org.eclipse.lsp4j.services.WorkspaceService +import org.junit.After +import org.junit.Test +import java.io.File +import java.io.InputStream +import java.io.OutputStream +import java.io.PipedInputStream +import java.io.PipedOutputStream +import java.nio.file.Files +import java.nio.file.Path +import java.util.concurrent.CompletableFuture +import java.util.concurrent.CopyOnWriteArrayList +import java.util.concurrent.CountDownLatch +import java.util.concurrent.TimeUnit +import java.util.concurrent.atomic.AtomicInteger +import org.eclipse.lsp4j.CompletionParams as LspCompletionParams +import org.eclipse.lsp4j.Position as LspPosition +import org.eclipse.lsp4j.Range as LspRange + +class ExternalLanguageServerTest { + private val servers = mutableListOf() + private val file: Path = Files.createTempFile("sample", ".py") + + @After + fun tearDown() { + servers.forEach { it.shutdown() } + Files.deleteIfExists(file) + } + + @Test + fun `documents opened before initialization reach the server`() { + val fake = FakeServer() + val server = newServer { fake.launch() } + + server.onDocumentOpen(DocumentOpenEvent(file, "print(1)\n", 0)) + + assertThat(fake.opened.awaitBriefly()).isTrue() + assertThat(fake.openedText).containsExactly("print(1)\n") + } + + @Test + fun `changes are sent as full text with an increasing version`() { + val fake = FakeServer() + val server = newServer { fake.launch() } + server.onDocumentOpen(DocumentOpenEvent(file, "a", 0)) + fake.opened.awaitBriefly() + + FileManager.onDocumentOpen(DocumentOpenEvent(file, "a", 0)) + val change = change("ab", 1) + FileManager.onDocumentContentChange(change) + server.onDocumentChange(change) + + assertThat(fake.changed.await(5, TimeUnit.SECONDS)).isTrue() + assertThat(fake.changes).containsExactly(1 to "ab") + } + + @Test + fun `completion inserts the text edit and not the label`() { + val fake = FakeServer() + fake.completion = + CompletionItem("append()").apply { + textEdit = Either.forLeft(TextEdit(LspRange(LspPosition(0, 0), LspPosition(0, 2)), "append")) + } + val server = newServer { fake.launch() } + server.onDocumentOpen(DocumentOpenEvent(file, "ap", 0)) + fake.opened.awaitBriefly() + + val result = + server.complete( + CompletionParams(Position(0, 2, 2), file, ICancelChecker.NOOP).apply { prefix = "ap" }, + ) + + assertThat(result.items.map { it.ideLabel to it.insertText }).containsExactly("append()" to "append") + } + + @Test + fun `completion sends the editor text the server has not seen yet`() { + val fake = FakeServer() + fake.completion = CompletionItem("greet") + val server = newServer { fake.launch() } + server.onDocumentOpen(DocumentOpenEvent(file, "g = 1\n", 0)) + fake.opened.awaitBriefly() + + val result = + server.complete( + CompletionParams(Position(1, 4, 10), file, ICancelChecker.NOOP).apply { + content = "g = 1\ng.gr" + prefix = "g.gr" + }, + ) + + assertThat(fake.textAtCompletion).isEqualTo("g = 1\ng.gr") + assertThat(result.items.map { it.ideLabel }).containsExactly("greet") + } + + @Test + fun `completion is not requested when nothing has been typed`() { + val fake = FakeServer() + fake.completion = CompletionItem("AbstractSet") + val server = newServer { fake.launch() } + server.onDocumentOpen(DocumentOpenEvent(file, "def f():\n", 0)) + fake.opened.awaitBriefly() + + val result = + server.complete( + CompletionParams(Position(0, 8, 8), file, ICancelChecker.NOOP).apply { + content = "def f():\n" + prefix = "" + }, + ) + + assertThat(result.items).isEmpty() + assertThat(fake.textAtCompletion).isNull() + } + + @Test + fun `format edits are ordered so applying them in sequence is correct`() { + val content = "a=1\nb=2\n" + val fake = FakeServer() + fake.formatting = + listOf( + TextEdit(LspRange(LspPosition(0, 1), LspPosition(0, 2)), " = "), + TextEdit(LspRange(LspPosition(1, 1), LspPosition(1, 2)), " = "), + ) + val server = newServer { fake.launch() } + server.onDocumentOpen(DocumentOpenEvent(file, content, 0)) + fake.opened.awaitBriefly() + + val result = server.formatCode(FormatCodeParams(content, Range(Position(0, 0, 0), Position(2, 0, content.length)))) + + val formatted = StringBuilder(content) + result.indexedTextEdits.forEach { formatted.replace(it.start, it.end, it.newText.toString()) } + assertThat(formatted.toString()).isEqualTo("a = 1\nb = 2\n") + } + + @Test + fun `published diagnostics carry indices into the open document`() { + val fake = FakeServer() + val client = RecordingClient() + val server = newServer { fake.launch() } + server.connectClient(client) + server.onDocumentOpen(DocumentOpenEvent(file, "x = 1\nundefined_name\n", 0)) + fake.opened.awaitBriefly() + + fake.publish(file, Diagnostic(LspRange(LspPosition(1, 0), LspPosition(1, 14)), "undefined")) + + val item = client.nextDiagnostics().diagnostics.single() + assertThat(item.range.start.index).isEqualTo(6) + assertThat(item.range.end.index).isEqualTo(20) + } + + @Test + fun `a server that keeps crashing is restarted a bounded number of times`() { + val starts = AtomicInteger() + val exited = CountDownLatch(3) + val server = + newServer { + starts.incrementAndGet() + FakeServer(exitAfterInitialized = true, onExit = exited::countDown).launch() + } + + server.onDocumentOpen(DocumentOpenEvent(file, "", 0)) + + assertThat(exited.await(10, TimeUnit.SECONDS)).isTrue() + Thread.sleep(1_000) + assertThat(starts.get()).isEqualTo(3) + } + + private fun newServer(processFactory: () -> Process): ExternalLanguageServer = + ExternalLanguageServer( + serverId = "test.python", + languageId = "python", + fileExtensions = setOf("py"), + initializationOptions = emptyMap(), + processFactory = { processFactory() }, + indentation = { ExternalLanguageServer.Indentation(4, true) }, + uiExecutor = { it.run() }, + ).also(servers::add) + + private fun change( + text: String, + version: Int, + ) = DocumentChangeEvent(file, text, text, version, ChangeType.NEW_TEXT, 0, Range.NONE) + + private fun CountDownLatch.awaitBriefly(): Boolean = await(5, TimeUnit.SECONDS) + + private class FakeServer( + private val exitAfterInitialized: Boolean = false, + private val onExit: () -> Unit = {}, + ) : LanguageServer, + LanguageClientAware, + TextDocumentService, + WorkspaceService { + val opened = CountDownLatch(1) + val changed = CountDownLatch(1) + val openedText = CopyOnWriteArrayList() + val changes = CopyOnWriteArrayList>() + var completion: CompletionItem? = null + + @Volatile + var textAtCompletion: String? = null + var formatting: List = emptyList() + private lateinit var client: LanguageClient + private lateinit var toClient: PipedOutputStream + + fun launch(): Process { + val clientToServer = PipedOutputStream() + val serverIn = PipedInputStream(clientToServer, PIPE_SIZE) + toClient = PipedOutputStream() + val clientIn = PipedInputStream(toClient, PIPE_SIZE) + val launcher = LSPLauncher.createServerLauncher(this, serverIn, toClient) + connect(launcher.remoteProxy) + launcher.startListening() + return PipeProcess(clientIn, clientToServer) + } + + fun publish( + file: Path, + diagnostic: Diagnostic, + ) = client.publishDiagnostics(PublishDiagnosticsParams(file.toUri().toString(), listOf(diagnostic))) + + override fun connect(client: LanguageClient) { + this.client = client + } + + override fun initialize(params: InitializeParams): CompletableFuture = + CompletableFuture.completedFuture( + InitializeResult( + ServerCapabilities().apply { + setTextDocumentSync(TextDocumentSyncKind.Full) + completionProvider = CompletionOptions() + setDocumentFormattingProvider(true) + }, + ), + ) + + override fun initialized(params: org.eclipse.lsp4j.InitializedParams) { + if (exitAfterInitialized) { + toClient.close() + onExit() + } + } + + override fun shutdown(): CompletableFuture = CompletableFuture.completedFuture(null) + + override fun exit() = Unit + + override fun getTextDocumentService(): TextDocumentService = this + + override fun getWorkspaceService(): WorkspaceService = this + + override fun didOpen(params: DidOpenTextDocumentParams) { + openedText += params.textDocument.text + opened.countDown() + } + + override fun didChange(params: DidChangeTextDocumentParams) { + changes += params.textDocument.version to params.contentChanges.single().text + changed.countDown() + } + + override fun didClose(params: DidCloseTextDocumentParams) = Unit + + override fun didSave(params: DidSaveTextDocumentParams) = Unit + + override fun completion(position: LspCompletionParams): CompletableFuture, CompletionList>> { + textAtCompletion = changes.lastOrNull()?.second ?: openedText.lastOrNull() + return CompletableFuture.completedFuture(Either.forLeft(listOfNotNull(completion))) + } + + override fun formatting(params: DocumentFormattingParams): CompletableFuture> = + CompletableFuture.completedFuture(formatting) + + override fun didChangeConfiguration(params: org.eclipse.lsp4j.DidChangeConfigurationParams) = Unit + + override fun didChangeWatchedFiles(params: org.eclipse.lsp4j.DidChangeWatchedFilesParams) = Unit + } + + private class PipeProcess( + private val input: InputStream, + private val output: OutputStream, + ) : Process() { + private val errors = PipedInputStream(PipedOutputStream().also { it.close() }) + + override fun getOutputStream(): OutputStream = output + + override fun getInputStream(): InputStream = input + + override fun getErrorStream(): InputStream = errors + + override fun waitFor(): Int = 0 + + override fun exitValue(): Int = 0 + + override fun destroy() { + output.close() + input.close() + } + } + + private class RecordingClient : ILanguageClient { + private val published = java.util.concurrent.LinkedBlockingQueue() + + fun nextDiagnostics(): DiagnosticResult = checkNotNull(published.poll(5, TimeUnit.SECONDS)) + + override fun getDiagnosticAt( + file: File, + line: Int, + column: Int, + ): DiagnosticItem? = null + + override fun performCodeAction(params: PerformCodeActionParams) = Unit + + override fun publishDiagnostics(result: DiagnosticResult) { + published.put(result) + } + + override fun showDocument(params: ShowDocumentParams): ShowDocumentResult = ShowDocumentResult(false) + + override fun showLocations(locations: List) = Unit + } + + companion object { + private const val PIPE_SIZE = 1 shl 16 + } +} diff --git a/plugin-api/api/plugin-api.api b/plugin-api/api/plugin-api.api index d710d3f2bc..991a3bd87e 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -922,6 +922,44 @@ public abstract interface class com/itsaky/androidide/plugins/extensions/IProjec public abstract fun getType ()Lcom/itsaky/androidide/plugins/extensions/ProjectType; } +public final class com/itsaky/androidide/plugins/extensions/LanguageDefinition { + public fun (Ljava/lang/String;Ljava/util/Set;Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar;Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition;)V + public synthetic fun (Ljava/lang/String;Ljava/util/Set;Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar;Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/util/Set; + public final fun component3 ()Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar; + public final fun component4 ()Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition; + public final fun copy (Ljava/lang/String;Ljava/util/Set;Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar;Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition;)Lcom/itsaky/androidide/plugins/extensions/LanguageDefinition; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/extensions/LanguageDefinition;Ljava/lang/String;Ljava/util/Set;Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar;Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/extensions/LanguageDefinition; + public fun equals (Ljava/lang/Object;)Z + public final fun getFileExtensions ()Ljava/util/Set; + public final fun getGrammar ()Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar; + public final fun getLanguageId ()Ljava/lang/String; + public final fun getServer ()Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + +public abstract interface class com/itsaky/androidide/plugins/extensions/LanguageExtension : com/itsaky/androidide/plugins/IPlugin { + public abstract fun getLanguages ()Ljava/util/List; +} + +public final class com/itsaky/androidide/plugins/extensions/LanguageServerDefinition { + public fun (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;)V + public synthetic fun (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;ILkotlin/jvm/internal/DefaultConstructorMarker;)V + public final fun component1 ()Ljava/util/List; + public final fun component2 ()Ljava/util/Map; + public final fun component3 ()Ljava/util/Map; + public final fun copy (Ljava/util/List;Ljava/util/Map;Ljava/util/Map;)Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition;Ljava/util/List;Ljava/util/Map;Ljava/util/Map;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/extensions/LanguageServerDefinition; + public fun equals (Ljava/lang/Object;)Z + public final fun getCommand ()Ljava/util/List; + public final fun getEnvironment ()Ljava/util/Map; + public final fun getInitializationOptions ()Ljava/util/Map; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public final class com/itsaky/androidide/plugins/extensions/MenuItem { public fun (Ljava/lang/String;Ljava/lang/String;Lkotlin/jvm/functions/Function0;)V public fun (Ljava/lang/String;Ljava/lang/String;ZLkotlin/jvm/functions/Function0;)V @@ -1391,6 +1429,19 @@ public final class com/itsaky/androidide/plugins/extensions/ToolbarActionIds { public final fun getBUILD_HIDEABLE ()Ljava/util/Set; } +public final class com/itsaky/androidide/plugins/extensions/TreeSitterGrammar { + public fun (Ljava/lang/String;Ljava/lang/String;)V + public final fun component1 ()Ljava/lang/String; + public final fun component2 ()Ljava/lang/String; + public final fun copy (Ljava/lang/String;Ljava/lang/String;)Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar; + public static synthetic fun copy$default (Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar;Ljava/lang/String;Ljava/lang/String;ILjava/lang/Object;)Lcom/itsaky/androidide/plugins/extensions/TreeSitterGrammar; + public fun equals (Ljava/lang/Object;)Z + public final fun getName ()Ljava/lang/String; + public final fun getQueriesAssetPath ()Ljava/lang/String; + public fun hashCode ()I + public fun toString ()Ljava/lang/String; +} + public abstract interface class com/itsaky/androidide/plugins/extensions/UIExtension : com/itsaky/androidide/plugins/IPlugin { public abstract fun getContextMenuItems (Lcom/itsaky/androidide/plugins/extensions/ContextMenuContext;)Ljava/util/List; public abstract fun getEditorTabs ()Ljava/util/List; diff --git a/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/LanguageExtension.kt b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/LanguageExtension.kt new file mode 100644 index 0000000000..5eb4893b9d --- /dev/null +++ b/plugin-api/src/main/kotlin/com/itsaky/androidide/plugins/extensions/LanguageExtension.kt @@ -0,0 +1,25 @@ +package com.itsaky.androidide.plugins.extensions + +import com.itsaky.androidide.plugins.IPlugin + +interface LanguageExtension : IPlugin { + fun getLanguages(): List +} + +data class LanguageDefinition( + val languageId: String, + val fileExtensions: Set, + val grammar: TreeSitterGrammar? = null, + val server: LanguageServerDefinition? = null, +) + +data class TreeSitterGrammar( + val name: String, + val queriesAssetPath: String, +) + +data class LanguageServerDefinition( + val command: List, + val environment: Map = emptyMap(), + val initializationOptions: Map = emptyMap(), +) diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index aedff8ed0b..196c986afb 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -19,6 +19,8 @@ import com.itsaky.androidide.plugins.extensions.DocumentationExtension import com.itsaky.androidide.plugins.extensions.EditorDecorationProvider import com.itsaky.androidide.plugins.extensions.FileOpenExtension import com.itsaky.androidide.plugins.extensions.FileTabMenuItem +import com.itsaky.androidide.plugins.extensions.LanguageDefinition +import com.itsaky.androidide.plugins.extensions.LanguageExtension import com.itsaky.androidide.plugins.extensions.PluginSettingsEntry import com.itsaky.androidide.plugins.extensions.SettingsExtension import com.itsaky.androidide.plugins.extensions.SnippetExtension @@ -33,6 +35,7 @@ import com.itsaky.androidide.plugins.manager.context.ServiceRegistryImpl import com.itsaky.androidide.plugins.manager.context.SharedServiceRegistry import com.itsaky.androidide.plugins.manager.documentation.PluginDocumentationManager import com.itsaky.androidide.plugins.manager.fragment.PluginFragmentFactory +import com.itsaky.androidide.plugins.manager.language.PluginLanguageContribution import com.itsaky.androidide.plugins.manager.loaders.PluginLoader import com.itsaky.androidide.plugins.manager.loaders.PluginManifest import com.itsaky.androidide.plugins.manager.loaders.PluginResourceContext @@ -330,6 +333,9 @@ class PluginManager private constructor( private val documentationManager = PluginDocumentationManager(context) private var templateReloadListener: (() -> Unit)? = null private var snippetRefreshListener: ((String) -> Unit)? = null + + @Volatile + private var languageContributionsListener: (() -> Unit)? = null val crashTracker = PluginCrashTracker(context, logger) private val statePersistScope = CoroutineScope(SupervisorJob() + Dispatchers.IO) @@ -344,6 +350,16 @@ class PluginManager private constructor( this.snippetRefreshListener = listener } + fun setLanguageContributionsListener(listener: (() -> Unit)?) { + this.languageContributionsListener = listener + } + + private fun notifyLanguageContributionsChanged(plugin: IPlugin) { + if (plugin is LanguageExtension) { + languageContributionsListener?.invoke() + } + } + // Helper methods for cleaner error handling private fun executeWithErrorHandling( operationDescription: String, @@ -719,6 +735,7 @@ class PluginManager private constructor( } buildActionManager.registerManifestActions(manifest.id, manifest.name, manifest) lifecycleDispatcher.notifyActivated(manifest.id) + notifyLanguageContributionsChanged(plugin) }.onFailure { e -> logger.error("Failed to activate plugin: ${manifest.id}", e) loadedPlugin.isEnabled = false @@ -833,6 +850,7 @@ class PluginManager private constructor( if (dir.exists()) dir.deleteRecursively() } + notifyLanguageContributionsChanged(loadedPlugin.plugin) logger.info("Unloaded plugin: $pluginId") return true } catch (e: Exception) { @@ -1005,6 +1023,50 @@ class PluginManager private constructor( .map { it.plugin } .filterIsInstance() + fun getEnabledLanguageContributions(): List = + loadedPlugins.entries + .filter { it.value.isEnabled && it.value.plugin is LanguageExtension } + .flatMap { (pluginId, loaded) -> + val definitions = + executeWithErrorHandling("get language definitions", pluginId) { + (loaded.plugin as LanguageExtension).getLanguages() + }.getOrDefault(emptyList()) + val assets = PluginFragmentHelper.getPluginContext(pluginId)?.assets + if (assets == null) { + logger.error("No resource context registered for plugin $pluginId; ignoring its languages") + return@flatMap emptyList() + } + val nativeLibraryDir = + File(context.getDir("plugin_native_libs", Context.MODE_PRIVATE), pluginId).takeIf { it.isDirectory } + definitions + .filter { definition -> isLanguageAllowed(pluginId, loaded.manifest, definition, nativeLibraryDir) } + .map { definition -> PluginLanguageContribution(pluginId, definition, assets, nativeLibraryDir) } + } + + private fun isLanguageAllowed( + pluginId: String, + manifest: PluginManifest, + definition: LanguageDefinition, + nativeLibraryDir: File?, + ): Boolean { + if (definition.server?.command?.isEmpty() == true) { + logger.error("Plugin $pluginId declares a language server for '${definition.languageId}' with no command") + return false + } + if (definition.server != null && PluginPermission.SYSTEM_COMMANDS.key !in manifest.permissions) { + logger.error( + "Plugin $pluginId declares a language server for '${definition.languageId}' without the " + + "'${PluginPermission.SYSTEM_COMMANDS.key}' permission", + ) + return false + } + if (definition.grammar != null && nativeLibraryDir == null) { + logger.error("Plugin $pluginId declares a grammar for '${definition.languageId}' but bundles no native libraries") + return false + } + return true + } + fun notifyFileOpened(file: File) { getEnabledFileOpenExtensions().forEach { extension -> executeWithErrorHandling("notify file opened") { @@ -1088,6 +1150,7 @@ class PluginManager private constructor( loadedPlugin.isEnabled = false savePluginState(pluginId, false) lifecycleDispatcher.notifyDeactivated(pluginId) + notifyLanguageContributionsChanged(loadedPlugin.plugin) logger.info("Disabled plugin: $pluginId") true @@ -1110,6 +1173,7 @@ class PluginManager private constructor( loadedPlugin.isEnabled = false savePluginState(pluginId, false) lifecycleDispatcher.notifyDeactivated(pluginId) + notifyLanguageContributionsChanged(loadedPlugin.plugin) logger.warn("Force-disabled plugin due to crashes: $pluginId") } diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/language/PluginLanguageContribution.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/language/PluginLanguageContribution.kt new file mode 100644 index 0000000000..491b8186e3 --- /dev/null +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/language/PluginLanguageContribution.kt @@ -0,0 +1,12 @@ +package com.itsaky.androidide.plugins.manager.language + +import android.content.res.AssetManager +import com.itsaky.androidide.plugins.extensions.LanguageDefinition +import java.io.File + +data class PluginLanguageContribution( + val pluginId: String, + val definition: LanguageDefinition, + val assets: AssetManager, + val nativeLibraryDir: File?, +) diff --git a/settings.gradle.kts b/settings.gradle.kts index 8216735c17..406b51f2d2 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -131,6 +131,7 @@ include( ":uidesigner", ":xml-inflater", ":lsp:api", + ":lsp:external", ":lsp:models", ":lsp:indexing", ":lsp:java", From df08d67ef908c99cf83fd5caafb899c4cb6972fe Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 4 Oct 2026 20:47:19 +0100 Subject: [PATCH 5/8] style: spotless reformat, no functional change --- .../treesitter/TreeSitterLanguageProvider.kt | 36 ++++++++++--------- 1 file changed, 19 insertions(+), 17 deletions(-) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt index 1d96de374b..27442210e6 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt @@ -26,24 +26,26 @@ import java.io.File * @author Akash Yadav */ object TreeSitterLanguageProvider { + fun hasTsLanguage(file: File): Boolean = TSLanguageRegistry.instance.hasLanguage(file.extension) - fun hasTsLanguage(file: File) : Boolean { - return TSLanguageRegistry.instance.hasLanguage(file.extension) - } + fun forFile( + file: File, + context: Context, + ): TreeSitterLanguage? { + if (!hasTsLanguage(file)) { + return null + } - fun forFile(file: File, context: Context): TreeSitterLanguage? { - if (!hasTsLanguage(file)) { - return null - } + return forType(file.extension, context) + } - return forType(file.extension, context) - } - - fun forType(type: String, context: Context): TreeSitterLanguage? { - return try { - TSLanguageRegistry.instance.getFactory(type).create(context) - } catch (e: TSLanguageRegistry.NotRegisteredException) { - null - } - } + fun forType( + type: String, + context: Context, + ): TreeSitterLanguage? = + try { + TSLanguageRegistry.instance.getFactory(type).create(context) + } catch (e: TSLanguageRegistry.NotRegisteredException) { + null + } } From 9d56adeff725e8fd15e5b54f8d780c6b515295d2 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 4 Oct 2026 20:47:40 +0100 Subject: [PATCH 6/8] ADFA-4851: Address review of plugin languages - Open editors pick up a plugin language when it loads, is enabled or is disabled: PluginLanguageSupport posts PluginLanguagesChangedEvent and EditorHandlerActivity reloads matching editors through postRead, the path a rename already uses. Files restored before plugins finished loading used to stay without highlighting or a server until reopened. - A grammar that will not load, has an invalid name or whose queries do not parse raises GrammarLoadException; TreeSitterLanguageProvider logs it and the file opens without tree-sitter instead of failing to open. - Plugin grammars are reference counted per TSLanguage instance and closed once their plugin is gone and no editor uses them, so an updated plugin loads its new grammar. Counting per instance keeps a re-enabled plugin from having the cached grammar its new editors share closed underneath them. - Server ids include the claimed extensions, so two definitions from one plugin with the same languageId no longer collide. - Plugin languages without a colour scheme of their own get the default locals capture spec again instead of the generic scheme, which maps no locals. - Formatting logs when no open document matches the editor text instead of returning nothing silently. --- .../editor/EditorHandlerActivity.kt | 9 ++ .../events/PluginLanguagesChangedEvent.kt | 7 ++ .../androidide/lsp/PluginLanguageSupport.kt | 42 ++++++--- .../itsaky/androidide/ui/CodeEditorView.kt | 5 ++ docs/PLUGIN_AUTHORING.md | 2 + .../treesitter/PluginTreeSitterLanguage.kt | 89 ++++++++++++++++--- .../treesitter/TreeSitterLanguageProvider.kt | 6 ++ .../schemes/LocalCaptureSpecProvider.kt | 5 +- .../lsp/external/ExternalLanguageServer.kt | 5 +- 9 files changed, 142 insertions(+), 28 deletions(-) create mode 100644 app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index 899b672a0b..beab95b325 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -81,6 +81,7 @@ import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.file.FileRenameEvent import com.itsaky.androidide.eventbus.events.plugin.PluginCrashedEvent import com.itsaky.androidide.eventbus.events.preferences.PreferenceChangeEvent +import com.itsaky.androidide.events.PluginLanguagesChangedEvent import com.itsaky.androidide.floating.model.DockingManager import com.itsaky.androidide.floating.window.OverlayDialogs import com.itsaky.androidide.fragments.sidebar.EditorSidebarFragment @@ -1963,6 +1964,14 @@ open class EditorHandlerActivity : tab.text = if (isModified) "*$baseName" else baseName } + @Subscribe(threadMode = ThreadMode.MAIN) + fun onPluginLanguagesChanged(event: PluginLanguagesChangedEvent) { + editorViewModel + .getOpenedFiles() + .filter { it.extension.lowercase() in event.fileTypes } + .forEach { getEditorForFile(it)?.reloadLanguage() } + } + @Subscribe(threadMode = ThreadMode.MAIN) fun onPluginCrashed(event: PluginCrashedEvent) { if (event.wasDisabled) { diff --git a/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt b/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt new file mode 100644 index 0000000000..08d5bff9ac --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt @@ -0,0 +1,7 @@ +package com.itsaky.androidide.events + +import com.itsaky.androidide.eventbus.events.Event + +data class PluginLanguagesChangedEvent( + val fileTypes: Set, +) : Event() diff --git a/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt b/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt index 85bb628e1c..1fa6ced58a 100644 --- a/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt +++ b/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt @@ -3,9 +3,11 @@ package com.itsaky.androidide.lsp import android.os.Handler import android.os.Looper import com.itsaky.androidide.app.IDEApplication +import com.itsaky.androidide.editor.language.treesitter.PluginGrammars import com.itsaky.androidide.editor.language.treesitter.PluginTreeSitterLanguage import com.itsaky.androidide.editor.language.treesitter.TSLanguageRegistry import com.itsaky.androidide.editor.language.treesitter.TreeSitterLanguage +import com.itsaky.androidide.events.PluginLanguagesChangedEvent import com.itsaky.androidide.lsp.api.ILanguageServerRegistry import com.itsaky.androidide.lsp.external.ExternalLanguageServer import com.itsaky.androidide.plugins.extensions.LanguageServerDefinition @@ -13,6 +15,7 @@ import com.itsaky.androidide.plugins.manager.language.PluginLanguageContribution import com.itsaky.androidide.preferences.internal.EditorPreferences import com.itsaky.androidide.utils.Environment import com.itsaky.androidide.utils.TermuxProcessEnvironment +import org.greenrobot.eventbus.EventBus import org.slf4j.LoggerFactory import java.io.File import java.util.concurrent.Executor @@ -49,18 +52,24 @@ object PluginLanguageSupport { private fun reconcile() { val contributions = IDEApplication.getPluginManager()?.getEnabledLanguageContributions() ?: emptyList() - synchronized(lock) { - val keep = installed.filter { it.contribution in contributions } - installed.filterNot { it in keep }.forEach(::uninstall) - - val claimed = keep.flatMapTo(mutableSetOf()) { it.fileTypes } - val added = - contributions - .filter { contribution -> keep.none { it.contribution == contribution } } - .mapNotNull { install(it, claimed) } - installed = keep + added - installed.forEach(::registerGrammar) - installed.forEach(::registerServer) + val changedTypes = + synchronized(lock) { + val keep = installed.filter { it.contribution in contributions } + val removed = installed.filterNot { it in keep } + removed.forEach(::uninstall) + + val claimed = keep.flatMapTo(mutableSetOf()) { it.fileTypes } + val added = + contributions + .filter { contribution -> keep.none { it.contribution == contribution } } + .mapNotNull { install(it, claimed) } + installed = keep + added + installed.forEach(::registerGrammar) + installed.forEach(::registerServer) + (removed + added).flatMapTo(mutableSetOf()) { it.fileTypes } + } + if (changedTypes.isNotEmpty()) { + EventBus.getDefault().post(PluginLanguagesChangedEvent(changedTypes)) } } @@ -82,7 +91,10 @@ object PluginLanguageSupport { if (fileTypes.isEmpty()) return null claimed += fileTypes - val serverId = definition.server?.let { "plugin.${contribution.pluginId}.${definition.languageId}" } + val serverId = + definition.server?.let { + "plugin.${contribution.pluginId}.${definition.languageId}.${fileTypes.sorted().joinToString("+")}" + } val factories = grammarFactories(contribution, fileTypes, serverId) log.info("Installed {} from plugin {} for {}", definition.languageId, contribution.pluginId, fileTypes) return InstalledLanguage(contribution, fileTypes, serverId, factories) @@ -107,6 +119,10 @@ object PluginLanguageSupport { private fun uninstall(language: InstalledLanguage) { language.grammarFactories.forEach { (type, factory) -> TSLanguageRegistry.instance.unregister(type, factory) } + if (language.grammarFactories.isNotEmpty()) { + language.contribution.definition.grammar + ?.let { PluginGrammars.retire(it.name) } + } val serverId = language.serverId ?: return val registry = ILanguageServerRegistry.default if (registry.getServer(serverId) != null) { diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index 125bce8982..e4fec5c51b 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -331,6 +331,11 @@ class CodeEditorView( postRead(file) } + fun reloadLanguage() { + if (_binding == null) return + postRead(file ?: return) + } + /** * Called when the editor has been selected and is visible to the user. */ diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 44332bc83f..a4292b2a6e 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -362,6 +362,8 @@ override fun getLanguages() = listOf( - **Grammar.** Ship `lib//libtree-sitter-.so` for `arm64-v8a` and `armeabi-v7a`, exporting `tree_sitter_`, generated at tree-sitter language ABI 13 or 14 (grammar releases up to 0.23.x). Build it from the grammar's `src/parser.c` and `src/scanner.c` with `externalNativeBuild`; `Python-Tools` and `Go-Tools` in `plugin-examples` do this. Needs `native.code`. - **Queries.** Put `highlights.scm` (optionally `locals.scm`, `blocks.scm`, `brackets.scm`, `indents.scm`) in `assets//`. Use standard capture names so every colour scheme can style them. When two patterns capture the same node at the same position, the one listed **first** wins, so put catch-all patterns such as `(identifier) @variable` last. - **Server.** Any stdio language server. A bare command resolves against the Termux `bin` directory; an absolute path runs as is. It starts when the first matching file opens, with the Termux environment plus `environment`, and is restarted up to twice if it crashes. Needs `system.commands`. Install the server yourself, for example with `IdeCommandService`. +- If the grammar library will not load or a query does not parse, the IDE logs the error and opens the file without tree-sitter highlighting. +- Files that are already open switch to the language when the plugin is loaded, enabled or disabled; the user does not need to reopen them. - File extensions the IDE already handles cannot be claimed. Set `plugin.min_ide_version` to `26.41` or later. ## Troubleshooting diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt index 0599480b16..de2c554bf1 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt @@ -9,14 +9,19 @@ import com.itsaky.androidide.editor.schemes.LocalCaptureSpecProvider.newLocalCap import com.itsaky.androidide.lsp.api.ILanguageServer import com.itsaky.androidide.lsp.api.ILanguageServerRegistry import com.itsaky.androidide.treesitter.TSLanguage +import com.itsaky.androidide.treesitter.TSLanguageCache import io.github.rosemoe.sora.util.MyCharacter import java.io.File +import java.util.IdentityHashMap class PluginTreeSitterLanguage( langType: String, languageSpec: TreeSitterLanguageSpec, + private val grammar: TSLanguage, private val languageServerId: String?, ) : TreeSitterLanguage(langType, languageSpec) { + private var destroyed = false + override val languageServer: ILanguageServer? get() = languageServerId?.let { ILanguageServerRegistry.default.getServer(it) } @@ -24,6 +29,13 @@ class PluginTreeSitterLanguage( override fun createNewlineHandlers(): Array = arrayOf(TSCStyleBracketsHandler(this)) + override fun destroy() { + if (destroyed) return + destroyed = true + super.destroy() + PluginGrammars.release(grammar) + } + class Factory( private val langType: String, private val grammarLibrary: File, @@ -33,19 +45,72 @@ class PluginTreeSitterLanguage( private val languageServerId: String?, ) : TreeSitterLanguage.Factory { override fun create(context: Context): PluginTreeSitterLanguage { - val grammar = - TSLanguage.loadLanguage(grammarLibrary.absolutePath, grammarName) - ?: throw IllegalStateException( - "Unable to load tree_sitter_$grammarName from ${grammarLibrary.absolutePath}", - ) + val grammar = PluginGrammars.acquire(grammarLibrary, grammarName) val spec = - LanguageSpecProvider.getLanguageSpec( - queriesAssets, - queriesDir, - grammar, - newLocalCaptureSpec(langType), - ) - return PluginTreeSitterLanguage(langType, spec, languageServerId) + try { + LanguageSpecProvider.getLanguageSpec( + queriesAssets, + queriesDir, + grammar, + newLocalCaptureSpec(langType), + ) + } catch (e: IllegalArgumentException) { + PluginGrammars.release(grammar) + throw GrammarLoadException("Invalid tree-sitter queries for $grammarName in $queriesDir", e) + } + return PluginTreeSitterLanguage(langType, spec, grammar, languageServerId) + } + } + + class GrammarLoadException( + message: String, + cause: Throwable? = null, + ) : RuntimeException(message, cause) +} + +object PluginGrammars { + private val users = IdentityHashMap() + private val retired = mutableSetOf() + + @Synchronized + fun acquire( + library: File, + name: String, + ): TSLanguage { + val grammar = + try { + TSLanguage.loadLanguage(library.absolutePath, name) + } catch (e: IllegalArgumentException) { + throw PluginTreeSitterLanguage.GrammarLoadException("Invalid tree-sitter grammar name '$name'", e) + } ?: throw PluginTreeSitterLanguage.GrammarLoadException( + "Unable to load tree_sitter_$name from ${library.absolutePath}", + ) + users[grammar] = (users[grammar] ?: 0) + 1 + return grammar + } + + @Synchronized + fun release(grammar: TSLanguage) { + val remaining = (users[grammar] ?: return) - 1 + if (remaining > 0) { + users[grammar] = remaining + return } + users.remove(grammar) + if (grammar.name in retired) closeGrammar(grammar) + } + + @Synchronized + fun retire(name: String) { + if (users.keys.any { it.name == name }) { + retired += name + return + } + TSLanguageCache.get(name)?.takeIf { it.isExternal }?.close() + } + + private fun closeGrammar(grammar: TSLanguage) { + retired.remove(grammar.name) + grammar.close() } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt index 27442210e6..adb550c1b9 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt @@ -18,6 +18,7 @@ package com.itsaky.androidide.editor.language.treesitter import android.content.Context +import org.slf4j.LoggerFactory import java.io.File /** @@ -26,6 +27,8 @@ import java.io.File * @author Akash Yadav */ object TreeSitterLanguageProvider { + private val log = LoggerFactory.getLogger(TreeSitterLanguageProvider::class.java) + fun hasTsLanguage(file: File): Boolean = TSLanguageRegistry.instance.hasLanguage(file.extension) fun forFile( @@ -47,5 +50,8 @@ object TreeSitterLanguageProvider { TSLanguageRegistry.instance.getFactory(type).create(context) } catch (e: TSLanguageRegistry.NotRegisteredException) { null + } catch (e: PluginTreeSitterLanguage.GrammarLoadException) { + log.error("Plugin grammar for '.{}' failed to load; opening without tree-sitter", type, e) + null } } diff --git a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt index 8a2f6dfb9a..f3f08f4d9d 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/schemes/LocalCaptureSpecProvider.kt @@ -30,14 +30,15 @@ object LocalCaptureSpecProvider { @JvmStatic fun newLocalCaptureSpec(type: String): LocalsCaptureSpec { - val lang = - IDEColorSchemeProvider.getColorSchemeForType(type)?.getLanguageScheme(type) + val scheme = + IDEColorSchemeProvider.getColorSchemeForType(type) ?: run { log.error( "Cannot create LocalsCaptureSpec. Failed to load current color scheme. Falling back to default implementation", ) return LocalsCaptureSpec.DEFAULT } + val lang = scheme.languages[type] ?: return LocalsCaptureSpec.DEFAULT return object : LocalsCaptureSpec() { override fun isDefinitionCapture(captureName: String): Boolean = lang.isLocalDef(captureName) diff --git a/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt index 224b120260..a7a545180e 100644 --- a/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt +++ b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt @@ -246,7 +246,10 @@ class ExternalLanguageServer( ) { server -> val file = openDocuments.entries.firstOrNull { it.value.text == content }?.key - ?: return@request CompletableFuture.completedFuture(null) + ?: run { + log.warn("Language server {} cannot format: no open document matches the editor text", serverId) + return@request CompletableFuture.completedFuture(null) + } val document = TextDocumentIdentifier(file.toLspUri()) val capabilities = session?.capabilities if (!wholeDocument && capabilities?.documentRangeFormattingProvider.isEnabled()) { From 1115cad070bacad6afae4a780e9174bcb969255f Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Sun, 4 Oct 2026 20:47:51 +0100 Subject: [PATCH 7/8] ADFA-4851: Warn about an undeclared package name only when the template uses it A plugin template built without showPackageNameOption(), such as Go Starter, declares no packageName, and ZipRecipeExecutor warned about that on every creation, so the new project opened with a false "Project creation finished with warnings/errors" banner. The warning now fires only when the template references PACKAGE_NAME in an entry path or a .peb source without declaring it, the case where the project silently gets the hidden default package name. renderTemplateEntry reuses the new source reader. --- .../templates/impl/zip/ZipRecipeExecutor.kt | 38 +++++++++--- .../impl/zip/ZipRecipeExecutorTest.kt | 58 ++++++++++++++++++- 2 files changed, 87 insertions(+), 9 deletions(-) diff --git a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt index 56445afcb2..859fd452a8 100644 --- a/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt +++ b/templates-impl/src/main/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutor.kt @@ -24,6 +24,7 @@ import org.adfa.constants.KOTLIN_VERSION import org.adfa.constants.Sdk import org.slf4j.LoggerFactory import java.io.File +import java.io.IOException import java.io.StringWriter import java.util.ServiceLoader import java.util.zip.ZipEntry @@ -131,7 +132,10 @@ class ZipRecipeExecutor( .build() val className = data.name.replace(CLASS_NAME_PATTERN, "") - val (baseIdentifiers, warnings) = metaJson.pebbleParams(ctx, data, defModule, params) + val (baseIdentifiers, warnings) = + metaJson.pebbleParams(ctx, data, defModule, params) { identifier -> + zip.templateReferences(ctx, identifier) + } val identifiers = baseIdentifiers + mapOf( @@ -192,6 +196,28 @@ class ZipRecipeExecutor( } } + private fun ZipFile.templateReferences( + ctx: Context, + identifier: String, + ): Boolean = + entries() + .asSequence() + .filter { it.name.startsWith("$basePath/") && !it.name.startsWith("$basePath/$META_FOLDER/") } + .any { entry -> + identifier in entry.name || + (entry.name.endsWith(TEMPLATE_EXTENSION) && identifier in readTemplateSource(ctx, entry)) + } + + private fun ZipFile.readTemplateSource( + ctx: Context, + entry: ZipEntry, + ): String = + try { + getInputStream(entry).bufferedReader().use { it.readText() } + } catch (e: IOException) { + throw e.wrap(ctx, R.string.template_exec_error_read_fail, entry.name) + } + private fun processEntry( ctx: Context, zip: ZipFile, @@ -225,12 +251,7 @@ class ZipRecipeExecutor( ) { info(ctx, R.string.template_exec_info_processing, entry.name) - val content = - try { - zip.getInputStream(entry).bufferedReader().use { it.readText() } - } catch (e: Exception) { - throw e.wrap(ctx, R.string.template_exec_error_read_fail, entry.name) - } + val content = zip.readTemplateSource(ctx, entry) val template = try { @@ -323,6 +344,7 @@ class ZipRecipeExecutor( data: ProjectTemplateData, defModule: ModuleTemplateData, params: MutableMap>, + templateReferences: (String) -> Boolean, ): Pair, List> { val warnings = mutableListOf() @@ -336,7 +358,7 @@ class ZipRecipeExecutor( } val packageName = resolveString(parameters?.required?.packageName?.identifier, KEY_PACKAGE_NAME) - if (packageName.usedDefault) { + if (packageName.usedDefault && templateReferences(packageName.value)) { warnings += ctx.getString( R.string.template_exec_warn_map_pkgname, diff --git a/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt index d1edb96050..2cc6a415bd 100644 --- a/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt +++ b/templates-impl/src/test/java/com/itsaky/androidide/templates/impl/zip/ZipRecipeExecutorTest.kt @@ -141,6 +141,36 @@ class ZipRecipeExecutorTest { assertThat(File(projectDir, "hello.txt").readText()).isEqualTo("Hello TestApp!") } + @Test + fun `a template that declares no package name finishes without warnings`() { + val zip = buildZip(mapOf("tpl/hello.txt.peb" to "Hello \${{ APP_NAME }}!")) + + val result = executor(zip, metaJson = templateWithoutPackageName()).execute(recipeExecutor()) + + assertThat(File(projectDir, "hello.txt").readText()).isEqualTo("Hello TestApp!") + assertThat(result.hasErrorsWarnings).isFalse() + } + + @Test + fun `a template that uses an undeclared package name in a file warns`() { + val zip = buildZip(mapOf("tpl/Main.kt.peb" to "package \${{ PACKAGE_NAME }}")) + + val result = executor(zip, metaJson = templateWithoutPackageName()).execute(recipeExecutor()) + + assertThat(File(projectDir, "Main.kt").readText()).isEqualTo("package com.example.app") + assertThat(result.hasErrorsWarnings).isTrue() + } + + @Test + fun `a template that uses an undeclared package name in a path warns`() { + val zip = buildZip(mapOf("tpl/src/PACKAGE_NAME/Main.txt" to "static")) + + val result = executor(zip, metaJson = templateWithoutPackageName()).execute(recipeExecutor()) + + assertThat(File(projectDir, "src/com/example/app/Main.txt").readText()).isEqualTo("static") + assertThat(result.hasErrorsWarnings).isTrue() + } + @Test fun `existing project dir is left untouched`() { projectDir.mkdirs() @@ -164,9 +194,36 @@ class ZipRecipeExecutorTest { return file } + private fun templateWithoutPackageName() = + TemplateJson( + name = "Test", + description = null, + version = null, + parameters = + ParametersJson( + required = + RequiredParametersJson( + appName = IdentifierJson("APP_NAME"), + saveLocation = IdentifierJson("SAVE_LOCATION"), + ), + ), + system = + SystemParametersJson( + agpVersion = IdentifierJson("AGP_VERSION"), + kotlinVersion = IdentifierJson("KOTLIN_VERSION"), + gradleVersion = IdentifierJson("GRADLE_VERSION"), + compileSdk = IdentifierJson("COMPILE_SDK"), + targetSdk = IdentifierJson("TARGET_SDK"), + javaSourceCompat = IdentifierJson("JAVA_SOURCE_COMPAT"), + javaTargetCompat = IdentifierJson("JAVA_TARGET_COMPAT"), + javaTarget = IdentifierJson("JAVA_TARGET"), + ), + ) + private fun executor( zip: File, basePath: String = "tpl", + metaJson: TemplateJson = TemplateJson(name = "Test", description = null, version = null), ): ZipRecipeExecutor { val data = ProjectTemplateData( @@ -186,7 +243,6 @@ class ZipRecipeExecutorTest { language = Language.Kotlin, minSdk = Sdk.Lollipop, ) - val metaJson = TemplateJson(name = "Test", description = null, version = null) return ZipRecipeExecutor({ ZipFile(zip) }, metaJson, mutableMapOf(), basePath, data, module) } From cf19bb66ba76bf74d2a706226a21aab4c9228725 Mon Sep 17 00:00:00 2001 From: Daniel Alome Date: Mon, 5 Oct 2026 16:50:48 +0100 Subject: [PATCH 8/8] ADFA-4851: Keep plugin languages correct across activity and plugin changes - Language changes made while the editor is stopped are no longer lost. Plugins are toggled in a separate activity and BaseIDEActivity unregisters from EventBus in onStop, so the change event never arrived. PluginLanguageSupport now keeps the changed file types until the editor takes them, on the event or in onStart. - Affected editors release their language before any of them reloads, so a retired grammar is closed before the new one loads and an updated plugin gets its new library instead of the cached old one. - PluginGrammars records the library each grammar came from. TSLanguageCache is keyed by name only, so a cached grammar is reused only when it came from the same library and its plugin was not retired; otherwise it is closed when unused, or the load fails with GrammarLoadException when an editor still uses it. A name the IDE itself loaded is never taken over. - A RuntimeException in an ExternalLanguageServer task is logged and stops the server instead of reaching the uncaught-exception handler and crashing the IDE. A server that returns no capabilities from initialize is stopped with an error, and PluginManager rejects environment variables that ProcessBuilder would refuse. --- .../editor/EditorHandlerActivity.kt | 22 ++++++-- .../events/PluginLanguagesChangedEvent.kt | 4 +- .../androidide/lsp/PluginLanguageSupport.kt | 14 +++-- .../itsaky/androidide/ui/CodeEditorView.kt | 5 ++ docs/PLUGIN_AUTHORING.md | 4 +- .../treesitter/PluginTreeSitterLanguage.kt | 32 +++++++++-- .../lsp/external/ExternalLanguageServer.kt | 31 ++++++++--- .../external/ExternalLanguageServerTest.kt | 53 +++++++++++++++---- .../plugins/manager/core/PluginManager.kt | 11 ++++ 9 files changed, 142 insertions(+), 34 deletions(-) diff --git a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt index beab95b325..f4f2f92e9b 100644 --- a/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt +++ b/app/src/main/java/com/itsaky/androidide/activities/editor/EditorHandlerActivity.kt @@ -639,6 +639,7 @@ open class EditorHandlerActivity : override fun onStart() { super.onStart() + reloadChangedPluginLanguages() lifecycleScope.launch { try { @@ -1965,11 +1966,22 @@ open class EditorHandlerActivity : } @Subscribe(threadMode = ThreadMode.MAIN) - fun onPluginLanguagesChanged(event: PluginLanguagesChangedEvent) { - editorViewModel - .getOpenedFiles() - .filter { it.extension.lowercase() in event.fileTypes } - .forEach { getEditorForFile(it)?.reloadLanguage() } + fun onPluginLanguagesChanged( + @Suppress("UNUSED_PARAMETER") event: PluginLanguagesChangedEvent, + ) { + reloadChangedPluginLanguages() + } + + private fun reloadChangedPluginLanguages() { + val fileTypes = PluginLanguageSupport.takeChangedFileTypes() + if (fileTypes.isEmpty()) return + val editors = + editorViewModel + .getOpenedFiles() + .filter { it.extension.lowercase() in fileTypes } + .mapNotNull { getEditorForFile(it) } + editors.forEach { it.releaseLanguage() } + editors.forEach { it.reloadLanguage() } } @Subscribe(threadMode = ThreadMode.MAIN) diff --git a/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt b/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt index 08d5bff9ac..f6bda67fd9 100644 --- a/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt +++ b/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt @@ -2,6 +2,4 @@ package com.itsaky.androidide.events import com.itsaky.androidide.eventbus.events.Event -data class PluginLanguagesChangedEvent( - val fileTypes: Set, -) : Event() +class PluginLanguagesChangedEvent : Event() diff --git a/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt b/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt index 1fa6ced58a..2f97d22482 100644 --- a/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt +++ b/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt @@ -28,6 +28,12 @@ object PluginLanguageSupport { private val refreshExecutor = Executors.newSingleThreadExecutor { Thread(it, "plugin-languages") } private val lock = Any() private var installed: List = emptyList() + private val changedFileTypes = mutableSetOf() + + fun takeChangedFileTypes(): Set = + synchronized(lock) { + changedFileTypes.toSet().also { changedFileTypes.clear() } + } fun serverIdFor(file: File): String? { val type = file.extension.lowercase() @@ -52,7 +58,7 @@ object PluginLanguageSupport { private fun reconcile() { val contributions = IDEApplication.getPluginManager()?.getEnabledLanguageContributions() ?: emptyList() - val changedTypes = + val changed = synchronized(lock) { val keep = installed.filter { it.contribution in contributions } val removed = installed.filterNot { it in keep } @@ -66,10 +72,10 @@ object PluginLanguageSupport { installed = keep + added installed.forEach(::registerGrammar) installed.forEach(::registerServer) - (removed + added).flatMapTo(mutableSetOf()) { it.fileTypes } + changedFileTypes.addAll((removed + added).flatMap { it.fileTypes }) } - if (changedTypes.isNotEmpty()) { - EventBus.getDefault().post(PluginLanguagesChangedEvent(changedTypes)) + if (changed) { + EventBus.getDefault().post(PluginLanguagesChangedEvent()) } } diff --git a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt index e4fec5c51b..6b18c644ba 100644 --- a/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt +++ b/app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt @@ -61,6 +61,7 @@ import com.itsaky.androidide.utils.dpToPx import io.github.rosemoe.sora.event.ClickEvent import io.github.rosemoe.sora.event.InterceptTarget import io.github.rosemoe.sora.event.TextSizeChangeEvent +import io.github.rosemoe.sora.lang.EmptyLanguage import io.github.rosemoe.sora.text.Content import io.github.rosemoe.sora.text.LineSeparator import io.github.rosemoe.sora.util.IntPair @@ -331,6 +332,10 @@ class CodeEditorView( postRead(file) } + fun releaseLanguage() { + _binding?.editor?.setEditorLanguage(EmptyLanguage()) + } + fun reloadLanguage() { if (_binding == null) return postRead(file ?: return) diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index a4292b2a6e..2a98c17a30 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -359,9 +359,9 @@ override fun getLanguages() = listOf( ) ``` -- **Grammar.** Ship `lib//libtree-sitter-.so` for `arm64-v8a` and `armeabi-v7a`, exporting `tree_sitter_`, generated at tree-sitter language ABI 13 or 14 (grammar releases up to 0.23.x). Build it from the grammar's `src/parser.c` and `src/scanner.c` with `externalNativeBuild`; `Python-Tools` and `Go-Tools` in `plugin-examples` do this. Needs `native.code`. +- **Grammar.** Ship `lib//libtree-sitter-.so` for `arm64-v8a` and `armeabi-v7a`, exporting `tree_sitter_`, generated at tree-sitter language ABI 13 or 14 (grammar releases up to 0.23.x). Build it from the grammar's `src/parser.c` and `src/scanner.c` with `externalNativeBuild`; `Python-Tools` and `Go-Tools` in `plugin-examples` do this. Needs `native.code`. Grammar names are shared by every plugin: while one plugin's `` grammar is open in an editor, another plugin's grammar of the same name does not load and its files open without highlighting. - **Queries.** Put `highlights.scm` (optionally `locals.scm`, `blocks.scm`, `brackets.scm`, `indents.scm`) in `assets//`. Use standard capture names so every colour scheme can style them. When two patterns capture the same node at the same position, the one listed **first** wins, so put catch-all patterns such as `(identifier) @variable` last. -- **Server.** Any stdio language server. A bare command resolves against the Termux `bin` directory; an absolute path runs as is. It starts when the first matching file opens, with the Termux environment plus `environment`, and is restarted up to twice if it crashes. Needs `system.commands`. Install the server yourself, for example with `IdeCommandService`. +- **Server.** Any stdio language server. A bare command resolves against the Termux `bin` directory; an absolute path runs as is. It starts when the first matching file opens, with the Termux environment plus `environment`, and is restarted up to twice if it crashes. Needs `system.commands`. Environment variable names must be non-empty and contain no `=` or NUL, and values no NUL, or the language is not registered. Install the server yourself, for example with `IdeCommandService`. - If the grammar library will not load or a query does not parse, the IDE logs the error and opens the file without tree-sitter highlighting. - Files that are already open switch to the language when the plugin is loaded, enabled or disabled; the user does not need to reopen them. - File extensions the IDE already handles cannot be claimed. Set `plugin.min_ide_version` to `26.41` or later. diff --git a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt index de2c554bf1..b3c6e2e699 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt @@ -70,6 +70,7 @@ class PluginTreeSitterLanguage( object PluginGrammars { private val users = IdentityHashMap() + private val libraries = IdentityHashMap() private val retired = mutableSetOf() @Synchronized @@ -77,18 +78,38 @@ object PluginGrammars { library: File, name: String, ): TSLanguage { + val path = library.absolutePath + TSLanguageCache.get(name)?.let { cached -> evictUnlessReusable(cached, name, path) } val grammar = try { - TSLanguage.loadLanguage(library.absolutePath, name) + TSLanguage.loadLanguage(path, name) } catch (e: IllegalArgumentException) { throw PluginTreeSitterLanguage.GrammarLoadException("Invalid tree-sitter grammar name '$name'", e) - } ?: throw PluginTreeSitterLanguage.GrammarLoadException( - "Unable to load tree_sitter_$name from ${library.absolutePath}", - ) + } ?: throw PluginTreeSitterLanguage.GrammarLoadException("Unable to load tree_sitter_$name from $path") + libraries[grammar] = path users[grammar] = (users[grammar] ?: 0) + 1 return grammar } + private fun evictUnlessReusable( + cached: TSLanguage, + name: String, + path: String, + ) { + val owner = + libraries[cached] + ?: throw PluginTreeSitterLanguage.GrammarLoadException( + "tree_sitter_$name is already loaded by the IDE; a plugin grammar cannot reuse its name", + ) + if (owner == path && name !in retired) return + if (users.containsKey(cached)) { + throw PluginTreeSitterLanguage.GrammarLoadException( + "tree_sitter_$name from $owner is still open in an editor; $path cannot be loaded until it is closed", + ) + } + closeGrammar(cached) + } + @Synchronized fun release(grammar: TSLanguage) { val remaining = (users[grammar] ?: return) - 1 @@ -106,11 +127,12 @@ object PluginGrammars { retired += name return } - TSLanguageCache.get(name)?.takeIf { it.isExternal }?.close() + TSLanguageCache.get(name)?.takeIf { it in libraries }?.let(::closeGrammar) } private fun closeGrammar(grammar: TSLanguage) { retired.remove(grammar.name) + libraries.remove(grammar) grammar.close() } } diff --git a/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt index a7a545180e..87d5a10c76 100644 --- a/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt +++ b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt @@ -139,9 +139,11 @@ class ExternalLanguageServer( } EventBus.getDefault().unregister(this) messageExecutor.execute { - val ending = session ?: return@execute - session = null - stopProcess(ending) + runGuarded { + val ending = session ?: return@runGuarded + session = null + stopProcess(ending) + } } messageExecutor.shutdown() } @@ -333,10 +335,21 @@ class ExternalLanguageServer( private fun submit(task: () -> Unit): Boolean = synchronized(lifecycleLock) { if (closed) return false - messageExecutor.execute(task) + messageExecutor.execute { runGuarded(task) } true } + private fun runGuarded(task: () -> Unit) { + try { + task() + } catch (e: RuntimeException) { + log.error("Language server {} failed and was stopped", serverId, e) + val failed = session ?: return + session = null + failed.process.destroy() + } + } + private fun request( cancelChecker: ICancelChecker, timeoutSeconds: Long, @@ -388,9 +401,9 @@ class ExternalLanguageServer( val launcher = LSPLauncher.createClientLauncher(ClientBridge(), process.inputStream, process.outputStream) val listening = launcher.startListening() val server = launcher.remoteProxy - val capabilities = + val initialized = try { - server.initialize(initializeParams()).get(INITIALIZE_TIMEOUT_SECONDS, TimeUnit.SECONDS).capabilities + server.initialize(initializeParams()).get(INITIALIZE_TIMEOUT_SECONDS, TimeUnit.SECONDS) } catch (e: ExecutionException) { log.error("Language server {} failed to initialize", serverId, e.cause ?: e) process.destroy() @@ -404,6 +417,12 @@ class ExternalLanguageServer( process.destroy() return null } + val capabilities = + initialized?.capabilities ?: run { + log.error("Language server {} returned no capabilities from initialize", serverId) + process.destroy() + return null + } server.initialized(InitializedParams()) val started = Session(process, server, capabilities) diff --git a/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt b/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt index e123e674a6..6bbc4170de 100644 --- a/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt +++ b/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt @@ -6,6 +6,7 @@ import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.models.CompletionParams +import com.itsaky.androidide.lsp.models.CompletionResult import com.itsaky.androidide.lsp.models.DiagnosticItem import com.itsaky.androidide.lsp.models.DiagnosticResult import com.itsaky.androidide.lsp.models.FormatCodeParams @@ -60,11 +61,13 @@ import org.eclipse.lsp4j.Range as LspRange class ExternalLanguageServerTest { private val servers = mutableListOf() private val file: Path = Files.createTempFile("sample", ".py") + private var restoreHandler: () -> Unit = {} @After fun tearDown() { servers.forEach { it.shutdown() } Files.deleteIfExists(file) + restoreHandler() } @Test @@ -206,6 +209,40 @@ class ExternalLanguageServerTest { assertThat(starts.get()).isEqualTo(3) } + @Test + fun `a server that reports no capabilities is left stopped instead of crashing the IDE`() { + val uncaught = captureUncaught() + val fake = FakeServer(capabilities = null) + val server = newServer { fake.launch() } + + server.onDocumentOpen(DocumentOpenEvent(file, "print(1)\n", 0)) + Thread.sleep(1_000) + + assertThat(uncaught).isEmpty() + assertThat( + server.complete(CompletionParams(Position(0, 1, 1), file, ICancelChecker.NOOP).apply { prefix = "p" }), + ).isEqualTo(CompletionResult.EMPTY) + } + + @Test + fun `a process that cannot be started is logged instead of crashing the IDE`() { + val uncaught = captureUncaught() + val server = newServer { throw IllegalArgumentException("Invalid environment variable name: \"A=B\"") } + + server.onDocumentOpen(DocumentOpenEvent(file, "", 0)) + Thread.sleep(1_000) + + assertThat(uncaught).isEmpty() + } + + private fun captureUncaught(): MutableList { + val uncaught = CopyOnWriteArrayList() + val previous = Thread.getDefaultUncaughtExceptionHandler() + Thread.setDefaultUncaughtExceptionHandler { _, e -> uncaught += e } + restoreHandler = { Thread.setDefaultUncaughtExceptionHandler(previous) } + return uncaught + } + private fun newServer(processFactory: () -> Process): ExternalLanguageServer = ExternalLanguageServer( serverId = "test.python", @@ -227,6 +264,12 @@ class ExternalLanguageServerTest { private class FakeServer( private val exitAfterInitialized: Boolean = false, private val onExit: () -> Unit = {}, + private val capabilities: ServerCapabilities? = + ServerCapabilities().apply { + setTextDocumentSync(TextDocumentSyncKind.Full) + completionProvider = CompletionOptions() + setDocumentFormattingProvider(true) + }, ) : LanguageServer, LanguageClientAware, TextDocumentService, @@ -264,15 +307,7 @@ class ExternalLanguageServerTest { } override fun initialize(params: InitializeParams): CompletableFuture = - CompletableFuture.completedFuture( - InitializeResult( - ServerCapabilities().apply { - setTextDocumentSync(TextDocumentSyncKind.Full) - completionProvider = CompletionOptions() - setDocumentFormattingProvider(true) - }, - ), - ) + CompletableFuture.completedFuture(capabilities?.let(::InitializeResult) ?: InitializeResult()) override fun initialized(params: org.eclipse.lsp4j.InitializedParams) { if (exitAfterInitialized) { diff --git a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt index 196c986afb..5ca056e890 100644 --- a/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt +++ b/plugin-manager/src/main/kotlin/com/itsaky/androidide/plugins/manager/core/PluginManager.kt @@ -1060,6 +1060,17 @@ class PluginManager private constructor( ) return false } + val invalidVariable = + definition.server?.environment?.entries?.firstOrNull { (name, value) -> + name.isEmpty() || '=' in name || '\u0000' in name || '\u0000' in value + } + if (invalidVariable != null) { + logger.error( + "Plugin $pluginId declares an invalid environment variable '${invalidVariable.key}' for the " + + "'${definition.languageId}' language server", + ) + return false + } if (definition.grammar != null && nativeLibraryDir == null) { logger.error("Plugin $pluginId declares a grammar for '${definition.languageId}' but bundles no native libraries") return false