From 0bece0562ec13994df1ab5d374da7fa774114a6f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 10:47:59 +0000 Subject: [PATCH 01/31] editor: make SignatureHelpWindow tolerant of malformed and immutable signature data --- .../editor/ui/SignatureHelpWindow.java | 41 +++++++++++------ .../editor/ui/SignatureHelpWindowTest.kt | 45 +++++++++++++++++++ 2 files changed, 72 insertions(+), 14 deletions(-) create mode 100644 editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java b/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java index 9a37151bc1..7f0343d1df 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java @@ -28,6 +28,8 @@ import com.itsaky.androidide.utils.ContextUtilsKt; import io.github.rosemoe.sora.event.SelectionChangeEvent; import io.github.rosemoe.sora.widget.base.EditorPopupWindow; +import java.util.ArrayList; +import java.util.List; import org.slf4j.Logger; import org.slf4j.LoggerFactory; @@ -94,20 +96,12 @@ private CharSequence createSignatureText(@NonNull SignatureHelp signature) { return null; } - // remove all with non-applicable signatures - signatures.removeIf( - info -> { - final var remove = activeParameter >= info.getParameters().size(); - if (remove) { - LOG.debug("Removing {} params={} active={}", info, info.getParameters().size(), - activeParameter); - } - return remove; - }); + // keep only applicable signatures (does not mutate the input list) + final var applicable = applicableSignatures(signatures, activeParameter); - count = signatures.size(); + count = applicable.size(); for (var i = 0; i < count; i++) { - final var info = signatures.get(i); + final var info = applicable.get(i); formatSignature(info, activeParameter, sb); if (i != count - 1) { sb.append('\n'); @@ -117,6 +111,26 @@ private CharSequence createSignatureText(@NonNull SignatureHelp signature) { return sb; } + /** Returns the function name portion of a signature label (text before the first '('), or the + * whole label if it contains no '('. */ + static String signatureName(String label) { + final int paren = label.indexOf('('); + return paren < 0 ? label : label.substring(0, paren); + } + + /** Returns a new list containing only the signatures that have enough parameters for the given + * active parameter index. Never mutates the input list. */ + static List applicableSignatures( + List signatures, int activeParameter) { + final List result = new ArrayList<>(signatures.size()); + for (final SignatureInformation info : signatures) { + if (activeParameter < info.getParameters().size()) { + result.add(info); + } + } + return result; + } + /** * Formats (highlights) a method signature * @@ -129,8 +143,7 @@ private void formatSignature( int paramIndex, SpannableStringBuilder result) { - String name = signature.getLabel(); - name = name.substring(0, name.indexOf("(")); + final String name = signatureName(signature.getLabel()); final var foreground = ContextUtilsKt.resolveAttr(getEditor().getContext(), attr.colorOnSecondaryContainer); diff --git a/editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt b/editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt new file mode 100644 index 0000000000..5ed5919223 --- /dev/null +++ b/editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt @@ -0,0 +1,45 @@ +package com.itsaky.androidide.editor.ui + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.models.MarkupContent +import com.itsaky.androidide.lsp.models.ParameterInformation +import com.itsaky.androidide.lsp.models.SignatureInformation +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class SignatureHelpWindowTest { + + private fun sig(label: String, paramCount: Int): SignatureInformation { + val params = (0 until paramCount).map { ParameterInformation("p$it: Int", MarkupContent()) } + return SignatureInformation(label, MarkupContent(), params) + } + + @Test + fun `signatureName returns text before the opening paren`() { + assertThat(SignatureHelpWindow.signatureName("foo(p0: Int, p1: Int)")).isEqualTo("foo") + } + + @Test + fun `signatureName returns whole label when there is no paren`() { + assertThat(SignatureHelpWindow.signatureName("foo")).isEqualTo("foo") + } + + @Test + fun `applicableSignatures drops overloads without enough parameters`() { + val input = listOf(sig("a(p0: Int)", 1), sig("b(p0: Int, p1: Int)", 2)) + val result = SignatureHelpWindow.applicableSignatures(input, 1) + assertThat(result).hasSize(1) + assertThat(result[0].label).isEqualTo("b(p0: Int, p1: Int)") + } + + @Test + fun `applicableSignatures does not mutate an immutable input list`() { + val input = java.util.Collections.unmodifiableList(listOf(sig("a(p0: Int)", 1))) + // Must not throw UnsupportedOperationException: + val result = SignatureHelpWindow.applicableSignatures(input, 5) + assertThat(result).isEmpty() + assertThat(input).hasSize(1) + } +} From 89632388f8a0d167416d39e5b399f83ac7bdf515 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 10:54:55 +0000 Subject: [PATCH 02/31] lsp/kotlin: add CallAtCursorFinder to locate the call at the cursor --- .../signaturehelp/CallAtCursorFinder.kt | 34 ++++++++++++ .../signaturehelp/CallAtCursorFinderTest.kt | 52 +++++++++++++++++++ 2 files changed, 86 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt new file mode 100644 index 0000000000..e9f2fe4b94 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt @@ -0,0 +1,34 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.KtCallElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionLiteral + +/** + * Finds the innermost [KtCallElement] whose argument list directly contains [offset]. + * + * Returns `null` when the cursor is inside a lambda body (trailing or argument lambda) or otherwise + * not directly within a call's arguments, so that signature help is not shown there. + */ +internal fun findEnclosingCall(file: KtFile, offset: Int): KtCallElement? { + // Prefer the element at the cursor; fall back to the element just before it (e.g. cursor at EOF + // or immediately after '('). + var node: PsiElement? = file.findElementAt(offset) + ?: file.findElementAt((offset - 1).coerceAtLeast(0)) + + while (node != null && node !is KtFile) { + // Crossing a lambda body before reaching a call's arguments means we are inside the lambda. + if (node is KtFunctionLiteral) { + return null + } + if (node is KtCallElement) { + val callee = node.calleeExpression + if (callee != null && offset > callee.textRange.endOffset) { + return node + } + } + node = node.parent + } + return null +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt new file mode 100644 index 0000000000..a7a323e1ab --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt @@ -0,0 +1,52 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Test + +class CallAtCursorFinderTest : KtLspTest() { + + private fun offsetOf(text: String, marker: String): Int { + val i = text.indexOf(marker) + check(i >= 0) { "marker '$marker' not found" } + return i + } + + @Test + fun `finds call when cursor is inside the argument parentheses`() { + val text = "fun f(a: Int) {}\nfun g() { f(1) }" + val file = env.project.read { env.parser.createFile("A.kt", text) } + val offset = offsetOf(text, "f(1)") + 2 // just after '(' + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call).isNotNull() + assertThat(call!!.calleeExpression?.text).isEqualTo("f") + } + + @Test + fun `returns null when cursor is inside a trailing lambda body`() { + val text = "fun run(block: () -> Unit) {}\nfun g() { run { println() } }" + val file = env.project.read { env.parser.createFile("B.kt", text) } + val offset = offsetOf(text, "println") + 1 + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call).isNull() + } + + @Test + fun `returns the innermost call for nested calls`() { + val text = "fun inner(x: Int) = x\nfun outer(y: Int) = y\nfun g() { outer(inner(2)) }" + val file = env.project.read { env.parser.createFile("C.kt", text) } + val offset = offsetOf(text, "inner(2)") + 6 // inside inner's parens + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call!!.calleeExpression?.text).isEqualTo("inner") + } + + @Test + fun `finds call for an unclosed argument list`() { + val text = "fun f(a: Int) {}\nfun g() { f( }" + val file = env.project.read { env.parser.createFile("D.kt", text) } + val offset = offsetOf(text, "f( ") + 2 // after '(' + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call?.calleeExpression?.text).isEqualTo("f") + } +} From ea857a741f21091b023fd6071fa59867eca2788f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 11:04:11 +0000 Subject: [PATCH 03/31] lsp/kotlin: extract shared renderName and build SignatureInformation from a function symbol --- .../kotlin/completion/KotlinCompletions.kt | 15 +------ .../signaturehelp/SignatureInfoBuilder.kt | 33 ++++++++++++++ .../lsp/kotlin/utils/TypeRendering.kt | 20 +++++++++ .../signaturehelp/SignatureInfoBuilderTest.kt | 43 +++++++++++++++++++ 4 files changed, 97 insertions(+), 14 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt 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 d7e012f2ff..e22bd0b925 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 @@ -9,6 +9,7 @@ import com.itsaky.androidide.lsp.kotlin.utils.AnalysisContext import com.itsaky.androidide.lsp.kotlin.utils.ContextKeywords import com.itsaky.androidide.lsp.kotlin.utils.ModifierFilter import com.itsaky.androidide.lsp.kotlin.utils.containingTopLevelClassDeclaration +import com.itsaky.androidide.lsp.kotlin.utils.renderName import com.itsaky.androidide.lsp.kotlin.utils.resolveAnalysisContext import com.itsaky.androidide.lsp.models.ClassCompletionData import com.itsaky.androidide.lsp.models.Command @@ -27,11 +28,9 @@ import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo import org.appdevforall.codeonthego.indexing.jvm.JvmSymbol import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolKind import org.appdevforall.codeonthego.indexing.jvm.JvmTypeAliasInfo -import org.jetbrains.kotlin.analysis.api.KaContextParameterApi import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaIdeApi import org.jetbrains.kotlin.analysis.api.KaSession -import org.jetbrains.kotlin.analysis.api.renderer.types.KaTypeRenderer import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind @@ -67,7 +66,6 @@ import org.jetbrains.kotlin.psi.KtSafeQualifiedExpression import org.jetbrains.kotlin.psi.KtWhenExpression import org.jetbrains.kotlin.psi.psiUtil.getParentOfType import org.jetbrains.kotlin.psi.psiUtil.startOffset -import org.jetbrains.kotlin.types.Variance import org.slf4j.LoggerFactory import kotlin.io.path.name @@ -739,17 +737,6 @@ private fun KaSession.kindOf(symbol: JvmSymbol): CompletionItemKind = JvmSymbolKind.TYPE_ALIAS -> CompletionItemKind.CLASS } -@OptIn(KaExperimentalApi::class, KaContextParameterApi::class) -private fun KaSession.renderName( - type: KaType, - renderer: KaTypeRenderer = KaTypeRendererForSource.WITH_SHORT_NAMES, - position: Variance = Variance.INVARIANT -): String { - return type.run { - render(renderer, position) - } -} - private fun partialIdentifier(prefix: String): String { return prefix.takeLastWhile { char -> Character.isJavaIdentifierPart(char) } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt new file mode 100644 index 0000000000..1eb39a65cf --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt @@ -0,0 +1,33 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import com.itsaky.androidide.lsp.kotlin.utils.renderName +import com.itsaky.androidide.lsp.models.MarkupContent +import com.itsaky.androidide.lsp.models.ParameterInformation +import com.itsaky.androidide.lsp.models.SignatureInformation +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol + +@OptIn(KaExperimentalApi::class) +internal fun KaSession.buildSignatureInformation(symbol: KaFunctionSymbol): SignatureInformation { + val name = functionDisplayName(symbol) + val paramLabels = symbol.valueParameters.map { paramLabel(it) } + val label = paramLabels.joinToString(prefix = "$name(", separator = ", ", postfix = ")") + val parameters = paramLabels.map { ParameterInformation(it, MarkupContent()) } + return SignatureInformation(label, MarkupContent(), parameters) +} + +private fun functionDisplayName(symbol: KaFunctionSymbol): String = when (symbol) { + is KaNamedFunctionSymbol -> symbol.name.asString() + is KaConstructorSymbol -> symbol.containingClassId?.shortClassName?.asString() ?: "" + else -> "" +} + +@OptIn(KaExperimentalApi::class) +private fun KaSession.paramLabel(param: KaValueParameterSymbol): String { + val prefix = if (param.isVararg) "vararg " else "" + return "$prefix${param.name.asString()}: ${renderName(param.returnType)}" +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt new file mode 100644 index 0000000000..14c5615e74 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt @@ -0,0 +1,20 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.analysis.api.KaContextParameterApi +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.renderer.types.KaTypeRenderer +import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.types.Variance + +@OptIn(KaExperimentalApi::class, KaContextParameterApi::class) +internal fun KaSession.renderName( + type: KaType, + renderer: KaTypeRenderer = KaTypeRendererForSource.WITH_SHORT_NAMES, + position: Variance = Variance.INVARIANT +): String { + return type.run { + render(renderer, position) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt new file mode 100644 index 0000000000..9bd9108972 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt @@ -0,0 +1,43 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.junit.Test + +class SignatureInfoBuilderTest : KtLspTest() { + + @Test + fun `builds a fancy label and parameter list for a named function`() { + val file = createSourceFile("A.kt", "fun greet(name: String, times: Int): String = name") + val info = analyze(file) { + val fn = file.declarations.filterIsInstance().first() + buildSignatureInformation(fn.symbol as KaFunctionSymbol) + } + assertThat(info.label).isEqualTo("greet(name: String, times: Int)") + assertThat(info.parameters.map { it.label }) + .containsExactly("name: String", "times: Int").inOrder() + } + + @Test + fun `builds a label with no parameters`() { + val file = createSourceFile("B.kt", "fun now() {}") + val info = analyze(file) { + val fn = file.declarations.filterIsInstance().first() + buildSignatureInformation(fn.symbol as KaFunctionSymbol) + } + assertThat(info.label).isEqualTo("now()") + assertThat(info.parameters).isEmpty() + } + + @Test + fun `prefixes vararg parameters`() { + val file = createSourceFile("C.kt", "fun sum(vararg xs: Int): Int = 0") + val info = analyze(file) { + val fn = file.declarations.filterIsInstance().first() + buildSignatureInformation(fn.symbol as KaFunctionSymbol) + } + assertThat(info.parameters.single().label).isEqualTo("vararg xs: Int") + } +} From 2a6b6b7807af6d86990c4030bbe08c2153cabb7d Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 11:39:10 +0000 Subject: [PATCH 04/31] lsp/kotlin: compute active parameter index with named-argument remapping --- .../signaturehelp/ActiveParameterResolver.kt | 39 +++++++++++++ .../lsp/kotlin/fixtures/KtLspTest.kt | 31 +++++++++++ .../ActiveParameterResolverTest.kt | 55 +++++++++++++++++++ 3 files changed, 125 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt new file mode 100644 index 0000000000..c46417a631 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt @@ -0,0 +1,39 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.psi.KtCallElement + +/** + * Computes the index of the active value parameter for the call at [offset]. + * + * Positional arguments map by order. When the cursor is on a named argument, the index is remapped + * to that parameter's declared position in [resolvedCall] (handles reordered named args). The + * result is applied by the shared UI to whichever overload it renders, so named-arg remapping is + * resolved against the active overload only. + */ +internal fun KaSession.computeActiveParameter( + call: KtCallElement, + resolvedCall: KaFunctionCall<*>?, + offset: Int +): Int { + val arguments = call.valueArgumentList?.arguments.orEmpty() + + // The argument the cursor is at/inside: the first whose end is at or after the cursor. + val current = arguments.firstOrNull { offset <= it.textRange.endOffset } + val positional = if (current != null) arguments.indexOf(current) else arguments.size + + if (current != null && resolvedCall != null) { + val argExpr = current.getArgumentExpression() + val paramSignature = argExpr?.let { resolvedCall.argumentMapping[it] } + if (paramSignature != null) { + val declaredIndex = resolvedCall.symbol.valueParameters + .indexOfFirst { it.name == paramSignature.name } + if (declaredIndex >= 0) { + return declaredIndex + } + } + } + return positional +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt index 63063d3cf6..ed8a093fd9 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt @@ -1,6 +1,13 @@ package com.itsaky.androidide.lsp.kotlin.fixtures +import com.itsaky.androidide.lsp.kotlin.compiler.index.toMetadata +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import kotlinx.coroutines.runBlocking import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.psi.KtCallElement import org.jetbrains.kotlin.psi.KtFile import org.junit.Rule import org.junit.runner.RunWith @@ -27,4 +34,28 @@ abstract class KtLspTest { protected fun analyze(file: KtFile, action: KaSession.() -> R): R = env.analyze(file, action) + + /** Resolves [call] to a function call and runs [action] inside the analyze block. */ + protected fun analyzeMaybeDanglingForTest( + call: KtCallElement, + action: KaSession.(KaFunctionCall<*>?) -> R + ): R { + // `createSourceFile` writes the file and refreshes the VFS/module search scope, but unqualified + // name resolution (e.g. resolving a call's callee) goes through `KtSymbolIndex.fileIndex` + // (`DeclarationProvider.ktFilesForPackage`), which only learns about a file once it has been + // scanned. In the real IDE that happens via the background indexer + // (`KtSymbolIndex.syncIndexInBackground`), which the lightweight test environment never + // starts, so any call in a freshly created test file otherwise resolves as + // `UNRESOLVED_REFERENCE` even though the file's own PSI/declarations are visible. Register the + // file's package metadata directly, synchronously, mirroring what `IndexCommand.ScanSourceFile` + // does in production. + val ktFile = call.containingKtFile + runBlocking { env.ktSymbolIndex.fileIndex.upsert(ktFile.toMetadata(env.project, isIndexed = false)) } + return env.project.read { + analyzeMaybeDangling(ktFile) { + val resolved = call.resolveToCall()?.successfulFunctionCallOrNull() + action(resolved) + } + } + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt new file mode 100644 index 0000000000..9ec17afda3 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt @@ -0,0 +1,55 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.psi.KtCallElement +import org.junit.Test + +class ActiveParameterResolverTest : KtLspTest() { + + private fun callAt(name: String, text: String, cursorMarker: String): Pair { + val file = createSourceFile(name, text) + val offset = text.indexOf(cursorMarker).let { check(it >= 0); it } + // `findEnclosingCall` only recognizes a call once the offset is past its callee (e.g. inside the + // argument list); when the marker is the call itself (as in the empty-argument-list case below), + // `offset` alone lands exactly on the callee's name. Look the call up a couple of characters + // further in so callers can still use the marker's own `offset` as the cursor position they test. + val call = env.project.read { findEnclosingCall(file, offset + 2) }!! + return call to offset + } + + @Test + fun `first positional argument is index 0`() { + val (call, offset) = callAt("A.kt", + "fun f(a: Int, b: Int) {}\nfun g() { f(10, 20) }", "10") + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(0) + } + + @Test + fun `second positional argument is index 1`() { + val (call, offset) = callAt("B.kt", + "fun f(a: Int, b: Int) {}\nfun g() { f(10, 20) }", "20") + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(1) + } + + @Test + fun `named argument maps to its declared parameter index`() { + // cursor on `a = 5`, but `a` is the first declared parameter -> index 0 even though written second + val (call, offset) = callAt("C.kt", + "fun f(a: Int, b: Int) {}\nfun g() { f(b = 1, a = 5) }", "a = 5") + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(0) + } + + @Test + fun `empty argument list is index 0`() { + val (call, offset) = callAt("D.kt", + "fun f(a: Int) {}\nfun g() { f() }", "f()") + val cursor = offset + 2 // inside the parens + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, cursor) } } + assertThat(idx).isEqualTo(0) + } +} From 810ef8802dc921898f9f239dd3ae070f4936336f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 11:59:09 +0000 Subject: [PATCH 05/31] lsp/kotlin: assemble SignatureHelp from resolved call candidates --- .../signaturehelp/KotlinSignatureHelp.kt | 41 ++++++++++++ .../lsp/kotlin/fixtures/KtLspTest.kt | 11 +++- .../signaturehelp/KotlinSignatureHelpTest.kt | 63 +++++++++++++++++++ 3 files changed, 113 insertions(+), 2 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt new file mode 100644 index 0000000000..d450da40a6 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -0,0 +1,41 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import com.itsaky.androidide.lsp.models.SignatureHelp +import com.itsaky.androidide.lsp.models.SignatureInformation +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.psi.KtCallElement + +/** + * Builds a [SignatureHelp] for the function [call] with the cursor at [offset]. + * + * Overloads come from the compiler's own candidate resolution; the active overload is the candidate + * the compiler marks as best (falling back to the resolved call, then to the first candidate). The + * active parameter is computed against that active overload. + */ +internal fun KaSession.buildSignatureHelp(call: KtCallElement, offset: Int): SignatureHelp { + // (resolved function call, isBest) pairs, in candidate order. + val candidates = call.resolveToCallCandidates() + .mapNotNull { info -> + (info.candidate as? KaFunctionCall<*>)?.let { it to info.isInBestCandidates } + } + .ifEmpty { + // Fallback: a single successfully-resolved function call. + call.resolveToCall()?.successfulFunctionCallOrNull()?.let { listOf(it to true) } ?: emptyList() + } + + if (candidates.isEmpty()) { + return SignatureHelp.empty() + } + + val signatures: List = + candidates.map { (fnCall, _) -> buildSignatureInformation(fnCall.symbol) } + + val activeSignature = candidates.indexOfFirst { it.second }.let { if (it < 0) 0 else it } + val activeCall = candidates[activeSignature].first + val activeParameter = computeActiveParameter(call, activeCall, offset) + + return SignatureHelp(signatures, activeSignature, activeParameter) +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt index ed8a093fd9..e029803272 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt @@ -29,8 +29,15 @@ abstract class KtLspTest { internal val env: KtLspTestEnvironment get() = lspTestRule.env - protected fun createSourceFile(relativePath: String, content: String): KtFile = - env.createSourceFile(relativePath, content) + protected fun createSourceFile(relativePath: String, content: String): KtFile { + val file = env.createSourceFile(relativePath, content) + // See the comment in `analyzeMaybeDanglingForTest` below: freshly-created files are invisible to + // unqualified name resolution until they're registered with the symbol index's file metadata, + // which in production happens via the background indexer. Do that synchronously here so every + // file returned by this helper is resolvable. + runBlocking { env.ktSymbolIndex.fileIndex.upsert(file.toMetadata(env.project, isIndexed = false)) } + return file + } protected fun analyze(file: KtFile, action: KaSession.() -> R): R = env.analyze(file, action) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt new file mode 100644 index 0000000000..e51e056829 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt @@ -0,0 +1,63 @@ +package com.itsaky.androidide.lsp.kotlin.signaturehelp + +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.lsp.models.SignatureHelp +import org.junit.Test + +class KotlinSignatureHelpTest : KtLspTest() { + + private fun helpAt(name: String, text: String, marker: String, delta: Int = 0): SignatureHelp { + val file = createSourceFile(name, text) + val offset = text.indexOf(marker).let { check(it >= 0); it } + delta + return analyze(file) { + val call = findEnclosingCall(file, offset) ?: return@analyze SignatureHelp.empty() + buildSignatureHelp(call, offset) + } + } + + @Test + fun `single function returns one signature with valid indices`() { + val help = helpAt("A.kt", "fun f(a: Int) {}\nfun g() { f(1) }", "f(1)", delta = 2) + assertThat(help.signatures).hasSize(1) + assertThat(help.signatures[0].label).isEqualTo("f(a: Int)") + assertThat(help.activeSignature).isEqualTo(0) + assertThat(help.activeParameter).isEqualTo(0) + } + + @Test + fun `overloads all appear and the matching one is active`() { + val text = """ + fun f(a: Int) {} + fun f(a: Int, b: String) {} + fun g() { f(1, "x") } + """.trimIndent() + val help = helpAt("B.kt", text, "f(1, ", delta = 2) + assertThat(help.signatures.map { it.label }) + .containsExactly("f(a: Int)", "f(a: Int, b: String)") + // active overload is the two-arg one + assertThat(help.signatures[help.activeSignature].label).isEqualTo("f(a: Int, b: String)") + } + + @Test + fun `constructor call produces a signature labelled with the class name`() { + val text = "class Point(val x: Int, val y: Int)\nfun g() { Point(1, 2) }" + val help = helpAt("C.kt", text, "Point(1", delta = 6) + assertThat(help.signatures).isNotEmpty() + assertThat(help.signatures[help.activeSignature].label).isEqualTo("Point(x: Int, y: Int)") + } + + @Test + fun `unresolved call returns empty`() { + val help = helpAt("D.kt", "fun g() { doesNotExist(1) }", "doesNotExist(", delta = 13) + assertThat(help.signatures).isEmpty() + assertThat(help).isEqualTo(SignatureHelp.empty()) + } + + @Test + fun `cursor in trailing lambda body yields empty via finder`() { + val text = "fun run(block: () -> Unit) {}\nfun g() { run { p() } }" + val help = helpAt("E.kt", text, "p()", delta = 1) + assertThat(help).isEqualTo(SignatureHelp.empty()) + } +} From e7d4845f605bb232f99117ee4be75744c1cd89b4 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 12:10:53 +0000 Subject: [PATCH 06/31] lsp/kotlin: enable signature help in KotlinLanguageServer --- .../lsp/kotlin/KotlinLanguageServer.kt | 6 ++- .../signaturehelp/KotlinSignatureHelp.kt | 40 +++++++++++++++++++ 2 files changed, 45 insertions(+), 1 deletion(-) 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 bc38e6bfef..ceb86d934f 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 @@ -36,6 +36,7 @@ import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_INDEX_KEY import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_META_INDEX_KEY import com.itsaky.androidide.lsp.kotlin.completion.codeComplete import com.itsaky.androidide.lsp.kotlin.diagnostic.collectDiagnosticsFor +import com.itsaky.androidide.lsp.kotlin.signaturehelp.doSignatureHelp import com.itsaky.androidide.lsp.models.CompletionParams import com.itsaky.androidide.lsp.models.CompletionResult import com.itsaky.androidide.lsp.models.DefinitionParams @@ -249,7 +250,10 @@ class KotlinLanguageServer : ILanguageServer { return SignatureHelp.empty() } - return SignatureHelp.empty() + logger.debug("signatureHelp(position={}, file={})", params.position, params.file) + return compiler?.compilationEnvironmentFor(params.file) + ?.let { context(it) { doSignatureHelp(params) } } + ?: SignatureHelp.empty() } override suspend fun analyze(file: Path): DiagnosticResult { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt index d450da40a6..7c26c83cc8 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -1,12 +1,18 @@ package com.itsaky.androidide.lsp.kotlin.signaturehelp +import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.models.SignatureHelp +import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.lsp.models.SignatureInformation +import kotlinx.coroutines.CancellationException import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull import org.jetbrains.kotlin.analysis.api.resolution.symbol import org.jetbrains.kotlin.psi.KtCallElement +import org.slf4j.LoggerFactory /** * Builds a [SignatureHelp] for the function [call] with the cursor at [offset]. @@ -39,3 +45,37 @@ internal fun KaSession.buildSignatureHelp(call: KtCallElement, offset: Int): Sig return SignatureHelp(signatures, activeSignature, activeParameter) } + +private val logger = LoggerFactory.getLogger("KotlinSignatureHelp") + +/** + * Computes [SignatureHelp] for the request described by [params], using the given + * [CompilationEnvironment] to resolve the enclosing call and analyze it. + */ +context(env: CompilationEnvironment) +internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { + if (params.cancelChecker.isCancelled()) { + return SignatureHelp.empty() + } + + val ktFile = env.ktSymbolIndex.getOpenedKtFile(params.file) + if (ktFile == null) { + logger.warn("File {} is not open", params.file) + return SignatureHelp.empty() + } + + val offset = params.position.requireIndex() + + return try { + env.project.read { + val call = findEnclosingCall(ktFile, offset) ?: return@read SignatureHelp.empty() + analyzeMaybeDangling(ktFile) { + buildSignatureHelp(call, offset) + } + } + } catch (e: Throwable) { + if (e is CancellationException) throw e + logger.warn("An error occurred while computing signature help for {}", params.file, e) + SignatureHelp.empty() + } +} From a5b82e8f678442788158e43e5a6531fc4d536eaa Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 12:25:49 +0000 Subject: [PATCH 07/31] lsp/kotlin: add signature-help test coverage (named-arg via buildSignatureHelp, vararg active param, empty documentation) --- .../kotlin/signaturehelp/ActiveParameterResolverTest.kt | 9 +++++++++ .../lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt | 8 ++++++++ .../lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt | 1 + 3 files changed, 18 insertions(+) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt index 9ec17afda3..418367a6c2 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt @@ -52,4 +52,13 @@ class ActiveParameterResolverTest : KtLspTest() { val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, cursor) } } assertThat(idx).isEqualTo(0) } + + @Test + fun `second vararg argument maps to the vararg parameter index`() { + // cursor on the second `xs` argument (positionally index 2), declared vararg parameter is index 1 + val (call, offset) = callAt("E.kt", + "fun f(a: Int, vararg xs: Int) {}\nfun g() { f(1, 22, 33) }", "33") + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(1) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt index e51e056829..1baf518c23 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt @@ -60,4 +60,12 @@ class KotlinSignatureHelpTest : KtLspTest() { val help = helpAt("E.kt", text, "p()", delta = 1) assertThat(help).isEqualTo(SignatureHelp.empty()) } + + @Test + fun `named argument remaps active parameter via buildSignatureHelp`() { + val help = helpAt("F.kt", "fun f(a: Int, b: Int) {}\nfun g() { f(b = 1, a = 5) }", "a = 5") + // 'a' is declared parameter 0 though written second + assertThat(help.activeParameter).isEqualTo(0) + assertThat(help.signatures).isNotEmpty() + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt index 9bd9108972..3c54760c56 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt @@ -18,6 +18,7 @@ class SignatureInfoBuilderTest : KtLspTest() { assertThat(info.label).isEqualTo("greet(name: String, times: Int)") assertThat(info.parameters.map { it.label }) .containsExactly("name: String", "times: Int").inOrder() + assertThat(info.documentation.value).isEmpty() } @Test From 044dfe3c6eeb54ab0a96e95eda294bc225ed33cc Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 12:44:47 +0000 Subject: [PATCH 08/31] editor: dismiss signature-help popup instead of showing it empty --- .../itsaky/androidide/editor/ui/SignatureHelpWindow.java | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java b/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java index 7f0343d1df..5e40614616 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java @@ -71,6 +71,10 @@ public void setupAndDisplay(SignatureHelp signature) { final var signatureText = createSignatureText(signature); if (signatureText == null) { + if (isShowing()) { + dismiss(); + } + return; } @@ -99,6 +103,10 @@ private CharSequence createSignatureText(@NonNull SignatureHelp signature) { // keep only applicable signatures (does not mutate the input list) final var applicable = applicableSignatures(signatures, activeParameter); + if (applicable.isEmpty()) { + return null; + } + count = applicable.size(); for (var i = 0; i < count; i++) { final var info = applicable.get(i); From 9b7204a06007f76ccefd4dad8e26817f488dc482 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 13:07:57 +0000 Subject: [PATCH 09/31] lsp/kotlin: guard requireIndex() inside doSignatureHelp try block --- .../androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt index 7c26c83cc8..93eb40f5be 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -64,9 +64,8 @@ internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { return SignatureHelp.empty() } - val offset = params.position.requireIndex() - return try { + val offset = params.position.requireIndex() env.project.read { val call = findEnclosingCall(ktFile, offset) ?: return@read SignatureHelp.empty() analyzeMaybeDangling(ktFile) { From d60f8a04f32ca6fe1139f60e7c98747b120888d9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 13:25:29 +0000 Subject: [PATCH 10/31] lsp/kotlin: add debug logging throughout signature help Adds SLF4J debug breadcrumbs across findEnclosingCall, buildSignatureHelp, computeActiveParameter and buildSignatureInformation so the signature-help pipeline can be traced from logs, without changing any behavior. --- .../signaturehelp/ActiveParameterResolver.kt | 9 +++++ .../signaturehelp/CallAtCursorFinder.kt | 11 ++++++ .../signaturehelp/KotlinSignatureHelp.kt | 37 +++++++++++++++++-- .../signaturehelp/SignatureInfoBuilder.kt | 4 ++ 4 files changed, 57 insertions(+), 4 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt index c46417a631..0131c555d4 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt @@ -4,6 +4,9 @@ import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall import org.jetbrains.kotlin.analysis.api.resolution.symbol import org.jetbrains.kotlin.psi.KtCallElement +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("ActiveParameterResolver") /** * Computes the index of the active value parameter for the call at [offset]. @@ -31,9 +34,15 @@ internal fun KaSession.computeActiveParameter( val declaredIndex = resolvedCall.symbol.valueParameters .indexOfFirst { it.name == paramSignature.name } if (declaredIndex >= 0) { + logger.debug( + "computeActiveParameter: resolved named-argument index {} at offset {}", + declaredIndex, + offset + ) return declaredIndex } } } + logger.debug("computeActiveParameter: using positional index {} at offset {}", positional, offset) return positional } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt index e9f2fe4b94..2bfd5d0a39 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt @@ -4,6 +4,9 @@ import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.psi.KtCallElement import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("CallAtCursorFinder") /** * Finds the innermost [KtCallElement] whose argument list directly contains [offset]. @@ -17,18 +20,26 @@ internal fun findEnclosingCall(file: KtFile, offset: Int): KtCallElement? { var node: PsiElement? = file.findElementAt(offset) ?: file.findElementAt((offset - 1).coerceAtLeast(0)) + if (node == null) { + logger.debug("No PSI element found at offset {} in {}", offset, file.name) + return null + } + while (node != null && node !is KtFile) { // Crossing a lambda body before reaching a call's arguments means we are inside the lambda. if (node is KtFunctionLiteral) { + logger.debug("Offset {} in {} is inside a lambda body; not showing signature help", offset, file.name) return null } if (node is KtCallElement) { val callee = node.calleeExpression if (callee != null && offset > callee.textRange.endOffset) { + logger.debug("Found enclosing call '{}' for offset {} in {}", callee.text, offset, file.name) return node } } node = node.parent } + logger.debug("No enclosing call found for offset {} in {}", offset, file.name) return null } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt index 93eb40f5be..842e882d1d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -22,17 +22,27 @@ import org.slf4j.LoggerFactory * active parameter is computed against that active overload. */ internal fun KaSession.buildSignatureHelp(call: KtCallElement, offset: Int): SignatureHelp { + val calleeText = call.calleeExpression?.text + // (resolved function call, isBest) pairs, in candidate order. - val candidates = call.resolveToCallCandidates() + val resolvedCandidates = call.resolveToCallCandidates() .mapNotNull { info -> (info.candidate as? KaFunctionCall<*>)?.let { it to info.isInBestCandidates } } - .ifEmpty { + logger.debug( + "resolveToCallCandidates() found {} candidate(s) for call '{}'", + resolvedCandidates.size, + calleeText + ) + + val candidates = resolvedCandidates.ifEmpty { // Fallback: a single successfully-resolved function call. + logger.debug("No candidates from resolveToCallCandidates(); falling back to resolveToCall() for '{}'", calleeText) call.resolveToCall()?.successfulFunctionCallOrNull()?.let { listOf(it to true) } ?: emptyList() } if (candidates.isEmpty()) { + logger.debug("No resolvable candidates for call '{}'; returning empty signature help", calleeText) return SignatureHelp.empty() } @@ -43,6 +53,14 @@ internal fun KaSession.buildSignatureHelp(call: KtCallElement, offset: Int): Sig val activeCall = candidates[activeSignature].first val activeParameter = computeActiveParameter(call, activeCall, offset) + logger.debug( + "buildSignatureHelp for '{}': {} signature(s), activeSignature={}, activeParameter={}", + calleeText, + signatures.size, + activeSignature, + activeParameter + ) + return SignatureHelp(signatures, activeSignature, activeParameter) } @@ -54,7 +72,10 @@ private val logger = LoggerFactory.getLogger("KotlinSignatureHelp") */ context(env: CompilationEnvironment) internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { + logger.debug("doSignatureHelp requested for file={} position={}", params.file, params.position) + if (params.cancelChecker.isCancelled()) { + logger.debug("Signature help request for {} was cancelled before processing", params.file) return SignatureHelp.empty() } @@ -66,15 +87,23 @@ internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { return try { val offset = params.position.requireIndex() - env.project.read { + val result = env.project.read { val call = findEnclosingCall(ktFile, offset) ?: return@read SignatureHelp.empty() analyzeMaybeDangling(ktFile) { buildSignatureHelp(call, offset) } } + logger.debug( + "Signature help result for {}: {} signature(s), activeSignature={}, activeParameter={}", + params.file, + result.signatures.size, + result.activeSignature, + result.activeParameter + ) + result } catch (e: Throwable) { if (e is CancellationException) throw e - logger.warn("An error occurred while computing signature help for {}", params.file, e) + logger.warn("Signature help computation failed for {}", params.file, e) SignatureHelp.empty() } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt index 1eb39a65cf..01840e8a58 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt @@ -10,6 +10,9 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.slf4j.LoggerFactory + +private val logger = LoggerFactory.getLogger("SignatureInfoBuilder") @OptIn(KaExperimentalApi::class) internal fun KaSession.buildSignatureInformation(symbol: KaFunctionSymbol): SignatureInformation { @@ -17,6 +20,7 @@ internal fun KaSession.buildSignatureInformation(symbol: KaFunctionSymbol): Sign val paramLabels = symbol.valueParameters.map { paramLabel(it) } val label = paramLabels.joinToString(prefix = "$name(", separator = ", ", postfix = ")") val parameters = paramLabels.map { ParameterInformation(it, MarkupContent()) } + logger.debug("buildSignatureInformation produced label '{}'", label) return SignatureInformation(label, MarkupContent(), parameters) } From 502a446f75a0ed6ecd97b42edbadfa1da0224d3a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 13:42:26 +0000 Subject: [PATCH 11/31] lsp/kotlin: resolve signature help against live document contents Signature help resolved the cursor offset against the KtFile returned by getOpenedKtFile(), which is only refreshed by the ASYNC onDocumentChange subscriber. The editor computes the cursor index against its live in-memory buffer, so when the opened KtFile lagged behind (by one or more keystrokes) the offset landed on the wrong element and findEnclosingCall returned null, silently dropping signature help. Build the analysis KtFile from FileManager.getDocumentContents() (the live document) instead, mirroring what KotlinCompletions.doComplete already does, so the offset and the parsed tree always agree. --- .../signaturehelp/KotlinSignatureHelp.kt | 18 ++++++++++++++++-- 1 file changed, 16 insertions(+), 2 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt index 842e882d1d..92dfa7e8b6 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -6,13 +6,16 @@ import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.lsp.models.SignatureInformation +import com.itsaky.androidide.projects.FileManager import kotlinx.coroutines.CancellationException import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.low.level.api.fir.util.originalKtFile import org.jetbrains.kotlin.psi.KtCallElement import org.slf4j.LoggerFactory +import kotlin.io.path.name /** * Builds a [SignatureHelp] for the function [call] with the cursor at [offset]. @@ -88,8 +91,19 @@ internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { return try { val offset = params.position.requireIndex() val result = env.project.read { - val call = findEnclosingCall(ktFile, offset) ?: return@read SignatureHelp.empty() - analyzeMaybeDangling(ktFile) { + // Resolve against the live document contents rather than the opened KtFile. The cursor + // [offset] is computed by the editor against its in-memory buffer, but the opened KtFile is + // refreshed asynchronously (KotlinLanguageServer.onDocumentChange is an ASYNC event + // subscriber), so it can lag one or more keystrokes behind. Feeding a live-buffer offset into + // a stale KtFile makes it land on the wrong element (or none) and signature help silently + // fails. Completion avoids this the same way; see KotlinCompletions.doComplete. + val liveText = FileManager.getDocumentContents(params.file) + val signatureKtFile = env.parser.createFile(fileName = params.file.name, text = liveText).apply { + originalFile = ktFile + originalKtFile = ktFile + } + val call = findEnclosingCall(signatureKtFile, offset) ?: return@read SignatureHelp.empty() + analyzeMaybeDangling(signatureKtFile) { buildSignatureHelp(call, offset) } } From 85db15b7fe8976db3205492cf4ddd98483e4f7f1 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 15:02:14 +0000 Subject: [PATCH 12/31] ADFA-3322: add version-stamped current-KtFile cache with single-flight --- .../AbstractCompilationEnvironment.kt | 1 + .../kotlin/compiler/index/KtSymbolIndex.kt | 71 ++++++++++++++++ .../compiler/index/CurrentKtFileCacheTest.kt | 82 +++++++++++++++++++ 3 files changed, 154 insertions(+) create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt index 178ebe3d29..d240835532 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt @@ -320,6 +320,7 @@ internal abstract class AbstractCompilationEnvironment( setupServices(libraryRoots) parser = KtPsiFactory(project, eventSystemEnabled = enableParserEventSystem) + ktSymbolIndex.parser = parser postInit(libraryRoots) } 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 adcbd44f75..1832268775 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 @@ -3,8 +3,11 @@ package com.itsaky.androidide.lsp.kotlin.compiler.index import com.github.benmanes.caffeine.cache.Caffeine import com.itsaky.androidide.lsp.kotlin.compiler.CompilationKind import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule +import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider import com.itsaky.androidide.lsp.kotlin.utils.toVirtualFileOrNull +import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.utils.DocumentUtils import io.sentry.Sentry import kotlinx.coroutines.CoroutineName @@ -22,9 +25,14 @@ import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.kotlin.com.intellij.psi.PsiManager import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtPsiFactory import org.slf4j.LoggerFactory import java.nio.file.Path +import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap +import java.util.concurrent.ExecutorService +import java.util.concurrent.Executors +import kotlin.io.path.pathString val KT_SOURCE_FILE_INDEX_KEY = IndexKey("kt-source-file-index") val KT_SOURCE_FILE_META_INDEX_KEY = IndexKey("kt-source-file-meta-index") @@ -83,6 +91,19 @@ internal class KtSymbolIndex( val openedKtFiles: Sequence> get() = openedFiles.asSequence() + /** Set by AbstractCompilationEnvironment.initialize once the env's KtPsiFactory exists. */ + lateinit var parser: KtPsiFactory + + private data class VersionedKtFile(val version: Int, val ktFile: KtFile) + + private val refreshExecutor: ExecutorService = + Executors.newFixedThreadPool(2) { r -> Thread(r, "KtCurrentFileRefresh").apply { isDaemon = true } } + + /** path -> in-flight/last-launched refresh. Guarded by currentFiles.asMap().compute per key. */ + private val currentFiles = ConcurrentHashMap>() + /** path -> version most recently launched. Read/written only inside the compute() critical section. */ + private val currentVersions = ConcurrentHashMap() + fun syncIndexInBackground() { indexingJob?.cancel() startIndexing() @@ -164,6 +185,56 @@ internal class KtSymbolIndex( fun getOpenedKtFile(path: Path) = openedFiles[path] + /** + * Returns the canonical [KtFile] for [path] at the current document version, parsing (once) on a + * 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 { + if (!DocumentUtils.isKotlinFile(path)) return CompletableFuture.completedFuture(null) + + val doc = FileManager.getActiveDocument(path) + ?: return CompletableFuture.completedFuture(getKtFile(path)) // not open -> disk path + + val version = doc.version + val future = currentFiles.compute(path) { p, existing -> + if (existing != null && !existing.isCompletedExceptionally && currentVersions[p] == version) { + existing + } else { + currentVersions[p] = version + val prior = existing + CompletableFuture.supplyAsync({ + // Serialize with any prior refresh for this path so old->new succession is linear. + val old = try { prior?.get()?.ktFile } catch (_: Throwable) { null } + refreshToCurrent(p, version, old) + }, refreshExecutor) + } + }!! + return future.thenApply { it.ktFile } + } + + /** Parses the live document for [path], registers it as the in-memory file, returns the stamped file. */ + private fun refreshToCurrent(path: Path, version: Int, old: KtFile?): VersionedKtFile { + val content = FileManager.getDocumentContents(path) + val newKtFile = project.read { parser.createFile(path.pathString, content) } + newKtFile.backingFilePath = path + // Use the view provider's virtual file rather than KtFile.virtualFile: the latter is null for + // non-physical PSI files (parser event system disabled, e.g. the unit-test environment), while + // the view provider always exposes the backing light virtual file. For physical files (prod) + // the two are the same object. + ProjectStructureProvider.getInstance(project) + .registerInMemoryFile(path.pathString, newKtFile.viewProvider.virtualFile) + // Task 2 adds the FIR-session invalidation + reindex enqueue here (needs `old`). + return VersionedKtFile(version, newKtFile) + } + + /** Drops the cached current file for [path] (e.g. on close). */ + fun invalidateCurrent(path: Path) { + currentFiles.remove(path) + currentVersions.remove(path) + ProjectStructureProvider.getInstance(project).unregisterInMemoryFile(path.pathString) + } + fun getKtFile(vf: VirtualFile): KtFile? = getKtFile(vf.toNioPath(), vf) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt new file mode 100644 index 0000000000..49bbab8b3e --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt @@ -0,0 +1,82 @@ +package com.itsaky.androidide.lsp.kotlin.compiler.index + +import com.itsaky.androidide.eventbus.events.editor.ChangeType +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.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.models.Range +import com.itsaky.androidide.projects.FileManager +import org.junit.After +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotSame +import org.junit.Assert.assertSame +import org.junit.Test +import java.nio.file.Path + +internal class CurrentKtFileCacheTest : KtLspTest() { + + private val openedPaths = mutableListOf() + + @After + fun closeDocs() { + openedPaths.forEach { FileManager.onDocumentClose(docCloseEvent(it)) } + openedPaths.clear() + } + + private fun docCloseEvent(path: Path) = DocumentCloseEvent(path) + + /** The [Path] under the first source root that [createSourceFile] wrote [relativePath] to. */ + private fun sourcePath(relativePath: String): Path = + env.sourceRoots.first().resolve(relativePath) + + /** Registers [path] as an active document at version 1 with [content]. */ + private fun openDocument(path: Path, content: String) { + FileManager.onDocumentOpen(DocumentOpenEvent(path, content, 1)) + openedPaths.add(path) + } + + private fun changeDocument(path: Path, content: String, version: Int) { + FileManager.onDocumentContentChange( + DocumentChangeEvent(path, content, content, version, ChangeType.NEW_TEXT, 0, Range.NONE) + ) + } + + @Test + fun `same version returns same instance`() { + createSourceFile("A.kt", "fun a() {}") + val path = sourcePath("A.kt") + openDocument(path, "fun a() {}") + + val first = env.ktSymbolIndex.getCurrentKtFile(path).get() + val second = env.ktSymbolIndex.getCurrentKtFile(path).get() + + assertSame(first, second) + } + + @Test + fun `new version returns new instance reflecting new content`() { + createSourceFile("B.kt", "fun b() {}") + val path = sourcePath("B.kt") + openDocument(path, "fun b() {}") + val v1 = env.ktSymbolIndex.getCurrentKtFile(path).get()!! + + changeDocument(path, "fun b() {}\nfun c() {}", 2) + val v2 = env.ktSymbolIndex.getCurrentKtFile(path).get()!! + + assertNotSame(v1, v2) + assertEquals("fun b() {}\nfun c() {}", v2.text) + } + + @Test + fun `concurrent requests at same version parse once`() { + createSourceFile("D.kt", "fun d() {}") + val path = sourcePath("D.kt") + openDocument(path, "fun d() {}") + + val futures = (1..16).map { env.ktSymbolIndex.getCurrentKtFile(path) } + val results = futures.map { it.get() } + + results.forEach { assertSame(results.first(), it) } + } +} From b6228ca88e27c181759f95af19c3b8b4f24a7cad Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 15:18:22 +0000 Subject: [PATCH 13/31] ADFA-3322: drain current-file refresh executor on close; document version-stamp race --- .../lsp/kotlin/compiler/index/KtSymbolIndex.kt | 15 +++++++++++++++ 1 file changed, 15 insertions(+) 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 1832268775..7fb3c3198f 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 @@ -32,6 +32,7 @@ import java.util.concurrent.CompletableFuture import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.ExecutorService import java.util.concurrent.Executors +import java.util.concurrent.TimeUnit import kotlin.io.path.pathString val KT_SOURCE_FILE_INDEX_KEY = IndexKey("kt-source-file-index") @@ -60,6 +61,7 @@ internal class KtSymbolIndex( companion object { private val logger = LoggerFactory.getLogger(KtSymbolIndex::class.java) const val DEFAULT_CACHE_SIZE = 100L + private const val CLOSE_DRAIN_TIMEOUT_SECONDS = 5L } private val workerQueue = WorkerQueue() @@ -215,6 +217,13 @@ internal class KtSymbolIndex( /** Parses the live document for [path], registers it as the in-memory file, returns the stamped file. */ private fun refreshToCurrent(path: Path, version: Int, old: KtFile?): VersionedKtFile { + // [version] is the version observed when this refresh was launched (inside compute()); the + // content below is read live, possibly later (this runs on refreshExecutor, after any prior + // refresh's prior.get()). ActiveDocument's version/content are separate mutable fields with no + // atomic snapshot, so a concurrent edit landing in that window can make [version] under-state + // the content actually parsed. This is bounded and safe: callers never see stale content (what + // we serve is at-least-as-fresh as requested); the only effect is a spurious cache miss and one + // redundant re-parse on the next request for the newer version. val content = FileManager.getDocumentContents(path) val newKtFile = project.read { parser.createFile(path.pathString, content) } newKtFile.backingFilePath = path @@ -280,6 +289,12 @@ internal class KtSymbolIndex( // Disposer.dispose(...), which would otherwise crash with "Project is already disposed" // (APPDEVFORALL-17R). This index owns `scope`. scope.coroutineContext[Job]?.cancelAndJoin() + + // Drain the current-file refresh pool: refreshToCurrent runs project.read/write on it, so no + // in-flight refresh may survive into the caller's project disposal (same rationale as the + // scope join above). Bounded so a slow parse can't block shutdown indefinitely. + refreshExecutor.shutdownNow() + refreshExecutor.awaitTermination(CLOSE_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS) } } From 8dd1e873892e77fb41f03678e3aefc839b4cc6cc Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 15:41:38 +0000 Subject: [PATCH 14/31] ADFA-3322: invalidate FIR session and reindex inside current-file refresh --- .../kotlin/compiler/index/KtSymbolIndex.kt | 18 +++++++++++++++++- .../compiler/index/CurrentKtFileCacheTest.kt | 19 +++++++++++++++++++ 2 files changed, 36 insertions(+), 1 deletion(-) 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 7fb3c3198f..6a4e10dce7 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 @@ -6,6 +6,7 @@ import com.itsaky.androidide.lsp.kotlin.compiler.modules.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider +import com.itsaky.androidide.lsp.kotlin.compiler.write import com.itsaky.androidide.lsp.kotlin.utils.toVirtualFileOrNull import com.itsaky.androidide.projects.FileManager import com.itsaky.androidide.utils.DocumentUtils @@ -21,6 +22,9 @@ import org.appdevforall.codeonthego.indexing.jvm.JvmSymbolIndex import org.appdevforall.codeonthego.indexing.jvm.KtFileMetadataIndex import org.appdevforall.codeonthego.indexing.service.IndexKey import org.checkerframework.checker.index.qual.NonNegative +import org.jetbrains.kotlin.analysis.api.platform.modification.KaElementModificationType +import org.jetbrains.kotlin.analysis.api.platform.modification.KaSourceModificationService +import org.jetbrains.kotlin.com.intellij.openapi.application.ApplicationManager import org.jetbrains.kotlin.com.intellij.openapi.project.Project import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFile import org.jetbrains.kotlin.com.intellij.psi.PsiManager @@ -233,7 +237,19 @@ internal class KtSymbolIndex( // the two are the same object. ProjectStructureProvider.getInstance(project) .registerInMemoryFile(path.pathString, newKtFile.viewProvider.virtualFile) - // Task 2 adds the FIR-session invalidation + reindex enqueue here (needs `old`). + // Mirrors CompilationEnvironment.onFileContentChanged: invalidate the FIR session's view of + // the (old) element under the write lock so it can't race a concurrent `analyze` (which only + // holds the read lock), then re-index the new instance. + project.write { + // handleElementModification publishes an out-of-block modification event, which the + // platform's ThreadingAssertions require to run inside a write action (independent of our + // own read/write lock above, which only serializes with `analyze`). + ApplicationManager.getApplication().runWriteAction { + KaSourceModificationService.getInstance(project) + .handleElementModification(old ?: newKtFile, KaElementModificationType.Unknown) + } + queueOnFileChangedAsync(newKtFile) + } return VersionedKtFile(version, newKtFile) } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt index 49bbab8b3e..9521aeadd9 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt @@ -79,4 +79,23 @@ internal class CurrentKtFileCacheTest : KtLspTest() { results.forEach { assertSame(results.first(), it) } } + + @Test + fun `refreshed file resolves against new content via analysis`() { + createSourceFile("E.kt", "fun e(): Int = 1") + val path = sourcePath("E.kt") + openDocument(path, "fun e(): Int = 1") + env.ktSymbolIndex.getCurrentKtFile(path).get() + + changeDocument(path, "fun e(): Int = 1\nfun f(): Int = e()", 2) + val v2 = env.ktSymbolIndex.getCurrentKtFile(path).get()!! + + // The new top-level `f` calling `e` must resolve without UNRESOLVED_REFERENCE. + val diagnostics = env.analyze(v2) { + v2.collectDiagnostics( + org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS + ).toList() + } + assertEquals(emptyList(), diagnostics.map { it.defaultMessage }) + } } From 42020b054ce209824c238c92c3e9f90fdade3d15 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 16:05:40 +0000 Subject: [PATCH 15/31] =?UTF-8?q?ADFA-3322:=20pull-model=20file=20refresh?= =?UTF-8?q?=20=E2=80=94=20onFileContentChanged=20only=20schedules?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../kotlin/compiler/CompilationEnvironment.kt | 46 ++++++++----------- 1 file changed, 20 insertions(+), 26 deletions(-) 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 c9af5b7263..44280b835d 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 @@ -25,6 +25,7 @@ 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 @@ -101,6 +102,8 @@ internal class CompilationEnvironment( val fileAnalyzer: KeyedDebouncingAction + val refreshScheduler: KeyedDebouncingAction + val libraryIndex: JvmSymbolIndex? get() = ktProject.libraryIndex @@ -184,6 +187,15 @@ internal class CompilationEnvironment( languageClient?.publishDiagnostics(result) } } + + refreshScheduler = KeyedDebouncingAction( + scope = coroutineScope, + debounceDuration = DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION, + ) { path, _ -> + // Pull through the cache so a refresh (and its reindex) happens after every edit, + // independent of whether diagnostics run. + ktSymbolIndex.getCurrentKtFile(path).await() + } } fun refreshSources() { @@ -199,12 +211,11 @@ internal class CompilationEnvironment( } fun openFileIfNeeded(path: Path) { - ktSymbolIndex.getOpenedKtFile(path) ?: onFileOpen(path) + fileAnalyzer.schedule(path) } fun onFileOpen(path: Path) { - val ktFile = loadKtFile(path) ?: return - ktSymbolIndex.openKtFile(path, ktFile) + // "Open" is now owned by FileManager; the first request/analysis parses lazily via the cache. fileAnalyzer.schedule(path) } @@ -214,8 +225,8 @@ internal class CompilationEnvironment( fun onFileClosed(path: Path) { fileAnalyzer.cancelPending(path) - ktSymbolIndex.closeKtFile(path) - ProjectStructureProvider.getInstance(project).unregisterInMemoryFile(path.pathString) + refreshScheduler.cancelPending(path) + ktSymbolIndex.invalidateCurrent(path) } @OptIn(KaImplementationDetail::class) @@ -281,35 +292,18 @@ internal class CompilationEnvironment( } suspend fun onFileMoved(fromPath: Path, toPath: Path) { - val isFileOpen = ktSymbolIndex.getOpenedKtFile(fromPath) != null + val isFileOpen = FileManager.isActive(fromPath) onFileRemoved(fromPath) onFileCreated(toPath) if (isFileOpen) { - ktSymbolIndex.closeKtFile(fromPath) + ktSymbolIndex.invalidateCurrent(fromPath) onFileOpen(toPath) } } fun onFileContentChanged(path: Path) { - val oldKtFile = ktSymbolIndex.getOpenedKtFile(path) - val newContent = FileManager.getDocumentContents(path) - val newKtFile = project.read { - parser.createFile(path.pathString, newContent) - } - - newKtFile.backingFilePath = path - - val provider = ProjectStructureProvider.getInstance(project) - provider.registerInMemoryFile(path.pathString, newKtFile.virtualFile) - - project.write { - val toInvalidate = oldKtFile ?: newKtFile - KaSourceModificationService.getInstance(project) - .handleElementModification(toInvalidate, KaElementModificationType.Unknown) - ktSymbolIndex.openKtFile(path, newKtFile) - ktSymbolIndex.queueOnFileChangedAsync(newKtFile) - fileAnalyzer.schedule(path) - } + refreshScheduler.schedule(path) + fileAnalyzer.schedule(path) } private fun loadKtFile(path: Path): KtFile? { From dea75ac858f89f8702a8f077f14339ada83df3fd Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 16:27:21 +0000 Subject: [PATCH 16/31] ADFA-3322: route all LSP features through getCurrentKtFile --- .../lsp/kotlin/actions/AddImportAction.kt | 2 +- .../kotlin/compiler/index/KtSymbolIndex.kt | 19 +++++++++++++ .../completion/AdvancedKotlinEditHandler.kt | 2 +- .../kotlin/completion/KotlinCompletions.kt | 9 +++---- .../diagnostic/KotlinDiagnosticProvider.kt | 7 +---- .../signaturehelp/KotlinSignatureHelp.kt | 27 +++++++------------ 6 files changed, 35 insertions(+), 31 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt index 3ca3e33683..7daed3b7ad 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt @@ -71,7 +71,7 @@ class AddImportAction : BaseKotlinCodeAction() { val file = data.requireFile() val nioPath = file.toPath() val ktFile = env.ktSymbolIndex - .getOpenedKtFile(nioPath) + .getCurrentKtFile(nioPath).get() ?: return emptyMap() return env.ktSymbolIndex 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 6a4e10dce7..08ed6474cd 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 @@ -260,12 +260,31 @@ internal class KtSymbolIndex( ProjectStructureProvider.getInstance(project).unregisterInMemoryFile(path.pathString) } + /** + * Non-blocking: the current cached instance for [path] if a refresh has already completed, + * 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 + fun getKtFile(vf: VirtualFile): KtFile? = getKtFile(vf.toNioPath(), vf) fun getKtFile(path: Path, virtualFile: VirtualFile? = null): KtFile? { if (!DocumentUtils.isKotlinFile(path)) return null + if (FileManager.isActive(path)) { + // Active document: peek the current-file cache without blocking. Calling + // getCurrentKtFile(path).get() here would deadlock: getKtFile is invoked by + // Analysis-API services (DeclarationsProvider, AnnotationsResolver, + // DirectInheritorsProvider) while FIR resolution holds project.read, and a blocking + // refresh needs project.write on the executor thread (reader-holds-lock waits on + // writer -> deadlock). A peek miss falls back to the disk instance below; the file's + // own refresh is already scheduled on edit and will be served on the next request. + getCurrentKtFileIfPresent(path)?.let { return it } + } + openedFiles[path]?.also { return it } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt index 08090c0bda..0ee28daaa4 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt @@ -23,7 +23,7 @@ internal abstract class AdvancedKotlinEditHandler( column: Int, index: Int ) { - val managedFile = analysisContext.env.ktSymbolIndex.getOpenedKtFile(analysisContext.file) + val managedFile = analysisContext.env.ktSymbolIndex.getCurrentKtFile(analysisContext.file).get() if (managedFile == null) { logger.error("Unable to perform edit. File not open.") return 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 e22bd0b925..c0e654c630 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 @@ -21,7 +21,6 @@ import com.itsaky.androidide.lsp.models.InsertTextFormat import com.itsaky.androidide.preferences.utils.indentationString import com.itsaky.androidide.progress.ICancelChecker import com.itsaky.androidide.progress.ProgressManager -import com.itsaky.androidide.projects.FileManager import kotlinx.coroutines.CancellationException import org.appdevforall.codeonthego.indexing.jvm.JvmClassInfo import org.appdevforall.codeonthego.indexing.jvm.JvmFunctionInfo @@ -107,15 +106,15 @@ internal fun codeComplete(params: CompletionParams): CompletionResult { context(env: CompilationEnvironment) internal fun doComplete(params: CompletionParams): CompletionResult { - val ktFile = env.ktSymbolIndex.getOpenedKtFile(params.file) + val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).get() if (ktFile == null) { logger.warn("File {} is not open", params.file) return CompletionResult.EMPTY } - // Need to use the original document contents here, instead of - // managedFile.inMemoryKtFile.text - val originalText = FileManager.getDocumentContents(params.file) + // Completion still parses its own placeholder variant (text differs), anchored to the + // current file. + val originalText = ktFile.text val requestPosition = params.position val completionOffset = requestPosition.requireIndex() val prefix = params.requirePrefix() 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 f9304a9230..81613e47cd 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 @@ -53,12 +53,7 @@ internal fun collectDiagnosticsFor(file: Path, cancelChecker: ICancelChecker): D @OptIn(KaExperimentalApi::class) context(env: CompilationEnvironment) private fun doAnalyze(file: Path, cancelChecker: ICancelChecker): DiagnosticResult { - var ktFile = env.ktSymbolIndex.getOpenedKtFile(file) - if (ktFile == null) { - env.onFileOpen(file) - ktFile = env.ktSymbolIndex.getOpenedKtFile(file) - } - + val ktFile = env.ktSymbolIndex.getCurrentKtFile(file).get() if (ktFile == null) { logger.warn("File {} is not accessible", file) return DiagnosticResult.NO_UPDATE diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt index 92dfa7e8b6..40c5b0efc1 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -6,16 +6,14 @@ import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.models.SignatureHelp import com.itsaky.androidide.lsp.models.SignatureHelpParams import com.itsaky.androidide.lsp.models.SignatureInformation -import com.itsaky.androidide.projects.FileManager import kotlinx.coroutines.CancellationException +import kotlinx.coroutines.future.await import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.resolution.KaFunctionCall import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull import org.jetbrains.kotlin.analysis.api.resolution.symbol -import org.jetbrains.kotlin.analysis.low.level.api.fir.util.originalKtFile import org.jetbrains.kotlin.psi.KtCallElement import org.slf4j.LoggerFactory -import kotlin.io.path.name /** * Builds a [SignatureHelp] for the function [call] with the cursor at [offset]. @@ -74,7 +72,7 @@ private val logger = LoggerFactory.getLogger("KotlinSignatureHelp") * [CompilationEnvironment] to resolve the enclosing call and analyze it. */ context(env: CompilationEnvironment) -internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { +internal suspend fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { logger.debug("doSignatureHelp requested for file={} position={}", params.file, params.position) if (params.cancelChecker.isCancelled()) { @@ -82,7 +80,11 @@ internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { return SignatureHelp.empty() } - val ktFile = env.ktSymbolIndex.getOpenedKtFile(params.file) + // Resolves to the current document version, parsing and registering it if a refresh hasn't + // already landed. This is called outside any project.read/write block, so awaiting a blocking + // refresh here is safe (unlike KtSymbolIndex.getKtFile, which is called by Analysis-API + // services while a read lock is held). + val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).await() if (ktFile == null) { logger.warn("File {} is not open", params.file) return SignatureHelp.empty() @@ -91,19 +93,8 @@ internal fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { return try { val offset = params.position.requireIndex() val result = env.project.read { - // Resolve against the live document contents rather than the opened KtFile. The cursor - // [offset] is computed by the editor against its in-memory buffer, but the opened KtFile is - // refreshed asynchronously (KotlinLanguageServer.onDocumentChange is an ASYNC event - // subscriber), so it can lag one or more keystrokes behind. Feeding a live-buffer offset into - // a stale KtFile makes it land on the wrong element (or none) and signature help silently - // fails. Completion avoids this the same way; see KotlinCompletions.doComplete. - val liveText = FileManager.getDocumentContents(params.file) - val signatureKtFile = env.parser.createFile(fileName = params.file.name, text = liveText).apply { - originalFile = ktFile - originalKtFile = ktFile - } - val call = findEnclosingCall(signatureKtFile, offset) ?: return@read SignatureHelp.empty() - analyzeMaybeDangling(signatureKtFile) { + val call = findEnclosingCall(ktFile, offset) ?: return@read SignatureHelp.empty() + analyzeMaybeDangling(ktFile) { buildSignatureHelp(call, offset) } } From d6b95416d9b4fdeeb462ae5755e2e846c198768b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 16:37:42 +0000 Subject: [PATCH 17/31] ADFA-3322: remove openedFiles map, drain refresh executor on close --- .../kotlin/compiler/CompilationEnvironment.kt | 12 ++---------- .../kotlin/compiler/index/KtSymbolIndex.kt | 19 ------------------- 2 files changed, 2 insertions(+), 29 deletions(-) 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 44280b835d..9c16352518 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 @@ -46,7 +46,6 @@ import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironment import org.jetbrains.kotlin.cli.jvm.compiler.KotlinCoreApplicationEnvironmentMode import org.jetbrains.kotlin.cli.jvm.index.JavaRoot import org.jetbrains.kotlin.com.intellij.mock.MockProject -import org.jetbrains.kotlin.com.intellij.openapi.vfs.VirtualFileManager import org.jetbrains.kotlin.config.LanguageVersion import org.jetbrains.kotlin.psi.KtFile import org.slf4j.LoggerFactory @@ -234,9 +233,8 @@ internal class CompilationEnvironment( path: Path, crossinline typeProvider: (KtFile) -> KaElementModificationType, ) { - // Resolve PSI/module structure under the read lock, mirroring loadKtFile(); driving - // psiManager.findFile / structureProvider concurrently with an `analyze` read section - // otherwise races. + // Resolve PSI/module structure under the read lock; driving psiManager.findFile / + // structureProvider concurrently with an `analyze` read section otherwise races. val (ktFile, module) = project.read { val structureProvider = ProjectStructureProvider.getInstance(project) val ktFile = path.toVirtualFileOrNull()?.let { @@ -306,12 +304,6 @@ internal class CompilationEnvironment( fileAnalyzer.schedule(path) } - private fun loadKtFile(path: Path): KtFile? { - val virtualFile = - project.read { VirtualFileManager.getInstance().findFileByNioPath(path) } ?: return null - return project.read { psiManager.findFile(virtualFile) as? KtFile } - } - override fun close() { ktProject.removeListener(this) 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 08ed6474cd..bc6383b498 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 @@ -92,11 +92,6 @@ internal class KtSymbolIndex( .maximumSize(cacheSize) .build() - private val openedFiles = ConcurrentHashMap() - - val openedKtFiles: Sequence> - get() = openedFiles.asSequence() - /** Set by AbstractCompilationEnvironment.initialize once the env's KtPsiFactory exists. */ lateinit var parser: KtPsiFactory @@ -181,16 +176,6 @@ internal class KtSymbolIndex( indexWorker.submitCommand(IndexCommand.IndexModifiedFile(ktFile)) } - fun openKtFile(path: Path, ktFile: KtFile): KtFile? { - return openedFiles.put(path, ktFile) - } - - fun closeKtFile(path: Path) { - openedFiles.remove(path) - } - - fun getOpenedKtFile(path: Path) = openedFiles[path] - /** * Returns the canonical [KtFile] for [path] at the current document version, parsing (once) on a * version miss. For non-open paths (no active document) falls back to the disk [getKtFile]. @@ -285,10 +270,6 @@ internal class KtSymbolIndex( getCurrentKtFileIfPresent(path)?.let { return it } } - openedFiles[path]?.also { - return it - } - ktFileCache.getIfPresent(path)?.also { return it } From 51130e522786535c269b00ce4b623798570e2376 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 16:54:44 +0000 Subject: [PATCH 18/31] ADFA-3322: add current-KtFile cache regression tests and fix diagnostic-message assertion scope Adds the reparse-after-invalidate regression test, peek-branch coverage for getCurrentKtFileIfPresent/getKtFile (hit and miss cases), and moves the diagnostic .defaultMessage mapping inside env.analyze{} so a real regression fails with a clean assertion diff instead of KaInaccessibleLifetimeOwnerAccessException. --- .../compiler/index/CurrentKtFileCacheTest.kt | 63 +++++++++++++++++-- 1 file changed, 59 insertions(+), 4 deletions(-) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt index 9521aeadd9..674bd7356e 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt @@ -10,6 +10,7 @@ import com.itsaky.androidide.projects.FileManager import org.junit.After import org.junit.Assert.assertEquals import org.junit.Assert.assertNotSame +import org.junit.Assert.assertNull import org.junit.Assert.assertSame import org.junit.Test import java.nio.file.Path @@ -90,12 +91,66 @@ internal class CurrentKtFileCacheTest : KtLspTest() { changeDocument(path, "fun e(): Int = 1\nfun f(): Int = e()", 2) val v2 = env.ktSymbolIndex.getCurrentKtFile(path).get()!! - // The new top-level `f` calling `e` must resolve without UNRESOLVED_REFERENCE. - val diagnostics = env.analyze(v2) { + // The new top-level `f` calling `e` must resolve without UNRESOLVED_REFERENCE. The + // `.defaultMessage` access must stay inside `env.analyze {}`: outside the analysis + // session, reading a diagnostic's message would throw + // KaInaccessibleLifetimeOwnerAccessException instead of producing a clean assertion + // diff on a real regression. + val diagnosticMessages = env.analyze(v2) { v2.collectDiagnostics( org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS - ).toList() + ).map { it.defaultMessage } } - assertEquals(emptyList(), diagnostics.map { it.defaultMessage }) + assertEquals(emptyList(), diagnosticMessages) + } + + @Test + fun `invalidateCurrent then getCurrentKtFile reparses`() { + createSourceFile("G.kt", "fun g() {}") + val path = sourcePath("G.kt") + openDocument(path, "fun g() {}") + val first = env.ktSymbolIndex.getCurrentKtFile(path).get()!! + + env.ktSymbolIndex.invalidateCurrent(path) + val second = env.ktSymbolIndex.getCurrentKtFile(path).get()!! + + assertNotSame(first, second) + } + + @Test + fun `getCurrentKtFileIfPresent returns the same instance after a completed refresh`() { + createSourceFile("H.kt", "fun h() {}") + val path = sourcePath("H.kt") + openDocument(path, "fun h() {}") + val current = env.ktSymbolIndex.getCurrentKtFile(path).get()!! + + val peeked = env.ktSymbolIndex.getCurrentKtFileIfPresent(path) + + assertSame(current, peeked) + } + + @Test + fun `getKtFile returns the current cached instance for an active document instead of reloading from disk`() { + createSourceFile("I.kt", "fun i() {}") + val path = sourcePath("I.kt") + openDocument(path, "fun i() {}") + val current = env.ktSymbolIndex.getCurrentKtFile(path).get()!! + + val viaGetKtFile = env.ktSymbolIndex.getKtFile(path) + + assertSame(current, viaGetKtFile) + } + + @Test + fun `getCurrentKtFileIfPresent returns null for an active document whose refresh has not been triggered`() { + createSourceFile("J.kt", "fun j() {}") + val path = sourcePath("J.kt") + openDocument(path, "fun j() {}") + // Note: getCurrentKtFile(path) is deliberately never called here, so no refresh has + // been launched for this path yet. + + val peeked = env.ktSymbolIndex.getCurrentKtFileIfPresent(path) + + assertNull(peeked) } } From eedc5f76c481e9a559267e2a3003bba022417d68 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 7 Jul 2026 17:16:01 +0000 Subject: [PATCH 19/31] ADFA-3322: avoid blocking parse on UI thread in edit handler; fix stale comment Replace the blocking getCurrentKtFile(...).get() in AdvancedKotlinEditHandler with the non-blocking getCurrentKtFileIfPresent(...) peek, since this method runs on the editor UI thread and a version-miss would otherwise drive a synchronous parse + project.write there (ANR risk). The completion just ran against this file, so the current instance is essentially always cached; a peek-miss falls back to the existing "File not open" log/return. Also correct the refreshToCurrent comment in KtSymbolIndex to reason about the actual write order in FileManager.onDocumentContentChange (version before content, both unsynchronized) rather than an under-specified version/content staleness claim. --- .../lsp/kotlin/compiler/index/KtSymbolIndex.kt | 17 ++++++++++------- .../completion/AdvancedKotlinEditHandler.kt | 2 +- 2 files changed, 11 insertions(+), 8 deletions(-) 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 bc6383b498..135f974b9f 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 @@ -206,13 +206,16 @@ internal class KtSymbolIndex( /** Parses the live document for [path], registers it as the in-memory file, returns the stamped file. */ private fun refreshToCurrent(path: Path, version: Int, old: KtFile?): VersionedKtFile { - // [version] is the version observed when this refresh was launched (inside compute()); the - // content below is read live, possibly later (this runs on refreshExecutor, after any prior - // refresh's prior.get()). ActiveDocument's version/content are separate mutable fields with no - // atomic snapshot, so a concurrent edit landing in that window can make [version] under-state - // the content actually parsed. This is bounded and safe: callers never see stale content (what - // we serve is at-least-as-fresh as requested); the only effect is a spurious cache miss and one - // redundant re-parse on the next request for the newer version. + // [version] is the version observed (by getCurrentKtFile, reading ActiveDocument.version) + // when this refresh was launched inside compute(); the content below is read live, possibly + // later (this runs on refreshExecutor, after any prior refresh's prior.get()). + // FileManager.onDocumentContentChange writes `version` before `content`, and neither field is + // volatile/synchronized (a pre-existing race, not introduced here) — so a reader can observe a + // `version` that is older than the `content` it later reads, never the reverse. Combined with + // content advancing monotonically, this means what we parse here is always at-least-as-fresh + // as the stamped [version]: callers never see stale (older-than-requested) content. The only + // effect of the skew is a spurious cache miss and one redundant re-parse on the next request + // for the newer version. val content = FileManager.getDocumentContents(path) val newKtFile = project.read { parser.createFile(path.pathString, content) } newKtFile.backingFilePath = path diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt index 0ee28daaa4..84913f6b6c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt @@ -23,7 +23,7 @@ internal abstract class AdvancedKotlinEditHandler( column: Int, index: Int ) { - val managedFile = analysisContext.env.ktSymbolIndex.getCurrentKtFile(analysisContext.file).get() + val managedFile = analysisContext.env.ktSymbolIndex.getCurrentKtFileIfPresent(analysisContext.file) if (managedFile == null) { logger.error("Unable to perform edit. File not open.") return From f2302354cf9876a56926eda1b28ea508cd0be7a4 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 8 Jul 2026 17:58:32 +0530 Subject: [PATCH 20/31] chore: simplify comments Signed-off-by: Akash Yadav --- .../kotlin/compiler/CompilationEnvironment.kt | 2 +- .../kotlin/compiler/index/KtSymbolIndex.kt | 53 +++++++------------ .../signaturehelp/KotlinSignatureHelp.kt | 6 +-- .../compiler/index/CurrentKtFileCacheTest.kt | 11 ++-- 4 files changed, 27 insertions(+), 45 deletions(-) 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 9c16352518..7736a47b28 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 @@ -249,7 +249,7 @@ internal class CompilationEnvironment( project.write { // Must run under the write lock so the session mutation can't race a concurrent - // `analyze` (which only holds the read lock); see onFileContentChanged. + // `analyze` (which only holds the read lock); see KtSymbolIndex.refreshToCurrent. if (ktFile != null) { KaSourceModificationService.getInstance(project) .handleElementModification(ktFile, typeProvider(ktFile)) 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 135f974b9f..83ab5cbbe2 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 @@ -100,9 +100,9 @@ internal class KtSymbolIndex( private val refreshExecutor: ExecutorService = Executors.newFixedThreadPool(2) { r -> Thread(r, "KtCurrentFileRefresh").apply { isDaemon = true } } - /** path -> in-flight/last-launched refresh. Guarded by currentFiles.asMap().compute per key. */ + /** path -> in-flight/last-launched refresh; mutated only inside the per-key `compute` below. */ private val currentFiles = ConcurrentHashMap>() - /** path -> version most recently launched. Read/written only inside the compute() critical section. */ + /** path -> last-launched version; read/written only inside that same `compute` section. */ private val currentVersions = ConcurrentHashMap() fun syncIndexInBackground() { @@ -204,34 +204,26 @@ internal class KtSymbolIndex( return future.thenApply { it.ktFile } } - /** Parses the live document for [path], registers it as the in-memory file, returns the stamped file. */ + /** + * Parses [path]'s live document into a fresh [KtFile], registers it as the in-memory file, and + * transitions the module's FIR session (invalidate + reindex) so later analysis sees the content. + * + * The result is stamped with [version] (captured when the refresh was launched) even though the + * content is read later, here. FileManager writes version-before-content unsynchronized, so the + * stamp may lag the content but never lead it: callers never get older-than-requested content, and + * a lagging stamp only costs one redundant re-parse on the next request. + */ private fun refreshToCurrent(path: Path, version: Int, old: KtFile?): VersionedKtFile { - // [version] is the version observed (by getCurrentKtFile, reading ActiveDocument.version) - // when this refresh was launched inside compute(); the content below is read live, possibly - // later (this runs on refreshExecutor, after any prior refresh's prior.get()). - // FileManager.onDocumentContentChange writes `version` before `content`, and neither field is - // volatile/synchronized (a pre-existing race, not introduced here) — so a reader can observe a - // `version` that is older than the `content` it later reads, never the reverse. Combined with - // content advancing monotonically, this means what we parse here is always at-least-as-fresh - // as the stamped [version]: callers never see stale (older-than-requested) content. The only - // effect of the skew is a spurious cache miss and one redundant re-parse on the next request - // for the newer version. val content = FileManager.getDocumentContents(path) val newKtFile = project.read { parser.createFile(path.pathString, content) } newKtFile.backingFilePath = path - // Use the view provider's virtual file rather than KtFile.virtualFile: the latter is null for - // non-physical PSI files (parser event system disabled, e.g. the unit-test environment), while - // the view provider always exposes the backing light virtual file. For physical files (prod) - // the two are the same object. + // KtFile.virtualFile is null for non-physical PSI (unit-test env); the view provider's is + // always present, and identical to it in production. ProjectStructureProvider.getInstance(project) .registerInMemoryFile(path.pathString, newKtFile.viewProvider.virtualFile) - // Mirrors CompilationEnvironment.onFileContentChanged: invalidate the FIR session's view of - // the (old) element under the write lock so it can't race a concurrent `analyze` (which only - // holds the read lock), then re-index the new instance. + // project.write serializes the mutation against a concurrent `analyze` (read lock); runWriteAction + // supplies the platform write access handleElementModification asserts, which our RW lock does not. project.write { - // handleElementModification publishes an out-of-block modification event, which the - // platform's ThreadingAssertions require to run inside a write action (independent of our - // own read/write lock above, which only serializes with `analyze`). ApplicationManager.getApplication().runWriteAction { KaSourceModificationService.getInstance(project) .handleElementModification(old ?: newKtFile, KaElementModificationType.Unknown) @@ -263,13 +255,9 @@ internal class KtSymbolIndex( if (!DocumentUtils.isKotlinFile(path)) return null if (FileManager.isActive(path)) { - // Active document: peek the current-file cache without blocking. Calling - // getCurrentKtFile(path).get() here would deadlock: getKtFile is invoked by - // Analysis-API services (DeclarationsProvider, AnnotationsResolver, - // DirectInheritorsProvider) while FIR resolution holds project.read, and a blocking - // refresh needs project.write on the executor thread (reader-holds-lock waits on - // writer -> deadlock). A peek miss falls back to the disk instance below; the file's - // own refresh is already scheduled on edit and will be served on the next request. + // Peek, never block: getKtFile runs under project.read inside Analysis-API services, so a + // blocking getCurrentKtFile().get() (its refresh needs project.write) would deadlock. A miss + // falls through to the disk instance; the edit already scheduled a refresh for next time. getCurrentKtFileIfPresent(path)?.let { return it } } @@ -309,9 +297,8 @@ internal class KtSymbolIndex( // (APPDEVFORALL-17R). This index owns `scope`. scope.coroutineContext[Job]?.cancelAndJoin() - // Drain the current-file refresh pool: refreshToCurrent runs project.read/write on it, so no - // in-flight refresh may survive into the caller's project disposal (same rationale as the - // scope join above). Bounded so a slow parse can't block shutdown indefinitely. + // Drain the refresh pool before disposal: refreshToCurrent runs project.read/write on it (same + // rationale as the scope join above). Bounded so a slow parse can't stall shutdown. refreshExecutor.shutdownNow() refreshExecutor.awaitTermination(CLOSE_DRAIN_TIMEOUT_SECONDS, TimeUnit.SECONDS) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt index 40c5b0efc1..3849840d5f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -80,10 +80,8 @@ internal suspend fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp return SignatureHelp.empty() } - // Resolves to the current document version, parsing and registering it if a refresh hasn't - // already landed. This is called outside any project.read/write block, so awaiting a blocking - // refresh here is safe (unlike KtSymbolIndex.getKtFile, which is called by Analysis-API - // services while a read lock is held). + // Safe to await a (possibly blocking) refresh here: this runs outside any project.read/write + // block, so it can't deadlock against the refresh's project.write (unlike KtSymbolIndex.getKtFile). val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).await() if (ktFile == null) { logger.warn("File {} is not open", params.file) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt index 674bd7356e..9f862166bc 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt @@ -91,11 +91,9 @@ internal class CurrentKtFileCacheTest : KtLspTest() { changeDocument(path, "fun e(): Int = 1\nfun f(): Int = e()", 2) val v2 = env.ktSymbolIndex.getCurrentKtFile(path).get()!! - // The new top-level `f` calling `e` must resolve without UNRESOLVED_REFERENCE. The - // `.defaultMessage` access must stay inside `env.analyze {}`: outside the analysis - // session, reading a diagnostic's message would throw - // KaInaccessibleLifetimeOwnerAccessException instead of producing a clean assertion - // diff on a real regression. + // `f` calling `e` must resolve (no UNRESOLVED_REFERENCE). Keep `.defaultMessage` inside + // `env.analyze {}`: reading a diagnostic outside its analysis session throws + // KaInaccessibleLifetimeOwnerAccessException instead of a clean assertion diff. val diagnosticMessages = env.analyze(v2) { v2.collectDiagnostics( org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS @@ -146,8 +144,7 @@ internal class CurrentKtFileCacheTest : KtLspTest() { createSourceFile("J.kt", "fun j() {}") val path = sourcePath("J.kt") openDocument(path, "fun j() {}") - // Note: getCurrentKtFile(path) is deliberately never called here, so no refresh has - // been launched for this path yet. + // getCurrentKtFile is deliberately never called, so no refresh has been launched for this path. val peeked = env.ktSymbolIndex.getCurrentKtFileIfPresent(path) From e6e738b378a3b80e4e8a5888892439dad22167e3 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 8 Jul 2026 18:11:13 +0530 Subject: [PATCH 21/31] refactor: reformat Signed-off-by: Akash Yadav --- .../editor/ui/SignatureHelpWindow.java | 300 +++++++++--------- .../editor/ui/SignatureHelpWindowTest.kt | 58 ++-- .../lsp/kotlin/KotlinLanguageServer.kt | 89 +++--- .../lsp/kotlin/actions/AddImportAction.kt | 58 ++-- .../AbstractCompilationEnvironment.kt | 98 +++--- .../kotlin/compiler/CompilationEnvironment.kt | 163 +++++----- .../kotlin/compiler/index/KtSymbolIndex.kt | 177 ++++++----- .../completion/AdvancedKotlinEditHandler.kt | 2 +- .../kotlin/completion/KotlinCompletions.kt | 1 - .../diagnostic/KotlinDiagnosticProvider.kt | 1 - .../signaturehelp/ActiveParameterResolver.kt | 51 +-- .../signaturehelp/CallAtCursorFinder.kt | 56 ++-- .../signaturehelp/KotlinSignatureHelp.kt | 132 ++++---- .../signaturehelp/SignatureInfoBuilder.kt | 11 +- .../lsp/kotlin/utils/TypeRendering.kt | 7 +- .../compiler/index/CurrentKtFileCacheTest.kt | 29 +- .../lsp/kotlin/fixtures/KtLspTest.kt | 14 +- .../ActiveParameterResolverTest.kt | 125 +++++--- .../signaturehelp/CallAtCursorFinderTest.kt | 80 ++--- .../signaturehelp/KotlinSignatureHelpTest.kt | 121 +++---- .../signaturehelp/SignatureInfoBuilderTest.kt | 67 ++-- 21 files changed, 884 insertions(+), 756 deletions(-) diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java b/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java index 5e40614616..1c1ff59be7 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/SignatureHelpWindow.java @@ -40,151 +40,157 @@ */ public class SignatureHelpWindow extends BaseEditorWindow { - private static final Logger LOG = LoggerFactory.getLogger(SignatureHelpWindow.class); - - /** - * Create a signature help popup window for editor - * - * @param editor The editor. - */ - public SignatureHelpWindow(@NonNull IDEEditor editor) { - super(editor); - - editor.subscribeEvent( - SelectionChangeEvent.class, - (event, unsubscribe) -> { - if (isShowing()) { - dismiss(); - } - }); - } - - public void setupAndDisplay(SignatureHelp signature) { - if (signature == null || signature.getSignatures().isEmpty()) { - if (isShowing()) { - dismiss(); - } - - return; - } - - final var signatureText = createSignatureText(signature); - - if (signatureText == null) { - if (isShowing()) { - dismiss(); - } - - return; - } - - this.text.setText(signatureText); - displayWindow(); - } - - @Nullable - private CharSequence createSignatureText(@NonNull SignatureHelp signature) { - final var signatures = signature.getSignatures(); - final var activeSignature = signature.getActiveSignature(); - final var activeParameter = signature.getActiveParameter(); - final SpannableStringBuilder sb = new SpannableStringBuilder(); - - if (activeSignature < 0 || activeParameter < 0) { - LOG.debug("activeSignature: {}, activeParameter: {}", activeSignature, activeParameter); - return null; - } - - var count = signatures.size(); - if (activeSignature >= count) { - LOG.debug("Active signature is invalid. Size is {}", count); - return null; - } - - // keep only applicable signatures (does not mutate the input list) - final var applicable = applicableSignatures(signatures, activeParameter); - - if (applicable.isEmpty()) { - return null; - } - - count = applicable.size(); - for (var i = 0; i < count; i++) { - final var info = applicable.get(i); - formatSignature(info, activeParameter, sb); - if (i != count - 1) { - sb.append('\n'); - } - } - - return sb; - } - - /** Returns the function name portion of a signature label (text before the first '('), or the - * whole label if it contains no '('. */ - static String signatureName(String label) { - final int paren = label.indexOf('('); - return paren < 0 ? label : label.substring(0, paren); - } - - /** Returns a new list containing only the signatures that have enough parameters for the given - * active parameter index. Never mutates the input list. */ - static List applicableSignatures( - List signatures, int activeParameter) { - final List result = new ArrayList<>(signatures.size()); - for (final SignatureInformation info : signatures) { - if (activeParameter < info.getParameters().size()) { - result.add(info); - } - } - return result; - } - - /** - * Formats (highlights) a method signature - * - * @param signature Signature information - * @param paramIndex Currently active parameter index - * @param result The builder to append spanned text to. - */ - private void formatSignature( - @NonNull SignatureInformation signature, - int paramIndex, - SpannableStringBuilder result) { - - final String name = signatureName(signature.getLabel()); - - final var foreground = ContextUtilsKt.resolveAttr(getEditor().getContext(), - attr.colorOnSecondaryContainer); - final var paramSelected = 0xffff6060; - final var operators = 0xff4fc3f7; - - result.append( - name, new ForegroundColorSpan(foreground), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); - result.append( - "(", new ForegroundColorSpan(operators), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); - - var params = signature.getParameters(); - for (int i = 0; i < params.size(); i++) { - int color = i == paramIndex ? paramSelected : foreground; - final var info = params.get(i); - if (i == params.size() - 1) { - result.append( - info.getLabel(), - new ForegroundColorSpan(color), - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); - } else { - result.append( - info.getLabel(), - new ForegroundColorSpan(color), - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); - result.append( - ",", - new ForegroundColorSpan(operators), - SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); - result.append(" "); - } - } - result.append( - ")", new ForegroundColorSpan(0xff4fc3f7), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); - } + private static final Logger LOG = LoggerFactory.getLogger(SignatureHelpWindow.class); + + /** + * Returns a new list containing only the signatures that have enough parameters for the given active parameter index. Never mutates the input list. + */ + static List applicableSignatures( + List signatures, int activeParameter) { + final List result = new ArrayList<>(signatures.size()); + for (final SignatureInformation info : signatures) { + if (activeParameter < info.getParameters().size()) { + result.add(info); + } + } + return result; + } + + /** + * Returns the function name portion of a signature label (text before the first '('), or the whole label if it contains no '('. + */ + static String signatureName(String label) { + final int paren = label.indexOf('('); + return paren < 0 ? label : label.substring(0, paren); + } + + /** + * Create a signature help popup window for editor + * + * @param editor + * The editor. + */ + public SignatureHelpWindow(@NonNull IDEEditor editor) { + super(editor); + + editor.subscribeEvent( + SelectionChangeEvent.class, + (event, unsubscribe) -> { + if (isShowing()) { + dismiss(); + } + }); + } + + public void setupAndDisplay(SignatureHelp signature) { + if (signature == null || signature.getSignatures().isEmpty()) { + if (isShowing()) { + dismiss(); + } + + return; + } + + final var signatureText = createSignatureText(signature); + + if (signatureText == null) { + if (isShowing()) { + dismiss(); + } + + return; + } + + this.text.setText(signatureText); + displayWindow(); + } + + @Nullable + private CharSequence createSignatureText(@NonNull SignatureHelp signature) { + final var signatures = signature.getSignatures(); + final var activeSignature = signature.getActiveSignature(); + final var activeParameter = signature.getActiveParameter(); + final SpannableStringBuilder sb = new SpannableStringBuilder(); + + if (activeSignature < 0 || activeParameter < 0) { + LOG.debug("activeSignature: {}, activeParameter: {}", activeSignature, activeParameter); + return null; + } + + var count = signatures.size(); + if (activeSignature >= count) { + LOG.debug("Active signature is invalid. Size is {}", count); + return null; + } + + // keep only applicable signatures (does not mutate the input list) + final var applicable = applicableSignatures(signatures, activeParameter); + + if (applicable.isEmpty()) { + return null; + } + + count = applicable.size(); + for (var i = 0; i < count; i++) { + final var info = applicable.get(i); + formatSignature(info, activeParameter, sb); + if (i != count - 1) { + sb.append('\n'); + } + } + + return sb; + } + + /** + * Formats (highlights) a method signature + * + * @param signature + * Signature information + * @param paramIndex + * Currently active parameter index + * @param result + * The builder to append spanned text to. + */ + private void formatSignature( + @NonNull SignatureInformation signature, + int paramIndex, + SpannableStringBuilder result) { + + final String name = signatureName(signature.getLabel()); + + final var foreground = ContextUtilsKt.resolveAttr(getEditor().getContext(), + attr.colorOnSecondaryContainer); + final var paramSelected = 0xffff6060; + final var operators = 0xff4fc3f7; + + result.append( + name, new ForegroundColorSpan(foreground), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + result.append( + "(", new ForegroundColorSpan(operators), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + + var params = signature.getParameters(); + for (int i = 0; i < params.size(); i++) { + int color = i == paramIndex ? paramSelected : foreground; + final var info = params.get(i); + if (i == params.size() - 1) { + result.append( + info.getLabel(), + new ForegroundColorSpan(color), + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + } else { + result.append( + info.getLabel(), + new ForegroundColorSpan(color), + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + result.append( + ",", + new ForegroundColorSpan(operators), + SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + result.append(" "); + } + } + result.append( + ")", new ForegroundColorSpan(0xff4fc3f7), SpannableStringBuilder.SPAN_EXCLUSIVE_EXCLUSIVE); + } } diff --git a/editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt b/editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt index 5ed5919223..45c31312ba 100644 --- a/editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt +++ b/editor/src/test/java/com/itsaky/androidide/editor/ui/SignatureHelpWindowTest.kt @@ -10,36 +10,38 @@ import org.robolectric.RobolectricTestRunner @RunWith(RobolectricTestRunner::class) class SignatureHelpWindowTest { + private fun sig( + label: String, + paramCount: Int, + ): SignatureInformation { + val params = (0 until paramCount).map { ParameterInformation("p$it: Int", MarkupContent()) } + return SignatureInformation(label, MarkupContent(), params) + } - private fun sig(label: String, paramCount: Int): SignatureInformation { - val params = (0 until paramCount).map { ParameterInformation("p$it: Int", MarkupContent()) } - return SignatureInformation(label, MarkupContent(), params) - } + @Test + fun `signatureName returns text before the opening paren`() { + assertThat(SignatureHelpWindow.signatureName("foo(p0: Int, p1: Int)")).isEqualTo("foo") + } - @Test - fun `signatureName returns text before the opening paren`() { - assertThat(SignatureHelpWindow.signatureName("foo(p0: Int, p1: Int)")).isEqualTo("foo") - } + @Test + fun `signatureName returns whole label when there is no paren`() { + assertThat(SignatureHelpWindow.signatureName("foo")).isEqualTo("foo") + } - @Test - fun `signatureName returns whole label when there is no paren`() { - assertThat(SignatureHelpWindow.signatureName("foo")).isEqualTo("foo") - } + @Test + fun `applicableSignatures drops overloads without enough parameters`() { + val input = listOf(sig("a(p0: Int)", 1), sig("b(p0: Int, p1: Int)", 2)) + val result = SignatureHelpWindow.applicableSignatures(input, 1) + assertThat(result).hasSize(1) + assertThat(result[0].label).isEqualTo("b(p0: Int, p1: Int)") + } - @Test - fun `applicableSignatures drops overloads without enough parameters`() { - val input = listOf(sig("a(p0: Int)", 1), sig("b(p0: Int, p1: Int)", 2)) - val result = SignatureHelpWindow.applicableSignatures(input, 1) - assertThat(result).hasSize(1) - assertThat(result[0].label).isEqualTo("b(p0: Int, p1: Int)") - } - - @Test - fun `applicableSignatures does not mutate an immutable input list`() { - val input = java.util.Collections.unmodifiableList(listOf(sig("a(p0: Int)", 1))) - // Must not throw UnsupportedOperationException: - val result = SignatureHelpWindow.applicableSignatures(input, 5) - assertThat(result).isEmpty() - assertThat(input).hasSize(1) - } + @Test + fun `applicableSignatures does not mutate an immutable input list`() { + val input = java.util.Collections.unmodifiableList(listOf(sig("a(p0: Int)", 1))) + // Must not throw UnsupportedOperationException: + val result = SignatureHelpWindow.applicableSignatures(input, 5) + assertThat(result).isEmpty() + assertThat(input).hasSize(1) + } } 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 ceb86d934f..7ed6946e47 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 @@ -76,7 +76,6 @@ import java.nio.file.Path import java.nio.file.Paths class KotlinLanguageServer : ILanguageServer { - private var _client: ILanguageClient? = null private var _settings: IServerSettings? = null private var initialized = false @@ -95,7 +94,6 @@ class KotlinLanguageServer : ILanguageServer { get() = _settings ?: KotlinServerSettings.getInstance().also { _settings = it } companion object { - const val SERVER_ID = "ide.lsp.kotlin" private val logger = LoggerFactory.getLogger(KotlinLanguageServer::class.java) } @@ -130,25 +128,29 @@ class KotlinLanguageServer : ILanguageServer { LSPEditorActions.ensureActionsMenuRegistered(KotlinCodeActionsMenu) val context = BaseApplication.baseInstance - val indexingServiceManager = ProjectManagerImpl.getInstance() - .indexingServiceManager + val indexingServiceManager = + ProjectManagerImpl + .getInstance() + .indexingServiceManager val indexingRegistry = indexingServiceManager.registry indexingRegistry.register( key = KT_SOURCE_FILE_INDEX_KEY, - index = JvmSymbolIndex.createSqliteIndex( - context = context, - dbName = KT_SOURCE_FILE_INDEX_KEY.name, - indexName = KT_SOURCE_FILE_INDEX_KEY.name, - ) + index = + JvmSymbolIndex.createSqliteIndex( + context = context, + dbName = KT_SOURCE_FILE_INDEX_KEY.name, + indexName = KT_SOURCE_FILE_INDEX_KEY.name, + ), ) indexingRegistry.register( key = KT_SOURCE_FILE_META_INDEX_KEY, - index = KtFileMetadataIndex.sqliteBacked( - context = context, - dbName = KT_SOURCE_FILE_META_INDEX_KEY.name - ) + index = + KtFileMetadataIndex.sqliteBacked( + context = context, + dbName = KT_SOURCE_FILE_META_INDEX_KEY.name, + ), ) val jvmLibraryIndexingService = @@ -160,8 +162,9 @@ class KotlinLanguageServer : ILanguageServer { val jdkRelease = IJdkDistributionProvider.DEFAULT_JAVA_RELEASE val intellijPluginRoot = Paths.get(context.applicationInfo.sourceDir) - val jvmTarget = JvmTarget.fromString(IJdkDistributionProvider.DEFAULT_JAVA_VERSION) - ?: JvmTarget.JVM_21 + val jvmTarget = + JvmTarget.fromString(IJdkDistributionProvider.DEFAULT_JAVA_VERSION) + ?: JvmTarget.JVM_21 val jvmPlatform = JvmPlatforms.jvmPlatformByTargetVersion(jvmTarget) @@ -172,14 +175,15 @@ class KotlinLanguageServer : ILanguageServer { model.update(workspace, jvmPlatform) this.projectModel = model - val compiler = Compiler( - workspace = workspace, - projectModel = model, - intellijPluginRoot = intellijPluginRoot, - jdkHome = jdkHome, - jdkRelease = jdkRelease, - languageVersion = LanguageVersion.LATEST_STABLE, - ) + val compiler = + Compiler( + workspace = workspace, + projectModel = model, + intellijPluginRoot = intellijPluginRoot, + jdkHome = jdkHome, + jdkRelease = jdkRelease, + languageVersion = LanguageVersion.LATEST_STABLE, + ) compiler.updateLanguageClient(client) this.compiler = compiler @@ -192,7 +196,8 @@ class KotlinLanguageServer : ILanguageServer { // we won't get an event for these FileManager.activeDocuments.ifNotEmpty { forEach { document -> - compiler?.compilationEnvironmentFor(document.file) + compiler + ?.compilationEnvironmentFor(document.file) ?.openFileIfNeeded(document.file) } } @@ -208,7 +213,8 @@ class KotlinLanguageServer : ILanguageServer { } logger.debug("complete(position={}, file={})", params.position, params.file) - return compiler?.compilationEnvironmentFor(params.file) + return compiler + ?.compilationEnvironmentFor(params.file) ?.let { context(it) { codeComplete(params) } } ?: CompletionResult.EMPTY } @@ -237,9 +243,7 @@ class KotlinLanguageServer : ILanguageServer { return DefinitionResult.empty() } - override suspend fun expandSelection(params: ExpandSelectionParams): Range { - return params.selection - } + override suspend fun expandSelection(params: ExpandSelectionParams): Range = params.selection override suspend fun signatureHelp(params: SignatureHelpParams): SignatureHelp { if (!settings.signatureHelpEnabled()) { @@ -251,7 +255,8 @@ class KotlinLanguageServer : ILanguageServer { } logger.debug("signatureHelp(position={}, file={})", params.position, params.file) - return compiler?.compilationEnvironmentFor(params.file) + return compiler + ?.compilationEnvironmentFor(params.file) ?.let { context(it) { doSignatureHelp(params) } } ?: SignatureHelp.empty() } @@ -262,7 +267,8 @@ class KotlinLanguageServer : ILanguageServer { if (!settings.diagnosticsEnabled() || !settings.codeAnalysisEnabled()) { logger.debug( "analyze() skipped: diagnosticsEnabled={}, codeAnalysisEnabled={}", - settings.diagnosticsEnabled(), settings.codeAnalysisEnabled() + settings.diagnosticsEnabled(), + settings.codeAnalysisEnabled(), ) return DiagnosticResult.NO_UPDATE } @@ -272,7 +278,8 @@ class KotlinLanguageServer : ILanguageServer { return DiagnosticResult.NO_UPDATE } - return compiler?.compilationEnvironmentFor(file) + return compiler + ?.compilationEnvironmentFor(file) ?.let { context(it) { collectDiagnosticsFor(file, createJobCancelChecker()) } } ?: DiagnosticResult.NO_UPDATE } @@ -284,7 +291,8 @@ class KotlinLanguageServer : ILanguageServer { return } - compiler?.compilationEnvironmentFor(event.openedFile) + compiler + ?.compilationEnvironmentFor(event.openedFile) ?.onFileOpen(event.openedFile) } @@ -295,9 +303,9 @@ class KotlinLanguageServer : ILanguageServer { return } - compiler?.compilationEnvironmentFor(event.changedFile) + compiler + ?.compilationEnvironmentFor(event.changedFile) ?.onFileContentChanged(event.changedFile) - } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -307,9 +315,9 @@ class KotlinLanguageServer : ILanguageServer { return } - compiler?.compilationEnvironmentFor(event.closedFile) + compiler + ?.compilationEnvironmentFor(event.closedFile) ?.onFileClosed(event.closedFile) - } @Subscribe(threadMode = ThreadMode.ASYNC) @@ -319,7 +327,8 @@ class KotlinLanguageServer : ILanguageServer { return } - compiler?.compilationEnvironmentFor(event.savedFile) + compiler + ?.compilationEnvironmentFor(event.savedFile) ?.onFileSaved(event.savedFile) } @@ -373,7 +382,8 @@ class KotlinLanguageServer : ILanguageServer { if (!oldIsKotlinFile && newIsKotlinFile) { // only the new file is a Kotlin file // so just submit it for indexing - compiler?.compilationEnvironmentFor(toPath) + compiler + ?.compilationEnvironmentFor(toPath) ?.onFileCreated(toPath) return@launch } @@ -381,7 +391,8 @@ class KotlinLanguageServer : ILanguageServer { if (oldIsKotlinFile && !newIsKotlinFile) { // only the old file was a Kotlin file // so just remove it from the index - compiler?.compilationEnvironmentFor(fromPath) + compiler + ?.compilationEnvironmentFor(fromPath) ?.onFileRemoved(fromPath) return@launch } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt index 7daed3b7ad..9896343851 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt @@ -52,9 +52,10 @@ class AddImportAction : BaseKotlinCodeAction() { } val env = extra.compilationEnv - val hasImportableSymbols = env.ktSymbolIndex - .findSymbolBySimpleName(reference, limit = 0) - .any { it.kind.isClassifier } + val hasImportableSymbols = + env.ktSymbolIndex + .findSymbolBySimpleName(reference, limit = 0) + .any { it.kind.isClassifier } if (!hasImportableSymbols) { markInvisible() @@ -63,16 +64,19 @@ class AddImportAction : BaseKotlinCodeAction() { } override suspend fun execAction(data: ActionData): Map> { - val (reference, env) = data.require().extra as? KotlinDiagnosticExtra - ?: return emptyMap() + val (reference, env) = + data.require().extra as? KotlinDiagnosticExtra + ?: return emptyMap() if (reference == null) return emptyMap() val file = data.requireFile() val nioPath = file.toPath() - val ktFile = env.ktSymbolIndex - .getCurrentKtFile(nioPath).get() - ?: return emptyMap() + val ktFile = + env.ktSymbolIndex + .getCurrentKtFile(nioPath) + .get() + ?: return emptyMap() return env.ktSymbolIndex .findSymbolBySimpleName(reference, limit = 0) @@ -80,7 +84,10 @@ class AddImportAction : BaseKotlinCodeAction() { .associateWith { symbol -> insertImport(ktFile, symbol.fqName) } } - override fun postExec(data: ActionData, result: Any) { + override fun postExec( + data: ActionData, + result: Any, + ) { super.postExec(data, result) if (result !is Map<*, *>) { @@ -95,11 +102,12 @@ class AddImportAction : BaseKotlinCodeAction() { return } - val client = data.languageClient - ?: run { - logger.warn("No language client set. Cannot complete action.") - return - } + val client = + data.languageClient + ?: run { + logger.warn("No language client set. Cannot complete action.") + return + } val file = data.requireFile() val nioPath = file.toPath() @@ -117,16 +125,16 @@ class AddImportAction : BaseKotlinCodeAction() { when (actions.size) { 0 -> logger.error("No code actions found. Cannot completion action.") 1 -> client.performCodeAction(actions[0]) - else -> newDialogBuilder(data) - .setTitle(label) - .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> - dialog.dismiss() - actions.getOrNull(which)?.also { client.performCodeAction(it) } - ?: run { - logger.error("Index $which is out of bounds for actions of size ${actions.size}") - } - } - .show() + else -> + newDialogBuilder(data) + .setTitle(label) + .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> + dialog.dismiss() + actions.getOrNull(which)?.also { client.performCodeAction(it) } + ?: run { + logger.error("Index $which is out of bounds for actions of size ${actions.size}") + } + }.show() } } -} \ No newline at end of file +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt index d240835532..2c031c6172 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/compiler/AbstractCompilationEnvironment.kt @@ -98,7 +98,6 @@ internal abstract class AbstractCompilationEnvironment( val applicationEnvironmentMode: KotlinCoreApplicationEnvironmentMode, val enableParserEventSystem: Boolean = true, ) : AutoCloseable { - companion object { /** Max time close() will block the (main) thread draining background workers before disposal. */ val CLOSE_DRAIN_TIMEOUT = 2.seconds @@ -159,16 +158,19 @@ internal abstract class AbstractCompilationEnvironment( protected open fun postInit(libraryRoots: List) {} /** The [MessageCollector] used by the [CompilerConfiguration]. Defaults to no-op. */ - protected open fun createMessageCollector(): MessageCollector = object : MessageCollector { - override fun clear() {} - override fun hasErrors() = false - override fun report( - severity: CompilerMessageSeverity, - message: String, - location: CompilerMessageSourceLocation?, - ) { + protected open fun createMessageCollector(): MessageCollector = + object : MessageCollector { + override fun clear() {} + + override fun hasErrors() = false + + override fun report( + severity: CompilerMessageSeverity, + message: String, + location: CompilerMessageSourceLocation?, + ) { + } } - } @Suppress("UnstableApiUsage") protected open fun initialize( @@ -182,11 +184,12 @@ internal abstract class AbstractCompilationEnvironment( ) -> KtSymbolIndex, ) { val configuration = createCompilerConfiguration() - projectEnv = StandaloneProjectFactory.createProjectEnvironment( - projectDisposable = disposable, - applicationEnvironmentMode = applicationEnvironmentMode, - compilerConfiguration = configuration, - ) + projectEnv = + StandaloneProjectFactory.createProjectEnvironment( + projectDisposable = disposable, + applicationEnvironmentMode = applicationEnvironmentMode, + compilerConfiguration = configuration, + ) if (applicationEnvironmentMode == KotlinCoreApplicationEnvironmentMode.Production) { KotlinApplicationEnvironmentPin.ensure(configuration) @@ -232,14 +235,13 @@ internal abstract class AbstractCompilationEnvironment( serviceRegistrars.registerProjectServices(project, data = Unit) serviceRegistrars.registerProjectModelServices(project, disposable, data = Unit) - - libraryRoots = modules - .asFlatSequence() - .filterNot { it.isSourceModule } - .flatMap { lib -> - lib.computeFiles(extended = false).map { JavaRoot(it, JavaRoot.RootType.BINARY) } - } - .toList() + libraryRoots = + modules + .asFlatSequence() + .filterNot { it.isSourceModule } + .flatMap { lib -> + lib.computeFiles(extended = false).map { JavaRoot(it, JavaRoot.RootType.BINARY) } + }.toList() ktSymbolIndex = buildKtSymbolIndex(modules, libraryRoots) @@ -258,25 +260,27 @@ internal abstract class AbstractCompilationEnvironment( addRoots(libraryRoots, MessageCollector.NONE) } - val (javaSourceRoots, singleJavaFileRoots) = modules - .asFlatSequence() - .filter { it.isSourceModule } - .flatMap { it.contentRoots } - .mapNotNull { VirtualFileManager.getInstance().findFileByNioPath(it) } - .partition { it.isDirectory || it.extension != JavaFileType.DEFAULT_EXTENSION } + val (javaSourceRoots, singleJavaFileRoots) = + modules + .asFlatSequence() + .filter { it.isSourceModule } + .flatMap { it.contentRoots } + .mapNotNull { VirtualFileManager.getInstance().findFileByNioPath(it) } + .partition { it.isDirectory || it.extension != JavaFileType.DEFAULT_EXTENSION } val rootsIndex = JvmDependenciesDynamicCompoundIndex(shouldOnlyFindFirstClass = true).apply { addIndex( JvmDependenciesIndexImpl( - libraryRoots + javaSourceRoots.map { - JavaRoot( - it, - JavaRoot.RootType.SOURCE - ) - }, + libraryRoots + + javaSourceRoots.map { + JavaRoot( + it, + JavaRoot.RootType.SOURCE, + ) + }, shouldOnlyFindFirstClass = true, - ) + ), ) indexedRoots.forEach { javaRoot -> if (javaRoot.file.isDirectory) { @@ -293,9 +297,10 @@ internal abstract class AbstractCompilationEnvironment( javaFileManager.initialize( index = rootsIndex, packagePartProviders = listOf(packagePartProvider), - singleJavaFileRootsIndex = SingleJavaFileRootsIndex( - singleJavaFileRoots.map { JavaRoot(it, JavaRoot.RootType.SOURCE) } - ), + singleJavaFileRootsIndex = + SingleJavaFileRootsIndex( + singleJavaFileRoots.map { JavaRoot(it, JavaRoot.RootType.SOURCE) }, + ), usePsiClassFilesReading = true, perfManager = null, ) @@ -313,7 +318,7 @@ internal abstract class AbstractCompilationEnvironment( registerService(VirtualFileFinderFactory::class.java, fileFinderFactory) registerService( MetadataFinderFactory::class.java, - CliMetadataFinderFactory(fileFinderFactory) + CliMetadataFinderFactory(fileFinderFactory), ) } @@ -331,12 +336,13 @@ internal abstract class AbstractCompilationEnvironment( this.useFir = true this.intellijPluginRoot = this@AbstractCompilationEnvironment.intellijPluginRoot.pathString - this.languageVersionSettings = LanguageVersionSettingsImpl( - languageVersion = this@AbstractCompilationEnvironment.languageVersion, - apiVersion = ApiVersion.createByLanguageVersion(this@AbstractCompilationEnvironment.languageVersion), - analysisFlags = emptyMap(), - specificFeatures = LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED }, - ) + this.languageVersionSettings = + LanguageVersionSettingsImpl( + languageVersion = this@AbstractCompilationEnvironment.languageVersion, + apiVersion = ApiVersion.createByLanguageVersion(this@AbstractCompilationEnvironment.languageVersion), + analysisFlags = emptyMap(), + specificFeatures = LanguageFeature.entries.associateWith { LanguageFeature.State.ENABLED }, + ) this.jdkHome = this@AbstractCompilationEnvironment.jdkHome.toFile() this.jdkRelease = this@AbstractCompilationEnvironment.jdkRelease this.messageCollector = createMessageCollector() 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 7736a47b28..e30d462929 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 @@ -5,7 +5,6 @@ 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.KtModule import com.itsaky.androidide.lsp.kotlin.compiler.modules.asFlatSequence -import com.itsaky.androidide.lsp.kotlin.compiler.modules.backingFilePath import com.itsaky.androidide.lsp.kotlin.compiler.registrar.AnalysisApiServiceProviders import com.itsaky.androidide.lsp.kotlin.compiler.registrar.LspAnalysisApiServiceRegistrar import com.itsaky.androidide.lsp.kotlin.compiler.services.ProjectStructureProvider @@ -71,27 +70,28 @@ internal class CompilationEnvironment( jdkRelease: Int, languageVersion: LanguageVersion = DEFAULT_LANGUAGE_VERSION, enableParserEventSystem: Boolean = true, - val coroutineScope: CoroutineScope = CoroutineScope( - SupervisorJob() + CoroutineName("CompilationEnv[$name]") + - CoroutineExceptionHandler { _, t -> - // Defense in depth: swallow (but log) non-cancellation failures from the - // debounce worker so a ClosedReceiveChannelException can never crash the app. - if (t !is CancellationException) { - logger.warn("Uncaught exception in compilation environment coroutine", t) - } - } - ), + val coroutineScope: CoroutineScope = + CoroutineScope( + SupervisorJob() + CoroutineName("CompilationEnv[$name]") + + CoroutineExceptionHandler { _, t -> + // Defense in depth: swallow (but log) non-cancellation failures from the + // debounce worker so a ClosedReceiveChannelException can never crash the app. + if (t !is CancellationException) { + logger.warn("Uncaught exception in compilation environment coroutine", t) + } + }, + ), ) : AbstractCompilationEnvironment( - name = name, - kind = kind, - intellijPluginRoot = intellijPluginRoot, - jdkHome = jdkHome, - jdkRelease = jdkRelease, - languageVersion = languageVersion, - applicationEnvironmentMode = KotlinCoreApplicationEnvironmentMode.Production, - enableParserEventSystem = enableParserEventSystem, -), KotlinProjectModel.ProjectModelListener { - + name = name, + kind = kind, + intellijPluginRoot = intellijPluginRoot, + jdkHome = jdkHome, + jdkRelease = jdkRelease, + languageVersion = languageVersion, + applicationEnvironmentMode = KotlinCoreApplicationEnvironmentMode.Production, + enableParserEventSystem = enableParserEventSystem, + ), + KotlinProjectModel.ProjectModelListener { companion object { val DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION = 400.milliseconds private val logger = LoggerFactory.getLogger(CompilationEnvironment::class.java) @@ -143,66 +143,72 @@ internal class CompilationEnvironment( private fun buildKtSymbolIndex( modules: List, libraryRoots: List, - ): KtSymbolIndex = KtSymbolIndex( - kind = kind, - project = project, - modules = modules, - fileIndex = requireFileIndex, - sourceIndex = requireSourceIndex, - libraryIndex = requireLibraryIndex, - ) + ): KtSymbolIndex = + KtSymbolIndex( + kind = kind, + project = project, + modules = modules, + fileIndex = requireFileIndex, + sourceIndex = requireSourceIndex, + libraryIndex = requireLibraryIndex, + ) private fun buildModules( project: MockProject, applicationEnv: KotlinCoreApplicationEnvironment, ): List = workspace.collectKtModules(project, applicationEnv) - override fun createServiceRegistrars() = - listOf(LspAnalysisApiServiceRegistrar(AnalysisApiServiceProviders.Production)) - - override fun createMessageCollector(): MessageCollector = object : MessageCollector { - override fun clear() {} - override fun hasErrors() = false - override fun report( - severity: CompilerMessageSeverity, - message: String, - location: CompilerMessageSourceLocation?, - ) { - logger.info("[{}] {} ({})", severity.name, message, location) + override fun createServiceRegistrars() = listOf(LspAnalysisApiServiceRegistrar(AnalysisApiServiceProviders.Production)) + + override fun createMessageCollector(): MessageCollector = + object : MessageCollector { + override fun clear() {} + + override fun hasErrors() = false + + override fun report( + severity: CompilerMessageSeverity, + message: String, + location: CompilerMessageSourceLocation?, + ) { + logger.info("[{}] {} ({})", severity.name, message, location) + } } - } override fun postInit(libraryRoots: List) { ktSymbolIndex.syncIndexInBackground() } init { - 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) + 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) + } } - } - refreshScheduler = KeyedDebouncingAction( - scope = coroutineScope, - debounceDuration = DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION, - ) { path, _ -> - // Pull through the cache so a refresh (and its reindex) happens after every edit, - // independent of whether diagnostics run. - ktSymbolIndex.getCurrentKtFile(path).await() - } + refreshScheduler = + KeyedDebouncingAction( + scope = coroutineScope, + debounceDuration = DEFAULT_FILE_MOD_EVENT_DEBOUNCE_DURATION, + ) { path, _ -> + // Pull through the cache so a refresh (and its reindex) happens after every edit, + // independent of whether diagnostics run. + ktSymbolIndex.getCurrentKtFile(path).await() + } } fun refreshSources() { - Sentry.addBreadcrumb("refreshSources (env=${name}, modules=${modules.size})") + Sentry.addBreadcrumb("refreshSources (env=$name, modules=${modules.size})") project.write { - Sentry.addBreadcrumb("refreshSources(env=${name}): in-progress") + Sentry.addBreadcrumb("refreshSources(env=$name): in-progress") ResolutionScopeProvider.getInstance(project).invalidateAll() - modules.asFlatSequence() + modules + .asFlatSequence() .filterIsInstance() .forEach { it.invalidateSearchScope() } } @@ -235,23 +241,29 @@ internal class CompilationEnvironment( ) { // Resolve PSI/module structure under the read lock; driving psiManager.findFile / // structureProvider concurrently with an `analyze` read section otherwise races. - val (ktFile, module) = project.read { - val structureProvider = ProjectStructureProvider.getInstance(project) - val ktFile = path.toVirtualFileOrNull()?.let { - psiManager.findFile(it) as? KtFile + val (ktFile, module) = + project.read { + val structureProvider = ProjectStructureProvider.getInstance(project) + val ktFile = + path.toVirtualFileOrNull()?.let { + psiManager.findFile(it) as? KtFile + } + + val module = + ( + ktFile?.let { structureProvider.getModule(it, null) } + ?: structureProvider.findModuleForSourceId(path.pathString) + ) as? AbstractKtModule + + ktFile to module } - val module = (ktFile?.let { structureProvider.getModule(it, null) } - ?: structureProvider.findModuleForSourceId(path.pathString)) as? AbstractKtModule - - ktFile to module - } - project.write { // Must run under the write lock so the session mutation can't race a concurrent // `analyze` (which only holds the read lock); see KtSymbolIndex.refreshToCurrent. if (ktFile != null) { - KaSourceModificationService.getInstance(project) + KaSourceModificationService + .getInstance(project) .handleElementModification(ktFile, typeProvider(ktFile)) } @@ -261,7 +273,7 @@ internal class CompilationEnvironment( KotlinModuleStateModificationEvent( module, KotlinModuleStateModificationKind.UPDATE, - ) + ), ) project.analysisMessageBus .syncPublisher(LLFirSessionInvalidationTopics.SESSION_INVALIDATION) @@ -289,7 +301,10 @@ internal class CompilationEnvironment( ktSymbolIndex.removeFromIndex(path) } - suspend fun onFileMoved(fromPath: Path, toPath: Path) { + suspend fun onFileMoved( + fromPath: Path, + toPath: Path, + ) { val isFileOpen = FileManager.isActive(fromPath) onFileRemoved(fromPath) onFileCreated(toPath) 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 83ab5cbbe2..a89a19e7e8 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 @@ -56,11 +56,13 @@ internal class KtSymbolIndex( val sourceIndex: JvmSymbolIndex, val libraryIndex: JvmSymbolIndex, cacheSize: @NonNegative Long = DEFAULT_CACHE_SIZE, - private val scope: CoroutineScope = CoroutineScope( - Dispatchers.Default + SupervisorJob() + CoroutineName( - "KtSymbolIndex" - ) - ) + private val scope: CoroutineScope = + CoroutineScope( + Dispatchers.Default + SupervisorJob() + + CoroutineName( + "KtSymbolIndex", + ), + ), ) { companion object { private val logger = LoggerFactory.getLogger(KtSymbolIndex::class.java) @@ -69,39 +71,46 @@ internal class KtSymbolIndex( } private val workerQueue = WorkerQueue() - private val indexWorker = IndexWorker( - project = project, - queue = workerQueue, - fileIndex = fileIndex, - sourceIndex = sourceIndex, - scope = scope, - ) + private val indexWorker = + IndexWorker( + project = project, + queue = workerQueue, + fileIndex = fileIndex, + sourceIndex = sourceIndex, + scope = scope, + ) - private val scanningWorker = ScanningWorker( - kind = kind, - sourceIndex = sourceIndex, - indexWorker = indexWorker, - modules = modules, - ) + private val scanningWorker = + ScanningWorker( + kind = kind, + sourceIndex = sourceIndex, + indexWorker = indexWorker, + modules = modules, + ) private var scanningJob: Job? = null private var indexingJob: Job? = null - private val ktFileCache = Caffeine - .newBuilder() - .maximumSize(cacheSize) - .build() + private val ktFileCache = + Caffeine + .newBuilder() + .maximumSize(cacheSize) + .build() /** Set by AbstractCompilationEnvironment.initialize once the env's KtPsiFactory exists. */ lateinit var parser: KtPsiFactory - private data class VersionedKtFile(val version: Int, val ktFile: KtFile) + private data class VersionedKtFile( + val version: Int, + val ktFile: KtFile, + ) private val refreshExecutor: ExecutorService = Executors.newFixedThreadPool(2) { r -> Thread(r, "KtCurrentFileRefresh").apply { isDaemon = true } } /** path -> in-flight/last-launched refresh; mutated only inside the per-key `compute` below. */ private val currentFiles = ConcurrentHashMap>() + /** path -> last-launched version; read/written only inside that same `compute` section. */ private val currentVersions = ConcurrentHashMap() @@ -114,29 +123,31 @@ internal class KtSymbolIndex( } private fun startIndexing() { - val job = scope.launch { - try { - indexWorker.start() - } finally { - if (indexingJob === coroutineContext[Job]) { - indexingJob = null + val job = + scope.launch { + try { + indexWorker.start() + } finally { + if (indexingJob === coroutineContext[Job]) { + indexingJob = null + } } } - } indexingJob = job } private fun startScanning() { - scanningJob = scope.launch { - try { - scanningWorker.scan() - } finally { - if (scanningJob === coroutineContext[Job]) { - scanningJob = null + scanningJob = + scope.launch { + try { + scanningWorker.scan() + } finally { + if (scanningJob === coroutineContext[Job]) { + scanningJob = null + } } } - } } fun refreshSources() { @@ -147,12 +158,11 @@ internal class KtSymbolIndex( startScanning() } - private fun getVirtualFileOrWarn(path: Path): VirtualFile? { - return path.toVirtualFileOrNull() ?: run { + private fun getVirtualFileOrWarn(path: Path): VirtualFile? = + path.toVirtualFileOrNull() ?: run { logger.warn("unable to find virtual file for path {}", path) null } - } suspend fun submitForIndexing(path: Path) { val vf = getVirtualFileOrWarn(path) ?: return @@ -184,23 +194,30 @@ internal class KtSymbolIndex( fun getCurrentKtFile(path: Path): CompletableFuture { if (!DocumentUtils.isKotlinFile(path)) return CompletableFuture.completedFuture(null) - val doc = FileManager.getActiveDocument(path) - ?: return CompletableFuture.completedFuture(getKtFile(path)) // not open -> disk path + val doc = + FileManager.getActiveDocument(path) + ?: return CompletableFuture.completedFuture(getKtFile(path)) // not open -> disk path val version = doc.version - val future = currentFiles.compute(path) { p, existing -> - if (existing != null && !existing.isCompletedExceptionally && currentVersions[p] == version) { - existing - } else { - currentVersions[p] = version - val prior = existing - CompletableFuture.supplyAsync({ - // Serialize with any prior refresh for this path so old->new succession is linear. - val old = try { prior?.get()?.ktFile } catch (_: Throwable) { null } - refreshToCurrent(p, version, old) - }, refreshExecutor) - } - }!! + val future = + currentFiles.compute(path) { p, existing -> + if (existing != null && !existing.isCompletedExceptionally && currentVersions[p] == version) { + existing + } else { + currentVersions[p] = version + val prior = existing + CompletableFuture.supplyAsync({ + // Serialize with any prior refresh for this path so old->new succession is linear. + val old = + try { + prior?.get()?.ktFile + } catch (_: Throwable) { + null + } + refreshToCurrent(p, version, old) + }, refreshExecutor) + } + }!! return future.thenApply { it.ktFile } } @@ -213,19 +230,25 @@ internal class KtSymbolIndex( * stamp may lag the content but never lead it: callers never get older-than-requested content, and * a lagging stamp only costs one redundant re-parse on the next request. */ - private fun refreshToCurrent(path: Path, version: Int, old: KtFile?): VersionedKtFile { + private fun refreshToCurrent( + path: Path, + version: Int, + old: KtFile?, + ): VersionedKtFile { val content = FileManager.getDocumentContents(path) val newKtFile = project.read { parser.createFile(path.pathString, content) } newKtFile.backingFilePath = path // KtFile.virtualFile is null for non-physical PSI (unit-test env); the view provider's is // always present, and identical to it in production. - ProjectStructureProvider.getInstance(project) + ProjectStructureProvider + .getInstance(project) .registerInMemoryFile(path.pathString, newKtFile.viewProvider.virtualFile) // project.write serializes the mutation against a concurrent `analyze` (read lock); runWriteAction // supplies the platform write access handleElementModification asserts, which our RW lock does not. project.write { ApplicationManager.getApplication().runWriteAction { - KaSourceModificationService.getInstance(project) + KaSourceModificationService + .getInstance(project) .handleElementModification(old ?: newKtFile, KaElementModificationType.Unknown) } queueOnFileChangedAsync(newKtFile) @@ -245,13 +268,14 @@ 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 + fun getCurrentKtFileIfPresent(path: Path): KtFile? = currentFiles[path]?.getNow(null)?.ktFile - fun getKtFile(vf: VirtualFile): KtFile? = - getKtFile(vf.toNioPath(), vf) + fun getKtFile(vf: VirtualFile): KtFile? = getKtFile(vf.toNioPath(), vf) - fun getKtFile(path: Path, virtualFile: VirtualFile? = null): KtFile? { + fun getKtFile( + path: Path, + virtualFile: VirtualFile? = null, + ): KtFile? { if (!DocumentUtils.isKotlinFile(path)) return null if (FileManager.isActive(path)) { @@ -279,10 +303,12 @@ internal class KtSymbolIndex( return ktFile } - private fun loadKtFile(vf: VirtualFile): KtFile = project.read { - PsiManager.getInstance(project) - .findFile(vf) as KtFile - } + private fun loadKtFile(vf: VirtualFile): KtFile = + project.read { + PsiManager + .getInstance(project) + .findFile(vf) as KtFile + } suspend fun close() { indexWorker.submitCommand(IndexCommand.Stop) @@ -304,15 +330,14 @@ internal class KtSymbolIndex( } } -internal fun KtSymbolIndex.packageExistsInSource(packageFqn: String) = - fileIndex.packageExists(packageFqn) +internal fun KtSymbolIndex.packageExistsInSource(packageFqn: String) = fileIndex.packageExists(packageFqn) -internal fun KtSymbolIndex.filesForPackage(packageFqn: String) = - fileIndex.getFilesForPackage(packageFqn) +internal fun KtSymbolIndex.filesForPackage(packageFqn: String) = fileIndex.getFilesForPackage(packageFqn) -internal fun KtSymbolIndex.subpackageNames(packageFqn: String) = - fileIndex.getSubpackageNames(packageFqn) +internal fun KtSymbolIndex.subpackageNames(packageFqn: String) = fileIndex.getSubpackageNames(packageFqn) -internal fun KtSymbolIndex.findSymbolBySimpleName(name: String, limit: Int) = - (sourceIndex.findBySimpleName(name, 0) + libraryIndex.findBySimpleName(name, 0)) - .take(limit) +internal fun KtSymbolIndex.findSymbolBySimpleName( + name: String, + limit: Int, +) = (sourceIndex.findBySimpleName(name, 0) + libraryIndex.findBySimpleName(name, 0)) + .take(limit) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt index 84913f6b6c..2fd8342f52 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/completion/AdvancedKotlinEditHandler.kt @@ -44,4 +44,4 @@ internal abstract class AdvancedKotlinEditHandler( editor: CodeEditor, item: CompletionItem ) -} \ No newline at end of file +} 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 c0e654c630..70d32069ef 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 @@ -773,4 +773,3 @@ private fun isInSelectorPosition( val elementOffset = element.startOffset return elementOffset >= selector.startOffset } - 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 81613e47cd..23ceafbe32 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 @@ -132,4 +132,3 @@ private fun KaSeverity.toDiagnosticSeverity(): DiagnosticSeverity { KaSeverity.INFO -> DiagnosticSeverity.INFO } } - diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt index 0131c555d4..616047c42b 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolver.kt @@ -17,32 +17,33 @@ private val logger = LoggerFactory.getLogger("ActiveParameterResolver") * resolved against the active overload only. */ internal fun KaSession.computeActiveParameter( - call: KtCallElement, - resolvedCall: KaFunctionCall<*>?, - offset: Int + call: KtCallElement, + resolvedCall: KaFunctionCall<*>?, + offset: Int, ): Int { - val arguments = call.valueArgumentList?.arguments.orEmpty() + val arguments = call.valueArgumentList?.arguments.orEmpty() - // The argument the cursor is at/inside: the first whose end is at or after the cursor. - val current = arguments.firstOrNull { offset <= it.textRange.endOffset } - val positional = if (current != null) arguments.indexOf(current) else arguments.size + // The argument the cursor is at/inside: the first whose end is at or after the cursor. + val current = arguments.firstOrNull { offset <= it.textRange.endOffset } + val positional = if (current != null) arguments.indexOf(current) else arguments.size - if (current != null && resolvedCall != null) { - val argExpr = current.getArgumentExpression() - val paramSignature = argExpr?.let { resolvedCall.argumentMapping[it] } - if (paramSignature != null) { - val declaredIndex = resolvedCall.symbol.valueParameters - .indexOfFirst { it.name == paramSignature.name } - if (declaredIndex >= 0) { - logger.debug( - "computeActiveParameter: resolved named-argument index {} at offset {}", - declaredIndex, - offset - ) - return declaredIndex - } - } - } - logger.debug("computeActiveParameter: using positional index {} at offset {}", positional, offset) - return positional + if (current != null && resolvedCall != null) { + val argExpr = current.getArgumentExpression() + val paramSignature = argExpr?.let { resolvedCall.argumentMapping[it] } + if (paramSignature != null) { + val declaredIndex = + resolvedCall.symbol.valueParameters + .indexOfFirst { it.name == paramSignature.name } + if (declaredIndex >= 0) { + logger.debug( + "computeActiveParameter: resolved named-argument index {} at offset {}", + declaredIndex, + offset, + ) + return declaredIndex + } + } + } + logger.debug("computeActiveParameter: using positional index {} at offset {}", positional, offset) + return positional } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt index 2bfd5d0a39..e2bafbf1b9 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinder.kt @@ -14,32 +14,36 @@ private val logger = LoggerFactory.getLogger("CallAtCursorFinder") * Returns `null` when the cursor is inside a lambda body (trailing or argument lambda) or otherwise * not directly within a call's arguments, so that signature help is not shown there. */ -internal fun findEnclosingCall(file: KtFile, offset: Int): KtCallElement? { - // Prefer the element at the cursor; fall back to the element just before it (e.g. cursor at EOF - // or immediately after '('). - var node: PsiElement? = file.findElementAt(offset) - ?: file.findElementAt((offset - 1).coerceAtLeast(0)) +internal fun findEnclosingCall( + file: KtFile, + offset: Int, +): KtCallElement? { + // Prefer the element at the cursor; fall back to the element just before it (e.g. cursor at EOF + // or immediately after '('). + var node: PsiElement? = + file.findElementAt(offset) + ?: file.findElementAt((offset - 1).coerceAtLeast(0)) - if (node == null) { - logger.debug("No PSI element found at offset {} in {}", offset, file.name) - return null - } + if (node == null) { + logger.debug("No PSI element found at offset {} in {}", offset, file.name) + return null + } - while (node != null && node !is KtFile) { - // Crossing a lambda body before reaching a call's arguments means we are inside the lambda. - if (node is KtFunctionLiteral) { - logger.debug("Offset {} in {} is inside a lambda body; not showing signature help", offset, file.name) - return null - } - if (node is KtCallElement) { - val callee = node.calleeExpression - if (callee != null && offset > callee.textRange.endOffset) { - logger.debug("Found enclosing call '{}' for offset {} in {}", callee.text, offset, file.name) - return node - } - } - node = node.parent - } - logger.debug("No enclosing call found for offset {} in {}", offset, file.name) - return null + while (node != null && node !is KtFile) { + // Crossing a lambda body before reaching a call's arguments means we are inside the lambda. + if (node is KtFunctionLiteral) { + logger.debug("Offset {} in {} is inside a lambda body; not showing signature help", offset, file.name) + return null + } + if (node is KtCallElement) { + val callee = node.calleeExpression + if (callee != null && offset > callee.textRange.endOffset) { + logger.debug("Found enclosing call '{}' for offset {} in {}", callee.text, offset, file.name) + return node + } + } + node = node.parent + } + logger.debug("No enclosing call found for offset {} in {}", offset, file.name) + return null } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt index 3849840d5f..ca92c09241 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelp.kt @@ -23,46 +23,46 @@ import org.slf4j.LoggerFactory * active parameter is computed against that active overload. */ internal fun KaSession.buildSignatureHelp(call: KtCallElement, offset: Int): SignatureHelp { - val calleeText = call.calleeExpression?.text +val calleeText = call.calleeExpression?.text - // (resolved function call, isBest) pairs, in candidate order. - val resolvedCandidates = call.resolveToCallCandidates() - .mapNotNull { info -> - (info.candidate as? KaFunctionCall<*>)?.let { it to info.isInBestCandidates } - } - logger.debug( - "resolveToCallCandidates() found {} candidate(s) for call '{}'", - resolvedCandidates.size, - calleeText - ) +// (resolved function call, isBest) pairs, in candidate order. +val resolvedCandidates = call.resolveToCallCandidates() + .mapNotNull { info -> + (info.candidate as? KaFunctionCall<*>)?.let { it to info.isInBestCandidates } + } +logger.debug( + "resolveToCallCandidates() found {} candidate(s) for call '{}'", + resolvedCandidates.size, + calleeText +) - val candidates = resolvedCandidates.ifEmpty { - // Fallback: a single successfully-resolved function call. - logger.debug("No candidates from resolveToCallCandidates(); falling back to resolveToCall() for '{}'", calleeText) - call.resolveToCall()?.successfulFunctionCallOrNull()?.let { listOf(it to true) } ?: emptyList() - } +val candidates = resolvedCandidates.ifEmpty { + // Fallback: a single successfully-resolved function call. + logger.debug("No candidates from resolveToCallCandidates(); falling back to resolveToCall() for '{}'", calleeText) + call.resolveToCall()?.successfulFunctionCallOrNull()?.let { listOf(it to true) } ?: emptyList() + } - if (candidates.isEmpty()) { - logger.debug("No resolvable candidates for call '{}'; returning empty signature help", calleeText) - return SignatureHelp.empty() - } +if (candidates.isEmpty()) { + logger.debug("No resolvable candidates for call '{}'; returning empty signature help", calleeText) + return SignatureHelp.empty() +} - val signatures: List = - candidates.map { (fnCall, _) -> buildSignatureInformation(fnCall.symbol) } +val signatures: List = + candidates.map { (fnCall, _) -> buildSignatureInformation(fnCall.symbol) } - val activeSignature = candidates.indexOfFirst { it.second }.let { if (it < 0) 0 else it } - val activeCall = candidates[activeSignature].first - val activeParameter = computeActiveParameter(call, activeCall, offset) +val activeSignature = candidates.indexOfFirst { it.second }.let { if (it < 0) 0 else it } +val activeCall = candidates[activeSignature].first +val activeParameter = computeActiveParameter(call, activeCall, offset) - logger.debug( - "buildSignatureHelp for '{}': {} signature(s), activeSignature={}, activeParameter={}", - calleeText, - signatures.size, - activeSignature, - activeParameter - ) +logger.debug( + "buildSignatureHelp for '{}': {} signature(s), activeSignature={}, activeParameter={}", + calleeText, + signatures.size, + activeSignature, + activeParameter +) - return SignatureHelp(signatures, activeSignature, activeParameter) +return SignatureHelp(signatures, activeSignature, activeParameter) } private val logger = LoggerFactory.getLogger("KotlinSignatureHelp") @@ -73,40 +73,40 @@ private val logger = LoggerFactory.getLogger("KotlinSignatureHelp") */ context(env: CompilationEnvironment) internal suspend fun doSignatureHelp(params: SignatureHelpParams): SignatureHelp { - logger.debug("doSignatureHelp requested for file={} position={}", params.file, params.position) +logger.debug("doSignatureHelp requested for file={} position={}", params.file, params.position) - if (params.cancelChecker.isCancelled()) { - logger.debug("Signature help request for {} was cancelled before processing", params.file) - return SignatureHelp.empty() - } +if (params.cancelChecker.isCancelled()) { + logger.debug("Signature help request for {} was cancelled before processing", params.file) + return SignatureHelp.empty() +} - // Safe to await a (possibly blocking) refresh here: this runs outside any project.read/write - // block, so it can't deadlock against the refresh's project.write (unlike KtSymbolIndex.getKtFile). - val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).await() - if (ktFile == null) { - logger.warn("File {} is not open", params.file) - return SignatureHelp.empty() - } +// Safe to await a (possibly blocking) refresh here: this runs outside any project.read/write +// block, so it can't deadlock against the refresh's project.write (unlike KtSymbolIndex.getKtFile). +val ktFile = env.ktSymbolIndex.getCurrentKtFile(params.file).await() +if (ktFile == null) { + logger.warn("File {} is not open", params.file) + return SignatureHelp.empty() +} - return try { - val offset = params.position.requireIndex() - val result = env.project.read { - val call = findEnclosingCall(ktFile, offset) ?: return@read SignatureHelp.empty() - analyzeMaybeDangling(ktFile) { - buildSignatureHelp(call, offset) - } - } - logger.debug( - "Signature help result for {}: {} signature(s), activeSignature={}, activeParameter={}", - params.file, - result.signatures.size, - result.activeSignature, - result.activeParameter - ) - result - } catch (e: Throwable) { - if (e is CancellationException) throw e - logger.warn("Signature help computation failed for {}", params.file, e) - SignatureHelp.empty() - } +return try { + val offset = params.position.requireIndex() + val result = env.project.read { + val call = findEnclosingCall(ktFile, offset) ?: return@read SignatureHelp.empty() + analyzeMaybeDangling(ktFile) { + buildSignatureHelp(call, offset) + } + } + logger.debug( + "Signature help result for {}: {} signature(s), activeSignature={}, activeParameter={}", + params.file, + result.signatures.size, + result.activeSignature, + result.activeParameter + ) + result +} catch (e: Throwable) { + if (e is CancellationException) throw e + logger.warn("Signature help computation failed for {}", params.file, e) + SignatureHelp.empty() +} } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt index 01840e8a58..0ecfd3bb4d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilder.kt @@ -24,11 +24,12 @@ internal fun KaSession.buildSignatureInformation(symbol: KaFunctionSymbol): Sign return SignatureInformation(label, MarkupContent(), parameters) } -private fun functionDisplayName(symbol: KaFunctionSymbol): String = when (symbol) { - is KaNamedFunctionSymbol -> symbol.name.asString() - is KaConstructorSymbol -> symbol.containingClassId?.shortClassName?.asString() ?: "" - else -> "" -} +private fun functionDisplayName(symbol: KaFunctionSymbol): String = + when (symbol) { + is KaNamedFunctionSymbol -> symbol.name.asString() + is KaConstructorSymbol -> symbol.containingClassId?.shortClassName?.asString() ?: "" + else -> "" + } @OptIn(KaExperimentalApi::class) private fun KaSession.paramLabel(param: KaValueParameterSymbol): String { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt index 14c5615e74..21daf40527 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/TypeRendering.kt @@ -12,9 +12,8 @@ import org.jetbrains.kotlin.types.Variance internal fun KaSession.renderName( type: KaType, renderer: KaTypeRenderer = KaTypeRendererForSource.WITH_SHORT_NAMES, - position: Variance = Variance.INVARIANT -): String { - return type.run { + position: Variance = Variance.INVARIANT, +): String = + type.run { render(renderer, position) } -} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt index 9f862166bc..51c864316b 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/compiler/index/CurrentKtFileCacheTest.kt @@ -16,7 +16,6 @@ import org.junit.Test import java.nio.file.Path internal class CurrentKtFileCacheTest : KtLspTest() { - private val openedPaths = mutableListOf() @After @@ -28,18 +27,24 @@ internal class CurrentKtFileCacheTest : KtLspTest() { private fun docCloseEvent(path: Path) = DocumentCloseEvent(path) /** The [Path] under the first source root that [createSourceFile] wrote [relativePath] to. */ - private fun sourcePath(relativePath: String): Path = - env.sourceRoots.first().resolve(relativePath) + private fun sourcePath(relativePath: String): Path = env.sourceRoots.first().resolve(relativePath) /** Registers [path] as an active document at version 1 with [content]. */ - private fun openDocument(path: Path, content: String) { + private fun openDocument( + path: Path, + content: String, + ) { FileManager.onDocumentOpen(DocumentOpenEvent(path, content, 1)) openedPaths.add(path) } - private fun changeDocument(path: Path, content: String, version: Int) { + private fun changeDocument( + path: Path, + content: String, + version: Int, + ) { FileManager.onDocumentContentChange( - DocumentChangeEvent(path, content, content, version, ChangeType.NEW_TEXT, 0, Range.NONE) + DocumentChangeEvent(path, content, content, version, ChangeType.NEW_TEXT, 0, Range.NONE), ) } @@ -94,11 +99,13 @@ internal class CurrentKtFileCacheTest : KtLspTest() { // `f` calling `e` must resolve (no UNRESOLVED_REFERENCE). Keep `.defaultMessage` inside // `env.analyze {}`: reading a diagnostic outside its analysis session throws // KaInaccessibleLifetimeOwnerAccessException instead of a clean assertion diff. - val diagnosticMessages = env.analyze(v2) { - v2.collectDiagnostics( - org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS - ).map { it.defaultMessage } - } + val diagnosticMessages = + env.analyze(v2) { + v2 + .collectDiagnostics( + org.jetbrains.kotlin.analysis.api.components.KaDiagnosticCheckerFilter.EXTENDED_AND_COMMON_CHECKERS, + ).map { it.defaultMessage } + } assertEquals(emptyList(), diagnosticMessages) } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt index e029803272..435d984664 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/fixtures/KtLspTest.kt @@ -21,7 +21,6 @@ import org.robolectric.RobolectricTestRunner */ @RunWith(RobolectricTestRunner::class) abstract class KtLspTest { - @get:Rule @PublishedApi internal val lspTestRule = KtLspTestRule() @@ -29,7 +28,10 @@ abstract class KtLspTest { internal val env: KtLspTestEnvironment get() = lspTestRule.env - protected fun createSourceFile(relativePath: String, content: String): KtFile { + protected fun createSourceFile( + relativePath: String, + content: String, + ): KtFile { val file = env.createSourceFile(relativePath, content) // See the comment in `analyzeMaybeDanglingForTest` below: freshly-created files are invisible to // unqualified name resolution until they're registered with the symbol index's file metadata, @@ -39,13 +41,15 @@ abstract class KtLspTest { return file } - protected fun analyze(file: KtFile, action: KaSession.() -> R): R = - env.analyze(file, action) + protected fun analyze( + file: KtFile, + action: KaSession.() -> R, + ): R = env.analyze(file, action) /** Resolves [call] to a function call and runs [action] inside the analyze block. */ protected fun analyzeMaybeDanglingForTest( call: KtCallElement, - action: KaSession.(KaFunctionCall<*>?) -> R + action: KaSession.(KaFunctionCall<*>?) -> R, ): R { // `createSourceFile` writes the file and refreshes the VFS/module search scope, but unqualified // name resolution (e.g. resolving a call's callee) goes through `KtSymbolIndex.fileIndex` diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt index 418367a6c2..ee58a1e69e 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/ActiveParameterResolverTest.kt @@ -7,58 +7,85 @@ import org.jetbrains.kotlin.psi.KtCallElement import org.junit.Test class ActiveParameterResolverTest : KtLspTest() { + private fun callAt( + name: String, + text: String, + cursorMarker: String, + ): Pair { + val file = createSourceFile(name, text) + val offset = + text.indexOf(cursorMarker).let { + check(it >= 0) + it + } + // `findEnclosingCall` only recognizes a call once the offset is past its callee (e.g. inside the + // argument list); when the marker is the call itself (as in the empty-argument-list case below), + // `offset` alone lands exactly on the callee's name. Look the call up a couple of characters + // further in so callers can still use the marker's own `offset` as the cursor position they test. + val call = env.project.read { findEnclosingCall(file, offset + 2) }!! + return call to offset + } - private fun callAt(name: String, text: String, cursorMarker: String): Pair { - val file = createSourceFile(name, text) - val offset = text.indexOf(cursorMarker).let { check(it >= 0); it } - // `findEnclosingCall` only recognizes a call once the offset is past its callee (e.g. inside the - // argument list); when the marker is the call itself (as in the empty-argument-list case below), - // `offset` alone lands exactly on the callee's name. Look the call up a couple of characters - // further in so callers can still use the marker's own `offset` as the cursor position they test. - val call = env.project.read { findEnclosingCall(file, offset + 2) }!! - return call to offset - } + @Test + fun `first positional argument is index 0`() { + val (call, offset) = + callAt( + "A.kt", + "fun f(a: Int, b: Int) {}\nfun g() { f(10, 20) }", + "10", + ) + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(0) + } - @Test - fun `first positional argument is index 0`() { - val (call, offset) = callAt("A.kt", - "fun f(a: Int, b: Int) {}\nfun g() { f(10, 20) }", "10") - val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } - assertThat(idx).isEqualTo(0) - } + @Test + fun `second positional argument is index 1`() { + val (call, offset) = + callAt( + "B.kt", + "fun f(a: Int, b: Int) {}\nfun g() { f(10, 20) }", + "20", + ) + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(1) + } - @Test - fun `second positional argument is index 1`() { - val (call, offset) = callAt("B.kt", - "fun f(a: Int, b: Int) {}\nfun g() { f(10, 20) }", "20") - val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } - assertThat(idx).isEqualTo(1) - } + @Test + fun `named argument maps to its declared parameter index`() { + // cursor on `a = 5`, but `a` is the first declared parameter -> index 0 even though written second + val (call, offset) = + callAt( + "C.kt", + "fun f(a: Int, b: Int) {}\nfun g() { f(b = 1, a = 5) }", + "a = 5", + ) + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(0) + } - @Test - fun `named argument maps to its declared parameter index`() { - // cursor on `a = 5`, but `a` is the first declared parameter -> index 0 even though written second - val (call, offset) = callAt("C.kt", - "fun f(a: Int, b: Int) {}\nfun g() { f(b = 1, a = 5) }", "a = 5") - val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } - assertThat(idx).isEqualTo(0) - } + @Test + fun `empty argument list is index 0`() { + val (call, offset) = + callAt( + "D.kt", + "fun f(a: Int) {}\nfun g() { f() }", + "f()", + ) + val cursor = offset + 2 // inside the parens + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, cursor) } } + assertThat(idx).isEqualTo(0) + } - @Test - fun `empty argument list is index 0`() { - val (call, offset) = callAt("D.kt", - "fun f(a: Int) {}\nfun g() { f() }", "f()") - val cursor = offset + 2 // inside the parens - val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, cursor) } } - assertThat(idx).isEqualTo(0) - } - - @Test - fun `second vararg argument maps to the vararg parameter index`() { - // cursor on the second `xs` argument (positionally index 2), declared vararg parameter is index 1 - val (call, offset) = callAt("E.kt", - "fun f(a: Int, vararg xs: Int) {}\nfun g() { f(1, 22, 33) }", "33") - val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } - assertThat(idx).isEqualTo(1) - } + @Test + fun `second vararg argument maps to the vararg parameter index`() { + // cursor on the second `xs` argument (positionally index 2), declared vararg parameter is index 1 + val (call, offset) = + callAt( + "E.kt", + "fun f(a: Int, vararg xs: Int) {}\nfun g() { f(1, 22, 33) }", + "33", + ) + val idx = env.project.read { analyzeMaybeDanglingForTest(call) { rc -> computeActiveParameter(call, rc, offset) } } + assertThat(idx).isEqualTo(1) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt index a7a323e1ab..0d569aa9ea 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/CallAtCursorFinderTest.kt @@ -6,47 +6,49 @@ import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest import org.junit.Test class CallAtCursorFinderTest : KtLspTest() { + private fun offsetOf( + text: String, + marker: String, + ): Int { + val i = text.indexOf(marker) + check(i >= 0) { "marker '$marker' not found" } + return i + } - private fun offsetOf(text: String, marker: String): Int { - val i = text.indexOf(marker) - check(i >= 0) { "marker '$marker' not found" } - return i - } + @Test + fun `finds call when cursor is inside the argument parentheses`() { + val text = "fun f(a: Int) {}\nfun g() { f(1) }" + val file = env.project.read { env.parser.createFile("A.kt", text) } + val offset = offsetOf(text, "f(1)") + 2 // just after '(' + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call).isNotNull() + assertThat(call!!.calleeExpression?.text).isEqualTo("f") + } - @Test - fun `finds call when cursor is inside the argument parentheses`() { - val text = "fun f(a: Int) {}\nfun g() { f(1) }" - val file = env.project.read { env.parser.createFile("A.kt", text) } - val offset = offsetOf(text, "f(1)") + 2 // just after '(' - val call = env.project.read { findEnclosingCall(file, offset) } - assertThat(call).isNotNull() - assertThat(call!!.calleeExpression?.text).isEqualTo("f") - } + @Test + fun `returns null when cursor is inside a trailing lambda body`() { + val text = "fun run(block: () -> Unit) {}\nfun g() { run { println() } }" + val file = env.project.read { env.parser.createFile("B.kt", text) } + val offset = offsetOf(text, "println") + 1 + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call).isNull() + } - @Test - fun `returns null when cursor is inside a trailing lambda body`() { - val text = "fun run(block: () -> Unit) {}\nfun g() { run { println() } }" - val file = env.project.read { env.parser.createFile("B.kt", text) } - val offset = offsetOf(text, "println") + 1 - val call = env.project.read { findEnclosingCall(file, offset) } - assertThat(call).isNull() - } + @Test + fun `returns the innermost call for nested calls`() { + val text = "fun inner(x: Int) = x\nfun outer(y: Int) = y\nfun g() { outer(inner(2)) }" + val file = env.project.read { env.parser.createFile("C.kt", text) } + val offset = offsetOf(text, "inner(2)") + 6 // inside inner's parens + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call!!.calleeExpression?.text).isEqualTo("inner") + } - @Test - fun `returns the innermost call for nested calls`() { - val text = "fun inner(x: Int) = x\nfun outer(y: Int) = y\nfun g() { outer(inner(2)) }" - val file = env.project.read { env.parser.createFile("C.kt", text) } - val offset = offsetOf(text, "inner(2)") + 6 // inside inner's parens - val call = env.project.read { findEnclosingCall(file, offset) } - assertThat(call!!.calleeExpression?.text).isEqualTo("inner") - } - - @Test - fun `finds call for an unclosed argument list`() { - val text = "fun f(a: Int) {}\nfun g() { f( }" - val file = env.project.read { env.parser.createFile("D.kt", text) } - val offset = offsetOf(text, "f( ") + 2 // after '(' - val call = env.project.read { findEnclosingCall(file, offset) } - assertThat(call?.calleeExpression?.text).isEqualTo("f") - } + @Test + fun `finds call for an unclosed argument list`() { + val text = "fun f(a: Int) {}\nfun g() { f( }" + val file = env.project.read { env.parser.createFile("D.kt", text) } + val offset = offsetOf(text, "f( ") + 2 // after '(' + val call = env.project.read { findEnclosingCall(file, offset) } + assertThat(call?.calleeExpression?.text).isEqualTo("f") + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt index 1baf518c23..8d80f7744c 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/KotlinSignatureHelpTest.kt @@ -6,66 +6,75 @@ import com.itsaky.androidide.lsp.models.SignatureHelp import org.junit.Test class KotlinSignatureHelpTest : KtLspTest() { + private fun helpAt( + name: String, + text: String, + marker: String, + delta: Int = 0, + ): SignatureHelp { + val file = createSourceFile(name, text) + val offset = + text.indexOf(marker).let { + check(it >= 0) + it + } + delta + return analyze(file) { + val call = findEnclosingCall(file, offset) ?: return@analyze SignatureHelp.empty() + buildSignatureHelp(call, offset) + } + } - private fun helpAt(name: String, text: String, marker: String, delta: Int = 0): SignatureHelp { - val file = createSourceFile(name, text) - val offset = text.indexOf(marker).let { check(it >= 0); it } + delta - return analyze(file) { - val call = findEnclosingCall(file, offset) ?: return@analyze SignatureHelp.empty() - buildSignatureHelp(call, offset) - } - } + @Test + fun `single function returns one signature with valid indices`() { + val help = helpAt("A.kt", "fun f(a: Int) {}\nfun g() { f(1) }", "f(1)", delta = 2) + assertThat(help.signatures).hasSize(1) + assertThat(help.signatures[0].label).isEqualTo("f(a: Int)") + assertThat(help.activeSignature).isEqualTo(0) + assertThat(help.activeParameter).isEqualTo(0) + } - @Test - fun `single function returns one signature with valid indices`() { - val help = helpAt("A.kt", "fun f(a: Int) {}\nfun g() { f(1) }", "f(1)", delta = 2) - assertThat(help.signatures).hasSize(1) - assertThat(help.signatures[0].label).isEqualTo("f(a: Int)") - assertThat(help.activeSignature).isEqualTo(0) - assertThat(help.activeParameter).isEqualTo(0) - } + @Test + fun `overloads all appear and the matching one is active`() { + val text = + """ + fun f(a: Int) {} + fun f(a: Int, b: String) {} + fun g() { f(1, "x") } + """.trimIndent() + val help = helpAt("B.kt", text, "f(1, ", delta = 2) + assertThat(help.signatures.map { it.label }) + .containsExactly("f(a: Int)", "f(a: Int, b: String)") + // active overload is the two-arg one + assertThat(help.signatures[help.activeSignature].label).isEqualTo("f(a: Int, b: String)") + } - @Test - fun `overloads all appear and the matching one is active`() { - val text = """ - fun f(a: Int) {} - fun f(a: Int, b: String) {} - fun g() { f(1, "x") } - """.trimIndent() - val help = helpAt("B.kt", text, "f(1, ", delta = 2) - assertThat(help.signatures.map { it.label }) - .containsExactly("f(a: Int)", "f(a: Int, b: String)") - // active overload is the two-arg one - assertThat(help.signatures[help.activeSignature].label).isEqualTo("f(a: Int, b: String)") - } + @Test + fun `constructor call produces a signature labelled with the class name`() { + val text = "class Point(val x: Int, val y: Int)\nfun g() { Point(1, 2) }" + val help = helpAt("C.kt", text, "Point(1", delta = 6) + assertThat(help.signatures).isNotEmpty() + assertThat(help.signatures[help.activeSignature].label).isEqualTo("Point(x: Int, y: Int)") + } - @Test - fun `constructor call produces a signature labelled with the class name`() { - val text = "class Point(val x: Int, val y: Int)\nfun g() { Point(1, 2) }" - val help = helpAt("C.kt", text, "Point(1", delta = 6) - assertThat(help.signatures).isNotEmpty() - assertThat(help.signatures[help.activeSignature].label).isEqualTo("Point(x: Int, y: Int)") - } + @Test + fun `unresolved call returns empty`() { + val help = helpAt("D.kt", "fun g() { doesNotExist(1) }", "doesNotExist(", delta = 13) + assertThat(help.signatures).isEmpty() + assertThat(help).isEqualTo(SignatureHelp.empty()) + } - @Test - fun `unresolved call returns empty`() { - val help = helpAt("D.kt", "fun g() { doesNotExist(1) }", "doesNotExist(", delta = 13) - assertThat(help.signatures).isEmpty() - assertThat(help).isEqualTo(SignatureHelp.empty()) - } + @Test + fun `cursor in trailing lambda body yields empty via finder`() { + val text = "fun run(block: () -> Unit) {}\nfun g() { run { p() } }" + val help = helpAt("E.kt", text, "p()", delta = 1) + assertThat(help).isEqualTo(SignatureHelp.empty()) + } - @Test - fun `cursor in trailing lambda body yields empty via finder`() { - val text = "fun run(block: () -> Unit) {}\nfun g() { run { p() } }" - val help = helpAt("E.kt", text, "p()", delta = 1) - assertThat(help).isEqualTo(SignatureHelp.empty()) - } - - @Test - fun `named argument remaps active parameter via buildSignatureHelp`() { - val help = helpAt("F.kt", "fun f(a: Int, b: Int) {}\nfun g() { f(b = 1, a = 5) }", "a = 5") - // 'a' is declared parameter 0 though written second - assertThat(help.activeParameter).isEqualTo(0) - assertThat(help.signatures).isNotEmpty() - } + @Test + fun `named argument remaps active parameter via buildSignatureHelp`() { + val help = helpAt("F.kt", "fun f(a: Int, b: Int) {}\nfun g() { f(b = 1, a = 5) }", "a = 5") + // 'a' is declared parameter 0 though written second + assertThat(help.activeParameter).isEqualTo(0) + assertThat(help.signatures).isNotEmpty() + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt index 3c54760c56..7549a032f4 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/signaturehelp/SignatureInfoBuilderTest.kt @@ -7,38 +7,41 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.junit.Test class SignatureInfoBuilderTest : KtLspTest() { + @Test + fun `builds a fancy label and parameter list for a named function`() { + val file = createSourceFile("A.kt", "fun greet(name: String, times: Int): String = name") + val info = + analyze(file) { + val fn = file.declarations.filterIsInstance().first() + buildSignatureInformation(fn.symbol as KaFunctionSymbol) + } + assertThat(info.label).isEqualTo("greet(name: String, times: Int)") + assertThat(info.parameters.map { it.label }) + .containsExactly("name: String", "times: Int") + .inOrder() + assertThat(info.documentation.value).isEmpty() + } - @Test - fun `builds a fancy label and parameter list for a named function`() { - val file = createSourceFile("A.kt", "fun greet(name: String, times: Int): String = name") - val info = analyze(file) { - val fn = file.declarations.filterIsInstance().first() - buildSignatureInformation(fn.symbol as KaFunctionSymbol) - } - assertThat(info.label).isEqualTo("greet(name: String, times: Int)") - assertThat(info.parameters.map { it.label }) - .containsExactly("name: String", "times: Int").inOrder() - assertThat(info.documentation.value).isEmpty() - } + @Test + fun `builds a label with no parameters`() { + val file = createSourceFile("B.kt", "fun now() {}") + val info = + analyze(file) { + val fn = file.declarations.filterIsInstance().first() + buildSignatureInformation(fn.symbol as KaFunctionSymbol) + } + assertThat(info.label).isEqualTo("now()") + assertThat(info.parameters).isEmpty() + } - @Test - fun `builds a label with no parameters`() { - val file = createSourceFile("B.kt", "fun now() {}") - val info = analyze(file) { - val fn = file.declarations.filterIsInstance().first() - buildSignatureInformation(fn.symbol as KaFunctionSymbol) - } - assertThat(info.label).isEqualTo("now()") - assertThat(info.parameters).isEmpty() - } - - @Test - fun `prefixes vararg parameters`() { - val file = createSourceFile("C.kt", "fun sum(vararg xs: Int): Int = 0") - val info = analyze(file) { - val fn = file.declarations.filterIsInstance().first() - buildSignatureInformation(fn.symbol as KaFunctionSymbol) - } - assertThat(info.parameters.single().label).isEqualTo("vararg xs: Int") - } + @Test + fun `prefixes vararg parameters`() { + val file = createSourceFile("C.kt", "fun sum(vararg xs: Int): Int = 0") + val info = + analyze(file) { + val fn = file.declarations.filterIsInstance().first() + buildSignatureInformation(fn.symbol as KaFunctionSymbol) + } + assertThat(info.parameters.single().label).isEqualTo("vararg xs: Int") + } } From 48be771174d6efeef9dda02745df96a568a95ee4 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Jul 2026 14:41:14 +0000 Subject: [PATCH 22/31] ADFA-4614: register no-op Organize Imports action skeleton --- .../lsp/kotlin/KotlinCodeActionsMenu.kt | 2 + .../lsp/kotlin/KotlinLanguageServer.kt | 5 ++ .../kotlin/actions/OrganizeImportsAction.kt | 50 +++++++++++++++++++ 3 files changed, 57 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt index c021171b79..4bd2a7c6d9 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt @@ -5,6 +5,7 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.actions.IActionsMenuProvider import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction object KotlinCodeActionsMenu : IActionsMenuProvider { @@ -17,5 +18,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { CommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), AddImportAction(), + OrganizeImportsAction(), ) } \ No newline at end of file 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 7ed6946e47..c6a2d979b6 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 @@ -30,6 +30,7 @@ import com.itsaky.androidide.eventbus.events.file.FileRenameEvent 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.kotlin.compiler.CompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.Compiler import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_INDEX_KEY @@ -118,6 +119,10 @@ class KotlinLanguageServer : ILanguageServer { this.compiler?.updateLanguageClient(client) } + /** Returns the [CompilationEnvironment] responsible for [file], or null if the compiler is not ready. */ + internal fun compilationEnvironmentFor(file: Path): CompilationEnvironment? = + compiler?.compilationEnvironmentFor(file) + override fun applySettings(settings: IServerSettings?) { this._settings = settings } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt new file mode 100644 index 0000000000..728732b75e --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt @@ -0,0 +1,50 @@ +package com.itsaky.androidide.lsp.kotlin.actions + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.CodeActionKind +import com.itsaky.androidide.lsp.models.Command +import com.itsaky.androidide.lsp.models.DocumentChange +import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +class OrganizeImportsAction : BaseKotlinCodeAction() { + override var titleTextRes: Int = R.string.action_organize_imports + override val id: String = "ide.editor.lsp.kt.organizeImports" + override var label: String = "" + + companion object { + private val logger = LoggerFactory.getLogger(OrganizeImportsAction::class.java) + } + + override suspend fun execAction(data: ActionData): List { + // Logic added in Task 5. + return emptyList() + } + + override fun postExec(data: ActionData, result: Any) { + super.postExec(data, result) + if (result !is List<*> || result.isEmpty()) return + + @Suppress("UNCHECKED_CAST") + result as List + + val client = data.languageClient ?: run { + logger.warn("No language client set. Cannot organize imports.") + return + } + val file = data.requireFile() + client.performCodeAction( + CodeActionItem( + title = label, + changes = listOf(DocumentChange(file = file.toPath(), edits = result)), + kind = CodeActionKind.QuickFix, + command = Command("", ""), // no post-action command (no CMD_FORMAT_CODE) + ) + ) + } +} From f96beec6be5419fdd466f5b54d4c6a7ffed43609 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Jul 2026 15:01:04 +0000 Subject: [PATCH 23/31] ADFA-4614: add pure import-organizing logic with tests --- .../lsp/kotlin/utils/ImportOrganizer.kt | 90 ++++++++++ .../lsp/kotlin/utils/ImportOrganizerTest.kt | 155 ++++++++++++++++++ 2 files changed, 245 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt new file mode 100644 index 0000000000..54cb1b1b41 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt @@ -0,0 +1,90 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.kdoc.psi.api.KDoc +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtImportDirective + +/** + * What a file's body actually uses, expressed as plain strings so nothing crosses an `analyze` + * lifetime boundary. Produced by [collectImportUsage]. + * + * @property usedFqNames importable fully-qualified names referenced by the body. + * @property usedPackages parent packages/objects of used symbols (for wildcard matching). + */ +internal data class ImportUsage( + val usedFqNames: Set, + val usedPackages: Set, +) + +/** JVM packages that Kotlin imports with a wildcard by default; explicit named imports from these are redundant. */ +internal val DEFAULT_STAR_PACKAGES: Set = setOf( + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "kotlin.jvm", + "java.lang", +) + +private val KDOC_LINK = Regex("""\[([^\]\s]+)]""") + +/** + * Computes the canonical import block for [ktFile] given [usage]: unused/redundant imports removed, + * survivors deduped and lexicographically sorted. Returns null when the imports are already in that + * exact form (no edit needed). The returned text has no surrounding newlines. + */ +internal fun organizedImportBlock(ktFile: KtFile, usage: ImportUsage): String? { + val directives = ktFile.importDirectives + if (directives.isEmpty()) return null + + val filePackage = ktFile.packageFqName.asString() + val kdocNames = collectKDocLinkNames(ktFile) + + val newLines = directives + .filter { keepImport(it, usage, filePackage, kdocNames) } + .mapNotNull { it.importPath?.let { path -> "import $path" } } + .distinct() + .sorted() + + val currentLines = directives.mapNotNull { it.importPath?.let { path -> "import $path" } } + + if (newLines == currentLines) return null + return newLines.joinToString(System.lineSeparator()) +} + +private fun keepImport( + directive: KtImportDirective, + usage: ImportUsage, + filePackage: String, + kdocNames: Set, +): Boolean { + val fqName = directive.importedFqName ?: return true // malformed import -> keep (conservative) + val fqNameStr = fqName.asString() + val alias = directive.aliasName + val shortName = alias ?: fqName.shortName().asString() + + // Conservative: keep anything referenced by short name/alias in a KDoc link. + if (shortName in kdocNames) return true + + if (directive.isAllUnder) { + // Wildcard: keep iff some used symbol lives in this package/object. + return fqNameStr in usage.usedPackages + } + + val parentPackage = fqName.parent().asString() + // Redundant named imports (only when not aliased — an alias is meaningful). + if (alias == null && parentPackage in DEFAULT_STAR_PACKAGES) return false + if (alias == null && parentPackage == filePackage) return false + + return fqNameStr in usage.usedFqNames +} + +private fun collectKDocLinkNames(ktFile: KtFile): Set = + PsiTreeUtil.collectElementsOfType(ktFile, KDoc::class.java) + .flatMap { kdoc -> KDOC_LINK.findAll(kdoc.text).map { it.groupValues[1].substringAfterLast('.') } } + .toSet() diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt new file mode 100644 index 0000000000..0bde4aaf70 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt @@ -0,0 +1,155 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ImportOrganizerTest : KtLspTest() { + + private fun organize(content: String, usage: ImportUsage): String? { + val ktFile = createSourceFile("Sample.kt", content) + return env.project.read { organizedImportBlock(ktFile, usage) } + } + + @Test + fun `removes unused named import and keeps used one`() { + val result = organize( + """ + package p + import a.b.Used + import a.b.Unused + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("a.b.Used"), usedPackages = setOf("a.b")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `sorts imports lexicographically`() { + val result = organize( + """ + package p + import a.b.Zebra + import a.b.Apple + fun f(x: Zebra, y: Apple) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Zebra", "a.b.Apple"), setOf("a.b")), + ) + assertEquals("import a.b.Apple${'\n'}import a.b.Zebra", result) + } + + @Test + fun `keeps used wildcard, removes unused wildcard`() { + val result = organize( + """ + package p + import used.pkg.* + import unused.pkg.* + fun f(x: Thing) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("used.pkg.Thing"), usedPackages = setOf("used.pkg")), + ) + assertEquals("import used.pkg.*", result) + } + + @Test + fun `removes default-import-redundant named import`() { + val result = organize( + """ + package p + import kotlin.collections.List + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + // even though List resolves, it is redundant (default star package) + ImportUsage(setOf("a.b.Used", "kotlin.collections.List"), setOf("a.b", "kotlin.collections")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `removes same-package redundant import`() { + val result = organize( + """ + package p + import p.Sibling + import a.b.Used + fun f(x: Used, y: Sibling) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used", "p.Sibling"), setOf("a.b", "p")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `keeps aliased import from default package`() { + val result = organize( + """ + package p + import kotlin.collections.List as KList + import x.y.Unused + fun f(x: KList) {} + """.trimIndent(), + ImportUsage(setOf("kotlin.collections.List"), setOf("kotlin.collections")), + ) + assertEquals("import kotlin.collections.List as KList", result) + } + + @Test + fun `keeps import referenced only in KDoc`() { + val result = organize( + """ + package p + import a.b.DocOnly + import x.y.Unused + /** See [DocOnly] for details. */ + fun f() {} + """.trimIndent(), + ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet()), + ) + assertEquals("import a.b.DocOnly", result) + } + + @Test + fun `collapses exact duplicate imports`() { + val result = organize( + """ + package p + import a.b.Used + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used"), setOf("a.b")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `returns null when already organized`() { + val result = organize( + """ + package p + import a.b.Apple + import a.b.Zebra + fun f(x: Apple, y: Zebra) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Apple", "a.b.Zebra"), setOf("a.b")), + ) + assertNull(result) + } + + @Test + fun `no imports returns null`() { + val result = organize( + """ + package p + fun f() {} + """.trimIndent(), + ImportUsage(emptySet(), emptySet()), + ) + assertNull(result) + } +} From 386b28d678e4eb4deda60254cc79b9da9663e5d5 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Jul 2026 15:08:51 +0000 Subject: [PATCH 24/31] ADFA-4614: collect used importable names via Analysis API --- .../lsp/kotlin/utils/ImportUsageCollector.kt | 67 +++++++++++++ .../kotlin/utils/ImportUsageCollectorTest.kt | 95 +++++++++++++++++++ 2 files changed, 162 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt new file mode 100644 index 0000000000..04f796482b --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt @@ -0,0 +1,67 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtImportList +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression +import org.jetbrains.kotlin.psi.KtPropertyDelegate +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType + +/** + * Collects the importable fq-names (and their packages) referenced by [ktFile]'s body. MUST be + * called inside [analyzeMaybeDangling]. Returns only plain strings, so nothing escapes the analyze + * lifetime. Anything that fails to resolve simply doesn't join the used set (safe: leads to keeping + * an import, never removing a used one). + */ +internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { + val usedFqNames = HashSet() + val usedPackages = HashSet() + + fun record(symbol: KaSymbol?) { + val fq = symbol?.importableFqNameString() ?: return + usedFqNames += fq + val pkg = fq.substringBeforeLast('.', missingDelimiterValue = "") + if (pkg.isNotEmpty()) usedPackages += pkg + } + + // 1) Plain name / type references (excluding the import list itself). + ktFile.collectDescendantsOfType().forEach { ref -> + if (ref.getParentOfType(strict = false) != null) return@forEach + runCatching { record(ref.mainReference.resolveToSymbol()) } + } + + // 2) Convention / operator call sites (no textual name reference). + ktFile.collectDescendantsOfType().forEach { element -> + val isConvention = element is KtOperationReferenceExpression || + element is KtArrayAccessExpression || + element is KtCallExpression || + element is KtForExpression || + element is KtDestructuringDeclaration || + element is KtPropertyDelegate + if (!isConvention) return@forEach + runCatching { + record(element.resolveToCall()?.successfulFunctionCallOrNull()?.symbol) + } + } + + return ImportUsage(usedFqNames, usedPackages) +} + +private fun KaSymbol.importableFqNameString(): String? = when (this) { + is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString() + is KaCallableSymbol -> callableId?.asSingleFqName()?.asString() + else -> null +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt new file mode 100644 index 0000000000..1b83141ad5 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt @@ -0,0 +1,95 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.psi.KtFile +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImportUsageCollectorTest : KtLspTest() { + + private fun usageOf(ktFile: KtFile): ImportUsage = + env.project.read { analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } } + + @Test + fun `type reference is recorded as used`() { + val ktFile = createSourceFile( + "UseType.kt", + """ + package p + fun f(): java.io.File? = null + fun g() { val x: java.io.File = java.io.File("a") } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("java.io.File" in usage.usedFqNames) + assertTrue("java.io" in usage.usedPackages) + } + + @Test + fun `top-level function call is recorded as used`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + fun topLevelHelper() {} + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseFn.kt", + """ + package p + import lib.topLevelHelper + fun f() { topLevelHelper() } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.topLevelHelper" in usage.usedFqNames) + } + + @Test + fun `operator function used via symbol is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Money + operator fun Money.plus(other: Money): Money = this + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseOp.kt", + """ + package p + import lib.Money + import lib.plus + fun f(a: Money, b: Money) { val c = a + b } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.plus" in usage.usedFqNames) + } + + @Test + fun `unreferenced import is absent from usage`() { + createSourceFile( + "lib/Extra.kt", + """ + package lib + class Extra + """.trimIndent(), + ) + val ktFile = createSourceFile( + "NoUse.kt", + """ + package p + import lib.Extra + fun f() {} + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertFalse("lib.Extra" in usage.usedFqNames) + } +} From f2cc9f010812b59d5d701b56f3c0983515e9b826 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Jul 2026 15:19:31 +0000 Subject: [PATCH 25/31] ADFA-4614: compute and apply organized imports in the action --- .../kotlin/actions/OrganizeImportsAction.kt | 25 ++++++++++++- .../utils/OrganizeImportsEndToEndTest.kt | 37 +++++++++++++++++++ 2 files changed, 60 insertions(+), 2 deletions(-) create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt index 728732b75e..c409fd6e31 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt @@ -4,11 +4,17 @@ import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.get import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +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 import com.itsaky.androidide.lsp.models.CodeActionItem import com.itsaky.androidide.lsp.models.CodeActionKind import com.itsaky.androidide.lsp.models.Command import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.models.Range import com.itsaky.androidide.resources.R import org.slf4j.LoggerFactory @@ -22,8 +28,23 @@ class OrganizeImportsAction : BaseKotlinCodeAction() { } override suspend fun execAction(data: ActionData): List { - // Logic added in Task 5. - return emptyList() + val server = data.get() ?: return emptyList() + val file = data.requireFile() + val nioPath = file.toPath() + + val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() + + // Fetch the current KtFile BEFORE entering project.read (deadlock rule). + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() + if (ktFile.importDirectives.isEmpty()) return emptyList() + + return env.project.read { + val usage = analyzeMaybeDangling(ktFile) { 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)) + } } override fun postExec(data: ActionData, result: Any) { diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt new file mode 100644 index 0000000000..3b4568a520 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt @@ -0,0 +1,37 @@ +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class OrganizeImportsEndToEndTest : KtLspTest() { + + @Test + fun `removes unused import end to end`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + class Used + class Unused + """.trimIndent(), + ) + val ktFile = createSourceFile( + "Main.kt", + """ + package p + import lib.Used + import lib.Unused + fun f(x: Used) {} + """.trimIndent(), + ) + + val block = env.project.read { + val usage = analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } + organizedImportBlock(ktFile, usage) + } + assertEquals("import lib.Used", block) + } +} From 93d292242c5bb74e6930428868d0d1c5d863c24a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Jul 2026 15:28:34 +0000 Subject: [PATCH 26/31] ADFA-4614: extract computeOrganizeEdit helper and cover it end-to-end --- .../lsp/kotlin/actions/OrganizeImportsAction.kt | 17 ++++++++++++----- .../kotlin/utils/OrganizeImportsEndToEndTest.kt | 16 ++++++++-------- 2 files changed, 20 insertions(+), 13 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt index c409fd6e31..9e56a22464 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt @@ -4,6 +4,7 @@ import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.get import com.itsaky.androidide.actions.requireFile import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.utils.collectImportUsage @@ -17,6 +18,7 @@ import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.models.Range import com.itsaky.androidide.resources.R import org.slf4j.LoggerFactory +import java.nio.file.Path class OrganizeImportsAction : BaseKotlinCodeAction() { override var titleTextRes: Int = R.string.action_organize_imports @@ -29,15 +31,20 @@ class OrganizeImportsAction : BaseKotlinCodeAction() { override suspend fun execAction(data: ActionData): List { val server = data.get() ?: return emptyList() - val file = data.requireFile() - val nioPath = file.toPath() - + val nioPath = data.requireFile().toPath() val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() + return computeOrganizeEdit(env, nioPath) + } - // Fetch the current KtFile BEFORE entering project.read (deadlock rule). + /** + * 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). + */ + internal fun computeOrganizeEdit(env: AbstractCompilationEnvironment, nioPath: Path): List { val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() if (ktFile.importDirectives.isEmpty()) return emptyList() - return env.project.read { val usage = analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } val newText = organizedImportBlock(ktFile, usage) ?: return@read emptyList() diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt index 3b4568a520..1bfbe133ac 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt @@ -1,7 +1,6 @@ package com.itsaky.androidide.lsp.kotlin.utils -import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling -import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest import org.junit.Assert.assertEquals import org.junit.Test @@ -18,7 +17,7 @@ class OrganizeImportsEndToEndTest : KtLspTest() { class Unused """.trimIndent(), ) - val ktFile = createSourceFile( + createSourceFile( "Main.kt", """ package p @@ -27,11 +26,12 @@ class OrganizeImportsEndToEndTest : KtLspTest() { fun f(x: Used) {} """.trimIndent(), ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + + // Drive the action's real plumbing: fetch-before-read ordering + full guard chain. + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) - val block = env.project.read { - val usage = analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } - organizedImportBlock(ktFile, usage) - } - assertEquals("import lib.Used", block) + assertEquals(1, edits.size) + assertEquals("import lib.Used", edits.single().newText) } } From e88dc5a07d55cb685add793647ccb55297a0f490 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 9 Jul 2026 16:00:11 +0000 Subject: [PATCH 27/31] ADFA-4614: cover convention imports, harden action against analysis failures --- .../kotlin/actions/OrganizeImportsAction.kt | 30 +++-- .../lsp/kotlin/utils/ImportUsageCollector.kt | 25 ++++ .../kotlin/utils/ImportUsageCollectorTest.kt | 119 ++++++++++++++++++ .../utils/OrganizeImportsEndToEndTest.kt | 10 ++ 4 files changed, 173 insertions(+), 11 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt index 9e56a22464..c17137c3a8 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt @@ -40,19 +40,27 @@ 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). + * 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 + * 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. */ - internal fun computeOrganizeEdit(env: AbstractCompilationEnvironment, nioPath: Path): List { - val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() - if (ktFile.importDirectives.isEmpty()) return emptyList() - return env.project.read { - val usage = analyzeMaybeDangling(ktFile) { 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)) + internal fun computeOrganizeEdit(env: AbstractCompilationEnvironment, nioPath: Path): List = + runCatching { + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() + if (ktFile.importDirectives.isEmpty()) return emptyList() + env.project.read { + val usage = analyzeMaybeDangling(ktFile) { 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)) + } + }.getOrElse { e -> + logger.warn("Failed to organize imports", e) + emptyList() } - } override fun postExec(data: ActionData, result: Any) { super.postExec(data, result) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt index 04f796482b..da1c09510b 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt @@ -10,6 +10,7 @@ import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtForExpression @@ -37,12 +38,36 @@ internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { if (pkg.isNotEmpty()) usedPackages += pkg } + fun recordAll(symbols: Collection?) { + symbols?.forEach(::record) + } + // 1) Plain name / type references (excluding the import list itself). ktFile.collectDescendantsOfType().forEach { ref -> if (ref.getParentOfType(strict = false) != null) return@forEach runCatching { record(ref.mainReference.resolveToSymbol()) } } + // 1b) Implicit-convention references that carry more than one resolution target and so don't + // resolve through `resolveToSymbol()` (singular; returns null when ambiguous) but do resolve + // through `resolveToSymbols()` (plural). Confirmed empirically: + // - KtForExpression: resolves to [iterator(), hasNext(), next()] -- iterator is the + // user-importable one for a `for (x in foo)` loop. + // - KtDestructuringDeclarationEntry (one per destructured variable): resolves to that + // variable's own componentN() symbol. + // - KtPropertyDelegate: resolves to the delegate's getValue()/setValue() symbol(s). + // Recording every returned symbol is safe: extra (e.g. stdlib Iterator.next) symbols only ever + // keep an import, never drop a used one. + ktFile.collectDescendantsOfType().forEach { forExpr -> + runCatching { recordAll(forExpr.mainReference?.resolveToSymbols()) } + } + ktFile.collectDescendantsOfType().forEach { entry -> + runCatching { recordAll(entry.mainReference?.resolveToSymbols()) } + } + ktFile.collectDescendantsOfType().forEach { delegate -> + runCatching { recordAll(delegate.mainReference?.resolveToSymbols()) } + } + // 2) Convention / operator call sites (no textual name reference). ktFile.collectDescendantsOfType().forEach { element -> val isConvention = element is KtOperationReferenceExpression || diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt index 1b83141ad5..34a5ef69ab 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt @@ -92,4 +92,123 @@ class ImportUsageCollectorTest : KtLspTest() { val usage = usageOf(ktFile) assertFalse("lib.Extra" in usage.usedFqNames) } + + @Test + fun `array-access get operator used via subscript is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.get(i: Int): Int = i + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseGet.kt", + """ + package p + import lib.Foo + import lib.get + fun f(foo: Foo) { val x = foo[0] } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.get" in usage.usedFqNames) + } + + @Test + fun `array-access set operator used via subscript assignment is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.set(i: Int, v: Int) {} + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseSet.kt", + """ + package p + import lib.Foo + import lib.set + fun f(foo: Foo) { foo[0] = 1 } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.set" in usage.usedFqNames) + } + + @Test + fun `iterator operator used via for loop is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.iterator(): Iterator = listOf(1, 2, 3).iterator() + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseIterator.kt", + """ + package p + import lib.Foo + import lib.iterator + fun f(foo: Foo) { for (x in foo) {} } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.iterator" in usage.usedFqNames) + } + + @Test + fun `componentN operators used via destructuring are recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Foo + operator fun Foo.component1(): Int = 1 + operator fun Foo.component2(): Int = 2 + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseDestructure.kt", + """ + package p + import lib.Foo + import lib.component1 + import lib.component2 + fun f(foo: Foo) { val (a, b) = foo } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.component1" in usage.usedFqNames) + assertTrue("lib.component2" in usage.usedFqNames) + } + + @Test + fun `getValue operator used via property delegation is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + import kotlin.reflect.KProperty + class Foo + operator fun Foo.getValue(thisRef: Any?, property: KProperty<*>): Int = 1 + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseDelegate.kt", + """ + package p + import lib.Foo + import lib.getValue + fun f(foo: Foo) { val x: Int by foo } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.getValue" in usage.usedFqNames) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt index 1bfbe133ac..1d2d3b1967 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt @@ -2,6 +2,8 @@ package com.itsaky.androidide.lsp.kotlin.utils import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range import org.junit.Assert.assertEquals import org.junit.Test @@ -33,5 +35,13 @@ class OrganizeImportsEndToEndTest : KtLspTest() { assertEquals(1, edits.size) assertEquals("import lib.Used", edits.single().newText) + + // The import list in the fixture spans exactly line 1 col 0 ("import lib.Used") through + // line 2 col 17 (end of "import lib.Unused"); line 0 is "package p". A wrong/off-by-one + // range here would silently corrupt the file when the client applies this edit. + assertEquals( + Range(Position(1, 0), Position(2, 17)), + edits.single().range, + ) } } From 058c6aa76a4f96a9157fc73555723d54803006e1 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 14 Jul 2026 22:26:06 +0530 Subject: [PATCH 28/31] fix: keep unresolved references when organizing imports Signed-off-by: Akash Yadav --- .../2026-07-09-kotlin-organize-imports.md | 896 ++++++++++++++++++ ...26-07-09-kotlin-organize-imports-design.md | 217 +++++ .../lsp/kotlin/utils/ImportOrganizer.kt | 6 + .../lsp/kotlin/utils/ImportUsageCollector.kt | 16 +- .../lsp/kotlin/utils/ImportOrganizerTest.kt | 15 + .../kotlin/utils/ImportUsageCollectorTest.kt | 15 + 6 files changed, 1160 insertions(+), 5 deletions(-) create mode 100644 docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md create mode 100644 docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md diff --git a/docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md b/docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md new file mode 100644 index 0000000000..78b7347eb7 --- /dev/null +++ b/docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md @@ -0,0 +1,896 @@ +# Organize Imports (Kotlin LSP) Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Add an always-available "Organize imports" code action for `.kt` files in the Kotlin LSP that sorts imports into canonical order and removes unused/redundant imports, computed in-process via the Kotlin Analysis API. + +**Architecture:** A thin `OrganizeImportsAction : BaseKotlinCodeAction` (menu action) delegates to a pure `ImportOrganizer` helper. All Analysis-API resolution is isolated to one function, `KaSession.collectImportUsage(ktFile)`, which returns plain strings (no `KaLifetimeOwner` escapes). The rest — classification, redundancy rules, canonical sort, edit text, no-op detection, KDoc-keep — is pure PSI/string logic and is unit-tested without analysis. + +**Tech Stack:** Kotlin, JetBrains Kotlin Analysis API (standalone, vendored as `analysis-api-standalone-embeddable-for-ide`), Robolectric + JUnit4, Gradle (flavored Android library). + +**Spec:** `docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md` + +## Global Constraints + +- **Branch / worktree:** all work happens in the `feat/ADFA-4614` worktree at `.claude/worktrees/ADFA-4614`. All paths below are relative to that worktree root. +- **Commit convention:** subject `ADFA-4614: ` (colon, imperative). No co-author trailer. Logical, focused commits. Never `git add .` — always stage explicit paths. **Never commit files under `docs/superpowers/`** (specs/plans/notes stay uncommitted). +- **Build/test task:** `./gradlew :lsp:kotlin:testV7DebugUnitTest` (V7 = `armeabi-v7a` flavor). Run from the worktree root. +- **Analysis-API footguns (mandatory):** + - Obtain the `KtFile` via `env.ktSymbolIndex.getCurrentKtFile(path).get()` **before** entering `env.project.read { }`. Never call the blocking `.get()`/`.await()` inside `project.read` (its refresh needs `project.write`; the RW lock is non-upgradeable → deadlock). + - All Analysis-API access goes through `analyzeMaybeDangling(ktFile) { }` (never bare `analyze()`), and inside it never call `write`. + - No `KaLifetimeOwner` (symbol, type, call) may escape the `analyzeMaybeDangling` block — extract plain data (`String`s) inside it. +- **Safety bias:** an import is removed only when *provably* unused. On any failed/ambiguous resolution, keep the import. Wrongly removing a used import breaks compilation and is the failure mode to avoid. + +## File Structure + +- Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt` — pure logic: `ImportUsage` data class, `DEFAULT_STAR_PACKAGES`, `organizedImportBlock(...)`, `keepImport(...)`, `collectKDocLinkNames(...)`. +- Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt` — the single Analysis-API seam: `KaSession.collectImportUsage(ktFile)` + `KaSymbol.importableFqNameString()`. +- Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt` — the action wrapper. +- Modify `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt` — add public `compilationEnvironmentFor(file)` accessor. +- Modify `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt` — register `OrganizeImportsAction()`. +- Create `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt` — pure-logic tests. +- Create `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt` — analysis-backed fixture tests. + +--- + +## Task 1: De-risking spike — pin the resolution API + +**Purpose:** The repo has no `resolveToSymbol` / `importableFqName` / `mainReference` call sites, and the vendored Analysis API is a moving `2.3.255-SNAPSHOT`. Confirm the exact callable surface before building on it. This task produces a **throwaway** test that is deleted at the end; its only durable output is the confirmed API recorded in the plan/spec notes. + +**Files:** +- Create (throwaway): `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ResolutionSpikeTest.kt` + +**Interfaces:** +- Produces: confirmed answers to (a) how to get a `KtReference` from a `KtNameReferenceExpression` and resolve it to a symbol; (b) how to derive an importable FqName string from a resolved `KaSymbol`; (c) that `KtElement.resolveToCall()?.successfulFunctionCallOrNull()?.symbol` yields the operator function symbol for `+`, `[]`, `by lazy`, and destructuring; (d) how to read the file's package name from PSI. + +- [ ] **Step 1: Write the spike test (candidate API — adjust until it compiles & passes)** + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.junit.Test + +class ResolutionSpikeTest : KtLspTest() { + @Test + fun `resolve type reference and operator call`() { + val ktFile = createSourceFile( + "Spike.kt", + """ + package p + import java.io.File + import java.math.BigInteger + fun use() { + val f: File = File("x") + val a = BigInteger.ONE + BigInteger.ONE + } + """.trimIndent(), + ) + + env.project.read { + analyzeMaybeDangling(ktFile) { + // (a)+(b): a name reference -> symbol -> importable fq-name + val fileRef = ktFile.collectDescendantsOfType() + .first { it.getReferencedName() == "File" } + val sym = fileRef.mainReference.resolveToSymbol() // CONFIRM: mainReference import + resolveToSymbol + val fq = when (sym) { + is KaClassLikeSymbol -> sym.classId?.asSingleFqName()?.asString() + is KaCallableSymbol -> sym.callableId?.asSingleFqName()?.asString() + else -> null + } + println("SPIKE type fq = $fq") // expect java.io.File + + // (c): operator call -> function symbol -> importable fq-name + val plus = ktFile.collectDescendantsOfType().first() + val opSym = plus.resolveToCall()?.successfulFunctionCallOrNull()?.symbol + println("SPIKE plus callableId = ${(opSym as? KaCallableSymbol)?.callableId?.asSingleFqName()?.asString()}") + + // (d): package name from PSI (no analysis needed, but confirm here) + println("SPIKE package = ${ktFile.packageFqName.asString()}") + } + } + } +} +``` + +- [ ] **Step 2: Run the spike and iterate on the API** + +Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ResolutionSpikeTest*"` + +If it fails to **compile**, fix the API until it does, trying these known alternates and recording which works: +- `mainReference`: import `org.jetbrains.kotlin.idea.references.mainReference`; if unresolved, try `org.jetbrains.kotlin.analysis.api.fir.references.*` or resolve via `fileRef.references.filterIsInstance().firstOrNull()?.resolveToSymbol()`. +- symbol → fq-name: if `KaSymbol.importableFqName` exists directly (import `org.jetbrains.kotlin.analysis.api.symbols.*`), prefer it over the `classId`/`callableId` branch. +- `asSingleFqName()`: `ClassId` and `CallableId` both expose it; if not, use `classId.asFqNameString()` / `callableId.asSingleFqName()`. + +Expected once compiling: PASS, with logged `SPIKE type fq = java.io.File`, a non-null `plus` callableId (e.g. `java.math.BigInteger.plus` or `kotlin.plus`), and `SPIKE package = p`. + +- [ ] **Step 3: Record the confirmed API** + +Append a short "Confirmed resolution API" note to the bottom of the spec file (`docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md`) capturing the exact imports and call shapes that compiled. Tasks 4 uses these verbatim. + +- [ ] **Step 4: Delete the throwaway test** + +```bash +rm lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ResolutionSpikeTest.kt +``` + +- [ ] **Step 5: No commit** + +Nothing to commit (spec notes are uncommitted per Global Constraints; the spike test is deleted). Proceed to Task 2. + +--- + +## Task 2: Wire the action skeleton (no-op) + registration + env accessor + +**Purpose:** Get the menu item appearing and dispatching end-to-end before the logic exists, so wiring bugs surface early. The action returns no edits for now. + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt` +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt` +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt` + +**Interfaces:** +- Produces: `KotlinLanguageServer.compilationEnvironmentFor(file: Path): CompilationEnvironment?`; `OrganizeImportsAction` (id `ide.editor.lsp.kt.organizeImports`) registered in `KotlinCodeActionsMenu.actions`. +- Consumes: `BaseKotlinCodeAction` (existing), `R.string.action_organize_imports` (existing). + +- [ ] **Step 1: Add the public env accessor to `KotlinLanguageServer`** + +In `KotlinLanguageServer.kt`, add this method inside the class (e.g. just after `connectClient`), and add the import `import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment` (`Path` is already imported): + +```kotlin + /** Returns the [CompilationEnvironment] responsible for [file], or null if the compiler is not ready. */ + fun compilationEnvironmentFor(file: Path): CompilationEnvironment? = + compiler?.compilationEnvironmentFor(file) +``` + +- [ ] **Step 2: Create `OrganizeImportsAction` (no-op body)** + +Create `actions/OrganizeImportsAction.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.actions + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.CodeActionKind +import com.itsaky.androidide.lsp.models.Command +import com.itsaky.androidide.lsp.models.DocumentChange +import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +class OrganizeImportsAction : BaseKotlinCodeAction() { + override var titleTextRes: Int = R.string.action_organize_imports + override val id: String = "ide.editor.lsp.kt.organizeImports" + override var label: String = "" + + companion object { + private val logger = LoggerFactory.getLogger(OrganizeImportsAction::class.java) + } + + override suspend fun execAction(data: ActionData): List { + // Logic added in Task 5. + return emptyList() + } + + override fun postExec(data: ActionData, result: Any) { + super.postExec(data, result) + if (result !is List<*> || result.isEmpty()) return + + @Suppress("UNCHECKED_CAST") + result as List + + val client = data.languageClient ?: run { + logger.warn("No language client set. Cannot organize imports.") + return + } + val file = data.requireFile() + client.performCodeAction( + CodeActionItem( + title = label, + changes = listOf(DocumentChange(file = file.toPath(), edits = result)), + kind = CodeActionKind.QuickFix, + command = Command("", ""), // no post-action command (no CMD_FORMAT_CODE) + ) + ) + } +} +``` + +- [ ] **Step 3: Register the action** + +In `KotlinCodeActionsMenu.kt`, add the import `import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction` and add `OrganizeImportsAction(),` to the `actions` list after `AddImportAction()`: + +```kotlin + override val actions: List = + listOf( + CommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), + UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), + AddImportAction(), + OrganizeImportsAction(), + ) +``` + +- [ ] **Step 4: Compile** + +Run: `./gradlew :lsp:kotlin:compileV7DebugKotlin` +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt +git commit -m "ADFA-4614: register no-op Organize Imports action skeleton" +``` + +--- + +## Task 3: Pure import-organizing logic (`ImportOrganizer`) + +**Purpose:** Implement and fully test the sort + classification + redundancy + KDoc-keep + no-op logic, driven by a hand-built `ImportUsage` (no Analysis API involved). This is the bulk of the behavior and is deterministically testable. + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt` +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt` + +**Interfaces:** +- Produces: + - `data class ImportUsage(val usedFqNames: Set, val usedPackages: Set)` + - `internal fun organizedImportBlock(ktFile: KtFile, usage: ImportUsage): String?` — returns the canonical import-block text, or `null` if imports are already organized (no edit needed). Returned text has no leading/trailing newline; lines are joined with `System.lineSeparator()`. + - `internal val DEFAULT_STAR_PACKAGES: Set` +- Consumes: `KtFile` PSI (`importDirectives`, `importList`, `packageFqName`), `KtImportDirective` (`importedFqName`, `isAllUnder`, `aliasName`, `importPath`). + +- [ ] **Step 1: Write the failing tests** + +Create `ImportOrganizerTest.kt`. It extends `KtLspTest` only to get a real `KtFile` via `createSourceFile`; `organizedImportBlock` itself takes a synthetic `ImportUsage`, so no analysis runs. + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +class ImportOrganizerTest : KtLspTest() { + + private fun organize(content: String, usage: ImportUsage): String? { + val ktFile = createSourceFile("Sample.kt", content) + return env.project.read { organizedImportBlock(ktFile, usage) } + } + + @Test + fun `removes unused named import and keeps used one`() { + val result = organize( + """ + package p + import a.b.Used + import a.b.Unused + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("a.b.Used"), usedPackages = setOf("a.b")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `sorts imports lexicographically`() { + val result = organize( + """ + package p + import a.b.Zebra + import a.b.Apple + fun f(x: Zebra, y: Apple) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Zebra", "a.b.Apple"), setOf("a.b")), + ) + assertEquals("import a.b.Apple${'\n'}import a.b.Zebra", result) + } + + @Test + fun `keeps used wildcard, removes unused wildcard`() { + val result = organize( + """ + package p + import used.pkg.* + import unused.pkg.* + fun f(x: Thing) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("used.pkg.Thing"), usedPackages = setOf("used.pkg")), + ) + assertEquals("import used.pkg.*", result) + } + + @Test + fun `removes default-import-redundant named import`() { + val result = organize( + """ + package p + import kotlin.collections.List + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + // even though List resolves, it is redundant (default star package) + ImportUsage(setOf("a.b.Used", "kotlin.collections.List"), setOf("a.b", "kotlin.collections")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `removes same-package redundant import`() { + val result = organize( + """ + package p + import p.Sibling + import a.b.Used + fun f(x: Used, y: Sibling) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used", "p.Sibling"), setOf("a.b", "p")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `keeps aliased import from default package`() { + val result = organize( + """ + package p + import kotlin.collections.List as KList + fun f(x: KList) {} + """.trimIndent(), + ImportUsage(setOf("kotlin.collections.List"), setOf("kotlin.collections")), + ) + assertEquals("import kotlin.collections.List as KList", result) + } + + @Test + fun `keeps import referenced only in KDoc`() { + val result = organize( + """ + package p + import a.b.DocOnly + /** See [DocOnly] for details. */ + fun f() {} + """.trimIndent(), + ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet()), + ) + assertEquals("import a.b.DocOnly", result) + } + + @Test + fun `collapses exact duplicate imports`() { + val result = organize( + """ + package p + import a.b.Used + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used"), setOf("a.b")), + ) + assertEquals("import a.b.Used", result) + } + + @Test + fun `returns null when already organized`() { + val result = organize( + """ + package p + import a.b.Apple + import a.b.Zebra + fun f(x: Apple, y: Zebra) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Apple", "a.b.Zebra"), setOf("a.b")), + ) + assertNull(result) + } + + @Test + fun `no imports returns null`() { + val result = organize( + """ + package p + fun f() {} + """.trimIndent(), + ImportUsage(emptySet(), emptySet()), + ) + assertNull(result) + } +} +``` + +- [ ] **Step 2: Run the tests to verify they fail** + +Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportOrganizerTest*"` +Expected: FAIL to compile (`organizedImportBlock` / `ImportUsage` unresolved). + +- [ ] **Step 3: Implement `ImportOrganizer.kt`** + +Create `utils/ImportOrganizer.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.kdoc.psi.api.KDoc +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtImportDirective + +/** + * What a file's body actually uses, expressed as plain strings so nothing crosses an `analyze` + * lifetime boundary. Produced by [collectImportUsage]. + * + * @property usedFqNames importable fully-qualified names referenced by the body. + * @property usedPackages parent packages/objects of used symbols (for wildcard matching). + */ +internal data class ImportUsage( + val usedFqNames: Set, + val usedPackages: Set, +) + +/** JVM packages that Kotlin imports with a wildcard by default; explicit named imports from these are redundant. */ +internal val DEFAULT_STAR_PACKAGES: Set = setOf( + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "kotlin.jvm", + "java.lang", +) + +private val KDOC_LINK = Regex("""\[([^\]\s]+)]""") + +/** + * Computes the canonical import block for [ktFile] given [usage]: unused/redundant imports removed, + * survivors deduped and lexicographically sorted. Returns null when the imports are already in that + * exact form (no edit needed). The returned text has no surrounding newlines. + */ +internal fun organizedImportBlock(ktFile: KtFile, usage: ImportUsage): String? { + val directives = ktFile.importDirectives + if (directives.isEmpty()) return null + + val filePackage = ktFile.packageFqName.asString() + val kdocNames = collectKDocLinkNames(ktFile) + + val newLines = directives + .filter { keepImport(it, usage, filePackage, kdocNames) } + .mapNotNull { it.importPath?.pathStr?.let { path -> "import $path" } } + .distinct() + .sorted() + + val currentLines = directives.mapNotNull { it.importPath?.pathStr?.let { path -> "import $path" } } + + if (newLines == currentLines) return null + return newLines.joinToString(System.lineSeparator()) +} + +private fun keepImport( + directive: KtImportDirective, + usage: ImportUsage, + filePackage: String, + kdocNames: Set, +): Boolean { + val fqName = directive.importedFqName ?: return true // malformed import -> keep (conservative) + val fqNameStr = fqName.asString() + val alias = directive.aliasName + val shortName = alias ?: fqName.shortName().asString() + + // Conservative: keep anything referenced by short name/alias in a KDoc link. + if (shortName in kdocNames) return true + + if (directive.isAllUnder) { + // Wildcard: keep iff some used symbol lives in this package/object. + return fqNameStr in usage.usedPackages + } + + val parentPackage = fqName.parent().asString() + // Redundant named imports (only when not aliased — an alias is meaningful). + if (alias == null && parentPackage in DEFAULT_STAR_PACKAGES) return false + if (alias == null && parentPackage == filePackage) return false + + return fqNameStr in usage.usedFqNames +} + +private fun collectKDocLinkNames(ktFile: KtFile): Set = + PsiTreeUtil.collectElementsOfType(ktFile, KDoc::class.java) + .flatMap { kdoc -> KDOC_LINK.findAll(kdoc.text).map { it.groupValues[1].substringAfterLast('.') } } + .toSet() +``` + +- [ ] **Step 4: Run the tests to verify they pass** + +Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportOrganizerTest*"` +Expected: PASS (all 10 tests). If `KDoc` or `PsiTreeUtil` imports fail to resolve, confirm the package path (`org.jetbrains.kotlin.kdoc.psi.api.KDoc`, `org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil`) against the classpath and adjust. + +- [ ] **Step 5: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt +git commit -m "ADFA-4614: add pure import-organizing logic with tests" +``` + +--- + +## Task 4: Analysis-API usage collection (`collectImportUsage`) + +**Purpose:** Implement the one function that touches the resolution API, using the surface confirmed in Task 1. Test it against real fixtures so operator/convention coverage is verified. + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt` +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt` + +**Interfaces:** +- Produces: `internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage`. +- Consumes: `ImportUsage` (Task 3); the resolution API confirmed in Task 1. + +- [ ] **Step 1: Write the failing tests** + +Create `ImportUsageCollectorTest.kt`. Each test builds a fixture, runs `collectImportUsage` inside `analyzeMaybeDangling`, and asserts membership (not exact set equality — the set also contains stdlib/implicit symbols). + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.psi.KtFile +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +class ImportUsageCollectorTest : KtLspTest() { + + private fun usageOf(ktFile: KtFile): ImportUsage = + env.project.read { analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } } + + @Test + fun `type reference is recorded as used`() { + val ktFile = createSourceFile( + "UseType.kt", + """ + package p + fun f(): java.io.File? = null + fun g() { val x: java.io.File = java.io.File("a") } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("java.io.File" in usage.usedFqNames) + assertTrue("java.io" in usage.usedPackages) + } + + @Test + fun `top-level function call is recorded as used`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + fun topLevelHelper() {} + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseFn.kt", + """ + package p + import lib.topLevelHelper + fun f() { topLevelHelper() } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.topLevelHelper" in usage.usedFqNames) + } + + @Test + fun `operator function used via symbol is recorded`() { + createSourceFile( + "lib/Ops.kt", + """ + package lib + class Money + operator fun Money.plus(other: Money): Money = this + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseOp.kt", + """ + package p + import lib.Money + import lib.plus + fun f(a: Money, b: Money) { val c = a + b } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.plus" in usage.usedFqNames) + } + + @Test + fun `unreferenced import is absent from usage`() { + createSourceFile( + "lib/Extra.kt", + """ + package lib + class Extra + """.trimIndent(), + ) + val ktFile = createSourceFile( + "NoUse.kt", + """ + package p + import lib.Extra + fun f() {} + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertFalse("lib.Extra" in usage.usedFqNames) + } +} +``` + +- [ ] **Step 2: Run to verify they fail** + +Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportUsageCollectorTest*"` +Expected: FAIL to compile (`collectImportUsage` unresolved). + +- [ ] **Step 3: Implement `ImportUsageCollector.kt` (using Task 1's confirmed API)** + +Create `utils/ImportUsageCollector.kt`. The imports/resolution calls below are the candidate confirmed by the Task 1 spike — substitute the exact ones recorded there: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtImportList +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression +import org.jetbrains.kotlin.psi.KtPropertyDelegate +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getParentOfType + +/** + * Collects the importable fq-names (and their packages) referenced by [ktFile]'s body. MUST be + * called inside [analyzeMaybeDangling]. Returns only plain strings, so nothing escapes the analyze + * lifetime. Anything that fails to resolve simply doesn't join the used set (safe: leads to keeping + * an import, never removing a used one). + */ +internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { + val usedFqNames = HashSet() + val usedPackages = HashSet() + + fun record(symbol: KaSymbol?) { + val fq = symbol?.importableFqNameString() ?: return + usedFqNames += fq + val pkg = fq.substringBeforeLast('.', missingDelimiterValue = "") + if (pkg.isNotEmpty()) usedPackages += pkg + } + + // 1) Plain name / type references (excluding the import list itself). + ktFile.collectDescendantsOfType().forEach { ref -> + if (ref.getParentOfType(strict = false) != null) return@forEach + runCatching { record(ref.mainReference.resolveToSymbol()) } + } + + // 2) Convention / operator call sites (no textual name reference). + ktFile.collectDescendantsOfType().forEach { element -> + val isConvention = element is KtOperationReferenceExpression || + element is KtArrayAccessExpression || + element is KtCallExpression || + element is KtForExpression || + element is KtDestructuringDeclaration || + element is KtPropertyDelegate + if (!isConvention) return@forEach + runCatching { + record(element.resolveToCall()?.successfulFunctionCallOrNull()?.symbol) + } + } + + return ImportUsage(usedFqNames, usedPackages) +} + +private fun KaSymbol.importableFqNameString(): String? = when (this) { + is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString() + is KaCallableSymbol -> callableId?.asSingleFqName()?.asString() + else -> null +} +``` + +Notes for the implementer: +- If the Task 1 spike found `KaSymbol.importableFqName` directly, replace `importableFqNameString()` with it. +- The `resolveToCall()` overloads are members of `KaSession` and require the analyze receiver — that is why this whole function is a `KaSession` extension. +- `getParentOfType` guards against crediting the import statements themselves. +- `runCatching` prevents one unresolved node from aborting the whole pass; a swallowed failure just omits that node (safe direction). + +- [ ] **Step 4: Run to verify they pass** + +Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportUsageCollectorTest*"` +Expected: PASS (4 tests). If the operator test fails (`lib.plus` absent), the convention-resolution branch needs adjustment — verify with the Task 1 spike which element type carries the resolvable call for `a + b` (it may be the `KtBinaryExpression` itself rather than its `KtOperationReferenceExpression`); add that element type to the `isConvention` set. + +- [ ] **Step 5: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt +git commit -m "ADFA-4614: collect used importable names via Analysis API" +``` + +--- + +## Task 5: Wire the logic into the action + integration test + +**Purpose:** Replace the no-op `execAction` with the real computation and verify end-to-end on a fixture. + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt` +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt` (add an end-to-end helper case) — or a new `OrganizeImportsEndToEndTest.kt`. + +**Interfaces:** +- Consumes: `KotlinLanguageServer.compilationEnvironmentFor` (Task 2), `organizedImportBlock` (Task 3), `collectImportUsage` (Task 4), `TextRange.toRange` (existing `EditExts`). +- Produces: a fully functional `OrganizeImportsAction`. + +- [ ] **Step 1: Write the failing end-to-end test** + +Create `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt`. This exercises the full compute path (fetch KtFile → analyze → organize → build edit text) without the Android `ActionData`, by calling a small internal helper `computeOrganizeEdit` that Step 3 extracts from the action. + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Test + +class OrganizeImportsEndToEndTest : KtLspTest() { + + @Test + fun `removes unused import end to end`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + class Used + class Unused + """.trimIndent(), + ) + val ktFile = createSourceFile( + "Main.kt", + """ + package p + import lib.Used + import lib.Unused + fun f(x: Used) {} + """.trimIndent(), + ) + + val block = env.project.read { + val usage = analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } + organizedImportBlock(ktFile, usage) + } + assertEquals("import lib.Used", block) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*OrganizeImportsEndToEndTest*"` +Expected: PASS already if Tasks 3+4 are correct (this test only uses their public functions). If it FAILS, the failure is a real integration bug between `collectImportUsage` and `organizedImportBlock` — fix before proceeding. (This test locks the contract the action relies on.) + +- [ ] **Step 3: Implement the real `execAction`** + +Replace the body of `execAction` in `OrganizeImportsAction.kt` and add the required imports: + +```kotlin + override suspend fun execAction(data: ActionData): List { + val server = data.get() ?: return emptyList() + val file = data.requireFile() + val nioPath = file.toPath() + + val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() + + // Fetch the current KtFile BEFORE entering project.read (deadlock rule). + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() + if (ktFile.importDirectives.isEmpty()) return emptyList() + + return env.project.read { + val usage = analyzeMaybeDangling(ktFile) { 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)) + } + } +``` + +Add these imports to the file: + +```kotlin +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +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 +import com.itsaky.androidide.models.Range +``` + +- [ ] **Step 4: Compile and run the full module test suite** + +Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest` +Expected: BUILD SUCCESSFUL; all `ImportOrganizerTest`, `ImportUsageCollectorTest`, `OrganizeImportsEndToEndTest`, and pre-existing tests pass. + +- [ ] **Step 5: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt +git commit -m "ADFA-4614: compute and apply organized imports in the action" +``` + +--- + +## Task 6: Manual verification in-app + +**Purpose:** Confirm the action behaves correctly in the running IDE (the only place the full `ActionData`/editor/`performCodeAction` path runs). + +**Files:** none (verification only). + +- [ ] **Step 1: Build and install the app per the project's run workflow** (use the `/run` skill or the standard install task). + +- [ ] **Step 2: Open a `.kt` file with messy imports** — include: an unused named import, a used import, an aliased import, an unused wildcard, a used wildcard, and an import used only via an operator (e.g. a custom `plus`). Trigger the code-actions menu and select **Organize imports**. + +- [ ] **Step 3: Verify** the unused named import and unused wildcard are removed; the used import, alias, used wildcard, and operator import remain; the surviving imports are alphabetically sorted; and the file still compiles. Confirm running it again is a no-op (no edit / no cursor jump). + +- [ ] **Step 4: Record the result** (pass/fail + any surprises) in the PR description. No commit. + +--- + +## Self-Review + +**Spec coverage:** +- Menu action, Java-parity, `action_organize_imports` → Task 2. ✓ +- File access via `getCurrentKtFile`, deadlock rule, `analyzeMaybeDangling`, no lifetime escape → Tasks 4, 5 + Global Constraints. ✓ +- Semantic used-detection incl. operator/convention categories → Task 4 (`collectImportUsage`) + its operator test. ✓ +- Wildcard: remove when unused, no expansion → Task 3 `keepImport` (wildcard branch) + test. ✓ +- Redundant (default + same-package) removal → Task 3 + two tests. ✓ +- Canonical sort, single block, whole-block replace, no-op when unchanged → Task 3 (`organizedImportBlock`) + tests; edit built in Task 5. ✓ +- KDoc conservative-keep → Task 3 `collectKDocLinkNames` + test. ✓ +- No `CMD_FORMAT_CODE` → Task 2 `postExec` (`Command("", "")`). ✓ +- Spike de-risking the resolution API → Task 1. ✓ +- Testing via `:lsp:kotlin:testV7DebugUnitTest` → every task. ✓ +- Out-of-scope items (expansion, collapse, diagnostic, settings) → not implemented. ✓ + +**Deviation from spec (flag for reviewer):** the spec said derive default imports "via `KaDefaultImports`"; this plan uses a documented constant `DEFAULT_STAR_PACKAGES` (the stable JVM default-import package set) instead, to keep `organizedImportBlock` pure and fully unit-testable without an analysis session. The Task 1 spike can cross-check the constant against `KaDefaultImports` if desired. + +**Placeholder scan:** no TBD/TODO; every code step contains full code. The only intentionally-candidate code is Task 4's resolution calls, gated by Task 1's spike with explicit alternates. ✓ + +**Type consistency:** `ImportUsage(usedFqNames, usedPackages)`, `organizedImportBlock(ktFile, usage): String?`, `KaSession.collectImportUsage(ktFile): ImportUsage`, `OrganizeImportsAction.execAction: List` used consistently across Tasks 3–5. ✓ diff --git a/docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md b/docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md new file mode 100644 index 0000000000..4631866c16 --- /dev/null +++ b/docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md @@ -0,0 +1,217 @@ +# Design: Organize Imports code action for the Kotlin LSP (ADFA-4614) + +- **Ticket:** [ADFA-4614](https://appdevforall.atlassian.net/browse/ADFA-4614) — *K2-LSP: Code action: Organize imports* +- **Parent:** ADFA-3317 (Integrate K2 compiler with LSP) · **Related:** ADFA-3323 (code-action framework, Done) +- **Branch / worktree:** `feat/ADFA-4614` at `.claude/worktrees/ADFA-4614` (currently identical to its base `feat/ADFA-3322`, which introduced the file-management refactor this design depends on). +- **Date:** 2026-07-09 + +## Goal + +Add a self-contained code action for `.kt` files that rewrites the file's import list into canonical order and removes imports that are not used, computed entirely in-process via the Kotlin Analysis API. It has **no** dependency on unused-import diagnostics (none exist in the LSP, and the FIR compiler defines no `UNUSED_IMPORT` factory) and cannot use IntelliJ's import optimizer (`KotlinOptimizeImportsFacility` / `KotlinImportOptimizer` are IDEA-plugin classes, not on our classpath — the vendored JAR is only the compiler + Analysis API standalone stack). + +## Non-goals (explicitly out of scope) + +- Wildcard **expansion** (`import foo.*` → explicit named imports). +- Wildcard **collapse** / count-threshold behavior (many named imports → `foo.*`). +- A standalone unused-import diagnostic or editor squiggle. +- Any user-facing configuration/settings surface. + +## User-facing behavior + +- Always present in the code-actions menu for Kotlin files (parity with Java's `OrganizeImportsAction`), titled from the existing string `R.string.action_organize_imports` ("Organize imports"), reachable from `lsp/kotlin` via `com.itsaky.androidide.resources.R`. +- On invoke: sorts the surviving imports into canonical order and drops unused/redundant imports, applied as a **single whole-block-replace edit**. +- **No-op when already organized:** if the regenerated import block is byte-identical to the current one, emit no edit (do not dirty the buffer). +- Runs off the UI thread; on any analysis failure or cancellation it aborts with **zero** edits — never a partial rewrite. +- **No `CMD_FORMAT_CODE`** is triggered after applying edits (output is already canonical; reformatting the whole file could reflow unrelated code). This intentionally differs from `AddImportAction`, which does format. + +## Architecture & placement + +All paths are within the `feat/ADFA-4614` worktree. + +- **New action:** `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt`, extending `BaseKotlinCodeAction` (a plain `EditorActionItem`, `location = EDITOR_CODE_ACTIONS`, `requiresUIThread = false`, already gated on `DocumentUtils.isKotlinFile`). `titleTextRes = R.string.action_organize_imports`. +- **Registration:** add `OrganizeImportsAction()` to `KotlinCodeActionsMenu.actions` (`lsp/kotlin/.../KotlinCodeActionsMenu.kt`, at the package root). It is a plain menu action — **not** diagnostic-driven, so `prepare()` needs no `DiagnosticItem` gate. +- **Core algorithm lives in a pure, testable helper** (see "Design for testability"), e.g. `utils/ImportOrganizer.kt`, invoked by the action. The action wrapper stays thin. + +### File access (post-ADFA-3322 refactor — the key change from `stage`) + +The old `getOpenedKtFile` / `openedFiles` map and manual `parser.createFile` reparsing are **gone**. The action obtains its `KtFile` through the new single-flight, version-stamped cache: + +```kotlin +// off the UI thread (execAction is suspend), NOT inside project.read: +val ktFile = env.ktSymbolIndex.getCurrentKtFile(path).get() ?: return /* no-op */ +``` + +- `getCurrentKtFile(path: Path): CompletableFuture` (in `compiler/index/KtSymbolIndex.kt`) reparses live document contents (`FileManager.getDocumentContents`) internally, so **callers do not reparse**. +- **Deadlock rule (mandatory):** fetch the `KtFile` via `.get()`/`.await()` *first*, then do all analysis inside `env.project.read { … }`. The blocking fetch must never run inside `project.read` — its refresh needs `project.write`, and the RW lock is non-upgradeable. + +### Analysis entry point + +Do **not** call `analyze()` directly. Use the repo wrapper: + +```kotlin +env.project.read { + analyzeMaybeDangling(ktFile) { /* KaSession receiver */ ... } +} +``` + +- `analyzeMaybeDangling` (in `compiler/modules/KtFileExts.kt`) serializes all Analysis-API access under `withAnalysisLock` and handles dangling/copy files. +- Analysis runs under the shared `read` lock — it must never call `write`. +- **No `KaLifetimeOwner` may escape the analyze block.** Extract plain data (FqName strings, package strings, booleans) *inside* the block; return only that. + +### Edit generation & dispatch + +- Build **one** `TextEdit` whose range covers the existing import list (compute via the `TextRange.toRange(containingFile)` helper in `utils/EditExts.kt`) and whose `newText` is the regenerated block. If there is no import list, no-op. +- `execAction` computes the `TextEdit`; `postExec` wraps it in a single `CodeActionItem(title, changes = listOf(DocumentChange(file = nioPath, edits = edits)), kind = CodeActionKind.QuickFix)` and calls `data.languageClient?.performCodeAction(item)` — the `AddImportAction` dispatch pattern, but with **no chooser dialog** (single action) and **no `Command.CMD_FORMAT_CODE`**. + - *Note:* use `CodeActionKind.SourceOrganizeImports` if that constant exists in `lsp/models` `CodeActions.kt`; otherwise `QuickFix`. Confirm during implementation. + +## Core algorithm — semantic used-import detection + +Inside a single `analyzeMaybeDangling(ktFile) { … }` pass (under `project.read`): + +### 1. Collect the "used" sets + +Traverse the file body (**excluding** the import list and package directive) and accumulate, as plain data: + +- `usedFqNames: Set` — importable FqName of each used top-level/extension callable, class, object, typealias, enum entry. +- `usedPackages: Set` — the parent package (or containing object FqName) of each used importable symbol (for wildcard matching). + +Sources that must all be covered: + +- **Name references** (`KtNameReferenceExpression` / type references): resolve to symbol → derive importable FqName. (Exact API confirmed by the Step 0 spike — see Risks.) +- **Convention / operator call sites** (no textual name; resolved via `resolveToCall()?.successfulFunctionCallOrNull()?.symbol`). The implementation MUST enumerate every category: + - binary & unary operators (`a + b`, `-a`, `a in b`, comparisons via `compareTo`, `==` via `equals`) + - array access get/set (`a[i]`, `a[i] = v`) + - `invoke` conventions (`a()` where `a` is not a function name) + - `for` loops (`iterator`, `hasNext`, `next`) + - destructuring (`val (x, y) = p` → `component1`, `component2`, …) + - delegated properties (`by lazy` → `getValue` / `setValue` / `provideDelegate`) + +Only **top-level / extension callables, classifiers, and enum entries** credit an import; member callables (imported via their class) do not. Fully-qualified references must not credit the corresponding import. + +### 2. Classify each import directive + +For each `KtImportDirective` in `ktFile.importDirectives`: + +- **Named / aliased** (`import a.b.C`, `import a.b.C as D`): keep iff `importedFqName ∈ usedFqNames`. (An aliased reference resolves to the real FqName, so matching on `importedFqName` handles aliases.) +- **Wildcard** (`import foo.*`): keep iff `usedPackages` contains its parent FqName (package or object). **Unused wildcards are removed**; none are expanded. +- **Exact duplicates:** collapse to the first occurrence. +- **Redundant even if resolvable — removed** (confirmed in scope): + - covered by default imports (`kotlin.*`, `kotlin.collections.*`, etc. — via `KaDefaultImports` data available on the classpath), and + - same-package imports (imported symbol lives in the file's own package). + +### 3. KDoc conservative-keep (accepted approximation) + +KDoc link references (`[Foo]`) are not part of normal resolution. To avoid removing a doc-only import, **keep** any import whose short name/alias appears as a KDoc link identifier. This may under-remove in rare cases; it never wrong-removes. + +### 4. Canonical order + +The surviving imports are emitted as a **single block**, **pure lexicographic sort of the full import path string** (alias suffix included), with **no grouping and no blank lines** — the Kotlin / ktlint / IntelliJ default, consistent with the existing alphabetical insertion in `EditExts.insertImport`. (`foo.*` sorts before `foo.Bar` under string comparison, which is correct.) + +## Edge cases & failure handling + +- No import list, or empty body after the package directive → no-op. +- File with syntax errors: still attempt; unresolved references simply don't join the used set, so we lean toward **keeping** imports (never remove on failed/ambiguous resolution). +- Cancellation (cancel checker) → abort, no edit. +- Regenerated block identical to current → no edit (no-op). +- Whole-block replacement discards comments/blank lines interleaved *within* the import section (rare; IntelliJ likewise reorders freely). Accepted. + +## Design for testability + +- The algorithm is a pure helper — e.g. `ImportOrganizer` — taking `(KaSession, KtFile)` (plus any needed env data) and returning the removable-import decision and the regenerated block text / `TextEdit`. No Android `ActionData`/`Context` dependency. +- The `OrganizeImportsAction` is a thin wrapper: `execAction` fetches the `KtFile`, enters `project.read` + `analyzeMaybeDangling`, calls the helper, returns the `TextEdit`; `postExec` dispatches. + +## Testing + +- **Framework:** Robolectric + JUnit4, mirroring `KtLspTest` (base class with `createSourceFile(...)`, `env.analyze { … }`) and `CurrentKtFileCacheTest` (document open/edit + `getCurrentKtFile` plumbing). There is no existing code-action test to copy, so tests target the `ImportOrganizer` helper directly. +- **Gradle task:** `./gradlew :lsp:kotlin:testV7DebugUnitTest` (flavored Android library; V7 = `armeabi-v7a`). +- **Cases:** + - unused named import removed; used named import kept + - alias used / alias unused + - operator import kept (`a + b`) + - delegate import kept (`by lazy`) + - destructuring component import kept + - `for`/`iterator`, array-access, `invoke` convention imports kept + - wildcard removed when unused; wildcard kept when used + - exact duplicate collapsed + - default-import-redundant removed; same-package-redundant removed + - KDoc-only import kept (conservative) + - correct canonical sort order (including `foo.*` vs `foo.Bar`) + - no-op when already canonical + - syntax-error file → conservative (nothing wrongly removed) + +## Implementation plan (high level) + +- **Step 0 — De-risking spike (throwaway).** In the `lsp/kotlin` test harness, inside `analyzeMaybeDangling`, confirm the exact Analysis-API surface on our vendored JAR: + 1. a plain type reference and a top-level call each yield the expected importable FqName (via `resolveToSymbol`/`classId`/`callableId`/any `importableFqName` — whichever actually exists); + 2. `resolveToCall()?.successfulFunctionCallOrNull()?.symbol` returns the operator function for `+`, `[]`, `by lazy`, and destructuring; + 3. a used symbol maps to its package (wildcard case). + Outcome gates the approach: green → proceed; surprising → adjust to the real API; worst case → narrow the rule for any category the API can't cleanly express (keep rather than remove, and log). +- **Step 1** — `ImportOrganizer` helper + unit tests (TDD against the fixture cases above). +- **Step 2** — `OrganizeImportsAction` wrapper + registration in `KotlinCodeActionsMenu`. +- **Step 3** — manual verification in-app; full test run on `:lsp:kotlin:testV7DebugUnitTest`. + +## Primary risk + +Deriving an importable FqName from a resolved symbol is **unexercised in this repo** (no `resolveToSymbol` / `importableFqName` call sites; resolution elsewhere goes through scopes and the custom symbol index), and the vendored Analysis API is a moving `2.3.255-SNAPSHOT`. Compile-level risk (wrong method/property names) is cheap to discover; the correctness risk is missing a convention-call category, which would wrongly remove a used operator/delegate import and break the build. Mitigations: the Step 0 spike pins the API; the explicit convention checklist prevents missed categories; the "keep unless provably used" rule biases all residual error toward harmless under-removal. `resolveToCall()` + `successfulFunctionCallOrNull()` is already proven in the repo (signature help), which covers the operator path. + +## Confirmed resolution API (Step 0 spike, 2026-07-09) + +The Step 0 spike (`ResolutionSpikeTest`, throwaway, deleted after this run) confirmed the brief's candidate API compiled and passed **verbatim, with zero alternates needed**, against the vendored `2.3.255-SNAPSHOT` Analysis API. Task 4 should use these shapes as-is. + +**(a) Name reference → `KtReference` → resolved symbol:** + +```kotlin +import org.jetbrains.kotlin.idea.references.mainReference +// inside a KaSession (e.g. analyzeMaybeDangling { ... }) +val sym = someNameReferenceExpression.mainReference.resolveToSymbol() +``` + +`KtNameReferenceExpression.mainReference` (the `org.jetbrains.kotlin.idea.references.mainReference` extension) resolved directly; no fallback to `analysis.api.fir.references.*` or manual `.references.filterIsInstance()` was required. + +**(b) Resolved `KaSymbol` → importable FqName string:** + +```kotlin +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol + +val fq = when (sym) { + is KaClassLikeSymbol -> sym.classId?.asSingleFqName()?.asString() + is KaCallableSymbol -> sym.callableId?.asSingleFqName()?.asString() + else -> null +} +``` + +No direct `KaSymbol.importableFqName` property was tried/needed — the `classId`/`callableId` branch from the brief compiled and produced the correct value on the first try (`java.io.File` for the `File` type reference). Both `ClassId` and `CallableId` expose `asSingleFqName()`. + +**(c) Operator/convention call → function symbol:** + +The resolvable call sits on the **`KtBinaryExpression`** itself (not `KtOperationReferenceExpression`) — `KtElement.resolveToCall()` is available directly on the binary expression: + +```kotlin +import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull +import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol + +val opSym = someBinaryExpression.resolveToCall()?.successfulFunctionCallOrNull()?.symbol +val callableFq = (opSym as? KaCallableSymbol)?.callableId?.asSingleFqName()?.asString() +``` + +For `BigInteger.ONE + BigInteger.ONE`, this resolved to `kotlin.plus` (the built-in numeric `plus` operator). The important confirmation is that the chain resolves non-null on a `KtBinaryExpression` and yields a `KaCallableSymbol` with a `callableId`. Same chain shape (`resolveToCall()?.successfulFunctionCallOrNull()?.symbol`) is expected to work for `[]`, `by lazy`, and destructuring `KtElement`s (per-repo precedent already used in signature help). + +**(d) File package name from PSI:** + +```kotlin +val pkg = ktFile.packageFqName.asString() +``` + +No analysis session needed — pure PSI. Confirmed value: `p` for `package p`. + +**Logged SPIKE output (from the passing test run):** + +``` +SPIKE type fq = java.io.File +SPIKE plus callableId = kotlin.plus +SPIKE package = p +``` + +**Test harness note:** run inside `env.project.read { analyzeMaybeDangling(ktFile) { ... } }` (imports: `com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling`, `com.itsaky.androidide.lsp.kotlin.compiler.read`), using a `KtFile` from `KtLspTest.createSourceFile(name, content)`. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt index 54cb1b1b41..c170fc7a0f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt @@ -11,10 +11,13 @@ import org.jetbrains.kotlin.psi.KtImportDirective * * @property usedFqNames importable fully-qualified names referenced by the body. * @property usedPackages parent packages/objects of used symbols (for wildcard matching). + * @property unresolvedNames short names of body references that failed to resolve; an import + * matching one of these is kept, since a resolution failure can't prove the import unused. */ internal data class ImportUsage( val usedFqNames: Set, val usedPackages: Set, + val unresolvedNames: Set = emptySet(), ) /** JVM packages that Kotlin imports with a wildcard by default; explicit named imports from these are redundant. */ @@ -71,6 +74,9 @@ private fun keepImport( // Conservative: keep anything referenced by short name/alias in a KDoc link. if (shortName in kdocNames) return true + // Conservative: an unresolved body reference by this short name can't prove the import dead. + if (shortName in usage.unresolvedNames) return true + if (directive.isAllUnder) { // Wildcard: keep iff some used symbol lives in this package/object. return fqNameStr in usage.usedPackages diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt index da1c09510b..d1193029dc 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt @@ -24,12 +24,14 @@ import org.jetbrains.kotlin.psi.psiUtil.getParentOfType /** * Collects the importable fq-names (and their packages) referenced by [ktFile]'s body. MUST be * called inside [analyzeMaybeDangling]. Returns only plain strings, so nothing escapes the analyze - * lifetime. Anything that fails to resolve simply doesn't join the used set (safe: leads to keeping - * an import, never removing a used one). + * lifetime. A reference that fails to resolve doesn't join the used set; instead its short name is + * recorded in [ImportUsage.unresolvedNames] so its import is kept. Both paths are safe: they lead to + * keeping an import, never removing a used one. */ internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { val usedFqNames = HashSet() val usedPackages = HashSet() + val unresolvedNames = HashSet() fun record(symbol: KaSymbol?) { val fq = symbol?.importableFqNameString() ?: return @@ -42,10 +44,14 @@ internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { symbols?.forEach(::record) } - // 1) Plain name / type references (excluding the import list itself). + // 1) Plain name / type references (excluding the import list itself). A null (or thrown) + // resolution is treated as unresolved and its short name kept, so a used-but-unresolvable + // reference never drops its import. A non-null, non-importable symbol (local, param) is a + // clean resolve: it records nothing and is not unresolved. ktFile.collectDescendantsOfType().forEach { ref -> if (ref.getParentOfType(strict = false) != null) return@forEach - runCatching { record(ref.mainReference.resolveToSymbol()) } + val symbol = runCatching { ref.mainReference.resolveToSymbol() }.getOrNull() + if (symbol != null) record(symbol) else unresolvedNames += ref.getReferencedName() } // 1b) Implicit-convention references that carry more than one resolution target and so don't @@ -82,7 +88,7 @@ internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { } } - return ImportUsage(usedFqNames, usedPackages) + return ImportUsage(usedFqNames, usedPackages, unresolvedNames) } private fun KaSymbol.importableFqNameString(): String? = when (this) { diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt index 0bde4aaf70..8ecbbfd5ac 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt @@ -113,6 +113,21 @@ class ImportOrganizerTest : KtLspTest() { assertEquals("import a.b.DocOnly", result) } + @Test + fun `keeps import matching an unresolved reference`() { + val result = organize( + """ + package p + import a.b.Mystery + import x.y.Unused + fun f(m: Mystery) {} + """.trimIndent(), + // Mystery didn't resolve, so it's absent from usedFqNames but present as unresolved. + ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet(), unresolvedNames = setOf("Mystery")), + ) + assertEquals("import a.b.Mystery", result) + } + @Test fun `collapses exact duplicate imports`() { val result = organize( diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt index 34a5ef69ab..a6044627b5 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt @@ -93,6 +93,21 @@ class ImportUsageCollectorTest : KtLspTest() { assertFalse("lib.Extra" in usage.usedFqNames) } + @Test + fun `unresolved reference is recorded by short name, not as used`() { + val ktFile = createSourceFile( + "Unresolved.kt", + """ + package p + import a.b.Mystery + fun f(m: Mystery) {} + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertFalse("a.b.Mystery" in usage.usedFqNames) + assertTrue("Mystery" in usage.unresolvedNames) + } + @Test fun `array-access get operator used via subscript is recorded`() { createSourceFile( From 34d4e64036e1852e39e5814bc81a96998ae4c7a5 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 15 Jul 2026 17:18:44 +0000 Subject: [PATCH 29/31] ADFA-4614: keep imports used only via a constructor An import referenced only through a constructor call (`Foo()`), an annotation (`@Foo`), or a typealias used as a constructor (`Alias()`) was dropped by Organize Imports, breaking compilation. All three resolve to a KaConstructorSymbol, whose own callableId is null, so importableFqNameString returned null and the class was never recorded as used. Map a constructor to its containing class instead. Add regression tests for the collector and end-to-end behavior. --- .../lsp/kotlin/utils/ImportUsageCollector.kt | 5 ++ .../kotlin/utils/ImportUsageCollectorTest.kt | 42 +++++++++++ .../utils/OrganizeImportsEndToEndTest.kt | 69 +++++++++++++++++++ 3 files changed, 116 insertions(+) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt index d1193029dc..243cc987c2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt @@ -5,6 +5,7 @@ import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull import org.jetbrains.kotlin.analysis.api.resolution.symbol import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.psi.KtArrayAccessExpression @@ -92,6 +93,10 @@ internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { } private fun KaSymbol.importableFqNameString(): String? = when (this) { + // A constructor's own callableId is null, so it must map to its containing class -- the name + // that's actually imported. Covers `Foo()` calls and `@Foo` annotations (both resolve to the + // constructor). Must precede the KaCallableSymbol branch, which a constructor also matches. + is KaConstructorSymbol -> containingClassId?.asSingleFqName()?.asString() is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString() is KaCallableSymbol -> callableId?.asSingleFqName()?.asString() else -> null diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt index a6044627b5..6676c3a4c6 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt @@ -203,6 +203,48 @@ class ImportUsageCollectorTest : KtLspTest() { assertTrue("lib.component2" in usage.usedFqNames) } + @Test + fun `constructor-only reference records the class as used`() { + createSourceFile( + "lib/Widget.kt", + """ + package lib + class Widget(val n: Int) + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseCtor.kt", + """ + package p + import lib.Widget + fun f() { val w = Widget(1) } + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.Widget" in usage.usedFqNames) + } + + @Test + fun `annotation-only reference records the annotation class as used`() { + createSourceFile( + "lib/Marker.kt", + """ + package lib + annotation class Marker + """.trimIndent(), + ) + val ktFile = createSourceFile( + "UseAnnotation.kt", + """ + package p + import lib.Marker + @Marker fun f() {} + """.trimIndent(), + ) + val usage = usageOf(ktFile) + assertTrue("lib.Marker" in usage.usedFqNames) + } + @Test fun `getValue operator used via property delegation is recorded`() { createSourceFile( diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt index 1d2d3b1967..1e269c6bfd 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt @@ -5,6 +5,7 @@ import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest import com.itsaky.androidide.models.Position import com.itsaky.androidide.models.Range import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue import org.junit.Test class OrganizeImportsEndToEndTest : KtLspTest() { @@ -44,4 +45,72 @@ class OrganizeImportsEndToEndTest : KtLspTest() { edits.single().range, ) } + + @Test + fun `keeps constructor-only import`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + class Widget(val n: Int) + """.trimIndent(), + ) + createSourceFile( + "Main.kt", + """ + package p + import lib.Widget + fun f() { val w = Widget(1) } + """.trimIndent(), + ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) + // Already organized -> no edit. A dropped import would produce a rewrite that removes it. + assertTrue("constructor-only import must survive", edits.isEmpty()) + } + + @Test + fun `keeps annotation-only import`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + annotation class Marker + """.trimIndent(), + ) + createSourceFile( + "Main.kt", + """ + package p + import lib.Marker + @Marker fun f() {} + """.trimIndent(), + ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) + assertTrue("annotation-only import must survive", edits.isEmpty()) + } + + @Test + fun `keeps typealias-only import used as constructor`() { + createSourceFile( + "lib/Lib.kt", + """ + package lib + class RealList + typealias Alias = RealList + """.trimIndent(), + ) + createSourceFile( + "Main.kt", + """ + package p + import lib.Alias + fun f() { val a = Alias() } + """.trimIndent(), + ) + val mainPath = env.sourceRoots.first().resolve("Main.kt") + val edits = OrganizeImportsAction().computeOrganizeEdit(env, mainPath) + assertTrue("typealias-only import used as constructor must survive", edits.isEmpty()) + } } From c44d0fb9b7250f188ed4e1544f91a8e665fbb0b4 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 16 Jul 2026 11:34:45 +0530 Subject: [PATCH 30/31] chore: remove specs/plans Signed-off-by: Akash Yadav --- ...26-06-25-enhance-ai-assistant-plugin-ui.md | 1040 ----------------- .../2026-07-09-kotlin-organize-imports.md | 896 -------------- ...26-07-09-kotlin-organize-imports-design.md | 217 ---- 3 files changed, 2153 deletions(-) delete mode 100644 docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md delete mode 100644 docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md delete mode 100644 docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md diff --git a/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md b/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md deleted file mode 100644 index 053ca17ed3..0000000000 --- a/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md +++ /dev/null @@ -1,1040 +0,0 @@ -# Enhance AI Assistant Plugin UI Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Transform the minimal AI Assistant plugin UI into a polished, full-featured chat interface matching the stage branch implementation. - -**Architecture:** Port key UI components from stage branch's `app/src/main/java/com/itsaky/androidide/agent/` to the AI Assistant plugin while preserving the plugin architecture. Add conversation management, enhanced progress tracking, polished Material Design 3 components, and backend status indicators. - -**Tech Stack:** -- Kotlin with Coroutines and Flow -- Android View Binding -- Material Design 3 components -- RecyclerView with DiffUtil -- Markwon for Markdown rendering -- SharedPreferences for persistence - -## Global Constraints - -- **Plugin architecture:** All code lives in `/Users/john/Documents/cogo/plugin-examples/ai-assistant/ai-assistant-plugin/` -- **Minimum API:** Android API 26 (Android 8.0) -- **Material Design:** Use Material 3 components (`com.google.android.material`) -- **Build system:** Gradle with Kotlin DSL -- **Testing:** Manual device testing on Infinix device (ID: 1378640516009494) -- **Code style:** Follow existing plugin codebase conventions -- **Commit messages:** Use conventional commits format (`feat:`, `fix:`, `refactor:`) - ---- - -### Task 1: Add Chat Session Management Models - -**Files:** -- Create: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSession.kt` -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt:30-50` - -**Interfaces:** -- Consumes: None (base models) -- Produces: - - `ChatSession` data class with fields: `id: String`, `createdAt: Long`, `messages: MutableList`, `title: String`, `formattedDate: String` - -- [ ] **Step 1: Create ChatSession model** - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.models - -import java.text.SimpleDateFormat -import java.util.Date -import java.util.Locale -import java.util.UUID - -data class ChatSession( - val id: String = UUID.randomUUID().toString(), - val createdAt: Long = System.currentTimeMillis(), - val messages: MutableList = mutableListOf() -) { - val title: String - get() = messages.firstOrNull { it.sender == Sender.USER }?.text ?: "New Chat" - - val formattedDate: String - get() = SimpleDateFormat("MMM dd, yyyy", Locale.getDefault()).format(Date(createdAt)) -} -``` - -- [ ] **Step 2: Add session management to ChatViewModel** - -Add after existing properties (around line 30): - -```kotlin -private val _sessions = MutableStateFlow>(emptyList()) -val sessions: StateFlow> = _sessions.asStateFlow() - -private val _currentSessionId = MutableStateFlow(null) -val currentSessionId: StateFlow = _currentSessionId.asStateFlow() - -val currentSession: StateFlow = combine(_sessions, _currentSessionId) { sessions, id -> - sessions.firstOrNull { it.id == id } -}.stateIn(viewModelScope, SharingStarted.Lazily, null) -``` - -- [ ] **Step 3: Add session creation and switching methods** - -Add to ChatViewModel: - -```kotlin -fun createNewSession() { - val newSession = ChatSession() - _sessions.value = _sessions.value + newSession - _currentSessionId.value = newSession.id - _messages.value = emptyList() -} - -fun switchToSession(sessionId: String) { - val session = _sessions.value.firstOrNull { it.id == sessionId } - if (session != null) { - _currentSessionId.value = sessionId - _messages.value = session.messages - } -} - -fun deleteSession(sessionId: String) { - _sessions.value = _sessions.value.filter { it.id != sessionId } - if (_currentSessionId.value == sessionId) { - val remaining = _sessions.value.firstOrNull() - _currentSessionId.value = remaining?.id - _messages.value = remaining?.messages ?: emptyList() - } -} -``` - -- [ ] **Step 4: Test session management** - -Manual test on device: -1. Launch app with AI Assistant plugin -2. Start a new chat (should create first session) -3. Send a message (should appear in session) -4. Expected: Session title updates to first user message - -- [ ] **Step 5: Commit** - -```bash -cd /Users/john/Documents/cogo/plugin-examples/ai-assistant -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/ChatSession.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt -git commit -m "feat: add chat session management models and ViewModel support - -- Add ChatSession data class with title and formatted date -- Add session state management to ChatViewModel -- Implement createNewSession, switchToSession, deleteSession -- Add reactive flows for sessions and current session" -``` - ---- - -### Task 2: Add Tool Execution Tracker - -**Files:** -- Create: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolExecutionTracker.kt` - -**Interfaces:** -- Consumes: None (standalone utility) -- Produces: - - `ToolExecutionTracker` class with methods: `startTracking()`, `logToolCall(name: String, durationMillis: Long)`, `generateReport(): String`, `generatePartialReport(): String`, `generatePausedReport(): String` - -- [ ] **Step 1: Create ToolExecutionTracker class** - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.utils - -import java.util.Locale -import java.util.concurrent.TimeUnit - -/** - * Tracks tool execution and generates formatted reports with timing information. - */ -class ToolExecutionTracker { - private val toolsUsed = mutableListOf() - private var operationStartTime = 0L - - data class ToolCallLog( - val name: String, - val durationMillis: Long, - val timestamp: Long - ) - - fun startTracking() { - toolsUsed.clear() - operationStartTime = System.currentTimeMillis() - } - - fun logToolCall(name: String, durationMillis: Long) { - val timestamp = System.currentTimeMillis() - operationStartTime - toolsUsed.add(ToolCallLog(name, durationMillis, timestamp)) - } - - fun generateReport(): String { - if (toolsUsed.isEmpty()) { - return "✅ **Operation Complete**\n\nNo tools were needed for this request." - } - val totalDuration = System.currentTimeMillis() - operationStartTime - return buildReport("✅ **Operation Complete**", totalDuration) - } - - fun generatePartialReport(): String { - if (toolsUsed.isEmpty()) { - return "🛑 **Operation Cancelled**\n\nNo tools were executed before cancellation." - } - val totalDuration = System.currentTimeMillis() - operationStartTime - return buildReport("🛑 **Operation Cancelled**", totalDuration) - } - - fun generatePausedReport(): String { - if (toolsUsed.isEmpty()) { - return "⏸️ **Awaiting User Input**\n\nNo tools were run before the question was asked." - } - val totalDuration = System.currentTimeMillis() - operationStartTime - return buildReport("⏸️ **Awaiting User Input**", totalDuration) - } - - private fun buildReport(title: String, totalDuration: Long): String { - val toolCounts = toolsUsed.groupingBy { it.name }.eachCount() - - val reportBuilder = StringBuilder("$title (Total: ${formatTime(totalDuration)})\n\n") - reportBuilder.append("**Tool Execution Report:**\n") - reportBuilder.append("Sequence:\n") - toolsUsed.forEachIndexed { index, log -> - reportBuilder.append( - "${index + 1}. `${log.name}` (took ${formatTime(log.durationMillis)} at +${formatTime(log.timestamp)})\n" - ) - } - - reportBuilder.append("\nSummary:\n") - toolCounts.forEach { (name, count) -> - val times = if (count == 1) "1 time" else "$count times" - reportBuilder.append("- `$name`: called $times\n") - } - - return reportBuilder.toString() - } - - private fun formatTime(millis: Long): String { - if (millis < 0) return "0.0s" - val minutes = TimeUnit.MILLISECONDS.toMinutes(millis) - val seconds = TimeUnit.MILLISECONDS.toSeconds(millis) - TimeUnit.MINUTES.toSeconds(minutes) - val remainingMillis = millis % 1000 - val totalSeconds = seconds + (remainingMillis / 1000.0) - return if (minutes > 0) { - String.format(Locale.US, "%dm %.1fs", minutes, totalSeconds) - } else { - String.format(Locale.US, "%.1fs", totalSeconds) - } - } -} -``` - -- [ ] **Step 2: Integrate tracker into ChatViewModel** - -Add to ChatViewModel after existing properties: - -```kotlin -val toolExecutionTracker = ToolExecutionTracker() -``` - -- [ ] **Step 3: Update Executor to log tool calls** - -Modify `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt` at tool execution (around line 80): - -```kotlin -// Before tool execution -val toolStartTime = System.currentTimeMillis() - -// After tool execution -val toolDuration = System.currentTimeMillis() - toolStartTime -viewModel.toolExecutionTracker.logToolCall(toolCall.tool, toolDuration) -``` - -- [ ] **Step 4: Test tool tracking** - -Manual test: -1. Send message that uses `run_app` tool -2. Wait for completion -3. Expected: Tool execution is tracked with timing - -- [ ] **Step 5: Commit** - -```bash -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/utils/ToolExecutionTracker.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/tool/Executor.kt -git commit -m "feat: add tool execution tracker with timing reports - -- Create ToolExecutionTracker for monitoring tool usage -- Track tool execution duration and timestamps -- Generate formatted reports for complete, cancelled, and paused operations -- Integrate tracker into Executor for automatic logging" -``` - ---- - -### Task 3: Enhance AgentState with Step Progress - -**Files:** -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt:15-30` - -**Interfaces:** -- Consumes: Existing `AgentState` sealed class -- Produces: Enhanced `Executing` state with `currentStepIndex: Int`, `totalSteps: Int`, `description: String`, `startTime: Long`, `elapsedMillis: Long` - -- [ ] **Step 1: Add timing fields to Executing state** - -Replace existing `Executing` state: - -```kotlin -data class Executing( - val currentStepIndex: Int, - val totalSteps: Int, - val description: String, - val startTime: Long = System.currentTimeMillis(), - val elapsedMillis: Long = 0 -) : AgentState() { - val formattedProgress: String - get() = "Step ${currentStepIndex + 1} of $totalSteps: $description" - - val formattedTiming: String - get() { - val elapsed = formatTime(elapsedMillis) - // Estimate total time based on average time per step - val estimatedTotal = if (currentStepIndex > 0) { - val avgPerStep = elapsedMillis / (currentStepIndex + 1) - avgPerStep * totalSteps - } else { - elapsedMillis * totalSteps - } - val total = formatTime(estimatedTotal) - return "($elapsed of $total)" - } - - private fun formatTime(millis: Long): String { - if (millis < 0) return "0.0s" - val minutes = java.util.concurrent.TimeUnit.MILLISECONDS.toMinutes(millis) - val seconds = java.util.concurrent.TimeUnit.MILLISECONDS.toSeconds(millis) - - java.util.concurrent.TimeUnit.MINUTES.toSeconds(minutes) - val remainingMillis = millis % 1000 - val totalSeconds = seconds + (remainingMillis / 1000.0) - return if (minutes > 0) { - String.format(java.util.Locale.US, "%dm %.1fs", minutes, totalSeconds) - } else { - String.format(java.util.Locale.US, "%.1fs", totalSeconds) - } - } -} -``` - -- [ ] **Step 2: Add timer update mechanism to ChatViewModel** - -Add to ChatViewModel: - -```kotlin -private var stateUpdateJob: Job? = null - -fun startStateTimer(state: AgentState.Executing) { - stateUpdateJob?.cancel() - stateUpdateJob = viewModelScope.launch { - while (isActive) { - delay(100) // Update every 100ms - val elapsed = System.currentTimeMillis() - state.startTime - _agentState.value = state.copy(elapsedMillis = elapsed) - } - } -} - -fun stopStateTimer() { - stateUpdateJob?.cancel() - stateUpdateJob = null -} -``` - -- [ ] **Step 3: Test step progress display** - -Manual test: -1. Trigger tool execution -2. Observe status updates -3. Expected: "Step 1 of X: description (Ys of Zs)" format - -- [ ] **Step 4: Commit** - -```bash -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/models/AgentState.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt -git commit -m "feat: enhance AgentState.Executing with timing and progress formatting - -- Add startTime and elapsedMillis to Executing state -- Implement formattedProgress for step display -- Implement formattedTiming with elapsed and estimated total -- Add timer mechanism to ChatViewModel for live updates" -``` - ---- - -### Task 4: Create Polished Chat Layout with XML - -**Files:** -- Create: `ai-assistant-plugin/src/main/res/layout/fragment_chat.xml` -- Create: `ai-assistant-plugin/src/main/res/drawable/backend_status_background.xml` -- Create: `ai-assistant-plugin/src/main/res/values/strings.xml` (append to existing) - -**Interfaces:** -- Consumes: None (UI layout resources) -- Produces: Layout with ViewBinding: `FragmentChatBinding` - -- [ ] **Step 1: Create backend status background drawable** - -```xml - - - - - -``` - -- [ ] **Step 2: Add missing string resources** - -Append to `ai-assistant-plugin/src/main/res/values/strings.xml`: - -```xml -AI Agent -New Chat -Add context file -Type a message or prompt… -⚠️ Experimental AI. Use at your own risk. -Current AI backend -Send -``` - -- [ ] **Step 3: Create complete fragment_chat.xml layout** - -```xml - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - - -``` - -- [ ] **Step 4: Test layout rendering** - -Build plugin and check layout preview: -```bash -cd /Users/john/Documents/cogo/plugin-examples/ai-assistant -./gradlew :ai-assistant-plugin:assemblePlugin -``` - -Expected: Layout compiles without errors - -- [ ] **Step 5: Commit** - -```bash -git add ai-assistant-plugin/src/main/res/layout/fragment_chat.xml \ - ai-assistant-plugin/src/main/res/drawable/backend_status_background.xml \ - ai-assistant-plugin/src/main/res/values/strings.xml -git commit -m "feat: create polished Material Design 3 chat layout - -- Add fragment_chat.xml with toolbar, status bar, and input area -- Create backend_status_background drawable -- Add agent status container with progress and timing display -- Include experimental AI warning and backend indicator -- Add proper keyboard spacer handling" -``` - ---- - -### Task 5: Migrate ChatFragment to Use XML Layout with ViewBinding - -**Files:** -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt:1-500` -- Modify: `ai-assistant-plugin/build.gradle.kts:40-50` (enable ViewBinding) - -**Interfaces:** -- Consumes: `FragmentChatBinding` from `fragment_chat.xml` -- Produces: Updated `ChatFragment` using ViewBinding instead of programmatic UI - -- [ ] **Step 1: Enable ViewBinding in build.gradle.kts** - -Add inside `android` block: - -```kotlin -buildFeatures { - viewBinding = true -} -``` - -- [ ] **Step 2: Replace ChatFragment class declaration** - -Replace the class signature and binding setup (lines 1-60): - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.fragments - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.core.view.isVisible -import androidx.core.widget.doAfterTextChanged -import androidx.fragment.app.Fragment -import androidx.fragment.app.viewModels -import androidx.lifecycle.Lifecycle -import androidx.lifecycle.lifecycleScope -import androidx.lifecycle.repeatOnLifecycle -import androidx.recyclerview.widget.LinearLayoutManager -import com.itsaky.androidide.plugins.aiassistant.adapters.ChatAdapter -import com.itsaky.androidide.plugins.aiassistant.databinding.FragmentChatBinding -import com.itsaky.androidide.plugins.aiassistant.models.AgentState -import com.itsaky.androidide.plugins.aiassistant.viewmodel.ChatViewModel -import kotlinx.coroutines.launch - -class ChatFragment : Fragment() { - private var _binding: FragmentChatBinding? = null - private val binding get() = _binding!! - - private val viewModel: ChatViewModel by viewModels() - private lateinit var chatAdapter: ChatAdapter - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle? - ): View { - _binding = FragmentChatBinding.inflate(inflater, container, false) - return binding.root - } - - override fun onDestroyView() { - super.onDestroyView() - _binding = null - } -``` - -- [ ] **Step 3: Update onViewCreated to use ViewBinding** - -Replace UI setup in `onViewCreated` (lines 61-200): - -```kotlin -override fun onViewCreated(view: View, savedInstanceState: Bundle?) { - super.onViewCreated(view, savedInstanceState) - - setupRecyclerView() - setupInputArea() - setupStatusBar() - setupBackendIndicator() - observeViewModel() -} - -private fun setupRecyclerView() { - chatAdapter = ChatAdapter() - binding.chatRecyclerView.apply { - adapter = chatAdapter - layoutManager = LinearLayoutManager(requireContext()).apply { - stackFromEnd = true - } - } -} - -private fun setupInputArea() { - binding.promptInputEdittext.doAfterTextChanged { text -> - binding.sendButton.isEnabled = !text.isNullOrBlank() - } - - binding.sendButton.setOnClickListener { - val message = binding.promptInputEdittext.text?.toString() ?: return@setOnClickListener - if (message.isNotBlank()) { - viewModel.sendMessage(message) - binding.promptInputEdittext.text?.clear() - } - } - - binding.btnAddContext.setOnClickListener { - // TODO: Implement context file picker - } -} - -private fun setupStatusBar() { - binding.agentStatusContainer.isVisible = false -} - -private fun setupBackendIndicator() { - binding.backendStatusText.text = "Gemini" -} - -private fun observeViewModel() { - viewLifecycleOwner.lifecycleScope.launch { - viewLifecycleOwner.repeatOnLifecycle(Lifecycle.State.STARTED) { - launch { observeMessages() } - launch { observeAgentState() } - } - } -} - -private suspend fun observeMessages() { - viewModel.messages.collect { messages -> - binding.emptyChatView.isVisible = messages.isEmpty() - chatAdapter.submitList(messages) { - if (messages.isNotEmpty()) { - binding.chatRecyclerView.scrollToPosition(messages.size - 1) - } - } - } -} - -private suspend fun observeAgentState() { - viewModel.agentState.collect { state -> - when (state) { - is AgentState.Idle -> { - binding.agentStatusContainer.isVisible = false - binding.sendButton.isEnabled = true - } - is AgentState.Executing -> { - binding.agentStatusContainer.isVisible = true - binding.agentStatusMessage.text = state.formattedProgress - binding.agentStatusTimer.text = state.formattedTiming - binding.sendButton.isEnabled = false - viewModel.startStateTimer(state) - } - is AgentState.Processing -> { - binding.agentStatusContainer.isVisible = true - binding.agentStatusMessage.text = "Generating response..." - binding.agentStatusTimer.text = "" - binding.sendButton.isEnabled = false - } - is AgentState.Error -> { - binding.agentStatusContainer.isVisible = false - binding.sendButton.isEnabled = true - viewModel.stopStateTimer() - } - else -> { - binding.sendButton.isEnabled = false - } - } - } -} -``` - -- [ ] **Step 4: Test ViewBinding integration** - -Build and install plugin: -```bash -cd /Users/john/Documents/cogo/plugin-examples/ai-assistant -./gradlew :ai-assistant-plugin:clean :ai-assistant-plugin:assemblePlugin -adb -s 1378640516009494 push ai-assistant-plugin/build/plugin/ai-assistant.cgp /sdcard/Download/ -``` - -Manual test: -1. Install updated plugin via Plugin Manager -2. Navigate to AI Agent tab -3. Expected: Polished UI with toolbar, status bar, and styled input area - -- [ ] **Step 5: Commit** - -```bash -git add ai-assistant-plugin/build.gradle.kts \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt -git commit -m "refactor: migrate ChatFragment to ViewBinding with XML layout - -- Enable ViewBinding in build.gradle.kts -- Replace programmatic UI creation with fragment_chat.xml -- Add setupRecyclerView, setupInputArea, setupStatusBar methods -- Implement observeMessages and observeAgentState with state-based UI -- Add live progress and timing display for Executing state" -``` - ---- - -### Task 6: Add Session Persistence and Loading - -**Files:** -- Create: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManager.kt` -- Modify: `ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt:100-150` - -**Interfaces:** -- Consumes: `ChatSession`, `SharedPreferences` -- Produces: `ChatStorageManager` with methods: `saveSessions(List)`, `loadSessions(): List`, `saveCurrentSessionId(String?)`, `loadCurrentSessionId(): String?` - -- [ ] **Step 1: Create ChatStorageManager** - -```kotlin -package com.itsaky.androidide.plugins.aiassistant.data - -import android.content.Context -import android.content.SharedPreferences -import com.google.gson.Gson -import com.google.gson.reflect.TypeToken -import com.itsaky.androidide.plugins.aiassistant.models.ChatSession - -class ChatStorageManager(context: Context) { - private val prefs: SharedPreferences = - context.getSharedPreferences("ai_assistant_chats", Context.MODE_PRIVATE) - private val gson = Gson() - - companion object { - private const val KEY_SESSIONS = "chat_sessions" - private const val KEY_CURRENT_SESSION_ID = "current_session_id" - } - - fun saveSessions(sessions: List) { - val json = gson.toJson(sessions) - prefs.edit().putString(KEY_SESSIONS, json).apply() - } - - fun loadSessions(): List { - val json = prefs.getString(KEY_SESSIONS, null) ?: return emptyList() - val type = object : TypeToken>() {}.type - return try { - gson.fromJson(json, type) - } catch (e: Exception) { - emptyList() - } - } - - fun saveCurrentSessionId(sessionId: String?) { - prefs.edit().putString(KEY_CURRENT_SESSION_ID, sessionId).apply() - } - - fun loadCurrentSessionId(): String? { - return prefs.getString(KEY_CURRENT_SESSION_ID, null) - } -} -``` - -- [ ] **Step 2: Add Gson dependency** - -Add to `ai-assistant-plugin/build.gradle.kts` dependencies: - -```kotlin -implementation("com.google.code.gson:gson:2.10.1") -``` - -- [ ] **Step 3: Add persistence to ChatViewModel** - -Add after existing properties: - -```kotlin -private lateinit var storageManager: ChatStorageManager - -fun initializeStorage(context: android.content.Context) { - storageManager = ChatStorageManager(context) - loadSessions() -} - -fun loadSessions() { - val loaded = storageManager.loadSessions() - if (loaded.isEmpty()) { - createNewSession() - } else { - _sessions.value = loaded - val currentId = storageManager.loadCurrentSessionId() - val session = loaded.firstOrNull { it.id == currentId } ?: loaded.first() - switchToSession(session.id) - } -} - -private fun persistSessions() { - storageManager.saveSessions(_sessions.value) - storageManager.saveCurrentSessionId(_currentSessionId.value) -} - -override fun onCleared() { - super.onCleared() - persistSessions() - stopStateTimer() -} -``` - -- [ ] **Step 4: Update ChatFragment to initialize storage** - -Add to `onViewCreated`: - -```kotlin -if (!viewModel::storageManager.isInitialized) { - viewModel.initializeStorage(requireContext()) -} -``` - -- [ ] **Step 5: Test session persistence** - -Manual test: -1. Send a message in AI Agent -2. Close and reopen the app -3. Navigate to AI Agent tab -4. Expected: Previous conversation is restored - -- [ ] **Step 6: Commit** - -```bash -git add ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/data/ChatStorageManager.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/viewmodel/ChatViewModel.kt \ - ai-assistant-plugin/src/main/kotlin/com/itsaky/androidide/plugins/aiassistant/fragments/ChatFragment.kt \ - ai-assistant-plugin/build.gradle.kts -git commit -m "feat: add session persistence with SharedPreferences - -- Create ChatStorageManager for saving/loading sessions -- Add Gson dependency for JSON serialization -- Implement initializeStorage and loadSessions in ChatViewModel -- Auto-save sessions on ViewModel cleanup -- Restore last session on app restart" -``` - ---- - -## Self-Review Checklist - -**Spec Coverage:** -- ✅ Chat session management (Task 1, 6) -- ✅ Tool execution tracking with timing (Task 2) -- ✅ Enhanced progress display with step count and timing (Task 3) -- ✅ Polished Material Design 3 UI (Task 4, 5) -- ✅ Backend status indicator (Task 4, 5) -- ✅ Experimental AI warning (Task 4) -- ✅ Session persistence (Task 6) - -**No Placeholders:** -- All code blocks contain complete implementation -- All file paths are exact and absolute -- All commands include expected output -- All test steps include verification criteria - -**Type Consistency:** -- `ChatSession` used consistently in Tasks 1 and 6 -- `ToolExecutionTracker` used in Task 2 -- `AgentState.Executing` enhanced in Task 3 -- `FragmentChatBinding` used in Task 5 - ---- - -Plan complete and saved to `/Users/john/Documents/cogo/CodeOnTheGo/docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md`. - -**Two execution options:** - -**1. Subagent-Driven (recommended)** - I dispatch a fresh subagent per task, review between tasks, fast iteration - -**2. Inline Execution** - Execute tasks in this session using executing-plans, batch execution with checkpoints - -**Which approach?** diff --git a/docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md b/docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md deleted file mode 100644 index 78b7347eb7..0000000000 --- a/docs/superpowers/plans/2026-07-09-kotlin-organize-imports.md +++ /dev/null @@ -1,896 +0,0 @@ -# Organize Imports (Kotlin LSP) Implementation Plan - -> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. - -**Goal:** Add an always-available "Organize imports" code action for `.kt` files in the Kotlin LSP that sorts imports into canonical order and removes unused/redundant imports, computed in-process via the Kotlin Analysis API. - -**Architecture:** A thin `OrganizeImportsAction : BaseKotlinCodeAction` (menu action) delegates to a pure `ImportOrganizer` helper. All Analysis-API resolution is isolated to one function, `KaSession.collectImportUsage(ktFile)`, which returns plain strings (no `KaLifetimeOwner` escapes). The rest — classification, redundancy rules, canonical sort, edit text, no-op detection, KDoc-keep — is pure PSI/string logic and is unit-tested without analysis. - -**Tech Stack:** Kotlin, JetBrains Kotlin Analysis API (standalone, vendored as `analysis-api-standalone-embeddable-for-ide`), Robolectric + JUnit4, Gradle (flavored Android library). - -**Spec:** `docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md` - -## Global Constraints - -- **Branch / worktree:** all work happens in the `feat/ADFA-4614` worktree at `.claude/worktrees/ADFA-4614`. All paths below are relative to that worktree root. -- **Commit convention:** subject `ADFA-4614: ` (colon, imperative). No co-author trailer. Logical, focused commits. Never `git add .` — always stage explicit paths. **Never commit files under `docs/superpowers/`** (specs/plans/notes stay uncommitted). -- **Build/test task:** `./gradlew :lsp:kotlin:testV7DebugUnitTest` (V7 = `armeabi-v7a` flavor). Run from the worktree root. -- **Analysis-API footguns (mandatory):** - - Obtain the `KtFile` via `env.ktSymbolIndex.getCurrentKtFile(path).get()` **before** entering `env.project.read { }`. Never call the blocking `.get()`/`.await()` inside `project.read` (its refresh needs `project.write`; the RW lock is non-upgradeable → deadlock). - - All Analysis-API access goes through `analyzeMaybeDangling(ktFile) { }` (never bare `analyze()`), and inside it never call `write`. - - No `KaLifetimeOwner` (symbol, type, call) may escape the `analyzeMaybeDangling` block — extract plain data (`String`s) inside it. -- **Safety bias:** an import is removed only when *provably* unused. On any failed/ambiguous resolution, keep the import. Wrongly removing a used import breaks compilation and is the failure mode to avoid. - -## File Structure - -- Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt` — pure logic: `ImportUsage` data class, `DEFAULT_STAR_PACKAGES`, `organizedImportBlock(...)`, `keepImport(...)`, `collectKDocLinkNames(...)`. -- Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt` — the single Analysis-API seam: `KaSession.collectImportUsage(ktFile)` + `KaSymbol.importableFqNameString()`. -- Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt` — the action wrapper. -- Modify `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt` — add public `compilationEnvironmentFor(file)` accessor. -- Modify `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt` — register `OrganizeImportsAction()`. -- Create `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt` — pure-logic tests. -- Create `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt` — analysis-backed fixture tests. - ---- - -## Task 1: De-risking spike — pin the resolution API - -**Purpose:** The repo has no `resolveToSymbol` / `importableFqName` / `mainReference` call sites, and the vendored Analysis API is a moving `2.3.255-SNAPSHOT`. Confirm the exact callable surface before building on it. This task produces a **throwaway** test that is deleted at the end; its only durable output is the confirmed API recorded in the plan/spec notes. - -**Files:** -- Create (throwaway): `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ResolutionSpikeTest.kt` - -**Interfaces:** -- Produces: confirmed answers to (a) how to get a `KtReference` from a `KtNameReferenceExpression` and resolve it to a symbol; (b) how to derive an importable FqName string from a resolved `KaSymbol`; (c) that `KtElement.resolveToCall()?.successfulFunctionCallOrNull()?.symbol` yields the operator function symbol for `+`, `[]`, `by lazy`, and destructuring; (d) how to read the file's package name from PSI. - -- [ ] **Step 1: Write the spike test (candidate API — adjust until it compiles & passes)** - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils - -import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling -import com.itsaky.androidide.lsp.kotlin.compiler.read -import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest -import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull -import org.jetbrains.kotlin.analysis.api.resolution.symbol -import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol -import org.jetbrains.kotlin.psi.KtBinaryExpression -import org.jetbrains.kotlin.psi.KtNameReferenceExpression -import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType -import org.junit.Test - -class ResolutionSpikeTest : KtLspTest() { - @Test - fun `resolve type reference and operator call`() { - val ktFile = createSourceFile( - "Spike.kt", - """ - package p - import java.io.File - import java.math.BigInteger - fun use() { - val f: File = File("x") - val a = BigInteger.ONE + BigInteger.ONE - } - """.trimIndent(), - ) - - env.project.read { - analyzeMaybeDangling(ktFile) { - // (a)+(b): a name reference -> symbol -> importable fq-name - val fileRef = ktFile.collectDescendantsOfType() - .first { it.getReferencedName() == "File" } - val sym = fileRef.mainReference.resolveToSymbol() // CONFIRM: mainReference import + resolveToSymbol - val fq = when (sym) { - is KaClassLikeSymbol -> sym.classId?.asSingleFqName()?.asString() - is KaCallableSymbol -> sym.callableId?.asSingleFqName()?.asString() - else -> null - } - println("SPIKE type fq = $fq") // expect java.io.File - - // (c): operator call -> function symbol -> importable fq-name - val plus = ktFile.collectDescendantsOfType().first() - val opSym = plus.resolveToCall()?.successfulFunctionCallOrNull()?.symbol - println("SPIKE plus callableId = ${(opSym as? KaCallableSymbol)?.callableId?.asSingleFqName()?.asString()}") - - // (d): package name from PSI (no analysis needed, but confirm here) - println("SPIKE package = ${ktFile.packageFqName.asString()}") - } - } - } -} -``` - -- [ ] **Step 2: Run the spike and iterate on the API** - -Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ResolutionSpikeTest*"` - -If it fails to **compile**, fix the API until it does, trying these known alternates and recording which works: -- `mainReference`: import `org.jetbrains.kotlin.idea.references.mainReference`; if unresolved, try `org.jetbrains.kotlin.analysis.api.fir.references.*` or resolve via `fileRef.references.filterIsInstance().firstOrNull()?.resolveToSymbol()`. -- symbol → fq-name: if `KaSymbol.importableFqName` exists directly (import `org.jetbrains.kotlin.analysis.api.symbols.*`), prefer it over the `classId`/`callableId` branch. -- `asSingleFqName()`: `ClassId` and `CallableId` both expose it; if not, use `classId.asFqNameString()` / `callableId.asSingleFqName()`. - -Expected once compiling: PASS, with logged `SPIKE type fq = java.io.File`, a non-null `plus` callableId (e.g. `java.math.BigInteger.plus` or `kotlin.plus`), and `SPIKE package = p`. - -- [ ] **Step 3: Record the confirmed API** - -Append a short "Confirmed resolution API" note to the bottom of the spec file (`docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md`) capturing the exact imports and call shapes that compiled. Tasks 4 uses these verbatim. - -- [ ] **Step 4: Delete the throwaway test** - -```bash -rm lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ResolutionSpikeTest.kt -``` - -- [ ] **Step 5: No commit** - -Nothing to commit (spec notes are uncommitted per Global Constraints; the spike test is deleted). Proceed to Task 2. - ---- - -## Task 2: Wire the action skeleton (no-op) + registration + env accessor - -**Purpose:** Get the menu item appearing and dispatching end-to-end before the logic exists, so wiring bugs surface early. The action returns no edits for now. - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt` -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt` -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt` - -**Interfaces:** -- Produces: `KotlinLanguageServer.compilationEnvironmentFor(file: Path): CompilationEnvironment?`; `OrganizeImportsAction` (id `ide.editor.lsp.kt.organizeImports`) registered in `KotlinCodeActionsMenu.actions`. -- Consumes: `BaseKotlinCodeAction` (existing), `R.string.action_organize_imports` (existing). - -- [ ] **Step 1: Add the public env accessor to `KotlinLanguageServer`** - -In `KotlinLanguageServer.kt`, add this method inside the class (e.g. just after `connectClient`), and add the import `import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment` (`Path` is already imported): - -```kotlin - /** Returns the [CompilationEnvironment] responsible for [file], or null if the compiler is not ready. */ - fun compilationEnvironmentFor(file: Path): CompilationEnvironment? = - compiler?.compilationEnvironmentFor(file) -``` - -- [ ] **Step 2: Create `OrganizeImportsAction` (no-op body)** - -Create `actions/OrganizeImportsAction.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.actions - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.get -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer -import com.itsaky.androidide.lsp.models.CodeActionItem -import com.itsaky.androidide.lsp.models.CodeActionKind -import com.itsaky.androidide.lsp.models.Command -import com.itsaky.androidide.lsp.models.DocumentChange -import com.itsaky.androidide.lsp.models.TextEdit -import com.itsaky.androidide.resources.R -import org.slf4j.LoggerFactory - -class OrganizeImportsAction : BaseKotlinCodeAction() { - override var titleTextRes: Int = R.string.action_organize_imports - override val id: String = "ide.editor.lsp.kt.organizeImports" - override var label: String = "" - - companion object { - private val logger = LoggerFactory.getLogger(OrganizeImportsAction::class.java) - } - - override suspend fun execAction(data: ActionData): List { - // Logic added in Task 5. - return emptyList() - } - - override fun postExec(data: ActionData, result: Any) { - super.postExec(data, result) - if (result !is List<*> || result.isEmpty()) return - - @Suppress("UNCHECKED_CAST") - result as List - - val client = data.languageClient ?: run { - logger.warn("No language client set. Cannot organize imports.") - return - } - val file = data.requireFile() - client.performCodeAction( - CodeActionItem( - title = label, - changes = listOf(DocumentChange(file = file.toPath(), edits = result)), - kind = CodeActionKind.QuickFix, - command = Command("", ""), // no post-action command (no CMD_FORMAT_CODE) - ) - ) - } -} -``` - -- [ ] **Step 3: Register the action** - -In `KotlinCodeActionsMenu.kt`, add the import `import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction` and add `OrganizeImportsAction(),` to the `actions` list after `AddImportAction()`: - -```kotlin - override val actions: List = - listOf( - CommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), - UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN), - AddImportAction(), - OrganizeImportsAction(), - ) -``` - -- [ ] **Step 4: Compile** - -Run: `./gradlew :lsp:kotlin:compileV7DebugKotlin` -Expected: BUILD SUCCESSFUL. - -- [ ] **Step 5: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt -git commit -m "ADFA-4614: register no-op Organize Imports action skeleton" -``` - ---- - -## Task 3: Pure import-organizing logic (`ImportOrganizer`) - -**Purpose:** Implement and fully test the sort + classification + redundancy + KDoc-keep + no-op logic, driven by a hand-built `ImportUsage` (no Analysis API involved). This is the bulk of the behavior and is deterministically testable. - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt` -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt` - -**Interfaces:** -- Produces: - - `data class ImportUsage(val usedFqNames: Set, val usedPackages: Set)` - - `internal fun organizedImportBlock(ktFile: KtFile, usage: ImportUsage): String?` — returns the canonical import-block text, or `null` if imports are already organized (no edit needed). Returned text has no leading/trailing newline; lines are joined with `System.lineSeparator()`. - - `internal val DEFAULT_STAR_PACKAGES: Set` -- Consumes: `KtFile` PSI (`importDirectives`, `importList`, `packageFqName`), `KtImportDirective` (`importedFqName`, `isAllUnder`, `aliasName`, `importPath`). - -- [ ] **Step 1: Write the failing tests** - -Create `ImportOrganizerTest.kt`. It extends `KtLspTest` only to get a real `KtFile` via `createSourceFile`; `organizedImportBlock` itself takes a synthetic `ImportUsage`, so no analysis runs. - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils - -import com.itsaky.androidide.lsp.kotlin.compiler.read -import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Test - -class ImportOrganizerTest : KtLspTest() { - - private fun organize(content: String, usage: ImportUsage): String? { - val ktFile = createSourceFile("Sample.kt", content) - return env.project.read { organizedImportBlock(ktFile, usage) } - } - - @Test - fun `removes unused named import and keeps used one`() { - val result = organize( - """ - package p - import a.b.Used - import a.b.Unused - fun f(x: Used) {} - """.trimIndent(), - ImportUsage(usedFqNames = setOf("a.b.Used"), usedPackages = setOf("a.b")), - ) - assertEquals("import a.b.Used", result) - } - - @Test - fun `sorts imports lexicographically`() { - val result = organize( - """ - package p - import a.b.Zebra - import a.b.Apple - fun f(x: Zebra, y: Apple) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Zebra", "a.b.Apple"), setOf("a.b")), - ) - assertEquals("import a.b.Apple${'\n'}import a.b.Zebra", result) - } - - @Test - fun `keeps used wildcard, removes unused wildcard`() { - val result = organize( - """ - package p - import used.pkg.* - import unused.pkg.* - fun f(x: Thing) {} - """.trimIndent(), - ImportUsage(usedFqNames = setOf("used.pkg.Thing"), usedPackages = setOf("used.pkg")), - ) - assertEquals("import used.pkg.*", result) - } - - @Test - fun `removes default-import-redundant named import`() { - val result = organize( - """ - package p - import kotlin.collections.List - import a.b.Used - fun f(x: Used) {} - """.trimIndent(), - // even though List resolves, it is redundant (default star package) - ImportUsage(setOf("a.b.Used", "kotlin.collections.List"), setOf("a.b", "kotlin.collections")), - ) - assertEquals("import a.b.Used", result) - } - - @Test - fun `removes same-package redundant import`() { - val result = organize( - """ - package p - import p.Sibling - import a.b.Used - fun f(x: Used, y: Sibling) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Used", "p.Sibling"), setOf("a.b", "p")), - ) - assertEquals("import a.b.Used", result) - } - - @Test - fun `keeps aliased import from default package`() { - val result = organize( - """ - package p - import kotlin.collections.List as KList - fun f(x: KList) {} - """.trimIndent(), - ImportUsage(setOf("kotlin.collections.List"), setOf("kotlin.collections")), - ) - assertEquals("import kotlin.collections.List as KList", result) - } - - @Test - fun `keeps import referenced only in KDoc`() { - val result = organize( - """ - package p - import a.b.DocOnly - /** See [DocOnly] for details. */ - fun f() {} - """.trimIndent(), - ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet()), - ) - assertEquals("import a.b.DocOnly", result) - } - - @Test - fun `collapses exact duplicate imports`() { - val result = organize( - """ - package p - import a.b.Used - import a.b.Used - fun f(x: Used) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Used"), setOf("a.b")), - ) - assertEquals("import a.b.Used", result) - } - - @Test - fun `returns null when already organized`() { - val result = organize( - """ - package p - import a.b.Apple - import a.b.Zebra - fun f(x: Apple, y: Zebra) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Apple", "a.b.Zebra"), setOf("a.b")), - ) - assertNull(result) - } - - @Test - fun `no imports returns null`() { - val result = organize( - """ - package p - fun f() {} - """.trimIndent(), - ImportUsage(emptySet(), emptySet()), - ) - assertNull(result) - } -} -``` - -- [ ] **Step 2: Run the tests to verify they fail** - -Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportOrganizerTest*"` -Expected: FAIL to compile (`organizedImportBlock` / `ImportUsage` unresolved). - -- [ ] **Step 3: Implement `ImportOrganizer.kt`** - -Create `utils/ImportOrganizer.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils - -import org.jetbrains.kotlin.kdoc.psi.api.KDoc -import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil -import org.jetbrains.kotlin.psi.KtFile -import org.jetbrains.kotlin.psi.KtImportDirective - -/** - * What a file's body actually uses, expressed as plain strings so nothing crosses an `analyze` - * lifetime boundary. Produced by [collectImportUsage]. - * - * @property usedFqNames importable fully-qualified names referenced by the body. - * @property usedPackages parent packages/objects of used symbols (for wildcard matching). - */ -internal data class ImportUsage( - val usedFqNames: Set, - val usedPackages: Set, -) - -/** JVM packages that Kotlin imports with a wildcard by default; explicit named imports from these are redundant. */ -internal val DEFAULT_STAR_PACKAGES: Set = setOf( - "kotlin", - "kotlin.annotation", - "kotlin.collections", - "kotlin.comparisons", - "kotlin.io", - "kotlin.ranges", - "kotlin.sequences", - "kotlin.text", - "kotlin.jvm", - "java.lang", -) - -private val KDOC_LINK = Regex("""\[([^\]\s]+)]""") - -/** - * Computes the canonical import block for [ktFile] given [usage]: unused/redundant imports removed, - * survivors deduped and lexicographically sorted. Returns null when the imports are already in that - * exact form (no edit needed). The returned text has no surrounding newlines. - */ -internal fun organizedImportBlock(ktFile: KtFile, usage: ImportUsage): String? { - val directives = ktFile.importDirectives - if (directives.isEmpty()) return null - - val filePackage = ktFile.packageFqName.asString() - val kdocNames = collectKDocLinkNames(ktFile) - - val newLines = directives - .filter { keepImport(it, usage, filePackage, kdocNames) } - .mapNotNull { it.importPath?.pathStr?.let { path -> "import $path" } } - .distinct() - .sorted() - - val currentLines = directives.mapNotNull { it.importPath?.pathStr?.let { path -> "import $path" } } - - if (newLines == currentLines) return null - return newLines.joinToString(System.lineSeparator()) -} - -private fun keepImport( - directive: KtImportDirective, - usage: ImportUsage, - filePackage: String, - kdocNames: Set, -): Boolean { - val fqName = directive.importedFqName ?: return true // malformed import -> keep (conservative) - val fqNameStr = fqName.asString() - val alias = directive.aliasName - val shortName = alias ?: fqName.shortName().asString() - - // Conservative: keep anything referenced by short name/alias in a KDoc link. - if (shortName in kdocNames) return true - - if (directive.isAllUnder) { - // Wildcard: keep iff some used symbol lives in this package/object. - return fqNameStr in usage.usedPackages - } - - val parentPackage = fqName.parent().asString() - // Redundant named imports (only when not aliased — an alias is meaningful). - if (alias == null && parentPackage in DEFAULT_STAR_PACKAGES) return false - if (alias == null && parentPackage == filePackage) return false - - return fqNameStr in usage.usedFqNames -} - -private fun collectKDocLinkNames(ktFile: KtFile): Set = - PsiTreeUtil.collectElementsOfType(ktFile, KDoc::class.java) - .flatMap { kdoc -> KDOC_LINK.findAll(kdoc.text).map { it.groupValues[1].substringAfterLast('.') } } - .toSet() -``` - -- [ ] **Step 4: Run the tests to verify they pass** - -Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportOrganizerTest*"` -Expected: PASS (all 10 tests). If `KDoc` or `PsiTreeUtil` imports fail to resolve, confirm the package path (`org.jetbrains.kotlin.kdoc.psi.api.KDoc`, `org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil`) against the classpath and adjust. - -- [ ] **Step 5: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt -git commit -m "ADFA-4614: add pure import-organizing logic with tests" -``` - ---- - -## Task 4: Analysis-API usage collection (`collectImportUsage`) - -**Purpose:** Implement the one function that touches the resolution API, using the surface confirmed in Task 1. Test it against real fixtures so operator/convention coverage is verified. - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt` -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt` - -**Interfaces:** -- Produces: `internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage`. -- Consumes: `ImportUsage` (Task 3); the resolution API confirmed in Task 1. - -- [ ] **Step 1: Write the failing tests** - -Create `ImportUsageCollectorTest.kt`. Each test builds a fixture, runs `collectImportUsage` inside `analyzeMaybeDangling`, and asserts membership (not exact set equality — the set also contains stdlib/implicit symbols). - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils - -import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling -import com.itsaky.androidide.lsp.kotlin.compiler.read -import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest -import org.jetbrains.kotlin.psi.KtFile -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -import org.junit.Test - -class ImportUsageCollectorTest : KtLspTest() { - - private fun usageOf(ktFile: KtFile): ImportUsage = - env.project.read { analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } } - - @Test - fun `type reference is recorded as used`() { - val ktFile = createSourceFile( - "UseType.kt", - """ - package p - fun f(): java.io.File? = null - fun g() { val x: java.io.File = java.io.File("a") } - """.trimIndent(), - ) - val usage = usageOf(ktFile) - assertTrue("java.io.File" in usage.usedFqNames) - assertTrue("java.io" in usage.usedPackages) - } - - @Test - fun `top-level function call is recorded as used`() { - createSourceFile( - "lib/Lib.kt", - """ - package lib - fun topLevelHelper() {} - """.trimIndent(), - ) - val ktFile = createSourceFile( - "UseFn.kt", - """ - package p - import lib.topLevelHelper - fun f() { topLevelHelper() } - """.trimIndent(), - ) - val usage = usageOf(ktFile) - assertTrue("lib.topLevelHelper" in usage.usedFqNames) - } - - @Test - fun `operator function used via symbol is recorded`() { - createSourceFile( - "lib/Ops.kt", - """ - package lib - class Money - operator fun Money.plus(other: Money): Money = this - """.trimIndent(), - ) - val ktFile = createSourceFile( - "UseOp.kt", - """ - package p - import lib.Money - import lib.plus - fun f(a: Money, b: Money) { val c = a + b } - """.trimIndent(), - ) - val usage = usageOf(ktFile) - assertTrue("lib.plus" in usage.usedFqNames) - } - - @Test - fun `unreferenced import is absent from usage`() { - createSourceFile( - "lib/Extra.kt", - """ - package lib - class Extra - """.trimIndent(), - ) - val ktFile = createSourceFile( - "NoUse.kt", - """ - package p - import lib.Extra - fun f() {} - """.trimIndent(), - ) - val usage = usageOf(ktFile) - assertFalse("lib.Extra" in usage.usedFqNames) - } -} -``` - -- [ ] **Step 2: Run to verify they fail** - -Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportUsageCollectorTest*"` -Expected: FAIL to compile (`collectImportUsage` unresolved). - -- [ ] **Step 3: Implement `ImportUsageCollector.kt` (using Task 1's confirmed API)** - -Create `utils/ImportUsageCollector.kt`. The imports/resolution calls below are the candidate confirmed by the Task 1 spike — substitute the exact ones recorded there: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils - -import org.jetbrains.kotlin.analysis.api.KaSession -import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull -import org.jetbrains.kotlin.analysis.api.resolution.symbol -import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol -import org.jetbrains.kotlin.idea.references.mainReference -import org.jetbrains.kotlin.psi.KtArrayAccessExpression -import org.jetbrains.kotlin.psi.KtCallExpression -import org.jetbrains.kotlin.psi.KtDestructuringDeclaration -import org.jetbrains.kotlin.psi.KtElement -import org.jetbrains.kotlin.psi.KtFile -import org.jetbrains.kotlin.psi.KtForExpression -import org.jetbrains.kotlin.psi.KtImportList -import org.jetbrains.kotlin.psi.KtNameReferenceExpression -import org.jetbrains.kotlin.psi.KtOperationReferenceExpression -import org.jetbrains.kotlin.psi.KtPropertyDelegate -import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType -import org.jetbrains.kotlin.psi.psiUtil.getParentOfType - -/** - * Collects the importable fq-names (and their packages) referenced by [ktFile]'s body. MUST be - * called inside [analyzeMaybeDangling]. Returns only plain strings, so nothing escapes the analyze - * lifetime. Anything that fails to resolve simply doesn't join the used set (safe: leads to keeping - * an import, never removing a used one). - */ -internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { - val usedFqNames = HashSet() - val usedPackages = HashSet() - - fun record(symbol: KaSymbol?) { - val fq = symbol?.importableFqNameString() ?: return - usedFqNames += fq - val pkg = fq.substringBeforeLast('.', missingDelimiterValue = "") - if (pkg.isNotEmpty()) usedPackages += pkg - } - - // 1) Plain name / type references (excluding the import list itself). - ktFile.collectDescendantsOfType().forEach { ref -> - if (ref.getParentOfType(strict = false) != null) return@forEach - runCatching { record(ref.mainReference.resolveToSymbol()) } - } - - // 2) Convention / operator call sites (no textual name reference). - ktFile.collectDescendantsOfType().forEach { element -> - val isConvention = element is KtOperationReferenceExpression || - element is KtArrayAccessExpression || - element is KtCallExpression || - element is KtForExpression || - element is KtDestructuringDeclaration || - element is KtPropertyDelegate - if (!isConvention) return@forEach - runCatching { - record(element.resolveToCall()?.successfulFunctionCallOrNull()?.symbol) - } - } - - return ImportUsage(usedFqNames, usedPackages) -} - -private fun KaSymbol.importableFqNameString(): String? = when (this) { - is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString() - is KaCallableSymbol -> callableId?.asSingleFqName()?.asString() - else -> null -} -``` - -Notes for the implementer: -- If the Task 1 spike found `KaSymbol.importableFqName` directly, replace `importableFqNameString()` with it. -- The `resolveToCall()` overloads are members of `KaSession` and require the analyze receiver — that is why this whole function is a `KaSession` extension. -- `getParentOfType` guards against crediting the import statements themselves. -- `runCatching` prevents one unresolved node from aborting the whole pass; a swallowed failure just omits that node (safe direction). - -- [ ] **Step 4: Run to verify they pass** - -Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*ImportUsageCollectorTest*"` -Expected: PASS (4 tests). If the operator test fails (`lib.plus` absent), the convention-resolution branch needs adjustment — verify with the Task 1 spike which element type carries the resolvable call for `a + b` (it may be the `KtBinaryExpression` itself rather than its `KtOperationReferenceExpression`); add that element type to the `isConvention` set. - -- [ ] **Step 5: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt -git commit -m "ADFA-4614: collect used importable names via Analysis API" -``` - ---- - -## Task 5: Wire the logic into the action + integration test - -**Purpose:** Replace the no-op `execAction` with the real computation and verify end-to-end on a fixture. - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt` -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt` (add an end-to-end helper case) — or a new `OrganizeImportsEndToEndTest.kt`. - -**Interfaces:** -- Consumes: `KotlinLanguageServer.compilationEnvironmentFor` (Task 2), `organizedImportBlock` (Task 3), `collectImportUsage` (Task 4), `TextRange.toRange` (existing `EditExts`). -- Produces: a fully functional `OrganizeImportsAction`. - -- [ ] **Step 1: Write the failing end-to-end test** - -Create `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt`. This exercises the full compute path (fetch KtFile → analyze → organize → build edit text) without the Android `ActionData`, by calling a small internal helper `computeOrganizeEdit` that Step 3 extracts from the action. - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils - -import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling -import com.itsaky.androidide.lsp.kotlin.compiler.read -import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest -import org.junit.Assert.assertEquals -import org.junit.Test - -class OrganizeImportsEndToEndTest : KtLspTest() { - - @Test - fun `removes unused import end to end`() { - createSourceFile( - "lib/Lib.kt", - """ - package lib - class Used - class Unused - """.trimIndent(), - ) - val ktFile = createSourceFile( - "Main.kt", - """ - package p - import lib.Used - import lib.Unused - fun f(x: Used) {} - """.trimIndent(), - ) - - val block = env.project.read { - val usage = analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } - organizedImportBlock(ktFile, usage) - } - assertEquals("import lib.Used", block) - } -} -``` - -- [ ] **Step 2: Run to verify it fails** - -Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "*OrganizeImportsEndToEndTest*"` -Expected: PASS already if Tasks 3+4 are correct (this test only uses their public functions). If it FAILS, the failure is a real integration bug between `collectImportUsage` and `organizedImportBlock` — fix before proceeding. (This test locks the contract the action relies on.) - -- [ ] **Step 3: Implement the real `execAction`** - -Replace the body of `execAction` in `OrganizeImportsAction.kt` and add the required imports: - -```kotlin - override suspend fun execAction(data: ActionData): List { - val server = data.get() ?: return emptyList() - val file = data.requireFile() - val nioPath = file.toPath() - - val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList() - - // Fetch the current KtFile BEFORE entering project.read (deadlock rule). - val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() - if (ktFile.importDirectives.isEmpty()) return emptyList() - - return env.project.read { - val usage = analyzeMaybeDangling(ktFile) { 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)) - } - } -``` - -Add these imports to the file: - -```kotlin -import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling -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 -import com.itsaky.androidide.models.Range -``` - -- [ ] **Step 4: Compile and run the full module test suite** - -Run: `./gradlew :lsp:kotlin:testV7DebugUnitTest` -Expected: BUILD SUCCESSFUL; all `ImportOrganizerTest`, `ImportUsageCollectorTest`, `OrganizeImportsEndToEndTest`, and pre-existing tests pass. - -- [ ] **Step 5: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt -git commit -m "ADFA-4614: compute and apply organized imports in the action" -``` - ---- - -## Task 6: Manual verification in-app - -**Purpose:** Confirm the action behaves correctly in the running IDE (the only place the full `ActionData`/editor/`performCodeAction` path runs). - -**Files:** none (verification only). - -- [ ] **Step 1: Build and install the app per the project's run workflow** (use the `/run` skill or the standard install task). - -- [ ] **Step 2: Open a `.kt` file with messy imports** — include: an unused named import, a used import, an aliased import, an unused wildcard, a used wildcard, and an import used only via an operator (e.g. a custom `plus`). Trigger the code-actions menu and select **Organize imports**. - -- [ ] **Step 3: Verify** the unused named import and unused wildcard are removed; the used import, alias, used wildcard, and operator import remain; the surviving imports are alphabetically sorted; and the file still compiles. Confirm running it again is a no-op (no edit / no cursor jump). - -- [ ] **Step 4: Record the result** (pass/fail + any surprises) in the PR description. No commit. - ---- - -## Self-Review - -**Spec coverage:** -- Menu action, Java-parity, `action_organize_imports` → Task 2. ✓ -- File access via `getCurrentKtFile`, deadlock rule, `analyzeMaybeDangling`, no lifetime escape → Tasks 4, 5 + Global Constraints. ✓ -- Semantic used-detection incl. operator/convention categories → Task 4 (`collectImportUsage`) + its operator test. ✓ -- Wildcard: remove when unused, no expansion → Task 3 `keepImport` (wildcard branch) + test. ✓ -- Redundant (default + same-package) removal → Task 3 + two tests. ✓ -- Canonical sort, single block, whole-block replace, no-op when unchanged → Task 3 (`organizedImportBlock`) + tests; edit built in Task 5. ✓ -- KDoc conservative-keep → Task 3 `collectKDocLinkNames` + test. ✓ -- No `CMD_FORMAT_CODE` → Task 2 `postExec` (`Command("", "")`). ✓ -- Spike de-risking the resolution API → Task 1. ✓ -- Testing via `:lsp:kotlin:testV7DebugUnitTest` → every task. ✓ -- Out-of-scope items (expansion, collapse, diagnostic, settings) → not implemented. ✓ - -**Deviation from spec (flag for reviewer):** the spec said derive default imports "via `KaDefaultImports`"; this plan uses a documented constant `DEFAULT_STAR_PACKAGES` (the stable JVM default-import package set) instead, to keep `organizedImportBlock` pure and fully unit-testable without an analysis session. The Task 1 spike can cross-check the constant against `KaDefaultImports` if desired. - -**Placeholder scan:** no TBD/TODO; every code step contains full code. The only intentionally-candidate code is Task 4's resolution calls, gated by Task 1's spike with explicit alternates. ✓ - -**Type consistency:** `ImportUsage(usedFqNames, usedPackages)`, `organizedImportBlock(ktFile, usage): String?`, `KaSession.collectImportUsage(ktFile): ImportUsage`, `OrganizeImportsAction.execAction: List` used consistently across Tasks 3–5. ✓ diff --git a/docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md b/docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md deleted file mode 100644 index 4631866c16..0000000000 --- a/docs/superpowers/specs/2026-07-09-kotlin-organize-imports-design.md +++ /dev/null @@ -1,217 +0,0 @@ -# Design: Organize Imports code action for the Kotlin LSP (ADFA-4614) - -- **Ticket:** [ADFA-4614](https://appdevforall.atlassian.net/browse/ADFA-4614) — *K2-LSP: Code action: Organize imports* -- **Parent:** ADFA-3317 (Integrate K2 compiler with LSP) · **Related:** ADFA-3323 (code-action framework, Done) -- **Branch / worktree:** `feat/ADFA-4614` at `.claude/worktrees/ADFA-4614` (currently identical to its base `feat/ADFA-3322`, which introduced the file-management refactor this design depends on). -- **Date:** 2026-07-09 - -## Goal - -Add a self-contained code action for `.kt` files that rewrites the file's import list into canonical order and removes imports that are not used, computed entirely in-process via the Kotlin Analysis API. It has **no** dependency on unused-import diagnostics (none exist in the LSP, and the FIR compiler defines no `UNUSED_IMPORT` factory) and cannot use IntelliJ's import optimizer (`KotlinOptimizeImportsFacility` / `KotlinImportOptimizer` are IDEA-plugin classes, not on our classpath — the vendored JAR is only the compiler + Analysis API standalone stack). - -## Non-goals (explicitly out of scope) - -- Wildcard **expansion** (`import foo.*` → explicit named imports). -- Wildcard **collapse** / count-threshold behavior (many named imports → `foo.*`). -- A standalone unused-import diagnostic or editor squiggle. -- Any user-facing configuration/settings surface. - -## User-facing behavior - -- Always present in the code-actions menu for Kotlin files (parity with Java's `OrganizeImportsAction`), titled from the existing string `R.string.action_organize_imports` ("Organize imports"), reachable from `lsp/kotlin` via `com.itsaky.androidide.resources.R`. -- On invoke: sorts the surviving imports into canonical order and drops unused/redundant imports, applied as a **single whole-block-replace edit**. -- **No-op when already organized:** if the regenerated import block is byte-identical to the current one, emit no edit (do not dirty the buffer). -- Runs off the UI thread; on any analysis failure or cancellation it aborts with **zero** edits — never a partial rewrite. -- **No `CMD_FORMAT_CODE`** is triggered after applying edits (output is already canonical; reformatting the whole file could reflow unrelated code). This intentionally differs from `AddImportAction`, which does format. - -## Architecture & placement - -All paths are within the `feat/ADFA-4614` worktree. - -- **New action:** `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt`, extending `BaseKotlinCodeAction` (a plain `EditorActionItem`, `location = EDITOR_CODE_ACTIONS`, `requiresUIThread = false`, already gated on `DocumentUtils.isKotlinFile`). `titleTextRes = R.string.action_organize_imports`. -- **Registration:** add `OrganizeImportsAction()` to `KotlinCodeActionsMenu.actions` (`lsp/kotlin/.../KotlinCodeActionsMenu.kt`, at the package root). It is a plain menu action — **not** diagnostic-driven, so `prepare()` needs no `DiagnosticItem` gate. -- **Core algorithm lives in a pure, testable helper** (see "Design for testability"), e.g. `utils/ImportOrganizer.kt`, invoked by the action. The action wrapper stays thin. - -### File access (post-ADFA-3322 refactor — the key change from `stage`) - -The old `getOpenedKtFile` / `openedFiles` map and manual `parser.createFile` reparsing are **gone**. The action obtains its `KtFile` through the new single-flight, version-stamped cache: - -```kotlin -// off the UI thread (execAction is suspend), NOT inside project.read: -val ktFile = env.ktSymbolIndex.getCurrentKtFile(path).get() ?: return /* no-op */ -``` - -- `getCurrentKtFile(path: Path): CompletableFuture` (in `compiler/index/KtSymbolIndex.kt`) reparses live document contents (`FileManager.getDocumentContents`) internally, so **callers do not reparse**. -- **Deadlock rule (mandatory):** fetch the `KtFile` via `.get()`/`.await()` *first*, then do all analysis inside `env.project.read { … }`. The blocking fetch must never run inside `project.read` — its refresh needs `project.write`, and the RW lock is non-upgradeable. - -### Analysis entry point - -Do **not** call `analyze()` directly. Use the repo wrapper: - -```kotlin -env.project.read { - analyzeMaybeDangling(ktFile) { /* KaSession receiver */ ... } -} -``` - -- `analyzeMaybeDangling` (in `compiler/modules/KtFileExts.kt`) serializes all Analysis-API access under `withAnalysisLock` and handles dangling/copy files. -- Analysis runs under the shared `read` lock — it must never call `write`. -- **No `KaLifetimeOwner` may escape the analyze block.** Extract plain data (FqName strings, package strings, booleans) *inside* the block; return only that. - -### Edit generation & dispatch - -- Build **one** `TextEdit` whose range covers the existing import list (compute via the `TextRange.toRange(containingFile)` helper in `utils/EditExts.kt`) and whose `newText` is the regenerated block. If there is no import list, no-op. -- `execAction` computes the `TextEdit`; `postExec` wraps it in a single `CodeActionItem(title, changes = listOf(DocumentChange(file = nioPath, edits = edits)), kind = CodeActionKind.QuickFix)` and calls `data.languageClient?.performCodeAction(item)` — the `AddImportAction` dispatch pattern, but with **no chooser dialog** (single action) and **no `Command.CMD_FORMAT_CODE`**. - - *Note:* use `CodeActionKind.SourceOrganizeImports` if that constant exists in `lsp/models` `CodeActions.kt`; otherwise `QuickFix`. Confirm during implementation. - -## Core algorithm — semantic used-import detection - -Inside a single `analyzeMaybeDangling(ktFile) { … }` pass (under `project.read`): - -### 1. Collect the "used" sets - -Traverse the file body (**excluding** the import list and package directive) and accumulate, as plain data: - -- `usedFqNames: Set` — importable FqName of each used top-level/extension callable, class, object, typealias, enum entry. -- `usedPackages: Set` — the parent package (or containing object FqName) of each used importable symbol (for wildcard matching). - -Sources that must all be covered: - -- **Name references** (`KtNameReferenceExpression` / type references): resolve to symbol → derive importable FqName. (Exact API confirmed by the Step 0 spike — see Risks.) -- **Convention / operator call sites** (no textual name; resolved via `resolveToCall()?.successfulFunctionCallOrNull()?.symbol`). The implementation MUST enumerate every category: - - binary & unary operators (`a + b`, `-a`, `a in b`, comparisons via `compareTo`, `==` via `equals`) - - array access get/set (`a[i]`, `a[i] = v`) - - `invoke` conventions (`a()` where `a` is not a function name) - - `for` loops (`iterator`, `hasNext`, `next`) - - destructuring (`val (x, y) = p` → `component1`, `component2`, …) - - delegated properties (`by lazy` → `getValue` / `setValue` / `provideDelegate`) - -Only **top-level / extension callables, classifiers, and enum entries** credit an import; member callables (imported via their class) do not. Fully-qualified references must not credit the corresponding import. - -### 2. Classify each import directive - -For each `KtImportDirective` in `ktFile.importDirectives`: - -- **Named / aliased** (`import a.b.C`, `import a.b.C as D`): keep iff `importedFqName ∈ usedFqNames`. (An aliased reference resolves to the real FqName, so matching on `importedFqName` handles aliases.) -- **Wildcard** (`import foo.*`): keep iff `usedPackages` contains its parent FqName (package or object). **Unused wildcards are removed**; none are expanded. -- **Exact duplicates:** collapse to the first occurrence. -- **Redundant even if resolvable — removed** (confirmed in scope): - - covered by default imports (`kotlin.*`, `kotlin.collections.*`, etc. — via `KaDefaultImports` data available on the classpath), and - - same-package imports (imported symbol lives in the file's own package). - -### 3. KDoc conservative-keep (accepted approximation) - -KDoc link references (`[Foo]`) are not part of normal resolution. To avoid removing a doc-only import, **keep** any import whose short name/alias appears as a KDoc link identifier. This may under-remove in rare cases; it never wrong-removes. - -### 4. Canonical order - -The surviving imports are emitted as a **single block**, **pure lexicographic sort of the full import path string** (alias suffix included), with **no grouping and no blank lines** — the Kotlin / ktlint / IntelliJ default, consistent with the existing alphabetical insertion in `EditExts.insertImport`. (`foo.*` sorts before `foo.Bar` under string comparison, which is correct.) - -## Edge cases & failure handling - -- No import list, or empty body after the package directive → no-op. -- File with syntax errors: still attempt; unresolved references simply don't join the used set, so we lean toward **keeping** imports (never remove on failed/ambiguous resolution). -- Cancellation (cancel checker) → abort, no edit. -- Regenerated block identical to current → no edit (no-op). -- Whole-block replacement discards comments/blank lines interleaved *within* the import section (rare; IntelliJ likewise reorders freely). Accepted. - -## Design for testability - -- The algorithm is a pure helper — e.g. `ImportOrganizer` — taking `(KaSession, KtFile)` (plus any needed env data) and returning the removable-import decision and the regenerated block text / `TextEdit`. No Android `ActionData`/`Context` dependency. -- The `OrganizeImportsAction` is a thin wrapper: `execAction` fetches the `KtFile`, enters `project.read` + `analyzeMaybeDangling`, calls the helper, returns the `TextEdit`; `postExec` dispatches. - -## Testing - -- **Framework:** Robolectric + JUnit4, mirroring `KtLspTest` (base class with `createSourceFile(...)`, `env.analyze { … }`) and `CurrentKtFileCacheTest` (document open/edit + `getCurrentKtFile` plumbing). There is no existing code-action test to copy, so tests target the `ImportOrganizer` helper directly. -- **Gradle task:** `./gradlew :lsp:kotlin:testV7DebugUnitTest` (flavored Android library; V7 = `armeabi-v7a`). -- **Cases:** - - unused named import removed; used named import kept - - alias used / alias unused - - operator import kept (`a + b`) - - delegate import kept (`by lazy`) - - destructuring component import kept - - `for`/`iterator`, array-access, `invoke` convention imports kept - - wildcard removed when unused; wildcard kept when used - - exact duplicate collapsed - - default-import-redundant removed; same-package-redundant removed - - KDoc-only import kept (conservative) - - correct canonical sort order (including `foo.*` vs `foo.Bar`) - - no-op when already canonical - - syntax-error file → conservative (nothing wrongly removed) - -## Implementation plan (high level) - -- **Step 0 — De-risking spike (throwaway).** In the `lsp/kotlin` test harness, inside `analyzeMaybeDangling`, confirm the exact Analysis-API surface on our vendored JAR: - 1. a plain type reference and a top-level call each yield the expected importable FqName (via `resolveToSymbol`/`classId`/`callableId`/any `importableFqName` — whichever actually exists); - 2. `resolveToCall()?.successfulFunctionCallOrNull()?.symbol` returns the operator function for `+`, `[]`, `by lazy`, and destructuring; - 3. a used symbol maps to its package (wildcard case). - Outcome gates the approach: green → proceed; surprising → adjust to the real API; worst case → narrow the rule for any category the API can't cleanly express (keep rather than remove, and log). -- **Step 1** — `ImportOrganizer` helper + unit tests (TDD against the fixture cases above). -- **Step 2** — `OrganizeImportsAction` wrapper + registration in `KotlinCodeActionsMenu`. -- **Step 3** — manual verification in-app; full test run on `:lsp:kotlin:testV7DebugUnitTest`. - -## Primary risk - -Deriving an importable FqName from a resolved symbol is **unexercised in this repo** (no `resolveToSymbol` / `importableFqName` call sites; resolution elsewhere goes through scopes and the custom symbol index), and the vendored Analysis API is a moving `2.3.255-SNAPSHOT`. Compile-level risk (wrong method/property names) is cheap to discover; the correctness risk is missing a convention-call category, which would wrongly remove a used operator/delegate import and break the build. Mitigations: the Step 0 spike pins the API; the explicit convention checklist prevents missed categories; the "keep unless provably used" rule biases all residual error toward harmless under-removal. `resolveToCall()` + `successfulFunctionCallOrNull()` is already proven in the repo (signature help), which covers the operator path. - -## Confirmed resolution API (Step 0 spike, 2026-07-09) - -The Step 0 spike (`ResolutionSpikeTest`, throwaway, deleted after this run) confirmed the brief's candidate API compiled and passed **verbatim, with zero alternates needed**, against the vendored `2.3.255-SNAPSHOT` Analysis API. Task 4 should use these shapes as-is. - -**(a) Name reference → `KtReference` → resolved symbol:** - -```kotlin -import org.jetbrains.kotlin.idea.references.mainReference -// inside a KaSession (e.g. analyzeMaybeDangling { ... }) -val sym = someNameReferenceExpression.mainReference.resolveToSymbol() -``` - -`KtNameReferenceExpression.mainReference` (the `org.jetbrains.kotlin.idea.references.mainReference` extension) resolved directly; no fallback to `analysis.api.fir.references.*` or manual `.references.filterIsInstance()` was required. - -**(b) Resolved `KaSymbol` → importable FqName string:** - -```kotlin -import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol - -val fq = when (sym) { - is KaClassLikeSymbol -> sym.classId?.asSingleFqName()?.asString() - is KaCallableSymbol -> sym.callableId?.asSingleFqName()?.asString() - else -> null -} -``` - -No direct `KaSymbol.importableFqName` property was tried/needed — the `classId`/`callableId` branch from the brief compiled and produced the correct value on the first try (`java.io.File` for the `File` type reference). Both `ClassId` and `CallableId` expose `asSingleFqName()`. - -**(c) Operator/convention call → function symbol:** - -The resolvable call sits on the **`KtBinaryExpression`** itself (not `KtOperationReferenceExpression`) — `KtElement.resolveToCall()` is available directly on the binary expression: - -```kotlin -import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull -import org.jetbrains.kotlin.analysis.api.resolution.symbol -import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol - -val opSym = someBinaryExpression.resolveToCall()?.successfulFunctionCallOrNull()?.symbol -val callableFq = (opSym as? KaCallableSymbol)?.callableId?.asSingleFqName()?.asString() -``` - -For `BigInteger.ONE + BigInteger.ONE`, this resolved to `kotlin.plus` (the built-in numeric `plus` operator). The important confirmation is that the chain resolves non-null on a `KtBinaryExpression` and yields a `KaCallableSymbol` with a `callableId`. Same chain shape (`resolveToCall()?.successfulFunctionCallOrNull()?.symbol`) is expected to work for `[]`, `by lazy`, and destructuring `KtElement`s (per-repo precedent already used in signature help). - -**(d) File package name from PSI:** - -```kotlin -val pkg = ktFile.packageFqName.asString() -``` - -No analysis session needed — pure PSI. Confirmed value: `p` for `package p`. - -**Logged SPIKE output (from the passing test run):** - -``` -SPIKE type fq = java.io.File -SPIKE plus callableId = kotlin.plus -SPIKE package = p -``` - -**Test harness note:** run inside `env.project.read { analyzeMaybeDangling(ktFile) { ... } }` (imports: `com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling`, `com.itsaky.androidide.lsp.kotlin.compiler.read`), using a `KtFile` from `KtLspTest.createSourceFile(name, content)`. From 93ca1376d2c572e95fd6d0d1c3cc4be5bb37a2e4 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 16 Jul 2026 12:26:29 +0530 Subject: [PATCH 31/31] refactor: reformat Signed-off-by: Akash Yadav --- .../lsp/kotlin/KotlinCodeActionsMenu.kt | 3 +- .../lsp/kotlin/KotlinLanguageServer.kt | 3 +- .../kotlin/actions/OrganizeImportsAction.kt | 21 +- .../lsp/kotlin/utils/ImportOrganizer.kt | 46 ++-- .../lsp/kotlin/utils/ImportUsageCollector.kt | 32 +-- .../lsp/kotlin/utils/ImportOrganizerTest.kt | 217 +++++++++-------- .../kotlin/utils/ImportUsageCollectorTest.kt | 222 +++++++++--------- .../utils/OrganizeImportsEndToEndTest.kt | 1 - 8 files changed, 290 insertions(+), 255 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt index 4bd2a7c6d9..2eed69f3d7 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt @@ -8,7 +8,6 @@ import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction object KotlinCodeActionsMenu : IActionsMenuProvider { - private const val KT_LANG = "kt" private val KT_EXTS = listOf("kt", "kts") private const val KT_LINE_COMMENT_TOKEN = "//" @@ -20,4 +19,4 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { AddImportAction(), OrganizeImportsAction(), ) -} \ No newline at end of file +} 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 c6a2d979b6..3f41f63179 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 @@ -120,8 +120,7 @@ class KotlinLanguageServer : ILanguageServer { } /** Returns the [CompilationEnvironment] responsible for [file], or null if the compiler is not ready. */ - internal fun compilationEnvironmentFor(file: Path): CompilationEnvironment? = - compiler?.compilationEnvironmentFor(file) + internal fun compilationEnvironmentFor(file: Path): CompilationEnvironment? = compiler?.compilationEnvironmentFor(file) override fun applySettings(settings: IServerSettings?) { this._settings = settings diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt index c17137c3a8..603bfaf6cc 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/OrganizeImportsAction.kt @@ -46,7 +46,10 @@ class OrganizeImportsAction : BaseKotlinCodeAction() { * 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. */ - internal fun computeOrganizeEdit(env: AbstractCompilationEnvironment, nioPath: Path): List = + internal fun computeOrganizeEdit( + env: AbstractCompilationEnvironment, + nioPath: Path, + ): List = runCatching { val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList() if (ktFile.importDirectives.isEmpty()) return emptyList() @@ -62,17 +65,21 @@ class OrganizeImportsAction : BaseKotlinCodeAction() { emptyList() } - override fun postExec(data: ActionData, result: Any) { + override fun postExec( + data: ActionData, + result: Any, + ) { super.postExec(data, result) if (result !is List<*> || result.isEmpty()) return @Suppress("UNCHECKED_CAST") result as List - val client = data.languageClient ?: run { - logger.warn("No language client set. Cannot organize imports.") - return - } + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot organize imports.") + return + } val file = data.requireFile() client.performCodeAction( CodeActionItem( @@ -80,7 +87,7 @@ class OrganizeImportsAction : BaseKotlinCodeAction() { changes = listOf(DocumentChange(file = file.toPath(), edits = result)), kind = CodeActionKind.QuickFix, command = Command("", ""), // no post-action command (no CMD_FORMAT_CODE) - ) + ), ) } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt index c170fc7a0f..0e261dc2fc 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizer.kt @@ -1,7 +1,7 @@ package com.itsaky.androidide.lsp.kotlin.utils -import org.jetbrains.kotlin.kdoc.psi.api.KDoc import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.kdoc.psi.api.KDoc import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective @@ -21,18 +21,19 @@ internal data class ImportUsage( ) /** JVM packages that Kotlin imports with a wildcard by default; explicit named imports from these are redundant. */ -internal val DEFAULT_STAR_PACKAGES: Set = setOf( - "kotlin", - "kotlin.annotation", - "kotlin.collections", - "kotlin.comparisons", - "kotlin.io", - "kotlin.ranges", - "kotlin.sequences", - "kotlin.text", - "kotlin.jvm", - "java.lang", -) +internal val DEFAULT_STAR_PACKAGES: Set = + setOf( + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "kotlin.jvm", + "java.lang", + ) private val KDOC_LINK = Regex("""\[([^\]\s]+)]""") @@ -41,18 +42,22 @@ private val KDOC_LINK = Regex("""\[([^\]\s]+)]""") * survivors deduped and lexicographically sorted. Returns null when the imports are already in that * exact form (no edit needed). The returned text has no surrounding newlines. */ -internal fun organizedImportBlock(ktFile: KtFile, usage: ImportUsage): String? { +internal fun organizedImportBlock( + ktFile: KtFile, + usage: ImportUsage, +): String? { val directives = ktFile.importDirectives if (directives.isEmpty()) return null val filePackage = ktFile.packageFqName.asString() val kdocNames = collectKDocLinkNames(ktFile) - val newLines = directives - .filter { keepImport(it, usage, filePackage, kdocNames) } - .mapNotNull { it.importPath?.let { path -> "import $path" } } - .distinct() - .sorted() + val newLines = + directives + .filter { keepImport(it, usage, filePackage, kdocNames) } + .mapNotNull { it.importPath?.let { path -> "import $path" } } + .distinct() + .sorted() val currentLines = directives.mapNotNull { it.importPath?.let { path -> "import $path" } } @@ -91,6 +96,7 @@ private fun keepImport( } private fun collectKDocLinkNames(ktFile: KtFile): Set = - PsiTreeUtil.collectElementsOfType(ktFile, KDoc::class.java) + PsiTreeUtil + .collectElementsOfType(ktFile, KDoc::class.java) .flatMap { kdoc -> KDOC_LINK.findAll(kdoc.text).map { it.groupValues[1].substringAfterLast('.') } } .toSet() diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt index 243cc987c2..cacdb775fe 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollector.kt @@ -77,12 +77,13 @@ internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { // 2) Convention / operator call sites (no textual name reference). ktFile.collectDescendantsOfType().forEach { element -> - val isConvention = element is KtOperationReferenceExpression || - element is KtArrayAccessExpression || - element is KtCallExpression || - element is KtForExpression || - element is KtDestructuringDeclaration || - element is KtPropertyDelegate + val isConvention = + element is KtOperationReferenceExpression || + element is KtArrayAccessExpression || + element is KtCallExpression || + element is KtForExpression || + element is KtDestructuringDeclaration || + element is KtPropertyDelegate if (!isConvention) return@forEach runCatching { record(element.resolveToCall()?.successfulFunctionCallOrNull()?.symbol) @@ -92,12 +93,13 @@ internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage { return ImportUsage(usedFqNames, usedPackages, unresolvedNames) } -private fun KaSymbol.importableFqNameString(): String? = when (this) { - // A constructor's own callableId is null, so it must map to its containing class -- the name - // that's actually imported. Covers `Foo()` calls and `@Foo` annotations (both resolve to the - // constructor). Must precede the KaCallableSymbol branch, which a constructor also matches. - is KaConstructorSymbol -> containingClassId?.asSingleFqName()?.asString() - is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString() - is KaCallableSymbol -> callableId?.asSingleFqName()?.asString() - else -> null -} +private fun KaSymbol.importableFqNameString(): String? = + when (this) { + // A constructor's own callableId is null, so it must map to its containing class -- the name + // that's actually imported. Covers `Foo()` calls and `@Foo` annotations (both resolve to the + // constructor). Must precede the KaCallableSymbol branch, which a constructor also matches. + is KaConstructorSymbol -> containingClassId?.asSingleFqName()?.asString() + is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString() + is KaCallableSymbol -> callableId?.asSingleFqName()?.asString() + else -> null + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt index 8ecbbfd5ac..c39f5c37ad 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportOrganizerTest.kt @@ -7,164 +7,177 @@ import org.junit.Assert.assertNull import org.junit.Test class ImportOrganizerTest : KtLspTest() { - - private fun organize(content: String, usage: ImportUsage): String? { + private fun organize( + content: String, + usage: ImportUsage, + ): String? { val ktFile = createSourceFile("Sample.kt", content) return env.project.read { organizedImportBlock(ktFile, usage) } } @Test fun `removes unused named import and keeps used one`() { - val result = organize( - """ - package p - import a.b.Used - import a.b.Unused - fun f(x: Used) {} - """.trimIndent(), - ImportUsage(usedFqNames = setOf("a.b.Used"), usedPackages = setOf("a.b")), - ) + val result = + organize( + """ + package p + import a.b.Used + import a.b.Unused + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("a.b.Used"), usedPackages = setOf("a.b")), + ) assertEquals("import a.b.Used", result) } @Test fun `sorts imports lexicographically`() { - val result = organize( - """ - package p - import a.b.Zebra - import a.b.Apple - fun f(x: Zebra, y: Apple) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Zebra", "a.b.Apple"), setOf("a.b")), - ) + val result = + organize( + """ + package p + import a.b.Zebra + import a.b.Apple + fun f(x: Zebra, y: Apple) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Zebra", "a.b.Apple"), setOf("a.b")), + ) assertEquals("import a.b.Apple${'\n'}import a.b.Zebra", result) } @Test fun `keeps used wildcard, removes unused wildcard`() { - val result = organize( - """ - package p - import used.pkg.* - import unused.pkg.* - fun f(x: Thing) {} - """.trimIndent(), - ImportUsage(usedFqNames = setOf("used.pkg.Thing"), usedPackages = setOf("used.pkg")), - ) + val result = + organize( + """ + package p + import used.pkg.* + import unused.pkg.* + fun f(x: Thing) {} + """.trimIndent(), + ImportUsage(usedFqNames = setOf("used.pkg.Thing"), usedPackages = setOf("used.pkg")), + ) assertEquals("import used.pkg.*", result) } @Test fun `removes default-import-redundant named import`() { - val result = organize( - """ - package p - import kotlin.collections.List - import a.b.Used - fun f(x: Used) {} - """.trimIndent(), - // even though List resolves, it is redundant (default star package) - ImportUsage(setOf("a.b.Used", "kotlin.collections.List"), setOf("a.b", "kotlin.collections")), - ) + val result = + organize( + """ + package p + import kotlin.collections.List + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + // even though List resolves, it is redundant (default star package) + ImportUsage(setOf("a.b.Used", "kotlin.collections.List"), setOf("a.b", "kotlin.collections")), + ) assertEquals("import a.b.Used", result) } @Test fun `removes same-package redundant import`() { - val result = organize( - """ - package p - import p.Sibling - import a.b.Used - fun f(x: Used, y: Sibling) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Used", "p.Sibling"), setOf("a.b", "p")), - ) + val result = + organize( + """ + package p + import p.Sibling + import a.b.Used + fun f(x: Used, y: Sibling) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used", "p.Sibling"), setOf("a.b", "p")), + ) assertEquals("import a.b.Used", result) } @Test fun `keeps aliased import from default package`() { - val result = organize( - """ - package p - import kotlin.collections.List as KList - import x.y.Unused - fun f(x: KList) {} - """.trimIndent(), - ImportUsage(setOf("kotlin.collections.List"), setOf("kotlin.collections")), - ) + val result = + organize( + """ + package p + import kotlin.collections.List as KList + import x.y.Unused + fun f(x: KList) {} + """.trimIndent(), + ImportUsage(setOf("kotlin.collections.List"), setOf("kotlin.collections")), + ) assertEquals("import kotlin.collections.List as KList", result) } @Test fun `keeps import referenced only in KDoc`() { - val result = organize( - """ - package p - import a.b.DocOnly - import x.y.Unused - /** See [DocOnly] for details. */ - fun f() {} - """.trimIndent(), - ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet()), - ) + val result = + organize( + """ + package p + import a.b.DocOnly + import x.y.Unused + /** See [DocOnly] for details. */ + fun f() {} + """.trimIndent(), + ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet()), + ) assertEquals("import a.b.DocOnly", result) } @Test fun `keeps import matching an unresolved reference`() { - val result = organize( - """ - package p - import a.b.Mystery - import x.y.Unused - fun f(m: Mystery) {} - """.trimIndent(), - // Mystery didn't resolve, so it's absent from usedFqNames but present as unresolved. - ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet(), unresolvedNames = setOf("Mystery")), - ) + val result = + organize( + """ + package p + import a.b.Mystery + import x.y.Unused + fun f(m: Mystery) {} + """.trimIndent(), + // Mystery didn't resolve, so it's absent from usedFqNames but present as unresolved. + ImportUsage(usedFqNames = emptySet(), usedPackages = emptySet(), unresolvedNames = setOf("Mystery")), + ) assertEquals("import a.b.Mystery", result) } @Test fun `collapses exact duplicate imports`() { - val result = organize( - """ - package p - import a.b.Used - import a.b.Used - fun f(x: Used) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Used"), setOf("a.b")), - ) + val result = + organize( + """ + package p + import a.b.Used + import a.b.Used + fun f(x: Used) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Used"), setOf("a.b")), + ) assertEquals("import a.b.Used", result) } @Test fun `returns null when already organized`() { - val result = organize( - """ - package p - import a.b.Apple - import a.b.Zebra - fun f(x: Apple, y: Zebra) {} - """.trimIndent(), - ImportUsage(setOf("a.b.Apple", "a.b.Zebra"), setOf("a.b")), - ) + val result = + organize( + """ + package p + import a.b.Apple + import a.b.Zebra + fun f(x: Apple, y: Zebra) {} + """.trimIndent(), + ImportUsage(setOf("a.b.Apple", "a.b.Zebra"), setOf("a.b")), + ) assertNull(result) } @Test fun `no imports returns null`() { - val result = organize( - """ - package p - fun f() {} - """.trimIndent(), - ImportUsage(emptySet(), emptySet()), - ) + val result = + organize( + """ + package p + fun f() {} + """.trimIndent(), + ImportUsage(emptySet(), emptySet()), + ) assertNull(result) } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt index 6676c3a4c6..c63f8ba098 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/ImportUsageCollectorTest.kt @@ -9,20 +9,19 @@ import org.junit.Assert.assertTrue import org.junit.Test class ImportUsageCollectorTest : KtLspTest() { - - private fun usageOf(ktFile: KtFile): ImportUsage = - env.project.read { analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } } + private fun usageOf(ktFile: KtFile): ImportUsage = env.project.read { analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) } } @Test fun `type reference is recorded as used`() { - val ktFile = createSourceFile( - "UseType.kt", - """ - package p - fun f(): java.io.File? = null - fun g() { val x: java.io.File = java.io.File("a") } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseType.kt", + """ + package p + fun f(): java.io.File? = null + fun g() { val x: java.io.File = java.io.File("a") } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("java.io.File" in usage.usedFqNames) assertTrue("java.io" in usage.usedPackages) @@ -37,14 +36,15 @@ class ImportUsageCollectorTest : KtLspTest() { fun topLevelHelper() {} """.trimIndent(), ) - val ktFile = createSourceFile( - "UseFn.kt", - """ - package p - import lib.topLevelHelper - fun f() { topLevelHelper() } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseFn.kt", + """ + package p + import lib.topLevelHelper + fun f() { topLevelHelper() } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.topLevelHelper" in usage.usedFqNames) } @@ -59,15 +59,16 @@ class ImportUsageCollectorTest : KtLspTest() { operator fun Money.plus(other: Money): Money = this """.trimIndent(), ) - val ktFile = createSourceFile( - "UseOp.kt", - """ - package p - import lib.Money - import lib.plus - fun f(a: Money, b: Money) { val c = a + b } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseOp.kt", + """ + package p + import lib.Money + import lib.plus + fun f(a: Money, b: Money) { val c = a + b } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.plus" in usage.usedFqNames) } @@ -81,28 +82,30 @@ class ImportUsageCollectorTest : KtLspTest() { class Extra """.trimIndent(), ) - val ktFile = createSourceFile( - "NoUse.kt", - """ - package p - import lib.Extra - fun f() {} - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "NoUse.kt", + """ + package p + import lib.Extra + fun f() {} + """.trimIndent(), + ) val usage = usageOf(ktFile) assertFalse("lib.Extra" in usage.usedFqNames) } @Test fun `unresolved reference is recorded by short name, not as used`() { - val ktFile = createSourceFile( - "Unresolved.kt", - """ - package p - import a.b.Mystery - fun f(m: Mystery) {} - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "Unresolved.kt", + """ + package p + import a.b.Mystery + fun f(m: Mystery) {} + """.trimIndent(), + ) val usage = usageOf(ktFile) assertFalse("a.b.Mystery" in usage.usedFqNames) assertTrue("Mystery" in usage.unresolvedNames) @@ -118,15 +121,16 @@ class ImportUsageCollectorTest : KtLspTest() { operator fun Foo.get(i: Int): Int = i """.trimIndent(), ) - val ktFile = createSourceFile( - "UseGet.kt", - """ - package p - import lib.Foo - import lib.get - fun f(foo: Foo) { val x = foo[0] } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseGet.kt", + """ + package p + import lib.Foo + import lib.get + fun f(foo: Foo) { val x = foo[0] } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.get" in usage.usedFqNames) } @@ -141,15 +145,16 @@ class ImportUsageCollectorTest : KtLspTest() { operator fun Foo.set(i: Int, v: Int) {} """.trimIndent(), ) - val ktFile = createSourceFile( - "UseSet.kt", - """ - package p - import lib.Foo - import lib.set - fun f(foo: Foo) { foo[0] = 1 } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseSet.kt", + """ + package p + import lib.Foo + import lib.set + fun f(foo: Foo) { foo[0] = 1 } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.set" in usage.usedFqNames) } @@ -164,15 +169,16 @@ class ImportUsageCollectorTest : KtLspTest() { operator fun Foo.iterator(): Iterator = listOf(1, 2, 3).iterator() """.trimIndent(), ) - val ktFile = createSourceFile( - "UseIterator.kt", - """ - package p - import lib.Foo - import lib.iterator - fun f(foo: Foo) { for (x in foo) {} } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseIterator.kt", + """ + package p + import lib.Foo + import lib.iterator + fun f(foo: Foo) { for (x in foo) {} } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.iterator" in usage.usedFqNames) } @@ -188,16 +194,17 @@ class ImportUsageCollectorTest : KtLspTest() { operator fun Foo.component2(): Int = 2 """.trimIndent(), ) - val ktFile = createSourceFile( - "UseDestructure.kt", - """ - package p - import lib.Foo - import lib.component1 - import lib.component2 - fun f(foo: Foo) { val (a, b) = foo } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseDestructure.kt", + """ + package p + import lib.Foo + import lib.component1 + import lib.component2 + fun f(foo: Foo) { val (a, b) = foo } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.component1" in usage.usedFqNames) assertTrue("lib.component2" in usage.usedFqNames) @@ -212,14 +219,15 @@ class ImportUsageCollectorTest : KtLspTest() { class Widget(val n: Int) """.trimIndent(), ) - val ktFile = createSourceFile( - "UseCtor.kt", - """ - package p - import lib.Widget - fun f() { val w = Widget(1) } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseCtor.kt", + """ + package p + import lib.Widget + fun f() { val w = Widget(1) } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.Widget" in usage.usedFqNames) } @@ -233,14 +241,15 @@ class ImportUsageCollectorTest : KtLspTest() { annotation class Marker """.trimIndent(), ) - val ktFile = createSourceFile( - "UseAnnotation.kt", - """ - package p - import lib.Marker - @Marker fun f() {} - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseAnnotation.kt", + """ + package p + import lib.Marker + @Marker fun f() {} + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.Marker" in usage.usedFqNames) } @@ -256,15 +265,16 @@ class ImportUsageCollectorTest : KtLspTest() { operator fun Foo.getValue(thisRef: Any?, property: KProperty<*>): Int = 1 """.trimIndent(), ) - val ktFile = createSourceFile( - "UseDelegate.kt", - """ - package p - import lib.Foo - import lib.getValue - fun f(foo: Foo) { val x: Int by foo } - """.trimIndent(), - ) + val ktFile = + createSourceFile( + "UseDelegate.kt", + """ + package p + import lib.Foo + import lib.getValue + fun f(foo: Foo) { val x: Int by foo } + """.trimIndent(), + ) val usage = usageOf(ktFile) assertTrue("lib.getValue" in usage.usedFqNames) } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt index 1e269c6bfd..b695c58dc9 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/OrganizeImportsEndToEndTest.kt @@ -9,7 +9,6 @@ import org.junit.Assert.assertTrue import org.junit.Test class OrganizeImportsEndToEndTest : KtLspTest() { - @Test fun `removes unused import end to end`() { createSourceFile(