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..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 @@ -81,12 +81,14 @@ 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 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 +383,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() } @@ -636,6 +639,7 @@ open class EditorHandlerActivity : override fun onStart() { super.onStart() + reloadChangedPluginLanguages() lifecycleScope.launch { try { @@ -1961,6 +1965,25 @@ open class EditorHandlerActivity : tab.text = if (isModified) "*$baseName" else baseName } + @Subscribe(threadMode = ThreadMode.MAIN) + 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) fun onPluginCrashed(event: PluginCrashedEvent) { if (event.wasDisabled) { 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/events/PluginLanguagesChangedEvent.kt b/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt new file mode 100644 index 0000000000..f6bda67fd9 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/events/PluginLanguagesChangedEvent.kt @@ -0,0 +1,5 @@ +package com.itsaky.androidide.events + +import com.itsaky.androidide.eventbus.events.Event + +class PluginLanguagesChangedEvent : Event() 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..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 @@ -30,13 +31,13 @@ import com.itsaky.androidide.utils.FeatureFlags * @author Akash Yadav */ object LspHandler { - fun registerLanguageServers() { ILanguageServerRegistry.default.apply { getServer(JavaLanguageServer.SERVER_ID) ?: register(JavaLanguageServer()) getServer(KotlinLanguageServer.SERVER_ID) ?: register(KotlinLanguageServer()) getServer(XMLLanguageServer.SERVER_ID) ?: register(XMLLanguageServer()) } + PluginLanguageSupport.registerServers() } fun connectClient(client: ILanguageClient) { @@ -44,8 +45,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/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..2f97d22482 --- /dev/null +++ b/app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt @@ -0,0 +1,184 @@ +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 +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 +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() + private val changedFileTypes = mutableSetOf() + + fun takeChangedFileTypes(): Set = + synchronized(lock) { + changedFileTypes.toSet().also { changedFileTypes.clear() } + } + + 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() + val changed = + 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) + changedFileTypes.addAll((removed + added).flatMap { it.fileTypes }) + } + if (changed) { + EventBus.getDefault().post(PluginLanguagesChangedEvent()) + } + } + + 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}.${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) + } + + 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) } + 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) { + 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..6b18c644ba 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 @@ -60,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 @@ -330,6 +332,15 @@ class CodeEditorView( postRead(file) } + fun releaseLanguage() { + _binding?.editor?.setEditorLanguage(EmptyLanguage()) + } + + fun reloadLanguage() { + if (_binding == null) return + postRead(file ?: return) + } + /** * Called when the editor has been selected and is visible to the user. */ @@ -619,7 +630,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/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/docs/PLUGIN_API_CHANGELOG.md b/docs/PLUGIN_API_CHANGELOG.md index 07d22a8b28..7a9f73b90d 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 — No cap on sidebar items** _(ADFA-4977)_ The sidebar held 12 items: the IDE's seven plus the slots plugins declared with `plugin.sidebar_items`. A plugin declaring more than the free slots failed to load, and diff --git a/docs/PLUGIN_AUTHORING.md b/docs/PLUGIN_AUTHORING.md index 4a2bbeca71..2a98c17a30 100644 --- a/docs/PLUGIN_AUTHORING.md +++ b/docs/PLUGIN_AUTHORING.md @@ -344,6 +344,28 @@ 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`. 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`. 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. + ## 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 396498cb35..9b1a83256e 100644 --- a/docs/plugin-api.md +++ b/docs/plugin-api.md @@ -10,14 +10,18 @@ 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`, `LanguageDefinition`, `TreeSitterGrammar`, `LanguageServerDefinition`). - 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`, `EmbeddingModelSelectable`, `StatusReportingBackend`, `ActiveModelReportingBackend`), `BackendChangeListener`, which a *consumer* implements, 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`, `StatusReportingToolSource`, `GroupedToolSource`, `ToolSpec` and `ToolGroup`, which a *contributing* plugin implements, `ToolSourceListener`, which a *consumer* implements, `ToolInvocation`, which AI-Core constructs and passes to `ToolSource.invoke`, and `ToolOutcome`, which the source returns. Both contracts report health as the top-level `CapabilityStatus` enum. - 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`); and `ai.LlmBackendRegistration`, which keeps a backend plugin registered with AI-Core's router. 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`). - 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..b3c6e2e699 --- /dev/null +++ b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/PluginTreeSitterLanguage.kt @@ -0,0 +1,138 @@ +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 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) } + + override fun checkIsCompletionChar(c: Char): Boolean = MyCharacter.isJavaIdentifierPart(c) || c == '.' + + 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, + 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 = PluginGrammars.acquire(grammarLibrary, grammarName) + val spec = + 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 libraries = IdentityHashMap() + private val retired = mutableSetOf() + + @Synchronized + fun acquire( + library: File, + name: String, + ): TSLanguage { + val path = library.absolutePath + TSLanguageCache.get(name)?.let { cached -> evictUnlessReusable(cached, name, path) } + val grammar = + try { + 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 $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 + 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 in libraries }?.let(::closeGrammar) + } + + private fun closeGrammar(grammar: TSLanguage) { + retired.remove(grammar.name) + libraries.remove(grammar) + grammar.close() + } +} 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..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 @@ -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]. @@ -71,17 +72,24 @@ 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. */ 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/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/TreeSitterLanguageProvider.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageProvider.kt index 1d96de374b..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,24 +27,31 @@ import java.io.File * @author Akash Yadav */ object TreeSitterLanguageProvider { + private val log = LoggerFactory.getLogger(TreeSitterLanguageProvider::class.java) - fun hasTsLanguage(file: File) : Boolean { - return TSLanguageRegistry.instance.hasLanguage(file.extension) - } + fun hasTsLanguage(file: File): Boolean = 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 + } 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/language/treesitter/TreeSitterLanguageSpec.kt b/editor/src/main/java/com/itsaky/androidide/editor/language/treesitter/TreeSitterLanguageSpec.kt index dc89e349d5..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 @@ -29,39 +29,37 @@ 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() + 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..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 @@ -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,17 @@ 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 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 79aadfe4fa..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 @@ -36,50 +37,61 @@ 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 = getLanguageSpec(context.assets, "${BASE_SPEC_PATH}/$type", lang, localsCaptureSpec) - @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( + assets: AssetManager, + queriesDir: String, + lang: TSLanguage, + localsCaptureSpec: LocalsCaptureSpec = LocalsCaptureSpec.DEFAULT, + ): TreeSitterLanguageSpec { + val editorLangSpec = + TsLanguageSpec( + language = lang, + highlightScmSource = readScheme(assets, queriesDir, "highlights"), + localsScmSource = readScheme(assets, queriesDir, "locals"), + codeBlocksScmSource = readScheme(assets, queriesDir, "blocks"), + bracketsScmSource = readScheme(assets, queriesDir, "brackets"), + localsCaptureSpec = localsCaptureSpec, + predicates = + listOf( + MatchPredicate, + NotMatchPredicate, + EqualPredicate, + NotEqualPredicate, + AnyOfPredicate, + ), + ) + return TreeSitterLanguageSpec( + spec = editorLangSpec, + indentsQueryScm = readScheme(assets, queriesDir, "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( + assets: AssetManager, + queriesDir: String, + name: String, + ): String = + try { + 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 {} 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 d9e9c49150..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 @@ -26,40 +26,29 @@ 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 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) + + 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..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 @@ -33,15 +33,22 @@ import kotlin.concurrent.withLock /** * Thread-safe implementation of [ILanguageServerRegistry]. - * + * * @author Akash Yadav */ 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) { @@ -55,9 +62,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 +79,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) @@ -96,6 +104,8 @@ class DefaultLanguageServerRegistry : ILanguageServerRegistry() { lock.writeLock().withLock { mRegister.clear() } + connectedClient = null + currentWorkspace = null } override fun getServer(serverId: String): ILanguageServer? { @@ -111,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() } @@ -124,21 +135,21 @@ 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) { - 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/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 + } +} 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..87d5a10c76 --- /dev/null +++ b/lsp/external/src/main/java/com/itsaky/androidide/lsp/external/ExternalLanguageServer.kt @@ -0,0 +1,579 @@ +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 { + runGuarded { + val ending = session ?: return@runGuarded + 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 + ?: 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()) { + 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 { 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, + 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 initialized = + try { + 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() + 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 + } + 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) + 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..6bbc4170de --- /dev/null +++ b/lsp/external/src/test/java/com/itsaky/androidide/lsp/external/ExternalLanguageServerTest.kt @@ -0,0 +1,401 @@ +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.CompletionResult +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") + private var restoreHandler: () -> Unit = {} + + @After + fun tearDown() { + servers.forEach { it.shutdown() } + Files.deleteIfExists(file) + restoreHandler() + } + + @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) + } + + @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", + 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 = {}, + private val capabilities: ServerCapabilities? = + ServerCapabilities().apply { + setTextDocumentSync(TextDocumentSyncKind.Full) + completionProvider = CompletionOptions() + setDocumentFormattingProvider(true) + }, + ) : 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(capabilities?.let(::InitializeResult) ?: InitializeResult()) + + 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 8fa5072a4b..adaf15aa4f 100644 --- a/plugin-api/api/plugin-api.api +++ b/plugin-api/api/plugin-api.api @@ -936,6 +936,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 @@ -1405,6 +1443,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 1fc1f3d164..46264abdf4 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 @@ -18,6 +18,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 @@ -32,6 +34,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 @@ -329,6 +332,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) @@ -343,6 +349,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, @@ -711,6 +727,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 @@ -825,6 +842,7 @@ class PluginManager private constructor( if (dir.exists()) dir.deleteRecursively() } + notifyLanguageContributionsChanged(loadedPlugin.plugin) logger.info("Unloaded plugin: $pluginId") return true } catch (e: Exception) { @@ -997,6 +1015,61 @@ 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 + } + 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 + } + return true + } + fun notifyFileOpened(file: File) { getEnabledFileOpenExtensions().forEach { extension -> executeWithErrorHandling("notify file opened") { @@ -1080,6 +1153,7 @@ class PluginManager private constructor( loadedPlugin.isEnabled = false savePluginState(pluginId, false) lifecycleDispatcher.notifyDeactivated(pluginId) + notifyLanguageContributionsChanged(loadedPlugin.plugin) logger.info("Disabled plugin: $pluginId") true @@ -1102,6 +1176,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/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..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 @@ -25,235 +26,225 @@ 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) + TermuxProcessEnvironment.applyTo(processBuilder.environment(), appFilesDir) + + 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", + ) + } + } + + 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 + } +} 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", 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) }