Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -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` `<meta-data>` contract, permissions, extensions. See [plugin-api.md](docs/plugin-api.md) for the API surface & compatibility policy. |
Expand Down
1 change: 1 addition & 0 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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()
}

Expand Down Expand Up @@ -636,6 +639,7 @@ open class EditorHandlerActivity :

override fun onStart() {
super.onStart()
reloadChangedPluginLanguages()

lifecycleScope.launch {
try {
Expand Down Expand Up @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -361,6 +362,8 @@ internal object CredentialProtectedApplicationLoader : ApplicationLoader {
logger = pluginLogger,
)

pluginManager?.setLanguageContributionsListener(PluginLanguageSupport::refresh)

// Set up plugin service providers
setupPluginServices()
setupPluginInflationErrorHandler()
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
package com.itsaky.androidide.events

import com.itsaky.androidide.eventbus.events.Event

class PluginLanguagesChangedEvent : Event()
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -30,22 +31,21 @@ 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) {
ILanguageServerRegistry.default.connectClient(client)
}

@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) {
Expand Down
184 changes: 184 additions & 0 deletions app/src/main/java/com/itsaky/androidide/lsp/PluginLanguageSupport.kt
Original file line number Diff line number Diff line change
@@ -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<InstalledLanguage> = emptyList()
private val changedFileTypes = mutableSetOf<String>()

fun takeChangedFileTypes(): Set<String> =
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<String>,
): 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<String>,
serverId: String?,
): Map<String, TreeSitterLanguage.Factory<*>> {
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<String>,
val serverId: String?,
val grammarFactories: Map<String, TreeSitterLanguage.Factory<*>>,
)
}
13 changes: 12 additions & 1 deletion app/src/main/java/com/itsaky/androidide/ui/CodeEditorView.kt
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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.
*/
Expand Down Expand Up @@ -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)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package com.itsaky.androidide.utils

import java.io.File

object TermuxProcessEnvironment {
fun applyTo(
env: MutableMap<String, String>,
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")
}
}
Loading
Loading