Skip to content
Open
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
Original file line number Diff line number Diff line change
@@ -0,0 +1,58 @@
package com.itsaky.androidide.utils

import com.google.common.truth.Truth.assertThat
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.delay
import kotlinx.coroutines.runBlocking
import org.junit.Test
import java.util.concurrent.CountDownLatch
import java.util.concurrent.TimeUnit
import java.util.concurrent.atomic.AtomicBoolean
import java.util.concurrent.atomic.AtomicInteger
import kotlin.time.Duration.Companion.milliseconds

/**
* Scheduling the key an action is *currently running for* cancels that run.
*
* The worker races `actionJob.onJoin` against `channel.onReceive`, so a send from inside the action
* is indistinguishable from a newer key arriving: the receive wins, the in-flight job is cancelled,
* and the key is re-sent. `KotlinDiagnosticProvider` relies on both halves of that - it reschedules
* from inside its own action and expects the analysis it just discarded to run again.
*/
class KeyedDebouncingActionSelfScheduleTest {
private companion object {
const val SECOND_RUN_TIMEOUT_SECONDS = 5L
}

@Test
fun `scheduling from inside the action cancels that run and re-runs it`() =
runBlocking {
val runs = AtomicInteger(0)
val firstRunFinished = AtomicBoolean(false)
val secondRunStarted = CountDownLatch(1)
lateinit var debouncer: KeyedDebouncingAction<String>

debouncer =
KeyedDebouncingAction(
scope = CoroutineScope(SupervisorJob()),
debounceDuration = 20.milliseconds,
action = { key, _ ->
if (runs.incrementAndGet() == 1) {
debouncer.schedule(key)
// A suspension point is where the cancellation from the self-send takes effect.
delay(200)
firstRunFinished.set(true)
} else {
secondRunStarted.countDown()
}
},
)

debouncer.schedule("k")

assertThat(secondRunStarted.await(SECOND_RUN_TIMEOUT_SECONDS, TimeUnit.SECONDS)).isTrue()
assertThat(firstRunFinished.get()).isFalse()
debouncer.cancelAll()
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -76,23 +76,35 @@ class AddImportAction : BaseKotlinCodeAction() {
* [postExec] shows in the chooser -- so two index entries for the same class collapse into one
* entry instead of duplicating it.
*
* Blocking: does the `getCurrentKtFile` `.get()` and a SQLite-backed index query, so callers must
* stay off the main thread ([execAction] wraps it in [Dispatchers.IO]).
* Blocking: the index query is SQLite-backed and pinning the file resolves that file before handing
* it over, so callers must stay off the main thread ([execAction] wraps it in [Dispatchers.IO]).
*/
internal fun computeImportCandidates(
env: AbstractCompilationEnvironment,
nioPath: Path,
referenceName: String,
): Map<String, List<TextEdit>> {
val ktFile =
/*
* Resolved before the file is pinned, not inside the pin: this is an unbounded SQLite scan that
* never reads the file, and a pin held across it freezes live-PSI refresh for the path - every
* concurrent acquirer joins the frozen instance and the refresh is only owed on release.
* Materialized here too, so the index's lazy source-active filter cannot trail into the scope.
*/
val classifiers =
env.ktSymbolIndex
.getCurrentKtFile(nioPath)
.get() ?: return emptyMap()
.findSymbolBySimpleName(referenceName, limit = 0)
.filter { it.kind.isClassifier }
.toList()

return env.ktSymbolIndex
.findSymbolBySimpleName(referenceName, limit = 0)
.filter { it.kind.isClassifier }
.associate { it.fqName to insertImport(ktFile, it.fqName) }
if (classifiers.isEmpty()) {
return emptyMap()
}

return env.ktSymbolIndex.withLiveKtFile(nioPath) { live ->
live.read { ktFile ->
classifiers.associate { it.fqName to insertImport(ktFile, it.fqName) }
}
} ?: emptyMap()
}

override fun postExec(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,10 +8,8 @@ import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer
import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment
import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority
import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling
import com.itsaky.androidide.lsp.kotlin.compiler.modules.isAnalysisCancellation
import com.itsaky.androidide.lsp.kotlin.compiler.modules.retryingOnPreemption
import com.itsaky.androidide.lsp.kotlin.compiler.read
import com.itsaky.androidide.lsp.kotlin.utils.membersToImplement
import com.itsaky.androidide.lsp.kotlin.utils.renderOverrideStub
import com.itsaky.androidide.lsp.kotlin.utils.toRange
Expand Down Expand Up @@ -59,8 +57,7 @@ class ImplementMembersAction : BaseKotlinCodeAction() {

/**
* Computes the edit that inserts stubs for the abstract members left unimplemented by the class or
* object enclosing [offset] in the file at [nioPath]. The current [KtFile] is fetched BEFORE
* entering [read] (deadlock rule: never block on `getCurrentKtFile(...).get()` inside `project.read`).
* object enclosing [offset] in the file at [nioPath].
*
* Returns an empty list when there is nothing to do (cursor not in a class/object, the declaration
* is abstract/an interface/enum, or every required member is already implemented) *and* whenever
Expand All @@ -77,25 +74,26 @@ class ImplementMembersAction : BaseKotlinCodeAction() {
runCatching {
// A user-invoked command: AnalysisPriority.COMMAND, retried once if keystroke-driven work
// preempts it (ADR 0011). Without the retry a preemption fell into the getOrElse below and the
// action silently inserted nothing. The file is re-fetched per attempt because the preemptor
// action silently inserted nothing. The file is re-pinned per attempt because the preemptor
// also refreshed the live PSI.
retryingOnPreemption(cancelChecker, "Implement members for $nioPath") { checker ->
val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return@retryingOnPreemption emptyList()
env.project.read {
val classOrObject = findEnclosingClassOrObject(ktFile, offset) ?: return@read emptyList()
analyzeMaybeDangling(ktFile, AnalysisPriority.COMMAND, checker) {
val classSymbol = classOrObject.symbol as? KaClassSymbol ?: return@analyzeMaybeDangling emptyList()
if (!isImplementable(classSymbol)) return@analyzeMaybeDangling emptyList()

val classIndent = classIndentOf(ktFile, classOrObject)
val unit = detectIndentUnit(ktFile.text)
val memberIndent = memberIndentOf(ktFile, classOrObject, classIndent, unit)
val stubs = membersToImplement(classSymbol).mapNotNull { renderOverrideStub(it, memberIndent, unit) }
if (stubs.isEmpty()) return@analyzeMaybeDangling emptyList()

buildInsertionEdit(ktFile, classOrObject, stubs, classIndent)
env.ktSymbolIndex.withLiveKtFile(nioPath) { live ->
live.read { ktFile ->
val classOrObject = findEnclosingClassOrObject(ktFile, offset) ?: return@read emptyList()
live.analyzing(AnalysisPriority.COMMAND, checker) {
val classSymbol = classOrObject.symbol as? KaClassSymbol ?: return@analyzing emptyList()
if (!isImplementable(classSymbol)) return@analyzing emptyList()

val classIndent = classIndentOf(ktFile, classOrObject)
val unit = detectIndentUnit(ktFile.text)
val memberIndent = memberIndentOf(ktFile, classOrObject, classIndent, unit)
val stubs = membersToImplement(classSymbol).mapNotNull { renderOverrideStub(it, memberIndent, unit) }
if (stubs.isEmpty()) return@analyzing emptyList()

buildInsertionEdit(ktFile, classOrObject, stubs, classIndent)
}
}
}
} ?: emptyList()
}
}.getOrElse { e ->
if (e.isAnalysisCancellation()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,6 @@ import com.itsaky.androidide.actions.requireFile
import com.itsaky.androidide.idetooltips.TooltipManager
import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.api.ILanguageClient
import com.itsaky.androidide.lsp.kotlin.compiler.read
import com.itsaky.androidide.lsp.kotlin.diagnostic.DiagnosticAction
import com.itsaky.androidide.lsp.kotlin.utils.NullSafetyKind
import com.itsaky.androidide.lsp.kotlin.utils.NullSafetyVariant
Expand Down Expand Up @@ -67,22 +66,19 @@ class NullSafetyAction : BaseKotlinCodeAction() {

val nioPath = data.requireFile().toPath()

// Fetch the live KtFile BEFORE entering `read` (deadlock rule: its refresh needs write access).
val ktFile =
withContext(Dispatchers.IO) {
extra.compilationEnv.ktSymbolIndex
.getCurrentKtFile(nioPath)
.get()
} ?: return emptyList()

extra.compilationEnv.project.read {
val qe =
findNullableMemberAccess(
ktFile,
diagnostic.range.start.requireIndex(),
diagnostic.range.end.requireIndex(),
) ?: return@read emptyList()
nullSafetyVariants(qe)
// Off the main thread: acquiring the pin resolves the file first, which can block on a refresh.
withContext(Dispatchers.IO) {
extra.compilationEnv.ktSymbolIndex.withLiveKtFile(nioPath) { live ->
live.read { ktFile ->
val qe =
findNullableMemberAccess(
ktFile,
diagnostic.range.start.requireIndex(),
diagnostic.range.end.requireIndex(),
) ?: return@read emptyList()
nullSafetyVariants(qe)
}
} ?: emptyList()
}
}.getOrElse { e ->
if (e is CancellationException) throw e
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,8 @@ import com.itsaky.androidide.idetooltips.TooltipTag
import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer
import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment
import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority
import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling
import com.itsaky.androidide.lsp.kotlin.compiler.modules.isAnalysisCancellation
import com.itsaky.androidide.lsp.kotlin.compiler.modules.retryingOnPreemption
import com.itsaky.androidide.lsp.kotlin.compiler.read
import com.itsaky.androidide.lsp.kotlin.utils.collectImportUsage
import com.itsaky.androidide.lsp.kotlin.utils.organizedImportBlock
import com.itsaky.androidide.lsp.kotlin.utils.toRange
Expand Down Expand Up @@ -48,11 +46,10 @@ class OrganizeImportsAction : BaseKotlinCodeAction() {

/**
* Computes the text edits that organize the imports of the file at [nioPath] within [env].
* The current [org.jetbrains.kotlin.psi.KtFile] is fetched BEFORE entering [read] (deadlock
* rule: never block on `getCurrentKtFile(...).get()` inside `project.read`). Returns an empty
* list when there is nothing to do (no imports, already organized, or no usable range) *and*
* whenever anything in this pipeline (the `.get()`, analysis, or PSI access) throws: the action
* framework only catches [IllegalArgumentException] and this runs on a coroutine scope with no
*
* Returns an empty list when there is nothing to do (no imports, already organized, or no usable
* range) *and* whenever anything in this pipeline (acquisition, analysis, or PSI access) throws: the
* action framework only catches [IllegalArgumentException] and this runs on a coroutine scope with no
* exception handler, so an uncaught throw here would crash the app. Degrading to zero edits is
* always safe -- it just leaves the imports as-is, never produces a partial/incorrect rewrite.
*/
Expand All @@ -64,18 +61,19 @@ class OrganizeImportsAction : BaseKotlinCodeAction() {
runCatching {
// A user-invoked command: AnalysisPriority.COMMAND, retried once if keystroke-driven work
// preempts it (ADR 0011). Without the retry a preemption fell into the getOrElse below and
// organize-imports silently did nothing. The file is re-fetched per attempt because the
// organize-imports silently did nothing. The file is re-pinned per attempt because the
// preemptor also refreshed the live PSI.
retryingOnPreemption(cancelChecker, "Organize imports for $nioPath") { checker ->
val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return@retryingOnPreemption emptyList()
if (ktFile.importDirectives.isEmpty()) return@retryingOnPreemption emptyList()
env.project.read {
val usage = analyzeMaybeDangling(ktFile, AnalysisPriority.COMMAND, checker) { collectImportUsage(ktFile) }
val newText = organizedImportBlock(ktFile, usage) ?: return@read emptyList()
val range = ktFile.importList?.textRange?.toRange(ktFile) ?: return@read emptyList()
if (range == Range.NONE) return@read emptyList()
listOf(TextEdit(range, newText))
}
env.ktSymbolIndex.withLiveKtFile(nioPath) { live ->
live.read { ktFile ->
if (ktFile.importDirectives.isEmpty()) return@read emptyList()
val usage = live.analyzing(AnalysisPriority.COMMAND, checker) { collectImportUsage(it) }
val newText = organizedImportBlock(ktFile, usage) ?: return@read emptyList()
val range = ktFile.importList?.textRange?.toRange(ktFile) ?: return@read emptyList()
if (range == Range.NONE) return@read emptyList()
listOf(TextEdit(range, newText))
}
} ?: emptyList()
}
}.getOrElse { e ->
if (e.isAnalysisCancellation()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,6 @@ import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancelAndJoin
import kotlinx.coroutines.future.await
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeoutOrNull
Expand Down Expand Up @@ -210,7 +209,7 @@ internal class CompilationEnvironment(
) { path, _ ->
// Pull through the cache so a refresh (and its reindex) happens after every edit,
// independent of whether diagnostics run.
ktSymbolIndex.getCurrentKtFile(path).await()
ktSymbolIndex.refreshCurrentKtFile(path)
}
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -227,7 +227,7 @@ internal class KtSymbolIndex(
* version miss. For non-open paths (no active document) falls back to the disk [getKtFile].
* Single-flight: concurrent callers at the same version share one parse.
*/
fun getCurrentKtFile(path: Path): CompletableFuture<KtFile?> =
private fun getCurrentKtFile(path: Path): CompletableFuture<KtFile?> =
getCurrentVersionedKtFile(path)?.thenApply { it.ktFile } ?: CompletableFuture.completedFuture(null)

/**
Expand Down Expand Up @@ -327,7 +327,7 @@ internal class KtSymbolIndex(
* else `null`. Safe to call while holding `project.read` (unlike [getCurrentKtFile], which may
* trigger a blocking refresh that needs `project.write`).
*/
fun getCurrentKtFileIfPresent(path: Path): KtFile? = currentFiles[path]?.getNow(null)?.ktFile
private fun getCurrentKtFileIfPresent(path: Path): KtFile? = currentFiles[path]?.getNow(null)?.ktFile

/**
* Runs [block] with the file at [path] pinned, or returns `null` if the path has no Kotlin PSI.
Expand Down Expand Up @@ -502,9 +502,17 @@ internal class KtSymbolIndex(
}
}

fun getKtFile(vf: VirtualFile): KtFile? = getKtFile(vf.toNioPath(), vf)
/** [getKtFile] for [vf], keyed by the path it maps to. */
internal fun getKtFile(vf: VirtualFile): KtFile? = getKtFile(vf.toNioPath(), vf)

fun getKtFile(
/**
* The resolution-side door: what the Analysis API service providers answer a path lookup with.
*
* A pinned path resolves to the pinned instance, so an open analysis and the declaration provider
* cannot disagree about which instance is the file. Otherwise the live cache is peeked, then the
* on-disk instance is loaded.
*/
internal fun getKtFile(
path: Path,
virtualFile: VirtualFile? = null,
): KtFile? {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
package com.itsaky.androidide.lsp.kotlin.completion

import com.itsaky.androidide.lsp.kotlin.compiler.index.UnpinnedKtFileAccess
import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext
import com.itsaky.androidide.lsp.models.CompletionItem
import io.github.rosemoe.sora.text.Content
Expand All @@ -10,20 +11,22 @@ import org.slf4j.LoggerFactory
internal abstract class AdvancedKotlinEditHandler(
protected val analysisContext: AnalysisContext,
) : BaseKotlinEditHandler() {

companion object {
private val logger = LoggerFactory.getLogger(AdvancedKotlinEditHandler::class.java)
}

@OptIn(UnpinnedKtFileAccess::class)
override fun performEdits(
item: CompletionItem,
editor: CodeEditor,
text: Content,
line: Int,
column: Int,
index: Int
index: Int,
) {
val managedFile = analysisContext.env.ktSymbolIndex.getCurrentKtFileIfPresent(analysisContext.file)
// PSI-only, on the UI thread, after completion has already returned: there is no analysis to
// keep coherent, and pinning here would block the UI thread on a refresh.
val managedFile = analysisContext.env.ktSymbolIndex.peekLiveKtFile(analysisContext.file)
if (managedFile == null) {
logger.error("Unable to perform edit. File not open.")
return
Expand All @@ -42,6 +45,6 @@ internal abstract class AdvancedKotlinEditHandler(
abstract fun performEdits(
ktFile: KtFile,
editor: CodeEditor,
item: CompletionItem
item: CompletionItem,
)
}
Loading
Loading