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
12 changes: 12 additions & 0 deletions .claude/.gitignore
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
# Local, machine-specific settings (not shared)
settings.local.json

# Claude Code worktrees
worktrees/

# Session/runtime artifacts
projects/
todos/
shell-snapshots/
statsig/
.credentials.json
6 changes: 6 additions & 0 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -97,11 +97,17 @@ subprojects {
// setAccessible on java.lang.Class fields.
// - java.base/java.io, java.util: needed by Robolectric/Gradle worker
// reflection in the same test JVM.
// - java.base/java.util.concurrent: the embedded IntelliJ scheduler
// (BoundedTaskExecutor.info -> AppDelayQueue "Periodic tasks thread")
// reflectively reads FutureTask.callable. Without this the periodic
// thread dies, disabling CoreProgressManager's cancellation poll that
// makes the Kotlin Analysis API interruptible mid-`analyze`.
jvmArgs(
"--add-opens=java.base/java.lang=ALL-UNNAMED",
"--add-opens=java.base/java.lang.reflect=ALL-UNNAMED",
"--add-opens=java.base/java.io=ALL-UNNAMED",
"--add-opens=java.base/java.util=ALL-UNNAMED",
"--add-opens=java.base/java.util.concurrent=ALL-UNNAMED",
"--add-opens=jdk.unsupported/sun.misc=ALL-UNNAMED",
)

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ import com.itsaky.androidide.lsp.debug.model.BreakpointDefinition
import com.itsaky.androidide.lsp.debug.model.BreakpointRequest
import com.itsaky.androidide.preferences.internal.EditorPreferences
import com.itsaky.androidide.progress.ICancelChecker
import com.itsaky.androidide.progress.ProgressManager
import io.github.rosemoe.sora.lang.Language
import io.github.rosemoe.sora.lang.completion.CompletionCancelledException
import io.github.rosemoe.sora.lang.completion.CompletionPublisher
Expand Down Expand Up @@ -67,11 +68,18 @@ abstract class IDELanguage : Language {
publisher: CompletionPublisher,
extraArguments: Bundle
) {
val completionThread = Thread.currentThread()
try {
val cancelChecker = CompletionCancelChecker(publisher)
Lookup.getDefault().update(ICancelChecker::class.java, cancelChecker)
// Bind the checker to this completion thread. EditorCompletionWindow.cancelCompletion()
// cancels the (old) thread via ProgressManager.cancel(thread); routing that to *this*
// checker's cancel() lets the LSP's invokeOnCancel listener abort the running analysis
// mid-`analyze` immediately, instead of only at coarse checkpoints.
ProgressManager.instance.register(completionThread, cancelChecker)
doComplete(content, position, publisher, cancelChecker, extraArguments)
} finally {
ProgressManager.instance.unregister(completionThread)
Lookup.getDefault().unregister(
ICancelChecker::class.java
)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,9 +41,22 @@ class EditorCompletionWindow(val editor: IDEEditor) : EditorAutoCompletion(edito
private var listView: ListView? = null
private val items: MutableList<CompletionItem> = mutableListOf()

/**
* A scheduled-but-not-yet-started completion request, kept so a newer keystroke can cancel it.
* See [requireCompletion].
*/
private var pendingCompletion: Runnable? = null

companion object {

private val log = LoggerFactory.getLogger(EditorCompletionWindow::class.java)

/**
* Quiet period used to coalesce a burst of keystrokes into a single completion request. Rapid
* typing (re)schedules the start; only after the user pauses this long does one analysis run,
* for the latest cursor position. Keeps at most one completion in flight.
*/
private const val COMPLETION_DEBOUNCE_MS = 80L
}

init {
Expand Down Expand Up @@ -119,29 +132,57 @@ class EditorCompletionWindow(val editor: IDEEditor) : EditorAutoCompletion(edito
}

override fun cancelCompletion() {
// Drop any request that was scheduled but hasn't started yet.
pendingCompletion?.let { editor.handler.removeCallbacks(it) }
pendingCompletion = null
if (completionThread != null) {
ProgressManager.instance.cancel(completionThread)
}
super.cancelCompletion()
}

override fun requireCompletion() {
/**
* Whether a completion may be shown for the current editor state. Hides the window (matching the
* prior inline behaviour) when the cursor is selected or completion is otherwise not applicable.
*/
private fun canStartCompletion(): Boolean {
if (cancelShowUp || !isEnabled || !editor.isAttachedToWindow) {
return
return false
}

val text = editor.text
if (text.cursor.isSelected || checkNoCompletion()) {
if (editor.text.cursor.isSelected || checkNoCompletion()) {
hide()
return
return false
}
return true
}

if (System.nanoTime() - requestTime < editor.props.cancelCompletionNs) {
requestTime = System.nanoTime()
override fun requireCompletion() {
if (!canStartCompletion()) {
return
}

// Coalesce a burst of keystrokes into a single completion. Cancel the in-flight completion and
// any pending (not-yet-started) one, then (re)schedule one start after a short quiet period.
// This guarantees at most one completion analysis in flight and that only the latest cursor
// position is computed — preventing the CompletionThread/allocation pile-up that saturated the
// heap and froze the editor during fast typing. cancelCompletion() clears any pending request,
// so we always schedule exactly one.
cancelCompletion()

val request = Runnable { startCompletion() }
pendingCompletion = request
editor.handler.postDelayed(request, COMPLETION_DEBOUNCE_MS)
}

/** Starts a single completion for the current cursor position. Runs on the UI thread. */
private fun startCompletion() {
pendingCompletion = null

// The editor state may have changed during the debounce delay; re-check the guards.
if (!canStartCompletion()) {
return
}

requestTime = System.nanoTime()
currentSelection = -1

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ 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.AbstractKtModule
import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPreemptedException
import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule
import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence
import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath
Expand Down Expand Up @@ -179,9 +180,16 @@ internal class CompilationEnvironment(
scope = coroutineScope,
debounceDuration = DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION,
) { path, cancelChecker ->
val result = collectDiagnosticsFor(path, cancelChecker)
withContext(Dispatchers.Main.immediate) {
languageClient?.publishDiagnostics(result)
try {
val result = collectDiagnosticsFor(path, cancelChecker)
withContext(Dispatchers.Main.immediate) {
languageClient?.publishDiagnostics(result)
}
} catch (e: AnalysisPreemptedException) {
// A higher-priority analysis (completion) preempted this diagnostics run.
// Re-schedule so diagnostics still run once the higher-priority work finishes.
logger.debug("diagnostics for {} preempted; rescheduling", path)
fileAnalyzer.schedule(path)
}
}
}
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,15 @@
package com.itsaky.androidide.lsp.kotlin.compiler.index

import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment
import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPreemptedException
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 kotlinx.coroutines.coroutineScope
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex
import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadata
import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex
Expand Down Expand Up @@ -54,8 +56,14 @@ internal class IndexWorker(
debounceDuration = CompilationEnvironment.DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION
) { (path, ktFile), cancelChecker ->
logger.debug("Indexing modified file: {}", path)
indexSourceFile(project, ktFile, fileIndex, sourceIndex, cancelChecker)
sourceIndexCount++
try {
indexSourceFile(project, ktFile, fileIndex, sourceIndex, cancelChecker)
sourceIndexCount++
} catch (e: AnalysisPreemptedException) {
// Preempted by higher-priority analysis; re-queue so the edit still gets indexed.
logger.debug("Indexing of modified file {} preempted; re-queueing", path)
scope.launch { submitCommand(IndexCommand.IndexModifiedFile(ktFile)) }
}
}

while (isActive) {
Expand Down Expand Up @@ -89,15 +97,23 @@ internal class IndexWorker(
continue
}

indexSourceFile(
project = project,
ktFile = ktFile,
fileIndex = fileIndex,
symbolsIndex = sourceIndex,
cancelChecker = ICancelChecker.NOOP
)
try {
indexSourceFile(
project = project,
ktFile = ktFile,
fileIndex = fileIndex,
symbolsIndex = sourceIndex,
// A real (cancellable) checker so the scheduler can preempt this pass
// in favour of completion/diagnostics.
cancelChecker = ICancelChecker.Default()
)

sourceIndexCount++
sourceIndexCount++
} catch (e: AnalysisPreemptedException) {
// Preempted by higher-priority analysis; re-queue so the file still gets indexed.
logger.debug("Indexing of {} preempted; re-queueing", cmd.vf.path)
scope.launch { submitCommand(cmd) }
}
}

is IndexCommand.IndexModifiedFile -> {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,7 @@
package com.itsaky.androidide.lsp.kotlin.compiler.index

import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority
import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker
import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling
import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath
import com.itsaky.androidide.lsp.kotlin.compiler.read
Expand Down Expand Up @@ -74,6 +76,12 @@ internal suspend fun indexSourceFile(
symbolsIndex: JvmSymbolIndex,
cancelChecker: ICancelChecker,
) {
// Indexing runs at the lowest (INDEXING) priority: it yields to both completion and diagnostics.
// Wrapping the checker lets the scheduler preempt an in-progress index pass; the preemption
// surfaces as AnalysisPreemptedException at the abortIfCancelled() checkpoints below, which the
// IndexWorker catches to re-queue the file.
val checker = cancelChecker as? ScheduledCancelChecker ?: ScheduledCancelChecker(cancelChecker)

// Defensive backstop: this runs on the debounced/async index scope, so a disposal path that
// didn't first drain & join the workers could otherwise touch PSI on a disposed project and
// throw "Project is already disposed" (APPDEVFORALL-17R). Cheap fast-path before the reads below.
Expand All @@ -86,7 +94,7 @@ internal suspend fun indexSourceFile(
ktFile.toMetadata(project, isIndexed = true)
} ?: return
val existingFile = fileIndex.get(newFile.filePath)
cancelChecker.abortIfCancelled()
checker.abortIfCancelled()

if (KtFileMetadata.shouldBeSkipped(existingFile, newFile) && existingFile?.isIndexed == true) {
return
Expand All @@ -95,21 +103,21 @@ internal suspend fun indexSourceFile(
// Remove stale symbols written during the previous indexing pass.
if (existingFile?.isIndexed == true) {
symbolsIndex.removeBySource(newFile.filePath)
cancelChecker.abortIfCancelled()
checker.abortIfCancelled()
}

val symbols = project.read {
// Atomic w.r.t. the read lock: bail if the project was disposed before we acquired it.
if (project.isDisposed) return@read emptyList()

val list = mutableListOf<JvmSymbol>()
analyzeMaybeDangling(ktFile) {
analyzeMaybeDangling(ktFile, AnalysisPriority.INDEXING, checker) {
val session = this
ktFile.accept(object : KtTreeVisitorVoid() {
override fun visitDeclaration(dcl: KtDeclaration) {
cancelChecker.abortIfCancelled()
checker.abortIfCancelled()
val symbol = with(session) { analyzeDeclaration(newFile.filePath, dcl) }
cancelChecker.abortIfCancelled()
checker.abortIfCancelled()
symbol?.let { list.add(it) }
super.visitDeclaration(dcl)
}
Expand Down
Loading
Loading