Skip to content

Commit c51101e

Browse files
authored
ADFA-4614: K2-LSP code action — Organize imports (#1502)
1 parent 1db7242 commit c51101e

9 files changed

Lines changed: 886 additions & 1042 deletions

File tree

‎docs/superpowers/plans/2026-06-25-enhance-ai-assistant-plugin-ui.md‎

Lines changed: 0 additions & 1040 deletions
This file was deleted.

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,9 +5,9 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction
55
import com.itsaky.androidide.lsp.actions.IActionsMenuProvider
66
import com.itsaky.androidide.lsp.actions.UncommentLineAction
77
import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction
8+
import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction
89

910
object KotlinCodeActionsMenu : IActionsMenuProvider {
10-
1111
private const val KT_LANG = "kt"
1212
private val KT_EXTS = listOf("kt", "kts")
1313
private const val KT_LINE_COMMENT_TOKEN = "//"
@@ -17,5 +17,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider {
1717
CommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN),
1818
UncommentLineAction(KT_LANG, KT_EXTS, KT_LINE_COMMENT_TOKEN),
1919
AddImportAction(),
20+
OrganizeImportsAction(),
2021
)
21-
}
22+
}

‎lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinLanguageServer.kt‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -30,6 +30,7 @@ import com.itsaky.androidide.eventbus.events.file.FileRenameEvent
3030
import com.itsaky.androidide.lsp.api.ILanguageClient
3131
import com.itsaky.androidide.lsp.api.ILanguageServer
3232
import com.itsaky.androidide.lsp.api.IServerSettings
33+
import com.itsaky.androidide.lsp.kotlin.compiler.CompilationEnvironment
3334
import com.itsaky.androidide.lsp.kotlin.compiler.Compiler
3435
import com.itsaky.androidide.lsp.kotlin.compiler.KotlinProjectModel
3536
import com.itsaky.androidide.lsp.kotlin.compiler.index.KT_SOURCE_FILE_INDEX_KEY
@@ -118,6 +119,9 @@ class KotlinLanguageServer : ILanguageServer {
118119
this.compiler?.updateLanguageClient(client)
119120
}
120121

122+
/** Returns the [CompilationEnvironment] responsible for [file], or null if the compiler is not ready. */
123+
internal fun compilationEnvironmentFor(file: Path): CompilationEnvironment? = compiler?.compilationEnvironmentFor(file)
124+
121125
override fun applySettings(settings: IServerSettings?) {
122126
this._settings = settings
123127
}
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
package com.itsaky.androidide.lsp.kotlin.actions
2+
3+
import com.itsaky.androidide.actions.ActionData
4+
import com.itsaky.androidide.actions.get
5+
import com.itsaky.androidide.actions.requireFile
6+
import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer
7+
import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment
8+
import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling
9+
import com.itsaky.androidide.lsp.kotlin.compiler.read
10+
import com.itsaky.androidide.lsp.kotlin.utils.collectImportUsage
11+
import com.itsaky.androidide.lsp.kotlin.utils.organizedImportBlock
12+
import com.itsaky.androidide.lsp.kotlin.utils.toRange
13+
import com.itsaky.androidide.lsp.models.CodeActionItem
14+
import com.itsaky.androidide.lsp.models.CodeActionKind
15+
import com.itsaky.androidide.lsp.models.Command
16+
import com.itsaky.androidide.lsp.models.DocumentChange
17+
import com.itsaky.androidide.lsp.models.TextEdit
18+
import com.itsaky.androidide.models.Range
19+
import com.itsaky.androidide.resources.R
20+
import org.slf4j.LoggerFactory
21+
import java.nio.file.Path
22+
23+
class OrganizeImportsAction : BaseKotlinCodeAction() {
24+
override var titleTextRes: Int = R.string.action_organize_imports
25+
override val id: String = "ide.editor.lsp.kt.organizeImports"
26+
override var label: String = ""
27+
28+
companion object {
29+
private val logger = LoggerFactory.getLogger(OrganizeImportsAction::class.java)
30+
}
31+
32+
override suspend fun execAction(data: ActionData): List<TextEdit> {
33+
val server = data.get<KotlinLanguageServer>() ?: return emptyList()
34+
val nioPath = data.requireFile().toPath()
35+
val env = server.compilationEnvironmentFor(nioPath) ?: return emptyList()
36+
return computeOrganizeEdit(env, nioPath)
37+
}
38+
39+
/**
40+
* Computes the text edits that organize the imports of the file at [nioPath] within [env].
41+
* The current [org.jetbrains.kotlin.psi.KtFile] is fetched BEFORE entering [read] (deadlock
42+
* rule: never block on `getCurrentKtFile(...).get()` inside `project.read`). Returns an empty
43+
* list when there is nothing to do (no imports, already organized, or no usable range) *and*
44+
* whenever anything in this pipeline (the `.get()`, analysis, or PSI access) throws: the action
45+
* framework only catches [IllegalArgumentException] and this runs on a coroutine scope with no
46+
* exception handler, so an uncaught throw here would crash the app. Degrading to zero edits is
47+
* always safe -- it just leaves the imports as-is, never produces a partial/incorrect rewrite.
48+
*/
49+
internal fun computeOrganizeEdit(
50+
env: AbstractCompilationEnvironment,
51+
nioPath: Path,
52+
): List<TextEdit> =
53+
runCatching {
54+
val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return emptyList()
55+
if (ktFile.importDirectives.isEmpty()) return emptyList()
56+
env.project.read {
57+
val usage = analyzeMaybeDangling(ktFile) { collectImportUsage(ktFile) }
58+
val newText = organizedImportBlock(ktFile, usage) ?: return@read emptyList()
59+
val range = ktFile.importList?.textRange?.toRange(ktFile) ?: return@read emptyList()
60+
if (range == Range.NONE) return@read emptyList()
61+
listOf(TextEdit(range, newText))
62+
}
63+
}.getOrElse { e ->
64+
logger.warn("Failed to organize imports", e)
65+
emptyList()
66+
}
67+
68+
override fun postExec(
69+
data: ActionData,
70+
result: Any,
71+
) {
72+
super.postExec(data, result)
73+
if (result !is List<*> || result.isEmpty()) return
74+
75+
@Suppress("UNCHECKED_CAST")
76+
result as List<TextEdit>
77+
78+
val client =
79+
data.languageClient ?: run {
80+
logger.warn("No language client set. Cannot organize imports.")
81+
return
82+
}
83+
val file = data.requireFile()
84+
client.performCodeAction(
85+
CodeActionItem(
86+
title = label,
87+
changes = listOf(DocumentChange(file = file.toPath(), edits = result)),
88+
kind = CodeActionKind.QuickFix,
89+
command = Command("", ""), // no post-action command (no CMD_FORMAT_CODE)
90+
),
91+
)
92+
}
93+
}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
package com.itsaky.androidide.lsp.kotlin.utils
2+
3+
import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil
4+
import org.jetbrains.kotlin.kdoc.psi.api.KDoc
5+
import org.jetbrains.kotlin.psi.KtFile
6+
import org.jetbrains.kotlin.psi.KtImportDirective
7+
8+
/**
9+
* What a file's body actually uses, expressed as plain strings so nothing crosses an `analyze`
10+
* lifetime boundary. Produced by [collectImportUsage].
11+
*
12+
* @property usedFqNames importable fully-qualified names referenced by the body.
13+
* @property usedPackages parent packages/objects of used symbols (for wildcard matching).
14+
* @property unresolvedNames short names of body references that failed to resolve; an import
15+
* matching one of these is kept, since a resolution failure can't prove the import unused.
16+
*/
17+
internal data class ImportUsage(
18+
val usedFqNames: Set<String>,
19+
val usedPackages: Set<String>,
20+
val unresolvedNames: Set<String> = emptySet(),
21+
)
22+
23+
/** JVM packages that Kotlin imports with a wildcard by default; explicit named imports from these are redundant. */
24+
internal val DEFAULT_STAR_PACKAGES: Set<String> =
25+
setOf(
26+
"kotlin",
27+
"kotlin.annotation",
28+
"kotlin.collections",
29+
"kotlin.comparisons",
30+
"kotlin.io",
31+
"kotlin.ranges",
32+
"kotlin.sequences",
33+
"kotlin.text",
34+
"kotlin.jvm",
35+
"java.lang",
36+
)
37+
38+
private val KDOC_LINK = Regex("""\[([^\]\s]+)]""")
39+
40+
/**
41+
* Computes the canonical import block for [ktFile] given [usage]: unused/redundant imports removed,
42+
* survivors deduped and lexicographically sorted. Returns null when the imports are already in that
43+
* exact form (no edit needed). The returned text has no surrounding newlines.
44+
*/
45+
internal fun organizedImportBlock(
46+
ktFile: KtFile,
47+
usage: ImportUsage,
48+
): String? {
49+
val directives = ktFile.importDirectives
50+
if (directives.isEmpty()) return null
51+
52+
val filePackage = ktFile.packageFqName.asString()
53+
val kdocNames = collectKDocLinkNames(ktFile)
54+
55+
val newLines =
56+
directives
57+
.filter { keepImport(it, usage, filePackage, kdocNames) }
58+
.mapNotNull { it.importPath?.let { path -> "import $path" } }
59+
.distinct()
60+
.sorted()
61+
62+
val currentLines = directives.mapNotNull { it.importPath?.let { path -> "import $path" } }
63+
64+
if (newLines == currentLines) return null
65+
return newLines.joinToString(System.lineSeparator())
66+
}
67+
68+
private fun keepImport(
69+
directive: KtImportDirective,
70+
usage: ImportUsage,
71+
filePackage: String,
72+
kdocNames: Set<String>,
73+
): Boolean {
74+
val fqName = directive.importedFqName ?: return true // malformed import -> keep (conservative)
75+
val fqNameStr = fqName.asString()
76+
val alias = directive.aliasName
77+
val shortName = alias ?: fqName.shortName().asString()
78+
79+
// Conservative: keep anything referenced by short name/alias in a KDoc link.
80+
if (shortName in kdocNames) return true
81+
82+
// Conservative: an unresolved body reference by this short name can't prove the import dead.
83+
if (shortName in usage.unresolvedNames) return true
84+
85+
if (directive.isAllUnder) {
86+
// Wildcard: keep iff some used symbol lives in this package/object.
87+
return fqNameStr in usage.usedPackages
88+
}
89+
90+
val parentPackage = fqName.parent().asString()
91+
// Redundant named imports (only when not aliased — an alias is meaningful).
92+
if (alias == null && parentPackage in DEFAULT_STAR_PACKAGES) return false
93+
if (alias == null && parentPackage == filePackage) return false
94+
95+
return fqNameStr in usage.usedFqNames
96+
}
97+
98+
private fun collectKDocLinkNames(ktFile: KtFile): Set<String> =
99+
PsiTreeUtil
100+
.collectElementsOfType(ktFile, KDoc::class.java)
101+
.flatMap { kdoc -> KDOC_LINK.findAll(kdoc.text).map { it.groupValues[1].substringAfterLast('.') } }
102+
.toSet()
Lines changed: 105 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,105 @@
1+
package com.itsaky.androidide.lsp.kotlin.utils
2+
3+
import org.jetbrains.kotlin.analysis.api.KaSession
4+
import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull
5+
import org.jetbrains.kotlin.analysis.api.resolution.symbol
6+
import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol
7+
import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol
8+
import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol
9+
import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol
10+
import org.jetbrains.kotlin.idea.references.mainReference
11+
import org.jetbrains.kotlin.psi.KtArrayAccessExpression
12+
import org.jetbrains.kotlin.psi.KtCallExpression
13+
import org.jetbrains.kotlin.psi.KtDestructuringDeclaration
14+
import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry
15+
import org.jetbrains.kotlin.psi.KtElement
16+
import org.jetbrains.kotlin.psi.KtFile
17+
import org.jetbrains.kotlin.psi.KtForExpression
18+
import org.jetbrains.kotlin.psi.KtImportList
19+
import org.jetbrains.kotlin.psi.KtNameReferenceExpression
20+
import org.jetbrains.kotlin.psi.KtOperationReferenceExpression
21+
import org.jetbrains.kotlin.psi.KtPropertyDelegate
22+
import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType
23+
import org.jetbrains.kotlin.psi.psiUtil.getParentOfType
24+
25+
/**
26+
* Collects the importable fq-names (and their packages) referenced by [ktFile]'s body. MUST be
27+
* called inside [analyzeMaybeDangling]. Returns only plain strings, so nothing escapes the analyze
28+
* lifetime. A reference that fails to resolve doesn't join the used set; instead its short name is
29+
* recorded in [ImportUsage.unresolvedNames] so its import is kept. Both paths are safe: they lead to
30+
* keeping an import, never removing a used one.
31+
*/
32+
internal fun KaSession.collectImportUsage(ktFile: KtFile): ImportUsage {
33+
val usedFqNames = HashSet<String>()
34+
val usedPackages = HashSet<String>()
35+
val unresolvedNames = HashSet<String>()
36+
37+
fun record(symbol: KaSymbol?) {
38+
val fq = symbol?.importableFqNameString() ?: return
39+
usedFqNames += fq
40+
val pkg = fq.substringBeforeLast('.', missingDelimiterValue = "")
41+
if (pkg.isNotEmpty()) usedPackages += pkg
42+
}
43+
44+
fun recordAll(symbols: Collection<KaSymbol>?) {
45+
symbols?.forEach(::record)
46+
}
47+
48+
// 1) Plain name / type references (excluding the import list itself). A null (or thrown)
49+
// resolution is treated as unresolved and its short name kept, so a used-but-unresolvable
50+
// reference never drops its import. A non-null, non-importable symbol (local, param) is a
51+
// clean resolve: it records nothing and is not unresolved.
52+
ktFile.collectDescendantsOfType<KtNameReferenceExpression>().forEach { ref ->
53+
if (ref.getParentOfType<KtImportList>(strict = false) != null) return@forEach
54+
val symbol = runCatching { ref.mainReference.resolveToSymbol() }.getOrNull()
55+
if (symbol != null) record(symbol) else unresolvedNames += ref.getReferencedName()
56+
}
57+
58+
// 1b) Implicit-convention references that carry more than one resolution target and so don't
59+
// resolve through `resolveToSymbol()` (singular; returns null when ambiguous) but do resolve
60+
// through `resolveToSymbols()` (plural). Confirmed empirically:
61+
// - KtForExpression: resolves to [iterator(), hasNext(), next()] -- iterator is the
62+
// user-importable one for a `for (x in foo)` loop.
63+
// - KtDestructuringDeclarationEntry (one per destructured variable): resolves to that
64+
// variable's own componentN() symbol.
65+
// - KtPropertyDelegate: resolves to the delegate's getValue()/setValue() symbol(s).
66+
// Recording every returned symbol is safe: extra (e.g. stdlib Iterator.next) symbols only ever
67+
// keep an import, never drop a used one.
68+
ktFile.collectDescendantsOfType<KtForExpression>().forEach { forExpr ->
69+
runCatching { recordAll(forExpr.mainReference?.resolveToSymbols()) }
70+
}
71+
ktFile.collectDescendantsOfType<KtDestructuringDeclarationEntry>().forEach { entry ->
72+
runCatching { recordAll(entry.mainReference?.resolveToSymbols()) }
73+
}
74+
ktFile.collectDescendantsOfType<KtPropertyDelegate>().forEach { delegate ->
75+
runCatching { recordAll(delegate.mainReference?.resolveToSymbols()) }
76+
}
77+
78+
// 2) Convention / operator call sites (no textual name reference).
79+
ktFile.collectDescendantsOfType<KtElement>().forEach { element ->
80+
val isConvention =
81+
element is KtOperationReferenceExpression ||
82+
element is KtArrayAccessExpression ||
83+
element is KtCallExpression ||
84+
element is KtForExpression ||
85+
element is KtDestructuringDeclaration ||
86+
element is KtPropertyDelegate
87+
if (!isConvention) return@forEach
88+
runCatching {
89+
record(element.resolveToCall()?.successfulFunctionCallOrNull()?.symbol)
90+
}
91+
}
92+
93+
return ImportUsage(usedFqNames, usedPackages, unresolvedNames)
94+
}
95+
96+
private fun KaSymbol.importableFqNameString(): String? =
97+
when (this) {
98+
// A constructor's own callableId is null, so it must map to its containing class -- the name
99+
// that's actually imported. Covers `Foo()` calls and `@Foo` annotations (both resolve to the
100+
// constructor). Must precede the KaCallableSymbol branch, which a constructor also matches.
101+
is KaConstructorSymbol -> containingClassId?.asSingleFqName()?.asString()
102+
is KaClassLikeSymbol -> classId?.asSingleFqName()?.asString()
103+
is KaCallableSymbol -> callableId?.asSingleFqName()?.asString()
104+
else -> null
105+
}

0 commit comments

Comments
 (0)