diff --git a/common/build.gradle.kts b/common/build.gradle.kts index ffb8278904..ed723738b4 100755 --- a/common/build.gradle.kts +++ b/common/build.gradle.kts @@ -28,6 +28,7 @@ dependencies { api(libs.androidx.core.ktx) api(libs.common.kotlin) + api(libs.kotlinx.coroutines.core) api(projects.buildInfo) api(projects.eventbusAndroid) diff --git a/common/src/main/java/com/itsaky/androidide/tasks/coroutineUtils.kt b/common/src/main/java/com/itsaky/androidide/tasks/coroutineUtils.kt index 510e9279ce..1505d54a07 100644 --- a/common/src/main/java/com/itsaky/androidide/tasks/coroutineUtils.kt +++ b/common/src/main/java/com/itsaky/androidide/tasks/coroutineUtils.kt @@ -27,6 +27,8 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.cancel import kotlinx.coroutines.coroutineScope +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.ensureActive import kotlinx.coroutines.launch import java.io.InterruptedIOException import kotlin.coroutines.CoroutineContext @@ -44,8 +46,17 @@ class JobCancelChecker @JvmOverloads constructor( job = null super.cancel() } + + override fun abortIfCancelled() { + job?.ensureActive() + } } +/** + * Create an [ICancelChecker] for the current [Job]. + */ +suspend fun createJobCancelChecker() = JobCancelChecker(currentCoroutineContext()[Job]) + /** * Calls [CoroutineScope.cancel] only if a job is active in the scope. * @@ -66,6 +77,8 @@ fun CoroutineScope.cancelIfActive(exception: CancellationException? = null) { job?.cancel(exception) } +suspend fun ensureCoroutineContextActive() = currentCoroutineContext().ensureActive() + /** * Launches a new coroutine without blocking the current thread. This method shows a progress * indicator using [Flashbar] while the [action] is being executed. The [Flashbar] is automatically diff --git a/common/src/main/java/com/itsaky/androidide/utils/CollectionExts.kt b/common/src/main/java/com/itsaky/androidide/utils/CollectionExts.kt new file mode 100644 index 0000000000..310b7b7a9a --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/CollectionExts.kt @@ -0,0 +1,5 @@ +package com.itsaky.androidide.utils + +inline fun > T.ifNotEmpty(crossinline action: T.() -> Unit) { + if (isNotEmpty()) action() +} diff --git a/common/src/main/java/com/itsaky/androidide/utils/KeyedDebouncingAction.kt b/common/src/main/java/com/itsaky/androidide/utils/KeyedDebouncingAction.kt new file mode 100644 index 0000000000..f10d9b9dd0 --- /dev/null +++ b/common/src/main/java/com/itsaky/androidide/utils/KeyedDebouncingAction.kt @@ -0,0 +1,69 @@ +package com.itsaky.androidide.utils + +import com.itsaky.androidide.progress.ICancelChecker +import com.itsaky.androidide.tasks.JobCancelChecker +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.channels.Channel +import kotlinx.coroutines.currentCoroutineContext +import kotlinx.coroutines.delay +import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.launch +import java.util.concurrent.ConcurrentHashMap +import kotlin.coroutines.CoroutineContext +import kotlin.time.Duration +import kotlin.time.Duration.Companion.milliseconds + +class KeyedDebouncingAction( + private val scope: CoroutineScope, + private val debounceDuration: Duration = DEBOUNCE_DURATION_DEFAULT, + private val actionContext: CoroutineContext = Dispatchers.Default, + private val action: suspend (T, ICancelChecker) -> Unit, +) { + + private data class ActionEntry( + val channel: Channel, + val job: Job, + ) { + fun cancel() { + channel.close() + job.cancel() + } + } + + private val pending = ConcurrentHashMap>() + + companion object { + val DEBOUNCE_DURATION_DEFAULT = 400.milliseconds + } + + fun cancelPending(key: T) { + pending.remove(key)?.cancel() + } + + fun schedule(key: T) { + val entry = pending.computeIfAbsent(key) { createEntry() } + entry.channel.trySend(key) + } + + private fun createEntry(): ActionEntry { + val channel = Channel(Channel.CONFLATED) + val job = scope.launch(actionContext) { + for (latestKey in channel) { + delay(debounceDuration) + ensureActive() + + val cancelChecker = JobCancelChecker(currentCoroutineContext()[Job]) + action(latestKey, cancelChecker) + } + } + + return ActionEntry(channel, job) + } + + fun cancelAll() { + pending.values.forEach { it.cancel() } + pending.clear() + } +} \ No newline at end of file diff --git a/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java b/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java index 5a6b39e86a..ceb427e721 100644 --- a/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java +++ b/logger/src/main/java/com/itsaky/androidide/logging/utils/LogUtils.java @@ -27,7 +27,7 @@ public class LogUtils { public static final int MAX_TAG_LENGTH = 23; - public static final String PATTERN_LAYOUT_MESSAGE_PATTERN = "%msg%n"; + public static final String PATTERN_LAYOUT_MESSAGE_PATTERN = "[%thread] %msg%n"; public static boolean isJvm() { try { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt index 1cd9653187..1da225d4df 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt @@ -22,7 +22,7 @@ import com.itsaky.androidide.app.configuration.IJdkDistributionProvider import com.itsaky.androidide.eventbus.events.editor.DocumentChangeEvent import com.itsaky.androidide.eventbus.events.editor.DocumentCloseEvent import com.itsaky.androidide.eventbus.events.editor.DocumentOpenEvent -import com.itsaky.androidide.eventbus.events.editor.DocumentSelectedEvent +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 @@ -48,17 +48,14 @@ import com.itsaky.androidide.models.Range import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.projects.ProjectManagerImpl import com.itsaky.androidide.projects.api.Workspace +import com.itsaky.androidide.tasks.createJobCancelChecker import com.itsaky.androidide.utils.DocumentUtils import com.itsaky.androidide.utils.Environment +import com.itsaky.androidide.utils.ifNotEmpty import kotlinx.coroutines.CoroutineName import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.cancel -import kotlinx.coroutines.delay -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext import org.appdevforall.codeonthego.indexing.jvm.JvmLibraryIndexingService import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex @@ -69,23 +66,19 @@ import org.jetbrains.kotlin.config.JvmTarget import org.jetbrains.kotlin.config.LanguageVersion import org.jetbrains.kotlin.platform.jvm.JvmPlatforms import org.slf4j.LoggerFactory -import java.nio.file.Files import java.nio.file.Path import java.nio.file.Paths -import kotlin.time.Duration.Companion.milliseconds class KotlinLanguageServer : ILanguageServer { private var _client: ILanguageClient? = null private var _settings: IServerSettings? = null - private var selectedFile: Path? = null private var initialized = false private val scope = CoroutineScope(SupervisorJob() + CoroutineName(KotlinLanguageServer::class.simpleName!!)) private var projectModel: KotlinProjectModel? = null private var compiler: Compiler? = null - private var analyzeJob: Job? = null override val serverId: String = SERVER_ID @@ -96,7 +89,6 @@ class KotlinLanguageServer : ILanguageServer { get() = _settings ?: KotlinServerSettings.getInstance().also { _settings = it } companion object { - private val ANALYZE_DEBOUNCE_DELAY = 400.milliseconds const val SERVER_ID = "ide.lsp.kotlin" private val logger = LoggerFactory.getLogger(KotlinLanguageServer::class.java) @@ -121,6 +113,7 @@ class KotlinLanguageServer : ILanguageServer { override fun connectClient(client: ILanguageClient?) { this._client = client + this.compiler?.updateLanguageClient(client) } override fun applySettings(settings: IServerSettings?) { @@ -184,13 +177,22 @@ class KotlinLanguageServer : ILanguageServer { languageVersion = LanguageVersion.LATEST_STABLE, ) + compiler.updateLanguageClient(client) this.compiler = compiler } else { logger.info("Updating project model") - projectModel?.update(workspace, jvmPlatform) } + // Open already open files + // we won't get an event for these + FileManager.activeDocuments.ifNotEmpty { + forEach { document -> + compiler?.compilationEnvironmentFor(document.file) + ?.openFileIfNeeded(document.file) + } + } + initialized = true logger.info("Kotlin project initialized") } @@ -262,7 +264,8 @@ class KotlinLanguageServer : ILanguageServer { return DiagnosticResult.NO_UPDATE } - return compiler?.compilationEnvironmentFor(file)?.collectDiagnosticsFor(file) + return compiler?.compilationEnvironmentFor(file) + ?.let { context(it) { collectDiagnosticsFor(file, createJobCancelChecker()) } } ?: DiagnosticResult.NO_UPDATE } @@ -273,32 +276,8 @@ class KotlinLanguageServer : ILanguageServer { return } - compiler?.compilationEnvironmentFor(event.openedFile)?.apply { - onFileOpen(event.openedFile) - } - - selectedFile = event.openedFile - debouncingAnalyze() - } - - private fun debouncingAnalyze() { - analyzeJob?.cancel() - analyzeJob = scope.launch(Dispatchers.Default) { - delay(ANALYZE_DEBOUNCE_DELAY) - analyzeSelected() - } - } - - private suspend fun analyzeSelected() { - val file = selectedFile ?: return - val client = _client ?: return - - if (!Files.exists(file)) return - - val result = analyze(file) - withContext(Dispatchers.Main) { - client.publishDiagnostics(result) - } + compiler?.compilationEnvironmentFor(event.openedFile) + ?.onFileOpen(event.openedFile) } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -308,11 +287,9 @@ class KotlinLanguageServer : ILanguageServer { return } - compiler?.compilationEnvironmentFor(event.changedFile)?.apply { - onFileContentChanged(event.changedFile) - } + compiler?.compilationEnvironmentFor(event.changedFile) + ?.onFileContentChanged(event.changedFile) - debouncingAnalyze() } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -322,26 +299,19 @@ class KotlinLanguageServer : ILanguageServer { return } - compiler?.compilationEnvironmentFor(event.closedFile)?.apply { - onFileClosed(event.closedFile) - } + compiler?.compilationEnvironmentFor(event.closedFile) + ?.onFileClosed(event.closedFile) - if (FileManager.getActiveDocumentCount() == 0) { - selectedFile = null - analyzeJob?.cancel("No active files") - } } @Subscribe(threadMode = ThreadMode.ASYNC) @Suppress("unused") - fun onDocumentSelected(event: DocumentSelectedEvent) { - if (!DocumentUtils.isKotlinFile(event.selectedFile)) { + fun onDocumentSaved(event: DocumentSaveEvent) { + if (!DocumentUtils.isKotlinFile(event.savedFile)) { return } - selectedFile = event.selectedFile - val uri = event.selectedFile.toUri().toString() - - logger.debug("onDocumentSelected: uri={}", uri) + compiler?.compilationEnvironmentFor(event.savedFile) + ?.onFileSaved(event.savedFile) } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt index 70e7019c29..b3b60f92bf 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/CompilationEnvironment.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.lsp.kotlin.compiler +import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.kotlin.compiler.index.KtSymbolIndex import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence @@ -12,9 +13,16 @@ import com.itsaky.androidide.lsp.kotlin.compiler.services.KtLspService import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider import com.itsaky.androidide.lsp.kotlin.compiler.services.WriteAccessGuard import com.itsaky.androidide.lsp.kotlin.compiler.services.latestLanguageVersionSettings +import com.itsaky.androidide.utils.KeyedDebouncingAction +import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor import com.itsaky.androidide.lsp.kotlin.utils.SymbolVisibilityChecker import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.projects.api.Workspace +import kotlinx.coroutines.CoroutineName +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.withContext import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex import org.jetbrains.kotlin.K1Deprecation @@ -88,6 +96,7 @@ import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path import kotlin.io.path.pathString +import kotlin.time.Duration.Companion.milliseconds /** * A compilation environment for compiling Kotlin sources. @@ -100,16 +109,20 @@ import kotlin.io.path.pathString @Suppress("UnstableApiUsage") @OptIn(K1Deprecation::class) internal class CompilationEnvironment( + name: String, workspace: Workspace, val ktProject: KotlinProjectModel, val intellijPluginRoot: Path, val jdkHome: Path, val jdkRelease: Int, val languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, - val enableParserEventSystem: Boolean = true + val enableParserEventSystem: Boolean = true, + val coroutineScope: CoroutineScope = CoroutineScope(SupervisorJob() + CoroutineName("CompilationEnv[$name]")) ) : KotlinProjectModel.ProjectModelListener, AutoCloseable { private var disposable = Disposer.newDisposable() + private var _languageClient: ILanguageClient? = null + val fileAnalyzer: KeyedDebouncingAction val projectEnv: KotlinCoreProjectEnvironment val applicationEnv: KotlinCoreApplicationEnvironment @@ -158,6 +171,12 @@ internal class CompilationEnvironment( SymbolVisibilityChecker(provider) } + var languageClient: ILanguageClient? + get() = _languageClient + set(value) { + _languageClient = value + } + val ktSymbolIndex by lazy { KtSymbolIndex( project = project, @@ -189,6 +208,9 @@ internal class CompilationEnvironment( } companion object { + + val DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION = 400.milliseconds + private val logger = LoggerFactory.getLogger(CompilationEnvironment::class.java) } @@ -341,6 +363,17 @@ internal class CompilationEnvironment( commandProcessor = application.getService(CommandProcessor::class.java) parser = KtPsiFactory(project, eventSystemEnabled = enableParserEventSystem) + fileAnalyzer = KeyedDebouncingAction( + scope = coroutineScope, + debounceDuration = DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION + ) { path, cancelChecker -> + val result = collectDiagnosticsFor(path, cancelChecker) + + withContext(Dispatchers.Main.immediate) { + languageClient?.publishDiagnostics(result) + } + } + // Sync the index in the background ktSymbolIndex.syncIndexInBackground() } @@ -364,12 +397,23 @@ internal class CompilationEnvironment( } } + fun openFileIfNeeded(path: Path) { + ktSymbolIndex.getOpenedKtFile(path) + ?: onFileOpen(path) + } + fun onFileOpen(path: Path) { val ktFile = loadKtFile(path) ?: return ktSymbolIndex.openKtFile(path, ktFile) + fileAnalyzer.schedule(path) + } + + fun onFileSaved(path: Path) { + fileAnalyzer.schedule(path) } fun onFileClosed(path: Path) { + fileAnalyzer.cancelPending(path) ktSymbolIndex.closeKtFile(path) (project.getService(KotlinProjectStructureProvider::class.java) as ProjectStructureProvider) .unregisterInMemoryFile(path.pathString) @@ -387,6 +431,7 @@ internal class CompilationEnvironment( ktSymbolIndex.openKtFile(path, newKtFile) ktSymbolIndex.queueOnFileChangedAsync(newKtFile) + fileAnalyzer.schedule(path) project.write { KaSourceModificationService.getInstance(project) .handleElementModification(newKtFile, KaElementModificationType.Unknown) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt index 977e1e61aa..e71fa50dd4 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/Compiler.kt @@ -1,5 +1,6 @@ package com.itsaky.androidide.lsp.kotlin.compiler +import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.projects.api.Workspace import com.itsaky.androidide.utils.DocumentUtils import org.jetbrains.kotlin.com.intellij.lang.Language @@ -37,6 +38,7 @@ internal class Compiler( init { defaultCompilationEnv = CompilationEnvironment( + name = "default", workspace = workspace, ktProject = projectModel, intellijPluginRoot = intellijPluginRoot, @@ -51,6 +53,11 @@ internal class Compiler( VirtualFileManager.getInstance().getFileSystem(StandardFileSystems.FILE_PROTOCOL) } + fun updateLanguageClient(client: ILanguageClient?) { + defaultCompilationEnv.languageClient = client + // TODO: update client for script env once we have one + } + fun compilationKindFor(file: Path): CompilationKind { // TODO: This should return a different environment for Kotlin script files return CompilationKind.Default diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt index 1d890426c1..c6f6027173 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexCommand.kt @@ -8,6 +8,8 @@ internal sealed interface IndexCommand { data object SourceScanningComplete: IndexCommand data object IndexingComplete: IndexCommand data class ScanSourceFile(val vf: VirtualFile): IndexCommand - data class IndexModifiedFile(val ktFile: KtFile): IndexCommand + data class IndexModifiedFile(val ktFile: KtFile): IndexCommand { + + } data class IndexSourceFile(val vf: VirtualFile): IndexCommand } \ No newline at end of file diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt index 60f55f8634..520c37e63d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/IndexWorker.kt @@ -1,6 +1,11 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index +import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.progress.ICancelChecker +import com.itsaky.androidide.utils.KeyedDebouncingAction +import kotlinx.coroutines.CoroutineScope import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex @@ -8,21 +13,48 @@ import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.psi.PsiManager import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory +import java.nio.file.Path internal class IndexWorker( private val project: Project, private val queue: WorkerQueue, private val fileIndex: KtFileMetadataIndex, private val sourceIndex: JvmSymbolIndex, + private val scope: CoroutineScope, ) { companion object { private val logger = LoggerFactory.getLogger(IndexWorker::class.java) } + private class ModFileIndexKey( + val path: Path, + val ktFile: KtFile, + ) { + override fun equals(other: Any?): Boolean { + return path == (other as? ModFileIndexKey)?.path + } + + override fun hashCode(): Int { + return path.hashCode() + } + + operator fun component1() = path + operator fun component2() = ktFile + } + suspend fun start() { var scanCount = 0 var sourceIndexCount = 0 + val modifiedFileIndexer = KeyedDebouncingAction( + scope = scope, + debounceDuration = CompilationEnvironment.DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION + ) { (path, ktFile), cancelChecker -> + logger.debug("Indexing modified file: {}", path) + indexSourceFile(project, ktFile, fileIndex, sourceIndex, cancelChecker) + sourceIndexCount++ + } + while (true) { when (val command = queue.take()) { is IndexCommand.IndexSourceFile -> { @@ -41,14 +73,24 @@ internal class IndexWorker( continue } - indexSourceFile(project, ktFile, fileIndex, sourceIndex) + indexSourceFile( + project = project, + ktFile = ktFile, + fileIndex = fileIndex, + symbolsIndex = sourceIndex, + cancelChecker = ICancelChecker.NOOP + ) + sourceIndexCount++ } is IndexCommand.IndexModifiedFile -> { - logger.debug("Indexing modified ktFile: {}", command.ktFile) - indexSourceFile(project, command.ktFile, fileIndex, sourceIndex) - sourceIndexCount++ + modifiedFileIndexer.schedule( + ModFileIndexKey( + command.ktFile.backingFilePath!!, + command.ktFile + ) + ) } IndexCommand.IndexingComplete -> { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt index 30ea96fe47..e345ab539c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/KtSymbolIndex.kt @@ -52,6 +52,7 @@ internal class KtSymbolIndex( queue = workerQueue, fileIndex = fileIndex, sourceIndex = sourceIndex, + scope = scope, ) private val scanningWorker = ScanningWorker( diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt index 3e8a131882..c70b096c90 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/index/SourceFileIndexer.kt @@ -3,6 +3,7 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.utils.toNioPathOrNull +import com.itsaky.androidide.progress.ICancelChecker import com.itsaky.androidide.projects.FileManager import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo import org.appdevforall.codeonthego.indexing.jvm.JvmFieldInfo @@ -62,9 +63,12 @@ internal suspend fun indexSourceFile( ktFile: KtFile, fileIndex: KtFileMetadataIndex, symbolsIndex: JvmSymbolIndex, + cancelChecker: ICancelChecker, ) { val newFile = ktFile.toMetadata(project, isIndexed = true) val existingFile = fileIndex.get(newFile.filePath) + cancelChecker.abortIfCancelled() + if (KtFileMetadata.shouldBeSkipped(existingFile, newFile) && existingFile?.isIndexed == true) { return } @@ -72,15 +76,20 @@ internal suspend fun indexSourceFile( // Remove stale symbols written during the previous indexing pass. if (existingFile?.isIndexed == true) { symbolsIndex.removeBySource(newFile.filePath) + cancelChecker.abortIfCancelled() } val symbols = project.read { val list = mutableListOf() ktFile.accept(object : KtTreeVisitorVoid() { override fun visitDeclaration(dcl: KtDeclaration) { + cancelChecker.abortIfCancelled() val symbol = analyze(dcl) { - analyzeDeclaration(newFile.filePath, dcl) + val result = analyzeDeclaration(newFile.filePath, dcl) + cancelChecker.abortIfCancelled() + result } + symbol?.let { list.add(it) } super.visitDeclaration(dcl) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt index 142fab4f2c..365d14adee 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/KotlinCompletions.kt @@ -96,6 +96,11 @@ internal fun CompilationEnvironment.complete(params: CompletionParams): Completi val prefix = params.requirePrefix() val partial = partialIdentifier(prefix) + if (partial.isBlank()) { + logger.warn("cannot complete for blank partial candidate") + return CompletionResult.EMPTY + } + // insert placeholder to fix broken trees val textWithPlaceholder = buildString { append(originalText, 0, completionOffset) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt index 87b7460259..696fccde4b 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/diagnostic/KotlinDiagnosticProvider.kt @@ -6,6 +6,7 @@ import com.itsaky.androidide.lsp.kotlin.utils.toRange import com.itsaky.androidide.lsp.models.DiagnosticItem import com.itsaky.androidide.lsp.models.DiagnosticResult import com.itsaky.androidide.lsp.models.DiagnosticSeverity +import com.itsaky.androidide.progress.ICancelChecker import kotlinx.coroutines.CancellationException import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.analyze @@ -26,24 +27,28 @@ internal data class KotlinDiagnosticExtra( val compilationEnv: CompilationEnvironment, ) -internal fun CompilationEnvironment.collectDiagnosticsFor(file: Path): DiagnosticResult = try { - logger.info("Analyzing file: {}", file) - return doAnalyze(file) -} catch (err: Throwable) { - if (err is CancellationException) { - logger.debug("analysis cancelled") - throw err +context(env: CompilationEnvironment) +internal fun collectDiagnosticsFor(file: Path, cancelChecker: ICancelChecker): DiagnosticResult { + try { + logger.info("Analyzing file: {}", file) + return doAnalyze(file, cancelChecker) + } catch (err: Throwable) { + if (err is CancellationException) { + logger.debug("analysis cancelled") + throw err + } + logger.error("An error occurred analyzing file: {}", file, err) + return DiagnosticResult.NO_UPDATE } - logger.error("An error occurred analyzing file: {}", file, err) - return DiagnosticResult.NO_UPDATE } @OptIn(KaExperimentalApi::class) -private fun CompilationEnvironment.doAnalyze(file: Path): DiagnosticResult { - var ktFile = ktSymbolIndex.getOpenedKtFile(file) +context(env: CompilationEnvironment) +private fun doAnalyze(file: Path, cancelChecker: ICancelChecker): DiagnosticResult { + var ktFile = env.ktSymbolIndex.getOpenedKtFile(file) if (ktFile == null) { - onFileOpen(file) - ktFile = ktSymbolIndex.getOpenedKtFile(file) + env.onFileOpen(file) + ktFile = env.ktSymbolIndex.getOpenedKtFile(file) } if (ktFile == null) { @@ -51,10 +56,11 @@ private fun CompilationEnvironment.doAnalyze(file: Path): DiagnosticResult { return DiagnosticResult.NO_UPDATE } - val diagnostics = project.read { + val diagnostics = env.project.read { buildList { PsiTreeUtil.collectElementsOfType(ktFile, PsiErrorElement::class.java) .forEach { errorElement -> + cancelChecker.abortIfCancelled() add( diagnosticItem( file = ktFile, @@ -65,11 +71,16 @@ private fun CompilationEnvironment.doAnalyze(file: Path): DiagnosticResult { ) } + // This should be canceled as well + // The analysis API uses a no-op implementation of + // Intellij's ProgressManager for cancellations, so the following + // is really cancellable at the moment analyze(ktFile) { ktFile.collectDiagnostics(KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS) .forEach { diagnostic -> + cancelChecker.abortIfCancelled() add(diagnostic.toDiagnosticItem().apply { - extra = KotlinDiagnosticExtra(diagnostic, this@doAnalyze) + extra = KotlinDiagnosticExtra(diagnostic, env) }) } } diff --git a/shared/src/main/java/com/itsaky/androidide/progress/ProgressManager.kt b/shared/src/main/java/com/itsaky/androidide/progress/ProgressManager.kt index 759f9c01c4..b977b9ac33 100644 --- a/shared/src/main/java/com/itsaky/androidide/progress/ProgressManager.kt +++ b/shared/src/main/java/com/itsaky/androidide/progress/ProgressManager.kt @@ -26,36 +26,36 @@ import java.util.concurrent.CancellationException */ class ProgressManager private constructor() { - private val threads = WeakHashMap() - - companion object { - - val instance by lazy { - ProgressManager() - } - - @JvmStatic - fun abortIfCancelled() { - instance.abortIfCancelled() - } - } - - fun cancel(thread: Thread) { - var checker = threads[thread] - if (checker == null) { - checker = Default() - } - checker.cancel() - threads[thread] = checker - } - - @JvmName("internalAbortIfCancelled") - private fun abortIfCancelled() { - val thisThread = Thread.currentThread() - val checker = threads[thisThread] - if (checker != null && checker.isCancelled()) { - threads.remove(thisThread) - throw CancellationException() - } - } + private val threads = WeakHashMap() + + companion object { + + val instance by lazy { + ProgressManager() + } + + @JvmStatic + fun abortIfCancelled() { + instance.abortIfCancelled() + } + } + + fun cancel(thread: Thread) { + var checker = threads[thread] + if (checker == null) { + checker = Default() + } + checker.cancel() + threads[thread] = checker + } + + @JvmName("internalAbortIfCancelled") + private fun abortIfCancelled() { + val thisThread = Thread.currentThread() + val checker = threads[thisThread] + if (checker != null && checker.isCancelled()) { + threads.remove(thisThread) + throw CancellationException() + } + } } \ No newline at end of file diff --git a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/FileManager.kt b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/FileManager.kt index 2a6c95cd1d..59d6242568 100644 --- a/subprojects/projects/src/main/java/com/itsaky/androidide/projects/FileManager.kt +++ b/subprojects/projects/src/main/java/com/itsaky/androidide/projects/FileManager.kt @@ -44,151 +44,157 @@ import java.util.concurrent.ConcurrentHashMap */ object FileManager { - private val log = LoggerFactory.getLogger(FileManager::class.java) - private val activeDocuments = ConcurrentHashMap() - - fun isActive(uri: URI): Boolean { - return isActive(Paths.get(uri)) - } - - fun isActive(file: Path): Boolean { - return this.activeDocuments.containsKey(file.normalize()) - } - - fun getActiveDocument(file: Path): ActiveDocument? { - return this.activeDocuments[file.normalize()] - } - - fun getActiveDocumentCount(): Int { - return this.activeDocuments.size - } - - fun getDocumentContents(file: Path): String { - val document = getActiveDocument(file) - if (document != null) { - return document.content - } - - return getFileContents(file) - } - - fun getLastModified(file: Path): Instant { - val document = getActiveDocument(file) - if (document != null) { - return document.modified - } - - return getLastModifiedFromDisk(file) - } - - fun getReader(file: Path): BufferedReader { - val document = getActiveDocument(file) - if (document != null) { - return document.reader() - } - - return createFileReader(file) - } - - fun getInputStream(file: Path): InputStream { - val document = getActiveDocument(file) - if (document != null) { - return document.inputStream() - } - - return createFileInputStream(file) - } - - fun onDocumentOpen(event: DocumentOpenEvent) { - activeDocuments[event.openedFile.normalize()] = createDocument(event) - } - - fun onDocumentContentChange(event: DocumentChangeEvent) { - val document = activeDocuments[event.changedFile.normalize()] - - if (document == null) { - // create document if not already created - // this should not happen under normal circumstances - activeDocuments[event.changedFile.normalize()] = createDocument(event) - log.warn("Document change event received before open event for file {}", event.changedFile) - return - } - - document.version = event.version - document.modified = Instant.now() - document.content = event.newText!! - event.newText = null - } - - fun onDocumentClose(event: DocumentCloseEvent) { - activeDocuments.remove(event.closedFile.normalize()) - } - - fun onFileRenamed(event: FileRenameEvent) { - val document = activeDocuments.remove(event.file.toPath().normalize()) - if (document != null) { - activeDocuments[event.newFile.toPath().normalize()] = document - } - } - - fun onFileDeleted(event: FileDeletionEvent) { - // If the file was an active document, remove the document cache - activeDocuments.remove(event.file.toPath().normalize()) - } - - private fun createDocument(event: DocumentOpenEvent): ActiveDocument { - return ActiveDocument( - file = event.openedFile, - version = event.version, - modified = Instant.now(), - content = event.text - ) - } - - private fun createDocument(event: DocumentChangeEvent): ActiveDocument { - return ActiveDocument( - file = event.changedFile, - version = event.version, - modified = Instant.now(), - content = event.changedText - ) - } - - private fun createFileReader(file: Path): BufferedReader { - return try { - Files.newBufferedReader(file) - } catch (noFile: java.nio.file.NoSuchFileException) { - log.warn("No such file", noFile) - "".reader().buffered() - } catch (cancelled: CancellationException) { - "".reader().buffered() - } - } - - private fun createFileInputStream(file: Path): InputStream { - return try { - Files.newInputStream(file) - } catch (noFile: java.nio.file.NoSuchFileException) { - log.warn("No such file", noFile) - "".byteInputStream() - } catch (cancelled: CancellationException) { - "".byteInputStream() - } - } - - private fun getLastModifiedFromDisk(file: Path): Instant { - return Files.getLastModifiedTime(file).toInstant() - } - - private fun getFileContents(file: Path): String { - return try { - ProgressManager.abortIfCancelled() - FileUtils.readFileToString(file.toFile(), Charset.defaultCharset()) - } catch (noFile: java.nio.file.NoSuchFileException) { - log.warn("No such file", noFile) - "" - } catch (cancelled: CancellationException) { - "" - } - } + private val log = LoggerFactory.getLogger(FileManager::class.java) + private val _activeDocuments = ConcurrentHashMap() + + val activeDocuments: Collection + get() = _activeDocuments.values.toSet() + + fun isActive(uri: URI): Boolean { + return isActive(Paths.get(uri)) + } + + fun isActive(file: Path): Boolean { + return this._activeDocuments.containsKey(file.normalize()) + } + + fun getActiveDocument(file: Path): ActiveDocument? { + return this._activeDocuments[file.normalize()] + } + + fun getActiveDocumentCount(): Int { + return this._activeDocuments.size + } + + fun getDocumentContents(file: Path): String { + val document = getActiveDocument(file) + if (document != null) { + return document.content + } + + return getFileContents(file) + } + + fun getLastModified(file: Path): Instant { + val document = getActiveDocument(file) + if (document != null) { + return document.modified + } + + return getLastModifiedFromDisk(file) + } + + fun getReader(file: Path): BufferedReader { + val document = getActiveDocument(file) + if (document != null) { + return document.reader() + } + + return createFileReader(file) + } + + fun getInputStream(file: Path): InputStream { + val document = getActiveDocument(file) + if (document != null) { + return document.inputStream() + } + + return createFileInputStream(file) + } + + fun onDocumentOpen(event: DocumentOpenEvent) { + _activeDocuments[event.openedFile.normalize()] = createDocument(event) + } + + fun onDocumentContentChange(event: DocumentChangeEvent) { + val document = _activeDocuments[event.changedFile.normalize()] + + if (document == null) { + // create document if not already created + // this should not happen under normal circumstances + _activeDocuments[event.changedFile.normalize()] = createDocument(event) + log.warn( + "Document change event received before open event for file {}", + event.changedFile + ) + return + } + + document.version = event.version + document.modified = Instant.now() + document.content = event.newText!! + event.newText = null + } + + fun onDocumentClose(event: DocumentCloseEvent) { + _activeDocuments.remove(event.closedFile.normalize()) + } + + fun onFileRenamed(event: FileRenameEvent) { + val document = _activeDocuments.remove(event.file.toPath().normalize()) + if (document != null) { + _activeDocuments[event.newFile.toPath().normalize()] = document + } + } + + fun onFileDeleted(event: FileDeletionEvent) { + // If the file was an active document, remove the document cache + _activeDocuments.remove(event.file.toPath().normalize()) + } + + private fun createDocument(event: DocumentOpenEvent): ActiveDocument { + return ActiveDocument( + file = event.openedFile, + version = event.version, + modified = Instant.now(), + content = event.text + ) + } + + private fun createDocument(event: DocumentChangeEvent): ActiveDocument { + return ActiveDocument( + file = event.changedFile, + version = event.version, + modified = Instant.now(), + content = event.changedText + ) + } + + private fun createFileReader(file: Path): BufferedReader { + return try { + Files.newBufferedReader(file) + } catch (noFile: java.nio.file.NoSuchFileException) { + log.warn("No such file", noFile) + "".reader().buffered() + } catch (cancelled: CancellationException) { + "".reader().buffered() + } + } + + private fun createFileInputStream(file: Path): InputStream { + return try { + Files.newInputStream(file) + } catch (noFile: java.nio.file.NoSuchFileException) { + log.warn("No such file", noFile) + "".byteInputStream() + } catch (cancelled: CancellationException) { + "".byteInputStream() + } + } + + private fun getLastModifiedFromDisk(file: Path): Instant { + return Files.getLastModifiedTime(file).toInstant() + } + + private fun getFileContents(file: Path): String { + return try { + ProgressManager.abortIfCancelled() + FileUtils.readFileToString(file.toFile(), Charset.defaultCharset()) + } catch (noFile: java.nio.file.NoSuchFileException) { + log.warn("No such file", noFile) + "" + } catch (cancelled: CancellationException) { + "" + } + } }