From f97a3989118d08569aebb18a9c0a656f4258603a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:17:35 +0000 Subject: [PATCH 01/25] ADFA-4826: Enable Compose in lsp/kotlin The refactoring bottom sheets are Compose (ADR 0009) and live in this module rather than a UI module because `editor` depends on it, not the reverse (ADR 0011). Adds the lifecycle-runtime-compose catalog entry for collectAsStateWithLifecycle(). --- gradle/libs.versions.toml | 2 ++ lsp/kotlin/build.gradle.kts | 23 +++++++++++++++++++++++ 2 files changed, 25 insertions(+) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5648a02daf..13124ed35f 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -96,6 +96,8 @@ androidx-fragment = { module = "androidx.fragment:fragment", version.ref = "frag androidx-lifecycle-viewmodel-ktx = { module = "androidx.lifecycle:lifecycle-viewmodel-ktx", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-process = { module = "androidx.lifecycle:lifecycle-process", version.ref = "lifecycleViewmodelKtx" } androidx-lifecycle-runtime-ktx = { module = "androidx.lifecycle:lifecycle-runtime-ktx", version.ref = "lifecycleViewmodelKtx" } +# Provides collectAsStateWithLifecycle(), the state-collection API mandated by ADR 0009. +androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleViewmodelKtx" } androidx-palette-ktx = { module = "androidx.palette:palette-ktx", version.ref = "paletteKtx" } androidx-preference-ktx = { module = "androidx.preference:preference-ktx", version.ref = "preferenceKtxVersion" } androidx-recyclerview-v132 = { module = "androidx.recyclerview:recyclerview", version.ref = "recyclerview" } diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 9b16f87796..27f92b80a7 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -21,11 +21,18 @@ plugins { id("com.android.library") id("kotlin-android") id("kotlin-kapt") + alias(libs.plugins.kotlin.compose) } android { namespace = "${BuildConfig.PACKAGE_NAME}.lsp.kotlin" + // The refactoring bottom sheets are Compose (ADR 0009); they live here rather than in a UI + // module because `editor` depends on this module, not the reverse (ADR 0011). + buildFeatures { + compose = true + } + kotlin.compilerOptions { freeCompilerArgs.addAll("-Xcontext-parameters") } @@ -51,6 +58,22 @@ dependencies { implementation(projects.subprojects.projects) implementation(projects.subprojects.projectModels) + implementation(projects.commonCompose) + + implementation(platform(libs.compose.bom)) + implementation(libs.compose.runtime) + implementation(libs.compose.ui) + implementation(libs.compose.foundation) + implementation(libs.compose.material3) + implementation(libs.compose.ui.tooling.preview) + debugImplementation(libs.compose.ui.tooling) + + implementation(libs.androidx.fragment.ktx) + implementation(libs.androidx.lifecycle.runtime.ktx) + implementation(libs.androidx.lifecycle.viewmodel.ktx) + implementation(libs.androidx.lifecycle.runtime.compose) + implementation(libs.google.material) + implementation(libs.common.jsonrpc) implementation(libs.common.kotlin) implementation(libs.common.kotlin.coroutines.core) From 27126725d8ac179b4abdaa4f2c2de853173549e1 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:18:00 +0000 Subject: [PATCH 02/25] ADFA-4826: Add extract-variable analysis, plan and rewrite One background analysis pass produces a plain-data ExtractionPlan covering every candidate expression - its legal scope chain, occurrence set and suggested name - so the UI does pure offset arithmetic and never touches PSI (ADR 0011). Occurrence matching is symbol-aware, not textual: two sites match only when they are structurally equal and every name reference resolves to the same declaration. Sites made unsound by an intervening write are excluded rather than warned about. --- .../utils/refactor/CandidateExpressions.kt | 220 ++++++++++ .../utils/refactor/ExtractVariableEdit.kt | 179 ++++++++ .../utils/refactor/ExtractVariablePlanner.kt | 132 ++++++ .../kotlin/utils/refactor/ExtractionPlan.kt | 164 ++++++++ .../kotlin/utils/refactor/NameSuggestion.kt | 155 +++++++ .../lsp/kotlin/utils/refactor/Occurrences.kt | 273 ++++++++++++ .../lsp/kotlin/utils/refactor/ScopeChain.kt | 262 ++++++++++++ .../utils/refactor/ExtractVariableEditTest.kt | 284 +++++++++++++ .../ExtractVariablePlanEndToEndTest.kt | 389 ++++++++++++++++++ .../utils/refactor/RefactorPrimitivesTest.kt | 142 +++++++ 10 files changed, 2200 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt new file mode 100644 index 0000000000..8c0510c27f --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt @@ -0,0 +1,220 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtAnnotationEntry +import org.jetbrains.kotlin.psi.KtAnonymousInitializer +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtBreakExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtConstantExpression +import org.jetbrains.kotlin.psi.KtContinueExpression +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry +import org.jetbrains.kotlin.psi.KtLoopExpression +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtReturnExpression +import org.jetbrains.kotlin.psi.KtStringTemplateEntry +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.KtSuperExpression +import org.jetbrains.kotlin.psi.KtSuperTypeListEntry +import org.jetbrains.kotlin.psi.KtThrowExpression + +/** How many candidate expressions are ever offered. Keeps the chooser scannable on a phone. */ +const val MAX_CANDIDATES = 3 + +/** + * The purely syntactic result of resolving a cursor or selection to extraction targets. + * + * [expressions] is innermost-first and at most [MAX_CANDIDATES] long. [selectionMatchedInnermost] is + * true when the caller passed a non-empty selection whose trimmed range is exactly the innermost + * candidate's range -- the user has already said which expression they mean, so the UI can skip + * asking. + */ +data class CandidateSyntax( + val expressions: List, + val selectionMatchedInnermost: Boolean, +) { + companion object { + val NONE = CandidateSyntax(emptyList(), selectionMatchedInnermost = false) + } +} + +/** + * Resolves `[selectionStart, selectionEnd)` in [file] to candidate expressions. A cursor is the + * degenerate case where the two offsets are equal, so callers need only one code path. + * + * The selection is whitespace-trimmed first, because a touch-screen selection routinely carries a + * leading or trailing space. From the resulting innermost element the parent chain is walked + * outwards, keeping legal targets ([isLegalExtractionTarget]) and stopping at the enclosing + * declaration. Blocks and other illegal nodes along the way are skipped rather than terminating the + * walk, so `if (c) a else b` is still offered from inside one of its branches. + * + * Returns [CandidateSyntax.NONE] when the position cannot host an extraction at all -- see + * [isExtractionPosition]. + */ +fun candidateExpressionsAt( + file: KtFile, + selectionStart: Int, + selectionEnd: Int, +): CandidateSyntax { + val text = file.text + val (start, end) = trimToCode(text, selectionStart, selectionEnd) ?: return CandidateSyntax.NONE + + val anchor = innermostElementFor(file, start, end) ?: return CandidateSyntax.NONE + if (!isExtractionPosition(anchor)) return CandidateSyntax.NONE + + val collected = mutableListOf() + val seen = mutableSetOf>() + var element: PsiElement? = anchor + while (element != null && element !is KtFile) { + if (element is KtDeclaration && element !is KtFunctionLiteral) break + if (element is KtExpression && element.isLegalExtractionTarget()) { + val range = element.textRange.startOffset to element.textRange.endOffset + if (seen.add(range)) { + collected += element + if (collected.size == MAX_CANDIDATES) break + } + } + element = element.parent + } + + if (collected.isEmpty()) return CandidateSyntax.NONE + + val innermost = collected.first().textRange + val matched = + selectionStart != selectionEnd && + innermost.startOffset == start && + innermost.endOffset == end + return CandidateSyntax(collected, matched) +} + +/** + * Trims whitespace off both ends of `[start, end)`. Returns null when nothing but whitespace was + * selected. A cursor (start == end) is returned unchanged. + */ +internal fun trimToCode( + text: String, + start: Int, + end: Int, +): Pair? { + if (start < 0 || end > text.length || start > end) return null + if (start == end) return start to end + var s = start + var e = end + while (s < e && text[s].isWhitespace()) s++ + while (e > s && text[e - 1].isWhitespace()) e-- + return if (s == e) null else s to e +} + +/** + * The innermost element covering `[start, end)`. For a cursor, [KtFile.findElementAt] is tried at + * the offset and then just before it, so a caret sitting immediately after a token still resolves. + */ +private fun innermostElementFor( + file: KtFile, + start: Int, + end: Int, +): PsiElement? { + if (start == end) { + val at = file.findElementAt(start)?.takeUnless { it is PsiWhiteSpace } + val before = file.findElementAt((start - 1).coerceAtLeast(0))?.takeUnless { it is PsiWhiteSpace } + return at ?: before + } + val first = file.findElementAt(start) ?: return null + val last = file.findElementAt(end - 1) ?: return null + return PsiTreeUtil.findCommonParent(first, last) +} + +/** + * Whether [element] sits somewhere an extraction can legally be anchored. + * + * Rejects the positions where no `val` can precede the expression: + * - **annotation arguments** -- must be compile-time constants; + * - **default parameter values** -- evaluated per call, and a hoisted local would not be in scope; + * - **super-constructor delegation arguments** -- nothing can precede them; + * - **anything outside an executable body** -- notably a class-body property initializer, which has + * no block to insert into. Converting one to a getter would change compute-once into + * compute-per-access, so it is declined instead. + */ +internal fun isExtractionPosition(element: PsiElement): Boolean { + if (PsiTreeUtil.getParentOfType(element, KtAnnotationEntry::class.java, false) != null) return false + if (PsiTreeUtil.getParentOfType(element, KtSuperTypeListEntry::class.java, false) != null) return false + + val parameter = PsiTreeUtil.getParentOfType(element, KtParameter::class.java, false) + if (parameter != null && parameter.defaultValue?.isAncestorOf(element) == true) return false + + return enclosingExecutableBody(element) != null +} + +/** + * The nearest enclosing thing with a body that can hold statements: a lambda, a named or anonymous + * function, a property accessor, an `init` block, or a constructor. Null when [element] is not + * inside any of them. + */ +internal fun enclosingExecutableBody(element: PsiElement): PsiElement? { + var current: PsiElement? = element + while (current != null && current !is KtFile) { + if (current is KtFunctionLiteral) return current + if (current is KtDeclarationWithBody && current.bodyExpression?.isAncestorOf(element) == true) return current + if (current is KtAnonymousInitializer && current.body?.isAncestorOf(element) == true) return current + current = current.parent + } + return null +} + +private fun PsiElement.isAncestorOf(other: PsiElement): Boolean = PsiTreeUtil.isAncestor(this, other, false) + +/** + * Whether this expression is a thing whose value can be bound to a `val`. + * + * Excluded, and why: + * - blocks, loops, `return`/`throw`/`break`/`continue` -- no useful value to bind; + * - operator tokens and call callees (`foo` in `foo(x)`) -- fragments, not expressions; + * - the selector of a qualified expression (`b` in `a.b`) -- only meaningful with its receiver; + * - the left side of an assignment -- a write target, not a value; + * - `super` -- not a value; + * - **bare literals** (`1`, `"text"`) -- extracting them is pointless, and excluding them removes + * the only case where omitting a type annotation could change meaning (an `Int` literal where a + * `Long` is expected, or a bare `null` inferring `Nothing?`). + */ +internal fun KtExpression.isLegalExtractionTarget(): Boolean { + if (this is KtBlockExpression) return false + if (this is KtLoopExpression) return false + if (this is KtReturnExpression || this is KtThrowExpression) return false + if (this is KtBreakExpression || this is KtContinueExpression) return false + if (this is KtOperationReferenceExpression) return false + if (this is KtSuperExpression) return false + if (this is KtFunctionLiteral) return false + if (isBareLiteral()) return false + + val parent = parent + if (parent is KtQualifiedExpression && parent.selectorExpression === this) return false + if (parent is KtCallExpression && parent.calleeExpression === this) return false + if (parent is KtBinaryExpression && + parent.operationToken == KtTokens.EQ && + parent.left === this + ) { + return false + } + return true +} + +/** A numeric/boolean/char/null literal, or a string with no interpolation. */ +private fun KtExpression.isBareLiteral(): Boolean = + when (this) { + is KtConstantExpression -> true + is KtStringTemplateExpression -> entries.all { it.isLiteralEntry() } + else -> false + } + +private fun KtStringTemplateEntry.isLiteralEntry(): Boolean = this is KtLiteralStringTemplateEntry diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt new file mode 100644 index 0000000000..da41a5e2fa --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -0,0 +1,179 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.models.TextEdit +import com.itsaky.androidide.models.Position +import com.itsaky.androidide.models.Range + +/** + * The one text replacement an extraction performs: replace `[span]` with [newText]. + * + * **Deliberately a single replacement, not a list of edits.** `IDELanguageClientImpl.applyActionEdits` + * applies each `TextEdit` in its own `runOnUiThread` with no `beginBatchEdit`, and every range is + * computed against the *original* text -- so a list of N edits would be applied against positions + * already shifted by its predecessors, and would cost the user N undo steps with a typing window + * between each. Rewriting one contiguous span sidesteps all of it. + */ +data class RewriteSpan( + val span: TextSpan, + val newText: String, +) + +/** + * Builds the extraction rewrite, or null when the inputs cannot produce one. + * + * [name] is the final variable name -- the caller has already validated it. [replaceAll] selects + * between every occurrence in [scope] and only [candidateSpan]. + * + * Occurrences are substituted right-to-left within the rewritten span so earlier substitutions + * cannot shift later offsets, and the whole span is emitted as one replacement. + */ +fun buildExtractVariableRewrite( + fileText: String, + candidateSpan: TextSpan, + scope: ScopeOption, + name: String, + replaceAll: Boolean, +): RewriteSpan? { + val targets = + (if (replaceAll) scope.occurrences else listOf(candidateSpan)) + .sortedBy { it.start } + .takeIf { it.isNotEmpty() } ?: return null + if (targets.any { it.end > fileText.length }) return null + + val expression = fileText.substring(candidateSpan.start, candidateSpan.end) + val declaration = "val $name = $expression" + + return when (val form = scope.anchorForm) { + AnchorForm.ExistingBlock -> existingBlockRewrite(fileText, targets, declaration, name) + is AnchorForm.WrapInBraces -> wrapInBracesRewrite(fileText, form, targets, declaration, name) + is AnchorForm.ConvertExpressionBody -> convertExpressionBodyRewrite(fileText, form, targets, declaration, name) + } +} + +/** + * Inserts the declaration as its own line before the first served occurrence's line, and rewrites + * everything from there through the last occurrence. + * + * The rewritten span starts at that line's start (not at the occurrence) so the declaration lands on + * a line of its own at the right indentation, and ends at the last occurrence so untouched trailing + * code is left alone. + */ +private fun existingBlockRewrite( + fileText: String, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + val first = targets.first() + val last = targets.last() + val lineStart = lineStartOffset(fileText, first.start) + val indent = leadingIndentAt(fileText, first.start) + val newline = detectNewline(fileText) + + val body = replaceOccurrences(fileText, TextSpan(lineStart, last.end), targets, name) + return RewriteSpan( + span = TextSpan(lineStart, last.end), + newText = indent + declaration + newline + body, + ) +} + +/** Wraps a braceless statement in a block containing the declaration and the original statement. */ +private fun wrapInBracesRewrite( + fileText: String, + form: AnchorForm.WrapInBraces, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + // Occurrences in a braceless scope are confined to the statement itself (the frame's search + // range *is* this span), so no cross-span targets are possible; replaceOccurrences filters anyway. + val span = TextSpan(form.bodyStart, form.bodyEnd) + val newline = detectNewline(fileText) + val body = replaceOccurrences(fileText, span, targets, name) + + val newText = + buildString { + append('{').append(newline) + append(form.innerIndent).append(declaration).append(newline) + append(form.innerIndent).append(body).append(newline) + append(form.indent).append('}') + } + return RewriteSpan(span, newText) +} + +/** Converts `= expr` into a block body holding the declaration and a `return` of the rewritten body. */ +private fun convertExpressionBodyRewrite( + fileText: String, + form: AnchorForm.ConvertExpressionBody, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + val bodySpan = TextSpan(form.bodyStart, form.bodyEnd) + val newline = detectNewline(fileText) + val body = replaceOccurrences(fileText, bodySpan, targets, name) + val returned = if (form.needsReturn) "return $body" else body + + val newText = + buildString { + append('{').append(newline) + append(form.innerIndent).append(declaration).append(newline) + append(form.innerIndent).append(returned).append(newline) + append(form.indent).append('}') + } + return RewriteSpan(TextSpan(form.assignStart, form.bodyEnd), newText) +} + +/** + * Returns `[span]`'s text with every occurrence inside it replaced by [name]. Substitutes + * right-to-left so an earlier replacement cannot invalidate a later offset. + */ +private fun replaceOccurrences( + fileText: String, + span: TextSpan, + targets: List, + name: String, +): String { + val builder = StringBuilder(fileText.substring(span.start, span.end)) + targets + .filter { it.start >= span.start && it.end <= span.end } + .sortedByDescending { it.start } + .forEach { builder.replace(it.start - span.start, it.end - span.start, name) } + return builder.toString() +} + +/** CRLF only when the file already uses it, so the edit does not mix line endings. */ +internal fun detectNewline(text: String): String = if (text.contains("\r\n")) "\r\n" else "\n" + +/** + * Converts a [RewriteSpan] into the `TextEdit` the language client consumes. [Position] carries + * line, column *and* index; all three are filled so neither the client's line/column path nor any + * index-based consumer sees a stale value. + */ +fun RewriteSpan.toTextEdit(fileText: String): TextEdit = + TextEdit( + range = + Range( + positionAt(fileText, span.start), + positionAt(fileText, span.end), + ), + newText = newText, + ) + +internal fun positionAt( + text: String, + offset: Int, +): Position { + val clamped = offset.coerceIn(0, text.length) + var line = 0 + var lineStart = 0 + var i = 0 + while (i < clamped) { + if (text[i] == '\n') { + line++ + lineStart = i + 1 + } + i++ + } + return Position(line, clamped - lineStart, clamped) +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt new file mode 100644 index 0000000000..bc39bde916 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -0,0 +1,132 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import com.itsaky.androidide.lsp.kotlin.utils.renderName +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.slf4j.LoggerFactory +import java.nio.file.Path + +private val logger = LoggerFactory.getLogger("ExtractVariablePlanner") + +/** + * Computes the whole [ExtractionPlan] in one background analysis pass. + * + * The current [KtFile] is fetched *before* entering [read] -- blocking on + * `getCurrentKtFile(...).get()` inside `project.read` deadlocks. + * + * Returns an empty plan both when there is genuinely nothing to extract and whenever anything in + * this pipeline throws: the action framework only catches [IllegalArgumentException] and this runs on + * a scope with no exception handler, so an uncaught throw would crash the app. Degrading to an empty + * plan is always safe -- the action reports "nothing to extract" instead of rewriting anything. + */ +internal fun buildExtractionPlan( + env: AbstractCompilationEnvironment, + nioPath: Path, + selectionStart: Int, + selectionEnd: Int, + documentVersion: Int, + cancelChecker: ScheduledCancelChecker, +): ExtractionPlan = + runCatching { + val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() ?: return ExtractionPlan.empty() + env.project.read { + val syntax = candidateExpressionsAt(ktFile, selectionStart, selectionEnd) + if (syntax.expressions.isEmpty()) return@read ExtractionPlan.empty(ktFile.text, documentVersion) + + analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { + val candidates = syntax.expressions.mapNotNull { candidateFor(it) } + ExtractionPlan( + fileText = ktFile.text, + documentVersion = documentVersion, + candidates = candidates, + // Only meaningful while the innermost candidate survived filtering; otherwise the + // user's selection no longer corresponds to the first option shown. + selectionMatchedCandidate = + syntax.selectionMatchedInnermost && + candidates.firstOrNull()?.span?.start == + syntax.expressions + .first() + .textRange.startOffset, + ) + } + } + }.getOrElse { error -> + logger.warn("Failed to build extract-variable plan for {}", nioPath, error) + ExtractionPlan.empty() + } + +/** + * Turns one syntactic candidate into a [CandidateExpression], or null when it should not be offered. + * + * Dropped when the expression produces no useful value (`Unit`, `Nothing` -- `val u = println(x)` + * compiles but is pointless) or when nothing remains of its legal scope chain. + */ +@OptIn(KaExperimentalApi::class) +private fun KaSession.candidateFor(expression: KtExpression): CandidateExpression? { + val type = runCatching { expression.expressionType }.getOrNull() + if (type == null || isValuelessType(type)) return null + + val frames = truncateAtCeiling(enclosingScopeFrames(expression), referencedDeclarationCeiling(expression)) + if (frames.isEmpty()) return null + + val span = TextSpan(expression.textRange.startOffset, expression.textRange.endOffset) + val scopes = frames.map { scopeOptionFor(expression, span, it) } + val takenNames = visibleNamesAt(expression) + + return CandidateExpression( + label = collapseForLabel(expression.text), + span = span, + suggestedName = suggestVariableName(expression, runCatching { renderName(type) }.getOrNull(), takenNames), + takenNames = takenNames, + scopes = scopes, + ) +} + +/** Builds one scope option, resolving its occurrence set and fixing up expression-body details. */ +private fun KaSession.scopeOptionFor( + expression: KtExpression, + span: TextSpan, + frame: ScopeFrame, +): ScopeOption { + val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) + val writes = writeOffsetsFor(expression, frame.scopeElement) + val occurrences = excludeUnsoundOccurrences(matches, span, writes) + + val anchorForm = + when (val form = frame.anchorForm) { + is AnchorForm.ConvertExpressionBody -> form.copy(needsReturn = expressionBodyNeedsReturn(frame.scopeElement)) + else -> form + } + + return ScopeOption(label = frame.label, anchorForm = anchorForm, occurrences = occurrences) +} + +/** + * Whether converting an expression body to a block body needs a `return`. + * + * False only for a `Unit`-returning function, where `return expr` on a non-`Unit` expression would + * not compile and is unnecessary anyway. Defaults to true, which is right for everything else + * including property accessors. + */ +private fun KaSession.expressionBodyNeedsReturn(bodyExpression: PsiElement): Boolean { + val declaration = bodyExpression.parent as? KtDeclarationWithBody ?: return true + val returnType = + runCatching { ((declaration as? KtDeclaration)?.symbol as? KaCallableSymbol)?.returnType }.getOrNull() + ?: return true + return !isValuelessType(returnType) +} + +/** `Unit` and `Nothing` carry no value worth binding to a `val`. */ +private fun KaSession.isValuelessType(type: KaType): Boolean = runCatching { type.isUnitType || type.isNothingType }.getOrDefault(false) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt new file mode 100644 index 0000000000..47d1f43538 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -0,0 +1,164 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** A half-open offset range `[start, end)` into the analysed file's text. */ +data class TextSpan( + val start: Int, + val end: Int, +) { + init { + require(start <= end) { "start=$start > end=$end" } + } + + val length: Int get() = end - start + + fun overlaps(other: TextSpan): Boolean = start < other.end && other.start < end +} + +/** + * How the new declaration is woven into an anchor scope. Kotlin scopes are not all blocks, so + * three shapes are needed; [ExistingBlock] is by far the common one. + */ +sealed interface AnchorForm { + /** + * The scope already has a `{ ... }` body (function body, `if` block, lambda body, ...), so the + * declaration is simply a new statement line. + * + * Deliberately field-free: the insertion offset and indentation are both derived from the first + * occurrence being served, which is the candidate itself when replacing only one site and an + * earlier statement when replacing all. Storing a precomputed anchor would duplicate that and + * let the two drift apart. + */ + data object ExistingBlock : AnchorForm + + /** + * A braceless statement position -- `if (c) foo()`, a `when` entry, a braceless loop body. + * `[bodyStart, bodyEnd)` (the statement) is replaced by a braced block holding the declaration + * and the original statement. No `return` is involved. + */ + data class WrapInBraces( + val bodyStart: Int, + val bodyEnd: Int, + val indent: String, + val innerIndent: String, + ) : AnchorForm + + /** + * An expression-bodied function or property accessor -- `fun area(r: Int) = r * r`. The `=` and + * the body are replaced by a block body. [needsReturn] is false only when the declaration + * returns `Unit`, where `return` is both unnecessary and wrong for a non-`Unit` expression. + */ + data class ConvertExpressionBody( + val assignStart: Int, + val bodyStart: Int, + val bodyEnd: Int, + val indent: String, + val innerIndent: String, + val needsReturn: Boolean, + ) : AnchorForm +} + +/** + * One member of a candidate's legal scope chain: a place the declaration may go, together with the + * occurrences that are sound to replace there. + * + * [occurrences] is ascending by offset and always contains the candidate's own span, so + * `occurrences.size` is the count shown as "Replace all N occurrences". Narrowing to an inner scope + * can only shrink this set, never grow it. + */ +data class ScopeOption( + val label: String, + val anchorForm: AnchorForm, + val occurrences: List, +) + +/** + * A legal extraction target and everything the UI needs to act on it. + * + * [label] is the expression's source text with runs of whitespace collapsed, so a multi-line + * expression stays readable in a one-line list item. + * + * [scopes] is the legal scope chain, innermost first, and is never empty -- a candidate with no + * legal anchor is not a candidate. + */ +data class CandidateExpression( + val label: String, + val span: TextSpan, + val suggestedName: String, + val takenNames: Set, + val scopes: List, +) + +/** + * The complete result of the background analysis pass, and the central type of the extract/inline + * refactorings. + * + * ## Vocabulary + * + * Used verbatim throughout this package, its tests and its review comments -- prefer these over + * ad-hoc synonyms. + * + * - **Candidate expression** -- a [org.jetbrains.kotlin.psi.KtExpression] at the cursor or selection + * that is a legal extraction target. At most [MAX_CANDIDATES], ordered innermost-first. + * - **Legal scope chain** -- the ordered anchors available for the new declaration: outward from the + * candidate's own statement through enclosing blocks, crossing a lambda boundary only when nothing + * lambda-scoped is referenced, and stopping at the enclosing method body. + * - **Anchor scope** -- the chain member the user picked. The `val` is declared inside it. + * - **Anchor point** -- the exact insertion offset: immediately before the first statement *within the + * anchor scope* that contains a replaced occurrence. + * - **Occurrence** -- a site inside the anchor scope that is structurally equal to the candidate *and* + * whose every name reference resolves to the same symbol. Sites made unsound by an intervening + * reassignment are excluded, so an occurrence set is always safe to replace wholesale. + * - **Extraction plan** -- this type. + * + * ## Why plain data + * + * The user's choices (which expression, what name, which scope, replace-all or not) arrive *after* + * analysis, from a sheet. Rather than re-entering analysis on confirm, one background pass produces + * this plan for *all* candidates at once and the UI does pure string/offset arithmetic on it. That + * keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation + * unit-testable without an editor, an activity or Compose. + * + * [fileText] is the text the offsets here refer to, carried so the UI can build the replacement text + * without PSI; [documentVersion] is what makes that safe -- if the live document has moved on by the + * time the user confirms, the plan is discarded rather than applied against shifted offsets. + * + * [selectionMatchedCandidate] is true when the user's selection exactly matched the innermost + * candidate, meaning they already expressed which expression they want and the UI should not ask. + */ +data class ExtractionPlan( + val fileText: String, + val documentVersion: Int, + val candidates: List, + val selectionMatchedCandidate: Boolean, +) { + val isEmpty: Boolean get() = candidates.isEmpty() + + companion object { + fun empty( + fileText: String = "", + documentVersion: Int = -1, + ) = ExtractionPlan(fileText, documentVersion, emptyList(), selectionMatchedCandidate = false) + } +} + +/** + * Collapses whitespace runs so a multi-line expression reads as one line in a list item. + * + * The space before a `.` or `?.` is then removed: a wrapped call chain is the most common multi-line + * expression in Kotlin, and a plain collapse turns `items\n\t.filter { ... }` into + * `items .filter { ... }`, which reads as a typo in a list the user is choosing from. + */ +internal fun collapseForLabel( + text: String, + maxLength: Int = 80, +): String { + val collapsed = + text + .replace(WHITESPACE_RUN, " ") + .replace(SPACE_BEFORE_DOT, "$1") + .trim() + return if (collapsed.length <= maxLength) collapsed else collapsed.take(maxLength - 3) + "..." +} + +private val WHITESPACE_RUN = Regex("\\s+") +private val SPACE_BEFORE_DOT = Regex(" (\\??\\.)") diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt new file mode 100644 index 0000000000..3427571a18 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt @@ -0,0 +1,155 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtParenthesizedExpression +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtStringTemplateExpression + +/** Used when neither the expression's shape nor its type suggests anything better. */ +const val FALLBACK_NAME = "value" + +/** + * Kotlin's hard keywords -- the ones that are never valid identifiers. Soft and modifier keywords + * (`by`, `data`, `it`, ...) are legal names and are deliberately absent. + */ +private val HARD_KEYWORDS = + setOf( + "as", + "break", + "class", + "continue", + "do", + "else", + "false", + "for", + "fun", + "if", + "in", + "interface", + "is", + "null", + "object", + "package", + "return", + "super", + "this", + "throw", + "true", + "try", + "typealias", + "typeof", + "val", + "var", + "when", + "while", + ) + +/** Why a proposed name cannot be used. Null-free alternative to throwing for user input. */ +enum class NameProblem { + Blank, + NotAnIdentifier, + Keyword, + AlreadyTaken, +} + +/** + * Validates a user-supplied name against Kotlin's identifier rules and the names already visible at + * the anchor point. Returns null when the name is usable. + * + * Backtick-quoted names are rejected rather than supported: they are legal Kotlin but a poor + * suggestion for a generated local, and accepting them would mean validating the quoted form too. + */ +fun validateVariableName( + name: String, + takenNames: Set, +): NameProblem? { + if (name.isBlank()) return NameProblem.Blank + if (!isIdentifier(name)) return NameProblem.NotAnIdentifier + if (name in HARD_KEYWORDS) return NameProblem.Keyword + if (name in takenNames) return NameProblem.AlreadyTaken + return null +} + +private fun isIdentifier(name: String): Boolean { + if (name.isEmpty()) return false + if (!(name[0].isLetter() || name[0] == '_')) return false + return name.all { it.isLetterOrDigit() || it == '_' } +} + +/** + * Suggests a name for the value [expression] produces. + * + * Tried in order: + * 1. **The expression's shape** -- `items.size` -> `size`, `a.b.c()` -> `c`, `getFoo()` -> `foo`, + * `foo(x)` -> `foo`, an interpolated string -> `text`, `xs[i]` -> `xs` element naming. + * 2. **The resolved type**, lowercased -- `List` -> `list`, `Duration` -> `duration`. Pass null + * when the type is unavailable. + * 3. [FALLBACK_NAME]. + * + * The result is then made unique against [takenNames] by appending `1`, `2`, ... Shape beats type + * because `size`, `count` and `name` are far better names than `int` and `string`, and type-derived + * names collide constantly. + */ +fun suggestVariableName( + expression: KtExpression, + typeName: String?, + takenNames: Set, +): String { + val base = + nameFromShape(expression) + ?: typeName?.let(::nameFromType) + ?: FALLBACK_NAME + val sanitised = base.takeIf { isIdentifier(it) && it !in HARD_KEYWORDS } ?: FALLBACK_NAME + return makeUnique(sanitised, takenNames) +} + +private fun nameFromShape(expression: KtExpression): String? = + when (expression) { + is KtParenthesizedExpression -> expression.expression?.let(::nameFromShape) + is KtQualifiedExpression -> expression.selectorExpression?.let(::nameFromShape) + is KtCallExpression -> (expression.calleeExpression as? KtNameReferenceExpression)?.getReferencedName()?.let(::stripAccessorPrefix) + is KtNameReferenceExpression -> expression.getReferencedName().let(::stripAccessorPrefix) + is KtStringTemplateExpression -> "text" + is KtArrayAccessExpression -> expression.arrayExpression?.let(::nameFromShape) + else -> null + }?.takeIf { it.isNotBlank() } + +/** `getFoo` -> `foo`, `isReady` -> `ready`. Leaves anything else alone. */ +private fun stripAccessorPrefix(name: String): String { + for (prefix in ACCESSOR_PREFIXES) { + if (name.length > prefix.length && + name.startsWith(prefix) && + name[prefix.length].isUpperCase() + ) { + return name.substring(prefix.length).decapitaliseFirst() + } + } + return name +} + +private val ACCESSOR_PREFIXES = listOf("get", "is", "has") + +/** `List` -> `list`, `kotlin.time.Duration` -> `duration`, `Array` -> `array`. */ +private fun nameFromType(typeName: String): String? = + typeName + .substringBefore('<') + .substringAfterLast('.') + .trimEnd('?', '!') + .takeIf { it.isNotBlank() } + ?.decapitaliseFirst() + +private fun String.decapitaliseFirst(): String = if (isEmpty()) this else this[0].lowercaseChar() + substring(1) + +/** `size` -> `size1` -> `size2` until nothing in [takenNames] matches. */ +private fun makeUnique( + base: String, + takenNames: Set, +): String { + if (base !in takenNames) return base + var suffix = 1 + while ("$base$suffix" in takenNames) suffix++ + return "$base$suffix" +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt new file mode 100644 index 0000000000..61eea683ae --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt @@ -0,0 +1,273 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol +import org.jetbrains.kotlin.builtins.StandardNames +import org.jetbrains.kotlin.com.intellij.psi.PsiComment +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.PsiWhiteSpace +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtUnaryExpression +import org.jetbrains.kotlin.psi.psiUtil.parents + +/** + * Whether [a] and [b] are the same expression for extraction purposes: structurally identical *and* + * every name reference in them resolving to the same declaration. + * + * The symbol check is the whole point. Text or structure alone would happily match `config.timeout` + * inside a nested lambda where `config` is a different `config`, or an `it` that means something + * else -- replacing those would silently change behaviour. The parent ticket (ADFA-3324) states the + * standard outright: text-based matching breaks things. + */ +internal fun KaSession.isSameExpression( + a: PsiElement, + b: PsiElement, +): Boolean { + if (a === b) return true + if (a.node?.elementType != b.node?.elementType) return false + + if (a is KtSimpleNameExpression && b is KtSimpleNameExpression) { + if (a.getReferencedName() != b.getReferencedName()) return false + if (!resolvesToSameDeclaration(a, b)) return false + } + + val childrenA = meaningfulChildren(a) + val childrenB = meaningfulChildren(b) + if (childrenA.size != childrenB.size) return false + if (childrenA.isEmpty()) return a.text == b.text + return childrenA.indices.all { isSameExpression(childrenA[it], childrenB[it]) } +} + +/** Whitespace and comments are formatting, not structure, so they never affect equality. */ +private fun meaningfulChildren(element: PsiElement): List = + element.children.filter { it !is PsiWhiteSpace && it !is PsiComment } + +/** + * Whether two same-named references point at the same declaration. + * + * Source declarations are compared by PSI identity, which is exactly the question being asked ("the + * same `val`?"). Symbols without source PSI -- library members, compiler-generated declarations -- + * fall back to symbol equality. Resolution over broken code throws, and a throw here must read as + * "not the same" rather than crash the action. + */ +private fun KaSession.resolvesToSameDeclaration( + a: KtSimpleNameExpression, + b: KtSimpleNameExpression, +): Boolean = + runCatching { + val symbolA = a.mainReference?.resolveToSymbols()?.firstOrNull() ?: return false + val symbolB = b.mainReference?.resolveToSymbols()?.firstOrNull() ?: return false + val psiA = symbolA.declarationPsi() + val psiB = symbolB.declarationPsi() + if (psiA != null || psiB != null) psiA === psiB else symbolA == symbolB + }.getOrDefault(false) + +private fun KaSymbol.declarationPsi(): PsiElement? = runCatching { psi }.getOrNull() + +/** + * Every site in [searchRoot] within [searchRange] that is the same expression as [candidate] and is + * itself a legal place to put the variable reference. + * + * The legality filter matters: in `a.a`, a candidate of `a` matches the selector too, but rewriting + * a selector would produce `v.v`. Overlapping matches are dropped so no site is rewritten twice. + * Ascending by offset, and always contains [candidate] itself. + */ +internal fun KaSession.findOccurrences( + candidate: KtExpression, + searchRoot: PsiElement, + searchRange: TextSpan, +): List { + val elementType = candidate.node?.elementType + val matches = + PsiTreeUtil + .collectElements(searchRoot) { element -> + element.node?.elementType == elementType && + element is KtExpression && + element.textRange.startOffset >= searchRange.start && + element.textRange.endOffset <= searchRange.end + }.filterIsInstance() + .filter { it === candidate || (it.isLegalExtractionTarget() && isSameExpression(candidate, it)) } + .map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) } + .sortedBy { it.start } + + val accepted = mutableListOf() + for (match in matches) { + if (accepted.none { it.overlaps(match) }) accepted += match + } + return accepted +} + +/** + * The innermost scope that must contain the declaration, or null when the candidate references + * nothing declared inside the enclosing scopes. + * + * This is what stops a hoist from escaping a lambda it depends on: if the candidate uses `it` or a + * lambda parameter, that lambda's body comes back as the ceiling and every outer rung of the scope + * chain is dropped by [truncateAtCeiling]. + */ +internal fun KaSession.referencedDeclarationCeiling(candidate: KtExpression): PsiElement? { + var deepest: PsiElement? = null + var deepestDepth = -1 + for (reference in candidate.collectDescendantsOfType()) { + val symbol = runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() ?: continue + val body = constrainingBodyFor(reference, symbol) ?: continue + val depth = depthOf(body) + if (depth > deepestDepth) { + deepest = body + deepestDepth = depth + } + } + return deepest +} + +/** + * The scope [reference] pins the declaration inside, or null when it constrains nothing. + * + * A declaration outside the candidate's own scopes -- a class member, a top-level property, anything + * from a library -- constrains nothing; only locals and parameters do. + * + * The implicit lambda parameter needs its own case: `it` has **no source PSI**, so the ordinary + * psi-based lookup finds nothing and would report "unconstrained", happily hoisting `it.length` clean + * out of its lambda into code that does not compile. A value-parameter symbol with no PSI, referenced + * by the name `it`, *is* by definition the implicit parameter of the innermost enclosing lambda -- a + * property of the language, not a guess about the text. + */ +private fun constrainingBodyFor( + reference: KtSimpleNameExpression, + symbol: KaSymbol, +): PsiElement? { + val declaration = runCatching { symbol.psi }.getOrNull() + if (declaration == null) { + if (symbol is KaValueParameterSymbol && reference.getReferencedName() == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString()) { + return PsiTreeUtil.getParentOfType(reference, KtFunctionLiteral::class.java, true)?.bodyExpression + } + return null + } + if (!PsiTreeUtil.isAncestor(reference.containingFile, declaration, false)) return null + return enclosingExecutableBody(declaration) +} + +private fun depthOf(element: PsiElement): Int = element.parents.count() + +private inline fun PsiElement.collectDescendantsOfType(): List = + PsiTreeUtil.collectElementsOfType(this, T::class.java).toList() + +/** + * Restricts [occurrences] to a contiguous run around [candidateSpan] that no write to a referenced + * mutable interrupts. + * + * A `var` the candidate reads can be reassigned between two occurrences, and then the two sites do + * not hold the same value even though they are the same expression: + * + * ``` + * var limit = 1 + * foo(limit + 1) // occurrence + * limit = 5 + * foo(limit + 1) // same expression, different value + * ``` + * + * Rather than warn, unsound sites are simply excluded, so "Replace all N occurrences" can never + * produce wrong code and N is always achievable. The walk grows outwards from the candidate -- never + * dropping the site the user actually selected -- and stops in each direction at the first write it + * would have to cross. + */ +internal fun excludeUnsoundOccurrences( + occurrences: List, + candidateSpan: TextSpan, + writeOffsets: List, +): List { + if (occurrences.isEmpty()) return occurrences + val ordered = occurrences.sortedBy { it.start } + val candidateIndex = ordered.indexOfFirst { it.start == candidateSpan.start && it.end == candidateSpan.end } + if (candidateIndex < 0) return listOf(candidateSpan) + + val writes = writeOffsets.sorted() + + fun writeBetween( + from: Int, + to: Int, + ): Boolean = writes.any { it in from until to } + + val accepted = mutableListOf(ordered[candidateIndex]) + for (i in candidateIndex - 1 downTo 0) { + if (writeBetween(ordered[i].end, ordered[candidateIndex].start)) break + accepted.add(0, ordered[i]) + } + for (i in candidateIndex + 1 until ordered.size) { + if (writeBetween(ordered[candidateIndex].end, ordered[i].start)) break + accepted += ordered[i] + } + return accepted +} + +/** + * Offsets of writes, within [searchRoot], to any mutable the candidate reads. Feeds + * [excludeUnsoundOccurrences]. + * + * Counts plain assignment, the augmented forms (`+=` and friends) and `++`/`--`. A `val` cannot be + * written, so only [KaVariableSymbol]s that report themselves mutable are tracked. + */ +internal fun KaSession.writeOffsetsFor( + candidate: KtExpression, + searchRoot: PsiElement, +): List { + val mutableDeclarations = + candidate + .collectDescendantsOfType() + .mapNotNull { reference -> + runCatching { + (reference.mainReference?.resolveToSymbols()?.firstOrNull() as? KaVariableSymbol) + ?.takeIf { !it.isVal } + ?.psi + }.getOrNull() + }.toSet() + if (mutableDeclarations.isEmpty()) return emptyList() + + return searchRoot + .collectDescendantsOfType() + .filter { it.isWriteTarget() } + .filter { reference -> + runCatching { + reference.mainReference + ?.resolveToSymbols() + ?.firstOrNull() + ?.psi + }.getOrNull() in mutableDeclarations + }.map { it.textRange.startOffset } +} + +/** Whether this reference is being written to rather than read. */ +private fun KtSimpleNameExpression.isWriteTarget(): Boolean { + val parent = parent + if (parent is KtBinaryExpression && parent.left === this && parent.operationToken in ASSIGNMENT_TOKENS) return true + if (parent is KtUnaryExpression && parent.operationToken in INCREMENT_TOKENS) return true + return false +} + +private val ASSIGNMENT_TOKENS = + setOf(KtTokens.EQ, KtTokens.PLUSEQ, KtTokens.MINUSEQ, KtTokens.MULTEQ, KtTokens.DIVEQ, KtTokens.PERCEQ) + +private val INCREMENT_TOKENS = setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) + +/** + * Names a suggestion must avoid: every declaration name in the file. + * + * Deliberately conservative rather than scope-exact. A real scope query would need resolution and + * would let `size` be suggested in one function because the collision is in another -- correct, but + * the cost of being over-broad is only a `size1` where `size` would have done, while the cost of + * being under-broad is generated code that shadows something. Cheap, needs no analysis, and being + * purely syntactic it is unit-testable. + */ +internal fun visibleNamesAt(candidate: KtExpression): Set = + PsiTreeUtil + .collectElementsOfType(candidate.containingFile, KtDeclaration::class.java) + .mapNotNullTo(mutableSetOf()) { it.name } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt new file mode 100644 index 0000000000..79ac67d2fe --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt @@ -0,0 +1,262 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtAnonymousInitializer +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtContainerNodeForControlStructureBody +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +import org.jetbrains.kotlin.psi.KtDoWhileExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtIfExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtPropertyAccessor +import org.jetbrains.kotlin.psi.KtWhenEntry +import org.jetbrains.kotlin.psi.KtWhileExpression + +/** + * One rung of the legal scope chain, before occurrences are known. + * + * [scopeElement] is the PSI node that *is* the scope, used to decide whether a referenced + * declaration lives inside it (see [truncateAtCeiling]). [searchRange] bounds the occurrence search + * for this rung. [statementSpan] is the statement within this scope that contains the candidate -- + * the fallback anchor when only the selected occurrence is replaced. + */ +data class ScopeFrame( + val label: String, + val scopeElement: PsiElement, + val searchRange: TextSpan, + val statementSpan: TextSpan, + val anchorForm: AnchorForm, +) + +/** + * Enumerates the scopes [candidate] could be hoisted into, innermost first. + * + * Walks outward from the candidate's own statement. Each rung is one of the three [AnchorForm] + * shapes: a real block, a braceless statement position that needs braces, or an expression body that + * needs converting. The walk stops after the enclosing **named function, accessor or `init` block** + * body -- the ceiling agreed for this refactoring. A class body or file is never an anchor, so a + * property initializer outside any executable body yields nothing (already rejected earlier by + * [isExtractionPosition]). + * + * Lambda boundaries are *crossed* here: whether crossing is actually legal depends on what the + * candidate references, which needs resolution, so it is applied afterwards by [truncateAtCeiling]. + */ +fun enclosingScopeFrames(candidate: KtExpression): List { + val text = candidate.containingFile.text + val frames = mutableListOf() + var inner: PsiElement = candidate + + while (true) { + val parent = inner.parent ?: break + if (parent is KtFile) break + + val frame = frameFor(inner, text) + if (frame == null) { + // Most nodes are not themselves anchorable -- a value argument, an argument list, a lambda + // literal. Keep climbing rather than stopping, otherwise the chain would end at the first + // such node and, in particular, a candidate inside a lambda could never be hoisted out of + // it even when that is legal. + inner = parent + continue + } + + frames += frame + // A named function / accessor / init body is the ceiling: record it, then stop. + if (isCeilingBody(frame.scopeElement)) break + inner = frame.scopeElement.parent ?: break + } + return frames +} + +/** + * Drops the rungs that lie outside [ceiling] -- the innermost scope holding a declaration the + * candidate references. Passing null keeps the whole chain (nothing scoped inside was referenced). + * + * This is what enforces "crossing a lambda boundary is allowed only when nothing lambda-scoped is + * referenced": if the candidate uses `it` or a lambda parameter, the lambda body *is* the ceiling + * and every outer rung disappears. + */ +fun truncateAtCeiling( + frames: List, + ceiling: PsiElement?, +): List { + if (ceiling == null) return frames + val kept = frames.takeWhile { PsiTreeUtil.isAncestor(ceiling, it.scopeElement, false) || it.scopeElement === ceiling } + return kept.ifEmpty { frames.take(1) } +} + +/** + * Builds the rung whose scope directly contains [inner], or null when [inner] is not in a position + * this refactoring anchors in. + */ +private fun frameFor( + inner: PsiElement, + text: String, +): ScopeFrame? { + val parent = inner.parent ?: return null + + // A braceless control-structure body is wrapped in a container node, so the `if`/loop is the + // grandparent, not the parent. Without unwrapping, no braceless body is ever detected and the + // declaration silently hoists to the enclosing block instead of braces being added. + val controlOwner = (parent as? KtContainerNodeForControlStructureBody)?.parent + + if (parent is KtBlockExpression) { + val lineStart = lineStartOffset(text, inner.textRange.startOffset) + return ScopeFrame( + label = blockLabel(parent), + scopeElement = parent, + searchRange = parent.textRange.let { TextSpan(it.startOffset, it.endOffset) }, + statementSpan = TextSpan(lineStart, inner.textRange.endOffset), + anchorForm = AnchorForm.ExistingBlock, + ) + } + + val bracelessOwner = controlOwner ?: parent + val bracelessLabel = bracelessOwnerLabel(inner, bracelessOwner) + if (bracelessLabel != null) { + val indent = leadingIndentAt(text, bracelessOwner.textRange.startOffset) + val span = TextSpan(inner.textRange.startOffset, inner.textRange.endOffset) + return ScopeFrame( + label = bracelessLabel, + scopeElement = inner, + searchRange = span, + statementSpan = span, + anchorForm = + AnchorForm.WrapInBraces( + bodyStart = span.start, + bodyEnd = span.end, + indent = indent, + innerIndent = indent + detectIndentUnit(text), + ), + ) + } + + if (parent is KtDeclarationWithBody && parent.bodyExpression === inner && !parent.hasBlockBody()) { + val assign = parent.equalsToken ?: return null + val indent = leadingIndentAt(text, parent.textRange.startOffset) + val span = TextSpan(inner.textRange.startOffset, inner.textRange.endOffset) + return ScopeFrame( + label = declarationLabel(parent), + scopeElement = inner, + searchRange = span, + statementSpan = span, + anchorForm = + AnchorForm.ConvertExpressionBody( + assignStart = assign.textRange.startOffset, + bodyStart = span.start, + bodyEnd = span.end, + indent = indent, + innerIndent = indent + detectIndentUnit(text), + // Filled in by the caller, which has the resolved return type. + needsReturn = true, + ), + ) + } + + return null +} + +/** True for the body of a named function, accessor or `init` block -- where the chain stops. */ +private fun isCeilingBody(scopeElement: PsiElement): Boolean { + val owner = scopeElement.parent ?: return false + return when (owner) { + is KtNamedFunction, is KtPropertyAccessor, is KtAnonymousInitializer -> true + else -> false + } +} + +private fun blockLabel(block: KtBlockExpression): String = + when (val owner = block.parent) { + is KtNamedFunction -> "fun ${owner.name ?: ""}" + is KtPropertyAccessor -> if (owner.isGetter) "getter" else "setter" + is KtAnonymousInitializer -> "init block" + is KtFunctionLiteral -> "lambda" + is KtIfExpression -> if (owner.then === block) "if block" else "else block" + is KtForExpression -> "for loop" + is KtWhileExpression -> "while loop" + is KtDoWhileExpression -> "do-while loop" + is KtWhenEntry -> "when branch" + else -> "block" + } + +private fun declarationLabel(declaration: KtDeclarationWithBody): String = + when (declaration) { + is KtNamedFunction -> "fun ${declaration.name ?: ""}" + is KtPropertyAccessor -> if (declaration.isGetter) "getter" else "setter" + else -> "body" + } + +/** A label when [inner] is a braceless body, else null. */ +private fun bracelessOwnerLabel( + inner: PsiElement, + parent: PsiElement, +): String? = + when (parent) { + is KtIfExpression -> { + if (parent.then === inner) { + "if branch" + } else if (parent.`else` === inner) { + "else branch" + } else { + null + } + } + + is KtForExpression -> { + if (parent.body === inner) "for body" else null + } + + is KtWhileExpression -> { + if (parent.body === inner) "while body" else null + } + + is KtDoWhileExpression -> { + if (parent.body === inner) "do-while body" else null + } + + is KtWhenEntry -> { + if (parent.expression === inner) "when branch" else null + } + + else -> { + null + } + } + +/** Offset of the start of the line containing [offset]. */ +internal fun lineStartOffset( + text: String, + offset: Int, +): Int = text.lastIndexOf('\n', (offset - 1).coerceAtLeast(0)).let { if (it < 0) 0 else it + 1 } + +/** The run of spaces/tabs at the start of [offset]'s line. */ +internal fun leadingIndentAt( + text: String, + offset: Int, +): String { + val lineStart = lineStartOffset(text, offset) + return text.substring(lineStart, offset.coerceAtLeast(lineStart)).takeWhile { it == ' ' || it == '\t' } +} + +/** + * One indentation level for [text], inferred from its own lines: a tab if any line is tab-indented, + * otherwise the smallest positive run of leading spaces, defaulting to a tab (the project + * convention). Code-action edits bypass the editor's auto-indent, so emitted text must already match + * the file's style. Mirrors the detection in `ImplementMembersAction`. + */ +internal fun detectIndentUnit(text: String): String { + var minSpaces = Int.MAX_VALUE + for (line in text.splitToSequence('\n')) { + if (line.isEmpty()) continue + if (line[0] == '\t') return "\t" + if (line[0] != ' ') continue + val spaces = line.takeWhile { it == ' ' }.length + if (spaces in 1 until minSpaces) minSpaces = spaces + } + return if (minSpaces == Int.MAX_VALUE) "\t" else " ".repeat(minSpaces) +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt new file mode 100644 index 0000000000..334146c19e --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -0,0 +1,284 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** + * Rewrite construction, with no PSI and no analysis session involved. + * + * Every assertion is on the **resulting file text** rather than on offsets. Indentation is the thing + * most likely to be wrong here -- code-action edits bypass the editor's auto-indent, so the emitted + * text has to be final -- and a range assertion cannot see an indentation bug at all. + */ +class ExtractVariableEditTest { + private fun apply( + text: String, + rewrite: RewriteSpan, + ): String = text.substring(0, rewrite.span.start) + rewrite.newText + text.substring(rewrite.span.end) + + private fun spanOf( + text: String, + snippet: String, + fromIndex: Int = 0, + ): TextSpan { + val start = text.indexOf(snippet, fromIndex) + require(start >= 0) { "'$snippet' not found" } + return TextSpan(start, start + snippet.length) + } + + private fun allSpansOf( + text: String, + snippet: String, + ): List { + val spans = mutableListOf() + var from = 0 + while (true) { + val start = text.indexOf(snippet, from) + if (start < 0) break + spans += TextSpan(start, start + snippet.length) + from = start + snippet.length + } + return spans + } + + private fun rewrite( + text: String, + candidate: TextSpan, + anchorForm: AnchorForm, + occurrences: List, + name: String, + replaceAll: Boolean, + ) = buildExtractVariableRewrite( + fileText = text, + candidateSpan = candidate, + scope = ScopeOption("scope", anchorForm, occurrences), + name = name, + replaceAll = replaceAll, + ) + + @Test + fun `inserts the declaration above the statement and replaces the selected occurrence`() { + val text = "fun f(items: List) {\n\tprintln(items.size * 2)\n}" + val candidate = spanOf(text, "items.size * 2") + + val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + + assertEquals( + "fun f(items: List) {\n" + + "\tval size = items.size * 2\n" + + "\tprintln(size)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `replace-all rewrites every occurrence and anchors above the first`() { + val text = + "fun f(items: List) {\n" + + "\tprintln(items.size * 2)\n" + + "\tlog(items.size * 2)\n" + + "\tuse(items.size * 2)\n" + + "}" + val occurrences = allSpansOf(text, "items.size * 2") + // The user selected the middle one; the declaration must still hoist above the first. + val candidate = occurrences[1] + + val result = rewrite(text, candidate, AnchorForm.ExistingBlock, occurrences, "size", replaceAll = true)!! + + assertEquals( + "fun f(items: List) {\n" + + "\tval size = items.size * 2\n" + + "\tprintln(size)\n" + + "\tlog(size)\n" + + "\tuse(size)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `replace-all off leaves the other occurrences alone`() { + val text = + "fun f(items: List) {\n" + + "\tprintln(items.size * 2)\n" + + "\tlog(items.size * 2)\n" + + "}" + val occurrences = allSpansOf(text, "items.size * 2") + + val result = rewrite(text, occurrences[0], AnchorForm.ExistingBlock, occurrences, "size", replaceAll = false)!! + + assertEquals( + "fun f(items: List) {\n" + + "\tval size = items.size * 2\n" + + "\tprintln(size)\n" + + "\tlog(items.size * 2)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `matches the file's space indentation rather than assuming tabs`() { + val text = "fun f(items: List) {\n println(items.size * 2)\n}" + val candidate = spanOf(text, "items.size * 2") + + val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + + assertEquals( + "fun f(items: List) {\n" + + " val size = items.size * 2\n" + + " println(size)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `keeps CRLF line endings when the file uses them`() { + val text = "fun f(items: List) {\r\n\tprintln(items.size * 2)\r\n}" + val candidate = spanOf(text, "items.size * 2") + + val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + + assertEquals( + "fun f(items: List) {\r\n" + + "\tval size = items.size * 2\r\n" + + "\tprintln(size)\r\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `deeper indentation is preserved`() { + val text = "class C {\n\tfun f(items: List) {\n\t\tprintln(items.size * 2)\n\t}\n}" + val candidate = spanOf(text, "items.size * 2") + + val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + + assertEquals( + "class C {\n" + + "\tfun f(items: List) {\n" + + "\t\tval size = items.size * 2\n" + + "\t\tprintln(size)\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `wraps a braceless if branch in braces`() { + val text = "fun f(c: Boolean, a: A) {\n\tif (c) log(a.b)\n}" + val candidate = spanOf(text, "a.b") + val body = spanOf(text, "log(a.b)") + val form = + AnchorForm.WrapInBraces( + bodyStart = body.start, + bodyEnd = body.end, + indent = "\t", + innerIndent = "\t\t", + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "b", replaceAll = false)!! + + assertEquals( + "fun f(c: Boolean, a: A) {\n" + + "\tif (c) {\n" + + "\t\tval b = a.b\n" + + "\t\tlog(b)\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `converts an expression body to a block body with return`() { + val text = "fun area(r: Int) = r * r + r * r" + val occurrences = allSpansOf(text, "r * r") + val form = + AnchorForm.ConvertExpressionBody( + assignStart = text.indexOf('='), + bodyStart = occurrences.first().start, + bodyEnd = text.length, + indent = "", + innerIndent = "\t", + needsReturn = true, + ) + + val result = rewrite(text, occurrences.first(), form, occurrences, "square", replaceAll = true)!! + + assertEquals( + "fun area(r: Int) {\n" + + "\tval square = r * r\n" + + "\treturn square + square\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `omits return when the expression body function returns Unit`() { + val text = "fun show(a: A) = log(a.b)" + val candidate = spanOf(text, "a.b") + val form = + AnchorForm.ConvertExpressionBody( + assignStart = text.indexOf('='), + bodyStart = text.indexOf("log(a.b)"), + bodyEnd = text.length, + indent = "", + innerIndent = "\t", + needsReturn = false, + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "b", replaceAll = false)!! + + assertEquals( + "fun show(a: A) {\n" + + "\tval b = a.b\n" + + "\tlog(b)\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `null when there is nothing to replace`() { + val text = "fun f() {}" + assertNull( + buildExtractVariableRewrite( + fileText = text, + candidateSpan = TextSpan(0, 3), + scope = ScopeOption("scope", AnchorForm.ExistingBlock, emptyList()), + name = "value", + replaceAll = true, + ), + ) + } + + @Test + fun `null when an occurrence lies outside the file`() { + val text = "fun f() {}" + assertNull( + buildExtractVariableRewrite( + fileText = text, + candidateSpan = TextSpan(0, 3), + scope = ScopeOption("scope", AnchorForm.ExistingBlock, listOf(TextSpan(0, text.length + 5))), + name = "value", + replaceAll = true, + ), + ) + } + + @Test + fun `position index line and column all agree`() { + val text = "aa\nbbb\nc" + val position = positionAt(text, text.indexOf('c')) + assertEquals(2, position.line) + assertEquals(0, position.column) + assertEquals(7, position.index) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt new file mode 100644 index 0000000000..11aff94443 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -0,0 +1,389 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The parts of the plan that need real symbol resolution: candidate filtering, the legal scope chain + * across lambda boundaries, occurrence matching by symbol identity, and reassignment soundness. + * + * Where a rewrite is produced, the assertion is on the **resulting file text** -- the only assertion + * that can catch an indentation or off-by-one error. + */ +class ExtractVariablePlanEndToEndTest : KtLspTest() { + private fun plan( + content: String, + start: Int, + end: Int = start, + ): ExtractionPlan { + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + return buildExtractionPlan(env, path, start, end, documentVersion = 1, cancelChecker = noopCancelChecker()) + } + + private fun apply( + text: String, + rewrite: RewriteSpan, + ): String = text.substring(0, rewrite.span.start) + rewrite.newText + text.substring(rewrite.span.end) + + @Test + fun `offers the innermost three candidates, innermost first`() { + val content = + """ + package p + class B { fun c(): Int = 1 } + class A { val b: B = B() } + fun wrap(n: Int): Int = n + fun demo(a: A) { + wrap(a.b.c() * 2) + } + """.trimIndent() + + // Anchor on the call site, not the `fun c()` declaration that appears earlier in the file. + val result = plan(content, content.indexOf("a.b.c()") + "a.b.c".length) + + assertEquals( + listOf("a.b.c()", "a.b.c() * 2", "wrap(a.b.c() * 2)"), + result.candidates.map { it.label }, + ) + } + + @Test + fun `does not offer bare literals`() { + val content = + """ + package p + fun demo(n: Int): Int { + return n * 2 + } + """.trimIndent() + + val result = plan(content, content.indexOf("2", content.indexOf("n * 2"))) + + assertFalse(result.candidates.any { it.label == "2" }) + assertTrue(result.candidates.any { it.label == "n * 2" }) + } + + @Test + fun `offers nothing for a class-body property initializer`() { + val content = + """ + package p + fun compute(): Int = 1 + class C { + val x = compute() + compute() + } + """.trimIndent() + + assertTrue(plan(content, content.indexOf("compute() + compute()") + 1).isEmpty) + } + + @Test + fun `offers nothing for a default parameter value`() { + val content = + """ + package p + fun base(): Int = 1 + fun demo(n: Int = base() * 2) { + println(n) + } + """.trimIndent() + + assertTrue(plan(content, content.indexOf("base() * 2") + 1).isEmpty) + } + + @Test + fun `offers nothing when the cursor is in a comment`() { + val content = + """ + package p + fun demo() { + // nothing here + } + """.trimIndent() + + assertTrue(plan(content, content.indexOf("nothing")).isEmpty) + } + + @Test + fun `a selection matching an expression exactly short-circuits the chooser`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(n: Int) { + wrap(n * 2) + } + """.trimIndent() + val start = content.indexOf("n * 2") + + val result = plan(content, start, start + "n * 2".length) + + assertTrue(result.selectionMatchedCandidate) + assertEquals("n * 2", result.candidates.first().label) + } + + @Test + fun `an off-boundary selection still resolves, without short-circuiting`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(n: Int) { + wrap(n * 2) + } + """.trimIndent() + val start = content.indexOf("n * 2") + + // Selection stops mid-expression, as a touch-screen drag routinely does. + val result = plan(content, start, start + 3) + + assertFalse(result.selectionMatchedCandidate) + assertEquals("n * 2", result.candidates.first().label) + } + + @Test + fun `a shadowed name in a nested lambda is not the same expression`() { + val content = + """ + package p + class Config(val timeout: Int) + fun log(n: Int) {} + fun demo(config: Config, list: List) { + log(config.timeout) + list.forEach { config -> log(config.timeout) } + } + """.trimIndent() + + val result = plan(content, content.indexOf("config.timeout") + 1) + val functionScope = + result.candidates + .first() + .scopes + .first() + + // `config` inside the lambda is a different declaration, so only one occurrence exists. + assertEquals(1, functionScope.occurrences.size) + } + + @Test + fun `the same expression in both branches of an if is one occurrence set`() { + val content = + """ + package p + class A(val b: Int) + fun log(n: Int) {} + fun warn(n: Int) {} + fun demo(c: Boolean, a: A) { + if (c) { + log(a.b) + } else { + warn(a.b) + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("a.b") + 1) + val candidate = result.candidates.first { it.label == "a.b" } + // The outermost rung is the function body, which contains both branches. + val functionScope = candidate.scopes.last() + + assertEquals(2, functionScope.occurrences.size) + } + + @Test + fun `a reassignment between occurrences drops the unsound one`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(): Int { + var limit = 1 + wrap(limit + 1) + limit = 5 + wrap(limit + 1) + return limit + } + """.trimIndent() + + val result = plan(content, content.indexOf("limit + 1") + 1) + val candidate = result.candidates.first { it.label == "limit + 1" } + val functionScope = candidate.scopes.last() + + // Both sites are the same expression, but `limit = 5` makes the second a different value. + assertEquals(1, functionScope.occurrences.size) + } + + @Test + fun `a candidate using the implicit lambda parameter cannot be hoisted out of the lambda`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(items: List) { + items.forEach { log(it.length + 1) } + } + """.trimIndent() + + val result = plan(content, content.indexOf("it.length + 1") + 1) + val candidate = result.candidates.first { it.label == "it.length + 1" } + + // `it` belongs to the lambda, so the lambda body is the only legal anchor. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + } + + @Test + fun `a lambda-invariant candidate can be hoisted to the enclosing function`() { + val content = + """ + package p + class Config(val timeout: Int) + fun log(n: Int) {} + fun demo(config: Config, items: List) { + items.forEach { log(config.timeout * 2) } + } + """.trimIndent() + + val result = plan(content, content.indexOf("config.timeout * 2") + 1) + val candidate = result.candidates.first { it.label == "config.timeout * 2" } + + // Nothing lambda-scoped is referenced, so hoisting out to the function body is offered. + assertEquals(listOf("lambda", "fun demo"), candidate.scopes.map { it.label }) + } + + @Test + fun `suggests a name from the expression shape`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List) { + wrap(items.size * 2) + } + """.trimIndent() + + val result = plan(content, content.indexOf("items.size") + 1) + + assertEquals("size", result.candidates.first { it.label == "items.size" }.suggestedName) + } + + @Test + fun `does not suggest a name that is already taken`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List) { + val size = 0 + wrap(items.size * 2) + } + """.trimIndent() + + val result = plan(content, content.indexOf("items.size") + 1) + + assertEquals("size1", result.candidates.first { it.label == "items.size" }.suggestedName) + } + + @Test + fun `end to end rewrite replaces all occurrences in the function body`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List): Int { + wrap(items.size * 2) + return items.size * 2 + } + """.trimIndent() + + val result = plan(content, content.indexOf("items.size * 2") + 1) + val candidate = result.candidates.first { it.label == "items.size * 2" } + val scope = candidate.scopes.last() + assertEquals(2, scope.occurrences.size) + + val rewrite = + buildExtractVariableRewrite(result.fileText, candidate.span, scope, "size", replaceAll = true) + assertNotNull(rewrite) + + assertEquals( + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List): Int { + val size = items.size * 2 + wrap(size) + return size + } + """.trimIndent(), + apply(content, rewrite!!), + ) + } + + @Test + fun `end to end rewrite converts an expression-bodied function to a block body`() { + val content = + """ + package p + fun area(r: Int) = r * r + r * r + """.trimIndent() + + val result = plan(content, content.indexOf("r * r") + 1) + val candidate = result.candidates.first { it.label == "r * r" } + val scope = candidate.scopes.first() + + val rewrite = + buildExtractVariableRewrite(result.fileText, candidate.span, scope, "square", replaceAll = true) + assertNotNull(rewrite) + + assertEquals( + """ + package p + fun area(r: Int) { + val square = r * r + return square + square + } + """.trimIndent(), + apply(content, rewrite!!), + ) + } + + @Test + fun `end to end rewrite wraps a braceless if branch`() { + val content = + """ + package p + class A(val b: Int) + fun log(n: Int) {} + fun demo(c: Boolean, a: A) { + if (c) log(a.b + 1) + } + """.trimIndent() + + val result = plan(content, content.indexOf("a.b + 1") + 1) + val candidate = result.candidates.first { it.label == "a.b + 1" } + val scope = candidate.scopes.first() + + val rewrite = + buildExtractVariableRewrite(result.fileText, candidate.span, scope, "offset", replaceAll = false) + assertNotNull(rewrite) + + assertEquals( + """ + package p + class A(val b: Int) + fun log(n: Int) {} + fun demo(c: Boolean, a: A) { + if (c) { + val offset = a.b + 1 + log(offset) + } + } + """.trimIndent(), + apply(content, rewrite!!), + ) + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt new file mode 100644 index 0000000000..1d212b8404 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt @@ -0,0 +1,142 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Test + +/** The analysis-free primitives the refactoring is built from: name rules, indentation, soundness. */ +class RefactorPrimitivesTest { + @Test + fun `rejects blank names`() { + assertEquals(NameProblem.Blank, validateVariableName("", emptySet())) + assertEquals(NameProblem.Blank, validateVariableName(" ", emptySet())) + } + + @Test + fun `rejects non-identifiers`() { + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("1size", emptySet())) + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("my size", emptySet())) + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("size!", emptySet())) + // Backticked names are legal Kotlin but deliberately unsupported for a generated local. + assertEquals(NameProblem.NotAnIdentifier, validateVariableName("`size`", emptySet())) + } + + @Test + fun `rejects hard keywords but allows soft ones`() { + assertEquals(NameProblem.Keyword, validateVariableName("val", emptySet())) + assertEquals(NameProblem.Keyword, validateVariableName("when", emptySet())) + assertEquals(NameProblem.Keyword, validateVariableName("this", emptySet())) + // `it`, `data` and `by` are soft keywords -- perfectly legal identifiers. + assertNull(validateVariableName("it", emptySet())) + assertNull(validateVariableName("data", emptySet())) + assertNull(validateVariableName("by", emptySet())) + } + + @Test + fun `rejects names already in use`() { + assertEquals(NameProblem.AlreadyTaken, validateVariableName("size", setOf("size"))) + assertNull(validateVariableName("size", setOf("count"))) + } + + @Test + fun `accepts underscores and digits`() { + assertNull(validateVariableName("_size", emptySet())) + assertNull(validateVariableName("size2", emptySet())) + } + + @Test + fun `detects a tab indent unit`() { + assertEquals("\t", detectIndentUnit("fun f() {\n\tval x = 1\n}")) + } + + @Test + fun `detects the smallest space indent unit`() { + assertEquals(" ", detectIndentUnit("fun f() {\n val x = 1\n val y = 2\n}")) + assertEquals(" ", detectIndentUnit("fun f() {\n val x = 1\n}")) + } + + @Test + fun `falls back to a tab when nothing is indented`() { + assertEquals("\t", detectIndentUnit("fun f() {}")) + } + + @Test + fun `leading indent is read from the offset's own line`() { + val text = "class C {\n\t\tval x = 1\n}" + assertEquals("\t\t", leadingIndentAt(text, text.indexOf("val x"))) + assertEquals("", leadingIndentAt(text, text.indexOf("class"))) + } + + @Test + fun `line start is found for the first and later lines`() { + val text = "aa\nbbb\nc" + assertEquals(0, lineStartOffset(text, 1)) + assertEquals(3, lineStartOffset(text, 4)) + assertEquals(7, lineStartOffset(text, 7)) + } + + @Test + fun `label collapses whitespace and truncates`() { + assertEquals("items.filter { it > 0 }", collapseForLabel("items\n\t.filter { it > 0 }")) + assertEquals("a?.b", collapseForLabel("a\n\t?.b")) + assertEquals("aaaaaaa...", collapseForLabel("aaaaaaaaaaaa", maxLength = 10)) + } + + @Test + fun `trim drops surrounding whitespace from a selection`() { + val text = " items.size " + assertEquals(2 to 12, trimToCode(text, 0, text.length)) + } + + @Test + fun `trim leaves a cursor untouched and rejects a whitespace-only selection`() { + assertEquals(3 to 3, trimToCode("a b", 3, 3)) + assertNull(trimToCode("a b", 1, 5)) + } + + @Test + fun `soundness keeps every occurrence when nothing is written`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + assertEquals( + occurrences, + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = emptyList()), + ) + } + + @Test + fun `soundness drops occurrences separated from the candidate by a write`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + // A reassignment between the second and third sites: the third no longer holds the same value. + assertEquals( + listOf(TextSpan(10, 20), TextSpan(30, 40)), + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = listOf(45)), + ) + } + + @Test + fun `soundness drops earlier occurrences when the write precedes the candidate`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + assertEquals( + listOf(TextSpan(30, 40), TextSpan(50, 60)), + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = listOf(25)), + ) + } + + @Test + fun `soundness always keeps the occurrence the user selected`() { + val occurrences = listOf(TextSpan(10, 20), TextSpan(30, 40), TextSpan(50, 60)) + // Writes on both sides isolate the candidate, but it must never be dropped. + assertEquals( + listOf(TextSpan(30, 40)), + excludeUnsoundOccurrences(occurrences, TextSpan(30, 40), writeOffsets = listOf(25, 45)), + ) + } + + @Test + fun `soundness falls back to the candidate alone when it is not among the occurrences`() { + assertEquals( + listOf(TextSpan(70, 80)), + excludeUnsoundOccurrences(listOf(TextSpan(10, 20)), TextSpan(70, 80), writeOffsets = emptyList()), + ) + } +} From 410f9db7e535772a4deea61e28ee2bbc92dd8087 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:18:22 +0000 Subject: [PATCH 03/25] ADFA-4826: Add the extract-variable Compose sheet One surface holding every choice - expression, name, scope, replace-all - because they are interdependent: a different expression changes the scope list and the occurrence count, and sequential dialogs would hide that. Each chooser is hidden when it has nothing to ask. State derives entirely from the plan, so the ViewModel is a plain unit test with no editor, activity or Compose. Uses the shared IdeTheme from common-compose. --- .../refactor/ui/ExtractVariableSheet.kt | 117 ++++++++++ .../ui/ExtractVariableSheetContent.kt | 200 ++++++++++++++++++ .../refactor/ui/ExtractVariableUiState.kt | 71 +++++++ .../refactor/ui/ExtractVariableViewModel.kt | 118 +++++++++++ .../ui/ExtractVariableViewModelTest.kt | 195 +++++++++++++++++ resources/src/main/res/values/strings.xml | 18 ++ 6 files changed, 719 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt new file mode 100644 index 0000000000..17ffdf7dba --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheet.kt @@ -0,0 +1,117 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import android.content.Context +import android.content.ContextWrapper +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.itsaky.androidide.common.compose.IdeTheme +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan + +/** + * Hosts [ExtractVariableSheetContent]. + * + * The plan is handed in directly rather than through fragment arguments: it carries the file's text and + * offset spans, which is neither `Parcelable` nor meaningful to restore -- after process death the + * document may be entirely different. So [plan] is null on a recreated instance and the sheet dismisses + * itself, which is the same outcome the action's document-version guard would reach anyway. + */ +class ExtractVariableSheet : BottomSheetDialogFragment() { + private var plan: ExtractionPlan? = null + private var onChoice: ((ExtractionChoice) -> Unit)? = null + + private val viewModel: ExtractVariableViewModel by viewModels { + ExtractVariableViewModel.factory(requireNotNull(plan) { "sheet shown without a plan" }) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + if (plan == null) { + dismissAllowingStateLoss() + return null + } + + return ComposeView(requireContext()).apply { + // The sheet's window is torn down with the fragment's view, so dispose with it. + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + IdeTheme { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ExtractVariableSheetContent( + state = state, + onEvent = ::handleEvent, + ) + } + } + } + } + + private fun handleEvent(event: ExtractVariableUiEvent) { + when (event) { + ExtractVariableUiEvent.Confirmed -> { + viewModel.choice()?.let { choice -> onChoice?.invoke(choice) } + dismiss() + } + + ExtractVariableUiEvent.Dismissed -> { + dismiss() + } + + else -> { + viewModel.onEvent(event) + } + } + } + + companion object { + private const val TAG = "extract_variable_sheet" + + /** + * Shows the sheet on [activity], calling [onChoice] once if the user confirms. + * + * Returns false when the sheet could not be shown, so the caller can report a failure rather + * than silently doing nothing. + */ + fun show( + activity: FragmentActivity, + plan: ExtractionPlan, + onChoice: (ExtractionChoice) -> Unit, + ): Boolean { + val manager = activity.supportFragmentManager + if (manager.isStateSaved || manager.isDestroyed) return false + ExtractVariableSheet() + .apply { + this.plan = plan + this.onChoice = onChoice + }.show(manager, TAG) + return true + } + } +} + +/** + * Finds the [FragmentActivity] hosting this context by unwrapping the [ContextWrapper] chain. + * + * A view inflated into an activity reports that activity as its context, but a theme overlay wraps it, + * so a direct cast is not reliable. `ActionData` carries only the editor's `Context`, and adding a + * `FragmentActivity` key would only move the same unwrapping one module upstream, into `editor`. + */ +fun Context.findFragmentActivity(): FragmentActivity? { + var context: Context? = this + while (context != null) { + if (context is FragmentActivity) return context + context = (context as? ContextWrapper)?.baseContext + } + return null +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt new file mode 100644 index 0000000000..25409974ee --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt @@ -0,0 +1,200 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.foundation.selection.toggleable +import androidx.compose.material3.Button +import androidx.compose.material3.Checkbox +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.pluralStringResource +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.resources.R + +/** + * The extract-variable sheet: one surface holding every choice, with no navigation between steps. + * + * Expression, name, scope and replace-all are interdependent -- picking a different expression changes + * the scope list and the occurrence count -- so they are shown together, where that relationship is + * visible, rather than across sequential dialogs the user would have to back out of to explore. + * + * Stateless: all state arrives in [state] and every interaction leaves as an [ExtractVariableUiEvent]. + */ +@Composable +fun ExtractVariableSheetContent( + state: ExtractVariableUiState, + onEvent: (ExtractVariableUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.title_extract_variable), + style = MaterialTheme.typography.titleLarge, + ) + + if (state.showCandidatePicker) { + LabelledSection(stringResource(R.string.label_extract_variable_expression)) { + OptionList( + options = state.candidateLabels, + selected = state.selectedCandidate, + monospace = true, + onSelect = { onEvent(ExtractVariableUiEvent.CandidateSelected(it)) }, + ) + } + } + + OutlinedTextField( + value = state.name, + onValueChange = { onEvent(ExtractVariableUiEvent.NameChanged(it)) }, + label = { Text(stringResource(R.string.label_extract_variable_name)) }, + isError = state.nameProblem != null, + singleLine = true, + supportingText = state.nameProblem?.let { problem -> { Text(stringResource(problem.messageRes())) } }, + modifier = Modifier.fillMaxWidth(), + ) + + if (state.showScopePicker) { + LabelledSection(stringResource(R.string.label_extract_variable_scope)) { + OptionList( + options = state.scopeLabels, + selected = state.selectedScope, + monospace = false, + onSelect = { onEvent(ExtractVariableUiEvent.ScopeSelected(it)) }, + ) + } + } + + if (state.showReplaceAll) { + val replaceAllLabel = + pluralStringResource( + R.plurals.label_extract_variable_replace_all, + state.occurrenceCount, + state.occurrenceCount, + ) + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .toggleable( + value = state.replaceAll, + role = Role.Checkbox, + onValueChange = { onEvent(ExtractVariableUiEvent.ReplaceAllChanged(it)) }, + ), + ) { + Checkbox( + checked = state.replaceAll, + // Null so the row, not the box, is the single accessibility target. + onCheckedChange = null, + ) + Text( + text = replaceAllLabel, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onEvent(ExtractVariableUiEvent.Dismissed) }) { + Text(stringResource(android.R.string.cancel)) + } + Button( + onClick = { onEvent(ExtractVariableUiEvent.Confirmed) }, + enabled = state.canConfirm, + modifier = Modifier.padding(start = 8.dp), + ) { + Text(stringResource(R.string.action_extract)) + } + } + } +} + +@Composable +private fun LabelledSection( + label: String, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + content() + } +} + +/** A radio group. Expression text is monospaced so a candidate reads as the code it is. */ +@Composable +private fun OptionList( + options: List, + selected: Int, + monospace: Boolean, + onSelect: (Int) -> Unit, +) { + Column( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEachIndexed { index, option -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = index == selected, + role = Role.RadioButton, + onClick = { onSelect(index) }, + ), + ) { + RadioButton( + selected = index == selected, + onClick = null, + ) + + Text( + text = option, + style = + if (monospace) { + MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + } else { + MaterialTheme.typography.bodyMedium + }, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +/** The message shown under the name field for each way a name can be unusable. */ +internal fun NameProblem.messageRes(): Int = + when (this) { + NameProblem.Blank -> R.string.msg_extract_variable_name_blank + NameProblem.NotAnIdentifier -> R.string.msg_extract_variable_name_invalid + NameProblem.Keyword -> R.string.msg_extract_variable_name_keyword + NameProblem.AlreadyTaken -> R.string.msg_extract_variable_name_taken + } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt new file mode 100644 index 0000000000..c5937f79dd --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt @@ -0,0 +1,71 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.CandidateExpression +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ScopeOption + +/** + * Everything the extract-variable sheet renders, derived entirely from the + * [com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan]. + * + * [showCandidatePicker] is false when the plan holds a single candidate, or when the user's selection + * already matched an expression exactly -- in both cases asking which expression they meant would be + * asking a question they have already answered. + * + * [occurrenceCount] counts every site the selected scope would rewrite, **including** the one the user + * selected, so "Replace all 3 occurrences" means three sites in total. [showReplaceAll] is false at a + * count of one, where the toggle would have nothing to do. + */ +data class ExtractVariableUiState( + val candidateLabels: List, + val selectedCandidate: Int, + val showCandidatePicker: Boolean, + val name: String, + val nameProblem: NameProblem?, + val scopeLabels: List, + val selectedScope: Int, + val occurrenceCount: Int, + val replaceAll: Boolean, +) { + val showReplaceAll: Boolean get() = occurrenceCount > 1 + + val showScopePicker: Boolean get() = scopeLabels.size > 1 + + val canConfirm: Boolean get() = nameProblem == null +} + +/** What the sheet reports back up; the ViewModel never touches the document itself. */ +sealed interface ExtractVariableUiEvent { + data class CandidateSelected( + val index: Int, + ) : ExtractVariableUiEvent + + data class NameChanged( + val name: String, + ) : ExtractVariableUiEvent + + data class ScopeSelected( + val index: Int, + ) : ExtractVariableUiEvent + + data class ReplaceAllChanged( + val replaceAll: Boolean, + ) : ExtractVariableUiEvent + + data object Confirmed : ExtractVariableUiEvent + + data object Dismissed : ExtractVariableUiEvent +} + +/** + * The user's finished decision, handed to the action to turn into an edit. + * + * Kept free of offsets and text so the sheet stays a pure chooser: resolving this into a rewrite, and + * checking the document has not moved on, both belong to the action. + */ +data class ExtractionChoice( + val candidate: CandidateExpression, + val scope: ScopeOption, + val name: String, + val replaceAll: Boolean, +) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt new file mode 100644 index 0000000000..6d9494593e --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt @@ -0,0 +1,118 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.validateVariableName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Derives the sheet's state from an [ExtractionPlan] and nothing else. + * + * The plan already contains every candidate's scope chain and occurrence set, so switching expression + * or scope is pure recomputation -- no analysis, no PSI, no I/O. That is what lets this class hold all + * the sheet's logic while remaining a plain unit test. + * + * A plain [ViewModelProvider.Factory] rather than a Koin definition (ADR 0006/0009 resolve ViewModels + * through Koin): this one is sheet-scoped, injects nothing, and takes the plan as a runtime argument, + * so a Koin definition would add indirection without providing anything. + */ +class ExtractVariableViewModel( + private val plan: ExtractionPlan, +) : ViewModel() { + private val _uiState = MutableStateFlow(initialState()) + val uiState: StateFlow = _uiState.asStateFlow() + + private fun initialState(): ExtractVariableUiState = stateFor(candidateIndex = 0, scopeIndex = 0, replaceAll = false, name = null) + + fun onEvent(event: ExtractVariableUiEvent) { + val current = _uiState.value + when (event) { + is ExtractVariableUiEvent.CandidateSelected -> { + if (event.index == current.selectedCandidate) return + // A different expression means a different suggested name, scope chain and count, so the + // name is re-suggested rather than carried over -- the old one described the old expression. + _uiState.value = stateFor(event.index, scopeIndex = 0, replaceAll = false, name = null) + } + + is ExtractVariableUiEvent.ScopeSelected -> { + if (event.index == current.selectedScope) return + _uiState.value = + stateFor(current.selectedCandidate, event.index, current.replaceAll, current.name) + } + + is ExtractVariableUiEvent.NameChanged -> { + _uiState.value = + current.copy( + name = event.name, + nameProblem = validateVariableName(event.name, candidate(current.selectedCandidate).takenNames), + ) + } + + is ExtractVariableUiEvent.ReplaceAllChanged -> { + _uiState.value = current.copy(replaceAll = event.replaceAll) + } + + ExtractVariableUiEvent.Confirmed, ExtractVariableUiEvent.Dismissed -> { + Unit + } + } + } + + /** The user's decision, or null when the name is unusable. */ + fun choice(): ExtractionChoice? { + val state = _uiState.value + if (!state.canConfirm) return null + val candidate = candidate(state.selectedCandidate) + val scope = candidate.scopes.getOrNull(state.selectedScope) ?: return null + return ExtractionChoice( + candidate = candidate, + scope = scope, + name = state.name, + // A single occurrence makes the toggle meaningless, and the sheet hides it; make sure a + // stale `true` from a previous candidate cannot leak into the choice. + replaceAll = state.replaceAll && state.occurrenceCount > 1, + ) + } + + private fun candidate(index: Int) = plan.candidates[index.coerceIn(plan.candidates.indices)] + + /** + * Recomputes the whole state for a (candidate, scope) pair. [name] carries the user's typed name + * across a scope change; pass null to take the candidate's suggestion. + */ + private fun stateFor( + candidateIndex: Int, + scopeIndex: Int, + replaceAll: Boolean, + name: String?, + ): ExtractVariableUiState { + val candidate = candidate(candidateIndex) + val boundedScope = scopeIndex.coerceIn(candidate.scopes.indices) + val scope = candidate.scopes[boundedScope] + val resolvedName = name ?: candidate.suggestedName + val occurrenceCount = scope.occurrences.size + + return ExtractVariableUiState( + candidateLabels = plan.candidates.map { it.label }, + selectedCandidate = candidateIndex.coerceIn(plan.candidates.indices), + showCandidatePicker = plan.candidates.size > 1 && !plan.selectionMatchedCandidate, + name = resolvedName, + nameProblem = validateVariableName(resolvedName, candidate.takenNames), + scopeLabels = candidate.scopes.map { it.label }, + selectedScope = boundedScope, + occurrenceCount = occurrenceCount, + replaceAll = replaceAll && occurrenceCount > 1, + ) + } + + companion object { + fun factory(plan: ExtractionPlan): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ExtractVariableViewModel(plan) as T + } + } +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt new file mode 100644 index 0000000000..4f25b9a3aa --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt @@ -0,0 +1,195 @@ +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.AnchorForm +import com.itsaky.androidide.lsp.kotlin.utils.refactor.CandidateExpression +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ScopeOption +import com.itsaky.androidide.lsp.kotlin.utils.refactor.TextSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The sheet's derivation logic, tested without Compose, a fragment or an activity. + * + * Every choice the sheet offers is recomputed from the plan, so all of this is exercisable as plain + * state transitions -- which is the point of keeping the plan plain data. + */ +class ExtractVariableViewModelTest { + private fun scope( + label: String, + occurrences: Int, + ) = ScopeOption( + label = label, + anchorForm = AnchorForm.ExistingBlock, + occurrences = (0 until occurrences).map { TextSpan(it * 10, it * 10 + 5) }, + ) + + private fun candidate( + label: String, + suggestedName: String, + scopes: List, + takenNames: Set = emptySet(), + ) = CandidateExpression( + label = label, + span = TextSpan(0, 5), + suggestedName = suggestedName, + takenNames = takenNames, + scopes = scopes, + ) + + private fun plan( + candidates: List, + selectionMatched: Boolean = false, + ) = ExtractionPlan( + fileText = "unused", + documentVersion = 1, + candidates = candidates, + selectionMatchedCandidate = selectionMatched, + ) + + private val threeCandidatePlan = + plan( + listOf( + candidate("items.size", "size", listOf(scope("lambda", 1), scope("fun demo", 3))), + candidate("items.size * 2", "size1", listOf(scope("fun demo", 2))), + candidate("wrap(items.size * 2)", "wrap", listOf(scope("fun demo", 1))), + ), + ) + + @Test + fun `starts on the innermost candidate, innermost scope, replace-all off`() { + val state = ExtractVariableViewModel(threeCandidatePlan).uiState.value + + assertEquals(0, state.selectedCandidate) + assertEquals(0, state.selectedScope) + assertEquals("size", state.name) + assertFalse(state.replaceAll) + assertTrue(state.canConfirm) + } + + @Test + fun `shows the candidate picker only when there is a real choice`() { + assertTrue(ExtractVariableViewModel(threeCandidatePlan).uiState.value.showCandidatePicker) + + val single = plan(listOf(candidate("items.size", "size", listOf(scope("fun demo", 1))))) + assertFalse(ExtractVariableViewModel(single).uiState.value.showCandidatePicker) + } + + @Test + fun `an exact selection suppresses the candidate picker`() { + // The user already said which expression they meant by selecting it. + val matched = plan(threeCandidatePlan.candidates, selectionMatched = true) + assertFalse(ExtractVariableViewModel(matched).uiState.value.showCandidatePicker) + } + + @Test + fun `changing the expression re-derives name, scopes and count`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + + viewModel.onEvent(ExtractVariableUiEvent.CandidateSelected(1)) + val state = viewModel.uiState.value + + assertEquals("size1", state.name) + assertEquals(listOf("fun demo"), state.scopeLabels) + assertEquals(2, state.occurrenceCount) + assertEquals(0, state.selectedScope) + } + + @Test + fun `changing the scope changes the occurrence count`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + assertEquals(1, viewModel.uiState.value.occurrenceCount) + + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + + assertEquals(1, viewModel.uiState.value.selectedScope) + assertEquals(3, viewModel.uiState.value.occurrenceCount) + } + + @Test + fun `a scope change keeps the name the user typed`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("mySize")) + + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + + assertEquals("mySize", viewModel.uiState.value.name) + } + + @Test + fun `the replace-all toggle is hidden at a single occurrence`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + assertFalse(viewModel.uiState.value.showReplaceAll) + + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + + assertTrue(viewModel.uiState.value.showReplaceAll) + } + + @Test + fun `an invalid name blocks confirming`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("val")) + + assertEquals(NameProblem.Keyword, viewModel.uiState.value.nameProblem) + assertFalse(viewModel.uiState.value.canConfirm) + assertNull(viewModel.choice()) + } + + @Test + fun `a name colliding with a visible declaration is rejected`() { + val colliding = + plan(listOf(candidate("items.size", "size1", listOf(scope("fun demo", 1)), takenNames = setOf("size")))) + val viewModel = ExtractVariableViewModel(colliding) + + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("size")) + + assertEquals(NameProblem.AlreadyTaken, viewModel.uiState.value.nameProblem) + } + + @Test + fun `the choice carries the selected expression, scope, name and toggle`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + viewModel.onEvent(ExtractVariableUiEvent.ReplaceAllChanged(true)) + viewModel.onEvent(ExtractVariableUiEvent.NameChanged("total")) + + val choice = viewModel.choice() + assertNotNull(choice) + assertEquals("items.size", choice!!.candidate.label) + assertEquals("fun demo", choice.scope.label) + assertEquals("total", choice.name) + assertTrue(choice.replaceAll) + } + + @Test + fun `replace-all cannot leak from a wider scope into a single-occurrence one`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + viewModel.onEvent(ExtractVariableUiEvent.ReplaceAllChanged(true)) + assertTrue(viewModel.uiState.value.replaceAll) + + // Back to the lambda scope, which has one occurrence and no visible toggle. + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(0)) + + assertFalse(viewModel.uiState.value.replaceAll) + assertFalse(viewModel.choice()!!.replaceAll) + } + + @Test + fun `switching expression resets replace-all`() { + val viewModel = ExtractVariableViewModel(threeCandidatePlan) + viewModel.onEvent(ExtractVariableUiEvent.ScopeSelected(1)) + viewModel.onEvent(ExtractVariableUiEvent.ReplaceAllChanged(true)) + + viewModel.onEvent(ExtractVariableUiEvent.CandidateSelected(1)) + + assertFalse(viewModel.uiState.value.replaceAll) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 15fa7d11b0..74df15362c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -523,6 +523,24 @@ Suppress \'unchecked\' warning Uncomment line Convert to statement + + + Extract variable + Extract variable + Expression + Name + Declare in + + Replace %1$d occurrence + Replace all %1$d occurrences + + Extract + Enter a name + Not a valid Kotlin name + That is a Kotlin keyword + That name is already used + No expression to extract here + The file changed. Try extracting again. Select fields No fields selected No fields found From 96c43653798b9d0d7272264ddbe7faae75599c7a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:18:34 +0000 Subject: [PATCH 04/25] ADFA-4826: Wire up the extract-variable code action execAction runs the analysis off the UI thread and returns the plan; postExec shows the sheet and turns the user's choice into one spanning TextEdit. The document version is re-read on confirm - the editor stays reachable while the sheet is open, and applying spans computed against older text would corrupt the file. No prepare() visibility gate: deciding extractability needs an analysis session, far too costly for the UI thread. Records the placement decision as ADR 0011. --- ...oring-ui-lives-in-the-owning-lsp-module.md | 52 + docs/adr/README.md | 1 + .../plans/2026-08-10-kotlin-extract-method.md | 3043 +++++++++++++++++ ...026-08-12-extract-variable-defect-fixes.md | 1779 ++++++++++ .../2026-08-18-extract-method-review-fixes.md | 1008 ++++++ .../androidide/idetooltips/TooltipTag.kt | 1 + lsp/kotlin/build.gradle.kts | 2 +- .../lsp/kotlin/KotlinCodeActionsMenu.kt | 2 + .../kotlin/actions/ExtractVariableAction.kt | 155 + .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 + 10 files changed, 6044 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md create mode 100644 docs/superpowers/plans/2026-08-10-kotlin-extract-method.md create mode 100644 docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md create mode 100644 docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt diff --git a/docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md b/docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md new file mode 100644 index 0000000000..92a2a0b16b --- /dev/null +++ b/docs/adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md @@ -0,0 +1,52 @@ +# 0012. Refactoring UI lives in the owning LSP module + +- **Status:** Proposed +- **Date:** 2026-08-03 +- **Deciders:** Code On The Go team + +## Context + +The K2 Kotlin LSP is gaining interactive refactorings: extract variable and extract method (ADFA-4826), inline variable (ADFA-4827), semantic rename (ADFA-4825). Unlike every existing Kotlin code action, these cannot be a single fire-and-forget edit — the user has to choose an expression, a name, a target scope, and whether to replace other occurrences. That is a real UI surface, not a `DialogUtils` one-liner. + +[ADR 0009](0009-jetpack-compose-for-new-ui.md) settles *what* that UI is built with (Compose, UDF, `ViewModel` + `StateFlow`). It says nothing about *where* language-specific UI lives, and the module graph makes that a genuine question: + +- `editor` depends on `lsp/kotlin` (`editor/build.gradle.kts`), so the dependency flows **LSP -> editor**. An LSP module cannot reach the editor or `app`. +- `ActionData` carries only a `Context` and the editor; there is no service-lookup mechanism for an LSP module to call *up* into a UI layer. +- `lsp/java` already owns UI code today — `AutoFixImportsAction` builds and shows a `DialogUtils` chooser directly. + +So a refactoring in `lsp/kotlin` either renders its own UI, or a new inversion mechanism has to be invented for it. + +## Decision + +**A language server module owns the UI for its own refactorings.** `lsp/kotlin` enables Compose and hosts the refactoring bottom sheets; the same applies to any future `lsp/*` module that grows an interactive refactoring. + +- Compose is enabled per-module exactly as `flamegraph`, `floating-window` and `profiler` do it: the `kotlin-compose` plugin, `compose = true`, and the Compose BOM with `ui`/`foundation`/`material3`. +- The UI is a `BottomSheetDialogFragment` hosting a `ComposeView`. The hosting `FragmentActivity` is found by walking `ContextWrapper.baseContext` up from `ActionData`'s `Context` — no new `ActionData` key, no change to the `editor` module. +- **The analysis/UI split is enforced by data, not by module boundaries.** The action's background pass produces a plain-data plan (candidate expressions, scope chains, occurrence ranges, suggested name, document version); the sheet performs no analysis and holds no PSI. All refactoring logic lives in pure functions, unit-testable without an editor, an activity, or Compose. +- ADR 0009 otherwise applies unchanged: `ViewModel` + `StateFlow`, sealed `UiEvent`, `collectAsStateWithLifecycle()`. + +## Consequences + +**Positive** +- No new indirection: one module, one PR per refactoring, no interface to register or resolve. +- Consistent with `lsp/java` already owning its dialogs, so there is one rule for LSP-owned UI rather than two. +- The plain-data plan boundary keeps the valuable logic testable regardless of where the UI sits, so the placement decision does not compromise test coverage. + +**Negative / costs** +- A language server module gains a UI surface, which is a layering smell: `lsp/kotlin` is no longer purely a language service. +- Compose and `lifecycle-viewmodel` are added to a module that previously had neither, growing its build surface and bringing ktlint's compose-rules ruleset to bear on it. +- Walking the `ContextWrapper` chain for a `FragmentActivity` is an implicit dependency on how the editor is hosted; a future change to that hosting breaks it at runtime rather than at compile time. +- If three or more `lsp/*` modules end up with Compose UI, extracting a shared UI module becomes worthwhile and this decision will need revisiting. + +## Alternatives considered + +- **Render in `editor`, invert via an interface.** Declare a refactoring-UI interface in `editorApi` or `lsp/models`, implement it in `editor`, have `lsp/kotlin` call up through it. Cleanest layering. Rejected: nothing registers such an implementation today, so it means inventing a service-lookup mechanism for one sheet, and the interface would be guessed from a single client. +- **Render in `app`.** `app` is the integration point and already hosts `BottomSheetDialogFragment`s and `ILanguageClient`. Rejected: same inversion problem, and it puts Kotlin-specific refactoring UI in the module where nothing else language-specific lives. +- **A new `lsp/kotlin-ui` module.** Keeps Compose out of `lsp/kotlin` without inverting. Rejected for now: a new Gradle module in a ~80-module build is disproportionate for one sheet. Reconsider once extract-method and inline-variable have landed and the UI surface is known. + +## Related + +- [ADR 0009](0009-jetpack-compose-for-new-ui.md) — Compose for new UI; this ADR answers *where*, not *what*. +- [ADR 0006](0006-koin-dependency-injection.md) — Koin DI, unchanged. +- [ADR 0010](0010-navigation-resolves-via-analysis-api.md) — the K2 Analysis API as the Kotlin semantic source of truth. +- [ARCHITECTURE.md](../../ARCHITECTURE.md) — module map, layering, UDF. diff --git a/docs/adr/README.md b/docs/adr/README.md index c682eefaca..dfabb5e15e 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -26,3 +26,4 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [0010](0010-navigation-resolves-via-analysis-api.md) | Kotlin navigation resolves via the Analysis API, not the symbol index | Proposed | | [0011](0011-command-analysis-priority.md) | User-invoked commands get their own analysis priority | Proposed | | [0012](0012-volatile-build-metadata-out-of-abis.md) | Keep volatile build metadata out of module ABIs | Proposed | +| [0013](0012-refactoring-ui-lives-in-the-owning-lsp-module.md) | Refactoring UI lives in the owning LSP module | Proposed | diff --git a/docs/superpowers/plans/2026-08-10-kotlin-extract-method.md b/docs/superpowers/plans/2026-08-10-kotlin-extract-method.md new file mode 100644 index 0000000000..848689cbcc --- /dev/null +++ b/docs/superpowers/plans/2026-08-10-kotlin-extract-method.md @@ -0,0 +1,3043 @@ +# Kotlin Extract Method 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 "Extract method" code action to the Kotlin K2 LSP that moves an expression or a range of sibling statements into a new `private fun` and replaces it with a call, declining with a specific reason wherever it cannot do that faithfully. + +**Architecture:** Same shape as extract variable (ADFA-4826), which is already on this branch. One background analysis pass produces a plain-data `ExtractMethodPlan` (no PSI); a Compose bottom sheet does pure string arithmetic on it; confirming re-reads the document version and emits **two** `TextEdit`s in one `DocumentChange`, ordered new-function-first. Every hard case is a typed `ExtractionRefusal` rather than a clever rewrite (ADR 0012). + +**Tech Stack:** Kotlin, K2 Analysis API (`org.jetbrains.kotlin.analysis.api`), IntelliJ PSI (`org.jetbrains.kotlin.psi`), Jetpack Compose + Material3, JUnit4 + Robolectric. + +## Global Constraints + +- **Module:** everything lives in `lsp/kotlin`, except one constant in `idetooltips/.../TooltipTag.kt` and new strings in `resources/src/main/res/values/strings.xml`. No new module, no new dependency. +- **Vocabulary:** the term is **method** in user-facing text and type names, even though the output is a Kotlin `fun`. Internal vocabulary is fixed by the spec: *statement range*, *enclosing declaration*, *captured declaration*, *output*, *exit*, *refusal*. +- **Code style:** tabs for indentation, LF endings, ktlint via Spotless. ASCII only in code and comments (`->` not the arrow glyph, `--` not an em dash). No separator/banner comments. Comment the non-obvious *why*, never the what. +- **Ticket:** ADFA-5080. Commit subjects are `ADFA-5080: Short description` (colon, imperative). **Never** add a `Co-Authored-By` trailer. **Never** `git add .` / `git add -A` -- stage named files only. +- **Never commit this plan file** or anything under `docs/superpowers/`. `docs/features/kotlin-extract-method.md` IS a real project doc and does get committed. +- **Build wrapper:** every Gradle call is `flox activate -d flox/local -- ./gradlew `. +- **Test task:** `:lsp:kotlin:testV7DebugUnitTest` (V7 flavor; there is no flavorless `testDebugUnitTest`). Compile-only check: `:lsp:kotlin:compileV7DebugKotlin`. +- **Tooltip tag string is fixed:** `"editor.codeactions.kotlin.extractmethod"`. Tooltip content lives in an out-of-repo database keyed by that tag, so it cannot be renamed. +- **Action id is fixed:** `ide.editor.lsp.kt.extractMethod`. +- **No `prepare()` visibility gate** and `requiresUIThread = false` -- deciding extractability needs an analysis session, far too costly for the UI thread. Never do I/O or analysis on the main thread. +- **Edit ordering is mandatory:** `IDELanguageClientImpl.applyActionEdits` applies edits in list order using line/column ranges against the text as it is at that moment. Emit the function insertion **before** the call-site replacement (descending document order) or the file is corrupted. +- **Emitted text must be fully indented.** Code-action `TextEdit`s bypass the editor's auto-indent and `CMD_FORMAT_CODE` is a no-op for Kotlin. + +--- + +## Existing code you will reuse + +All in `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/`. Read these before starting -- the plan assumes their exact signatures. + +| Symbol | File | Signature | +|---|---|---| +| `TextSpan` | `utils/refactor/ExtractionPlan.kt` | `data class TextSpan(val start: Int, val end: Int)`, `.length`, `.overlaps(other)` | +| `collapseForLabel` | `utils/refactor/ExtractionPlan.kt` | `internal fun collapseForLabel(text: String, maxLength: Int = 80): String` | +| `candidateExpressionsAt` | `utils/refactor/CandidateExpressions.kt` | `fun candidateExpressionsAt(file: KtFile, selectionStart: Int, selectionEnd: Int): CandidateSyntax` | +| `CandidateSyntax` | `utils/refactor/CandidateExpressions.kt` | `data class CandidateSyntax(val expressions: List, val selectionMatchedInnermost: Boolean)` | +| `trimToCode` | `utils/refactor/CandidateExpressions.kt` | `internal fun trimToCode(text: String, start: Int, end: Int): Pair?` | +| `isExtractionPosition` | `utils/refactor/CandidateExpressions.kt` | `internal fun isExtractionPosition(element: PsiElement): Boolean` | +| `enclosingExecutableBody` | `utils/refactor/CandidateExpressions.kt` | `internal fun enclosingExecutableBody(element: PsiElement): PsiElement?` | +| `NameProblem`, `validateVariableName` | `utils/refactor/NameSuggestion.kt` | `fun validateVariableName(name: String, takenNames: Set): NameProblem?` | +| `suggestVariableName` | `utils/refactor/NameSuggestion.kt` | `fun suggestVariableName(expression: KtExpression, typeName: String?, takenNames: Set): String` | +| `detectIndentUnit`, `leadingIndentAt`, `lineStartOffset` | `utils/refactor/ScopeChain.kt` | `internal fun detectIndentUnit(text: String): String` etc. | +| `detectNewline`, `positionAt`, `RewriteSpan`, `toTextEdit` | `utils/refactor/ExtractVariableEdit.kt` | `data class RewriteSpan(val span: TextSpan, val newText: String)`, `fun RewriteSpan.toTextEdit(fileText: String): TextEdit` | +| `renderName` | `utils/TypeRendering.kt` | `internal fun KaSession.renderName(type: KaType, ...): String` | +| `analyzeMaybeDangling` | `compiler/modules/` | `analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { ... }` | +| `env.project.read { }` | `compiler/` | plain read lock; `getCurrentKtFile(...).get()` must be called **outside** it or it deadlocks | +| `KtLspTest` | `src/test/.../fixtures/KtLspTest.kt` | base class; `createSourceFile(name, content)`, `env`, `noopCancelChecker()` | + +Deliberately **not** reused: `ScopeOption`, `AnchorForm`, `CandidateExpression`, `Occurrences.kt`. Those are shaped by extract variable's legal scope chain, which this refactoring does not have. + +## File Structure + +**Created (all under `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/`):** + +| File | Responsibility | +|---|---| +| `utils/refactor/RefactoringPlan.kt` | The sealed supertype both plans share: `fileText`, `documentVersion` (R3). | +| `utils/refactor/ExtractionRegion.kt` | Resolving a selection to one region: expression candidates or a statement range (R2). Pure PSI. | +| `utils/refactor/ExtractMethodPlan.kt` | `ExtractMethodPlan`, `ExtractMethodCandidate`, `MethodParameter`, `ExtractedBody`, `CallSiteForm`, `ExtractionRefusal`, `signatureText` (R5-R6, R11, R14). Plain data. | +| `utils/refactor/ExtractMethodEdit.kt` | The two rewrites and their descending order (R15). Pure text and offsets. | +| `utils/refactor/MethodSignature.kt` | The analysis: captured declarations -> parameters, outputs, exits, receivers, modifiers, taken names, refusals (R5-R10, R12). | +| `utils/refactor/ExtractMethodPlanner.kt` | The single background pass (R3, R16). | +| `refactor/ui/SheetComponents.kt` | `LabelledSection`, `OptionList`, `NameProblem.messageRes()` promoted out of the extract-variable sheet (R11). | +| `refactor/ui/ExtractMethodUiState.kt` | `ExtractMethodUiState`, `ExtractMethodUiEvent`, `ExtractMethodChoice` (R11). | +| `refactor/ui/ExtractMethodViewModel.kt` | State derivation, name validation, signature preview (R11, R12). | +| `refactor/ui/ExtractMethodSheetContent.kt` | Stateless Compose content (R11). | +| `refactor/ui/ExtractMethodSheet.kt` | `BottomSheetDialogFragment` hosting a `ComposeView` (R11). | +| `actions/ExtractMethodAction.kt` | The only class touching the editor, the document version or the language client (R1, R3, R14, R15). | + +**Modified:** + +| File | Change | +|---|---| +| `utils/refactor/ExtractionPlan.kt` | `ExtractionPlan` implements `RefactoringPlan`. | +| `utils/refactor/NameSuggestion.kt` | Expose `uniqueName(base, taken)` (was the private `makeUnique`). | +| `refactor/ui/ExtractVariableSheetContent.kt` | Delete the local `LabelledSection`, `OptionList`, `messageRes()`; they move to `SheetComponents.kt`. | +| `KotlinCodeActionsMenu.kt` | Register `ExtractMethodAction()`. | +| `idetooltips/.../TooltipTag.kt` | `EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD`. | +| `resources/src/main/res/values/strings.xml` | Title, labels, and the seven refusal messages. | +| `docs/features/kotlin-extract-method.md` | Status line. | + +**Tests (under `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/`):** + +- `utils/refactor/ExtractMethodRegionTest.kt` -- PSI only (R2). +- `utils/refactor/ExtractMethodEditTest.kt` -- pure text (R6, R15). +- `utils/refactor/ExtractMethodPlanEndToEndTest.kt` -- analysis-backed (R5-R10, R12, R14). +- `refactor/ui/ExtractMethodViewModelTest.kt` -- state derivation (R11, R12). +- `KotlinCodeActionTooltipTagTest.kt` -- one new row. + +--- + +## Task 1: The shared `RefactoringPlan` supertype + +The spec (R3) says the version guard is "shared via the `RefactoringPlan` supertype", described as already introduced by the extract-variable PR. **It was not** -- `ExtractionPlan` is a standalone data class. This task introduces it so extract method is purely additive, exactly as the spec assumes. + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt` +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt` (the `data class ExtractionPlan(...)` declaration, around line 128) + +**Interfaces:** +- Consumes: nothing. +- Produces: `sealed interface RefactoringPlan { val fileText: String; val documentVersion: Int }`. Task 3's `ExtractMethodPlan` implements it. + +- [ ] **Step 1: Create the supertype** + +Create `RefactoringPlan.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** + * What every interactive refactoring's background pass returns. + * + * The two fields are what makes applying a plan safe long after it was computed: [fileText] is the + * text its offsets refer to, and [documentVersion] is re-read on confirm so a plan computed against + * text the user has since edited is discarded rather than applied against shifted offsets. + */ +sealed interface RefactoringPlan { + val fileText: String + val documentVersion: Int +} +``` + +- [ ] **Step 2: Make `ExtractionPlan` implement it** + +In `ExtractionPlan.kt`, change the declaration (keep the whole KDoc block above it untouched): + +```kotlin +data class ExtractionPlan( + override val fileText: String, + override val documentVersion: Int, + val candidates: List, + val selectionMatchedCandidate: Boolean, +) : RefactoringPlan { +``` + +- [ ] **Step 3: Verify the existing tests still pass** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` +Expected: PASS. This is a pure retrofit; a failure means something else broke. + +- [ ] **Step 4: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt +git commit -m "ADFA-5080: Hoist the shared refactoring plan supertype" +``` + +--- + +## Task 2: Region resolution + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt` +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt` + +**Interfaces:** +- Consumes: `TextSpan`, `trimToCode`, `candidateExpressionsAt`, `CandidateSyntax`, `isExtractionPosition`. +- Produces: + - `sealed interface ExtractionRegion { val span: TextSpan }` + - `data class ExtractionRegion.Expressions(val candidates: List, val selectionMatchedInnermost: Boolean)` + - `data class ExtractionRegion.Statements(val statements: List, val block: KtBlockExpression)` + - `fun resolveExtractionRegion(file: KtFile, selectionStart: Int, selectionEnd: Int): ExtractionRegion?` + +- [ ] **Step 1: Write the failing test** + +Create `ExtractMethodRegionTest.kt`. It extends `KtLspTest` for the PSI factory only -- it never opens an analysis session. + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test +import org.jetbrains.kotlin.psi.KtFile + +/** + * Region resolution is purely syntactic, so it is tested with no analysis session at all -- the same + * split `CandidateExpressions.kt` already has. + */ +class ExtractMethodRegionTest : KtLspTest() { + private fun file(content: String): KtFile = createSourceFile("Main.kt", content) + + private fun region( + content: String, + start: Int, + end: Int = start, + ): ExtractionRegion? = resolveExtractionRegion(file(content), start, end) + + private val twoStatements = + """ + package p + fun log(n: Int) {} + fun demo(a: Int, b: Int) { + val sum = a + b + log(sum) + } + """.trimIndent() + + @Test + fun `a bare cursor resolves to expression candidates`() { + val region = region(twoStatements, twoStatements.indexOf("a + b") + 1) + + assertTrue(region is ExtractionRegion.Expressions) + assertEquals("a + b", (region as ExtractionRegion.Expressions).candidates.first().text) + } + + @Test + fun `a selection over two whole statements resolves to a statement range`() { + val start = twoStatements.indexOf("val sum") + val end = twoStatements.indexOf("log(sum)") + "log(sum)".length + + val region = region(twoStatements, start, end) + + assertTrue(region is ExtractionRegion.Statements) + assertEquals( + listOf("val sum = a + b", "log(sum)"), + (region as ExtractionRegion.Statements).statements.map { it.text }, + ) + } + + @Test + fun `ragged boundaries snap outward to whole statements`() { + // Starts mid-`sum` and stops mid-`log(sum)`, as a touch drag routinely does. + val start = twoStatements.indexOf("sum = a + b") + val end = twoStatements.indexOf("log(sum)") + 3 + + val region = region(twoStatements, start, end) + + assertTrue(region is ExtractionRegion.Statements) + assertEquals( + listOf("val sum = a + b", "log(sum)"), + (region as ExtractionRegion.Statements).statements.map { it.text }, + ) + } + + @Test + fun `a selection inside a single statement stays an expression selection`() { + val start = twoStatements.indexOf("a + b") + + val region = region(twoStatements, start, start + "a + b".length) + + assertTrue(region is ExtractionRegion.Expressions) + assertEquals("a + b", (region as ExtractionRegion.Expressions).candidates.first().text) + assertTrue(region.selectionMatchedInnermost) + } + + @Test + fun `a selection spanning two different blocks resolves to nothing`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(c: Boolean, a: Int) { + if (c) { + log(a) + } + log(a + 1) + } + """.trimIndent() + val start = content.indexOf("log(a)") + val end = content.indexOf("log(a + 1)") + "log(a + 1)".length + + assertNull(region(content, start, end)) + } + + @Test + fun `the statement range span covers first to last statement`() { + val start = twoStatements.indexOf("val sum") + val end = twoStatements.indexOf("log(sum)") + "log(sum)".length + + val region = region(twoStatements, start, end) as ExtractionRegion.Statements + + assertEquals(TextSpan(start, end), region.span) + } + + @Test + fun `a whitespace-only selection resolves to nothing`() { + val start = twoStatements.indexOf("val sum") - 1 + + assertNull(region(twoStatements, start, start + 1)) + } + + @Test + fun `a property initializer outside an executable body resolves to nothing`() { + val content = + """ + package p + fun compute(): Int = 1 + class C { + val x = compute() + compute() + } + """.trimIndent() + + assertNull(region(content, content.indexOf("compute() + compute()") + 1)) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodRegionTest"` +Expected: compilation failure -- `Unresolved reference: resolveExtractionRegion`. + +- [ ] **Step 3: Write the implementation** + +Create `ExtractionRegion.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile + +/** + * What a selection resolved to. Exactly two kinds, which is the whole reason the hard cases never + * arise: a selection covering half an `if` and half its `else`, or straddling a lambda boundary, + * is neither, and is declined by construction rather than filtered out later. + */ +sealed interface ExtractionRegion { + /** The region's covering span in the file's text. */ + val span: TextSpan + + /** + * One or more nested expressions at the cursor, innermost first. The user picks between them in + * the sheet unless [selectionMatchedInnermost] says they already have. + */ + data class Expressions( + val candidates: List, + val selectionMatchedInnermost: Boolean, + ) : ExtractionRegion { + override val span: TextSpan + get() = candidates.first().textRange.let { TextSpan(it.startOffset, it.endOffset) } + } + + /** One or more sibling statements in a single [block]. */ + data class Statements( + val statements: List, + val block: KtBlockExpression, + ) : ExtractionRegion { + override val span: TextSpan + get() = + TextSpan( + statements.first().textRange.startOffset, + statements.last().textRange.endOffset, + ) + } +} + +/** + * Resolves `[selectionStart, selectionEnd)` to the one region the refactoring will act on, or null + * when it is neither kind. + * + * A bare cursor is always the expression path. A non-empty selection snaps **outward** to whole + * statements -- a touch selection will not land on a boundary -- but a selection that lies strictly + * inside one statement is still an expression selection: widening it to the whole statement would + * silently extract more than the user picked. + */ +fun resolveExtractionRegion( + file: KtFile, + selectionStart: Int, + selectionEnd: Int, +): ExtractionRegion? { + val (start, end) = trimToCode(file.text, selectionStart, selectionEnd) ?: return null + if (start == end) return expressionRegion(file, selectionStart, selectionEnd) + + val statements = snapToStatements(file, start, end) ?: return expressionRegion(file, selectionStart, selectionEnd) + + val only = statements.singleOrNull() + if (only != null && (start > only.textRange.startOffset || end < only.textRange.endOffset)) { + expressionRegion(file, selectionStart, selectionEnd)?.let { return it } + } + + val block = statements.first().parent as? KtBlockExpression ?: return null + return ExtractionRegion.Statements(statements, block) +} + +private fun expressionRegion( + file: KtFile, + selectionStart: Int, + selectionEnd: Int, +): ExtractionRegion.Expressions? { + val syntax = candidateExpressionsAt(file, selectionStart, selectionEnd) + if (syntax.expressions.isEmpty()) return null + return ExtractionRegion.Expressions(syntax.expressions, syntax.selectionMatchedInnermost) +} + +/** + * The whole statements `[start, end)` touches, when they are siblings in one [KtBlockExpression]. + * + * Null when the two ends land in different blocks, which is what rejects a selection spanning an + * `if` body and the code after it without needing to reason about the constructs involved. + */ +private fun snapToStatements( + file: KtFile, + start: Int, + end: Int, +): List? { + val first = statementContaining(file, start) ?: return null + val last = statementContaining(file, (end - 1).coerceAtLeast(start)) ?: return null + + val block = first.parent as? KtBlockExpression ?: return null + if (last.parent !== block) return null + if (!isExtractionPosition(first)) return null + + val statements = block.statements + val from = statements.indexOfFirst { it === first } + val to = statements.indexOfFirst { it === last } + if (from < 0 || to < from) return null + return statements.subList(from, to + 1).toList() +} + +/** + * The statement containing [offset]: the nearest ancestor that is a direct expression child of a + * block. Null for a position that is not inside one, such as a comment or a class body. + */ +private fun statementContaining( + file: KtFile, + offset: Int, +): KtExpression? { + var current: PsiElement? = file.findElementAt(offset) ?: return null + while (current != null && current !is KtFile) { + if (current is KtExpression && current.parent is KtBlockExpression) return current + current = current.parent + } + return null +} +``` + +- [ ] **Step 4: Run the test to verify it passes** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodRegionTest"` +Expected: PASS, 8 tests. + +- [ ] **Step 5: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt +git commit -m "ADFA-5080: Resolve a selection to an extraction region" +``` + +--- + +## Task 3: The plan data model and the two rewrites + +Pure data and pure text: no PSI, no analysis. Doing this before the analysis means the edit shape is pinned down and tested before anything has to derive it. + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt` +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt` +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt` + +**Interfaces:** +- Consumes: `RefactoringPlan` (Task 1), `TextSpan`, `RewriteSpan`, `detectNewline`, `detectIndentUnit`, `leadingIndentAt`. +- Produces, all used by Tasks 4-7: + - `data class MethodParameter(val name: String, val typeText: String)` + - `sealed interface ExtractedBody` with `ExpressionBody(needsReturn: Boolean)` and `StatementBody(trailingReturn: String?)` + - `sealed interface CallSiteForm` with `Call`, `AssignOutput(name: String)`, `Return` + - `data class ExtractMethodCandidate(label, span, suggestedName, takenNames, annotations, modifiers, receiverTypeText, parameters, returnTypeText, body, callSite, insertOffset, insertIndent)` + - `sealed interface ExtractionRefusal` with `NotASingleRegion`, `MultipleOutputs(names: List)`, `ReassignsOuterVar(name: String)`, `ExitsRegion`, `InnerImplicitReceiver(construct: String)`, `UsesTypeParameter(name: String)`, `UnrenderableType` + - `data class ExtractMethodPlan(fileText, documentVersion, candidates, selectionMatchedCandidate, refusal) : RefactoringPlan` with `.isEmpty` and `companion object { fun refused(refusal, fileText = "", documentVersion = -1) }` + - `fun ExtractMethodCandidate.signatureText(name: String): String` + - `fun buildExtractMethodRewrites(fileText: String, candidate: ExtractMethodCandidate, name: String): List?` + +- [ ] **Step 1: Write the failing test** + +Create `ExtractMethodEditTest.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The emitted text, with every candidate built by hand -- no PSI, no analysis. Assertions are on the + * resulting file text, the only kind that catches an indentation or off-by-one error. + */ +class ExtractMethodEditTest { + private val file = + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = a + b\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n" + + private val enclosingStart = file.indexOf("fun demo") + private val enclosingEnd = file.indexOf("\t}\n}") + 2 + + private fun candidate( + span: TextSpan, + body: ExtractedBody, + callSite: CallSiteForm, + parameters: List = emptyList(), + returnTypeText: String? = null, + modifiers: List = listOf("private"), + annotations: List = emptyList(), + receiverTypeText: String? = null, + ) = ExtractMethodCandidate( + label = "region", + span = span, + suggestedName = "extracted", + takenNames = emptySet(), + annotations = annotations, + modifiers = modifiers, + receiverTypeText = receiverTypeText, + parameters = parameters, + returnTypeText = returnTypeText, + body = body, + callSite = callSite, + insertOffset = enclosingEnd, + insertIndent = "\t", + ) + + /** Applies the rewrites in the order they are returned, exactly as the language client does. */ + private fun apply( + text: String, + rewrites: List, + ): String = + rewrites.fold(text) { current, rewrite -> + current.substring(0, rewrite.span.start) + rewrite.newText + current.substring(rewrite.span.end) + } + + @Test + fun `the function insertion comes before the call site`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ), + "total", + ) + + assertNotNull(rewrites) + assertEquals(2, rewrites!!.size) + assertTrue( + "the insertion must be at a higher offset than the call site", + rewrites[0].span.start > rewrites[1].span.start, + ) + } + + @Test + fun `an expression region becomes a call and a returning function`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ), + "total", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = total(a, b)\n" + + "\t\treturn sum\n" + + "\t}\n" + + "\n" + + "\tprivate fun total(a: Int, b: Int): Int {\n" + + "\t\treturn a + b\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `a statement range with one output assigns at the call site`() { + val span = TextSpan(file.indexOf("val sum"), file.indexOf("val sum") + "val sum = a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = "return sum"), + CallSiteForm.AssignOutput("sum"), + parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), + returnTypeText = "Int", + ), + "total", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = total(a, b)\n" + + "\t\treturn sum\n" + + "\t}\n" + + "\n" + + "\tprivate fun total(a: Int, b: Int): Int {\n" + + "\t\tval sum = a + b\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `a tail return region returns the call`() { + val span = TextSpan(file.indexOf("return sum"), file.indexOf("return sum") + "return sum".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Return, + parameters = listOf(MethodParameter("sum", "Int")), + returnTypeText = "Int", + ), + "finish", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = a + b\n" + + "\t\treturn finish(sum)\n" + + "\t}\n" + + "\n" + + "\tprivate fun finish(sum: Int): Int {\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `a multi-line statement range is reindented under the new function`() { + val text = + "package p\n" + + "fun demo(a: Int) {\n" + + "\tif (a > 0) {\n" + + "\t\tprintln(a)\n" + + "\t}\n" + + "}\n" + val start = text.indexOf("if (a > 0)") + val rewrites = + buildExtractMethodRewrites( + text, + ExtractMethodCandidate( + label = "region", + span = TextSpan(start, text.indexOf("\t}\n}") + 2), + suggestedName = "extracted", + takenNames = emptySet(), + annotations = emptyList(), + modifiers = listOf("private"), + receiverTypeText = null, + parameters = listOf(MethodParameter("a", "Int")), + returnTypeText = null, + body = ExtractedBody.StatementBody(trailingReturn = null), + callSite = CallSiteForm.Call, + insertOffset = text.length - 1, + insertIndent = "", + ), + "report", + )!! + + assertEquals( + "package p\n" + + "fun demo(a: Int) {\n" + + "\treport(a)\n" + + "}\n" + + "\n" + + "private fun report(a: Int) {\n" + + "\tif (a > 0) {\n" + + "\t\tprintln(a)\n" + + "\t}\n" + + "}\n", + apply(text, rewrites), + ) + } + + @Test + fun `a CRLF file keeps CRLF`() { + val text = + "package p\r\n" + + "fun demo(a: Int) {\r\n" + + "\tprintln(a)\r\n" + + "}\r\n" + val start = text.indexOf("println(a)") + val rewrites = + buildExtractMethodRewrites( + text, + ExtractMethodCandidate( + label = "region", + span = TextSpan(start, start + "println(a)".length), + suggestedName = "extracted", + takenNames = emptySet(), + annotations = emptyList(), + modifiers = listOf("private"), + receiverTypeText = null, + parameters = listOf(MethodParameter("a", "Int")), + returnTypeText = null, + body = ExtractedBody.StatementBody(trailingReturn = null), + callSite = CallSiteForm.Call, + insertOffset = text.length - 2, + insertIndent = "", + ), + "report", + )!! + + assertTrue(rewrites.all { !it.newText.contains("\n") || it.newText.contains("\r\n") }) + assertTrue(apply(text, rewrites).contains("\r\nprivate fun report(a: Int) {\r\n")) + } + + @Test + fun `the signature preview matches what is emitted`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val subject = + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = true), + CallSiteForm.Call, + parameters = listOf(MethodParameter("a", "Int")), + returnTypeText = "Int", + modifiers = listOf("private", "suspend"), + annotations = listOf("@Composable"), + receiverTypeText = "Foo", + ) + + assertEquals("@Composable private suspend fun Foo.total(a: Int): Int", subject.signatureText("total")) + assertTrue( + buildExtractMethodRewrites(file, subject, "total")!![0] + .newText + .contains("@Composable private suspend fun Foo.total(a: Int): Int {"), + ) + } + + @Test + fun `a span past the end of the text produces nothing`() { + val subject = + candidate( + TextSpan(file.length - 1, file.length + 10), + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Call, + ) + + assertNull(buildExtractMethodRewrites(file, subject, "total")) + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest"` +Expected: compilation failure -- `Unresolved reference: ExtractMethodCandidate`. + +- [ ] **Step 3: Write the data model** + +Create `ExtractMethodPlan.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** One derived parameter of the new function. Names are the originals, unchanged (R5). */ +data class MethodParameter( + val name: String, + val typeText: String, +) + +/** What goes inside the new function's braces. */ +sealed interface ExtractedBody { + /** + * The region's expression text. [needsReturn] is false only for a `Unit`-valued expression, where + * the function returns `Unit` and a bare statement reads better than `return println(x)`. + */ + data class ExpressionBody( + val needsReturn: Boolean, + ) : ExtractedBody + + /** + * The statements verbatim. [trailingReturn] is the `return ` line appended for the + * single-output case, and null otherwise -- including the tail-return case, where the region + * already ends in a `return`. + */ + data class StatementBody( + val trailingReturn: String?, + ) : ExtractedBody +} + +/** How the region's own text is replaced (R6). */ +sealed interface CallSiteForm { + /** `extracted(args)` -- an expression in place, or a statement. */ + data object Call : CallSiteForm + + /** `val x = extracted(args)` for the single output [name]. */ + data class AssignOutput( + val name: String, + ) : CallSiteForm + + /** `return extracted(args)` for the tail-return case (R8). */ + data object Return : CallSiteForm +} + +/** + * One extractable region, fully derived: everything the sheet renders and the edit builder emits, + * with no PSI left in it. + * + * [span] is what the call site replaces. [insertOffset] is the end of the enclosing declaration -- + * the new function goes immediately after it (R4) -- and [insertIndent] is that declaration's own + * indentation, since nothing re-indents a code-action edit after it is applied. + * + * [returnTypeText] is null for a `Unit` function, where the `: Unit` is left off. + */ +data class ExtractMethodCandidate( + val label: String, + val span: TextSpan, + val suggestedName: String, + val takenNames: Set, + val annotations: List, + val modifiers: List, + val receiverTypeText: String?, + val parameters: List, + val returnTypeText: String?, + val body: ExtractedBody, + val callSite: CallSiteForm, + val insertOffset: Int, + val insertIndent: String, +) + +/** + * Why a region could not be extracted. A refusal is a designed outcome, not an error (ADR 0012): + * each reason gets its own message naming the construct in the way, because a generic one reads as + * the feature being broken. + */ +sealed interface ExtractionRefusal { + /** The selection is neither one expression nor whole statements inside one block (R2). */ + data object NotASingleRegion : ExtractionRefusal + + /** Two or more locals declared inside the region are read after it (R7). */ + data class MultipleOutputs( + val names: List, + ) : ExtractionRefusal + + /** A `var` declared outside the region is assigned inside it. ADFA-5082 lifts this (R7). */ + data class ReassignsOuterVar( + val name: String, + ) : ExtractionRefusal + + /** A `return`, `break` or `continue` whose target is outside the region (R8). */ + data object ExitsRegion : ExtractionRefusal + + /** Members of a `with`/`apply`/`run` receiver introduced inside the enclosing declaration (R9). */ + data class InnerImplicitReceiver( + val construct: String, + ) : ExtractionRefusal + + /** A type parameter declared on the enclosing function (R10). */ + data class UsesTypeParameter( + val name: String, + ) : ExtractionRefusal + + /** A parameter or return type that cannot be written out as source (R5). */ + data object UnrenderableType : ExtractionRefusal +} + +/** + * The complete result of the background pass. + * + * Unlike extract variable's plan this carries a [refusal] rather than merely being empty, because + * "why not" is most of what this refactoring has to say (ADR 0012). [candidates] and [refusal] are + * mutually exclusive in practice: a non-empty candidate list means at least one region survived. + */ +data class ExtractMethodPlan( + override val fileText: String, + override val documentVersion: Int, + val candidates: List, + val selectionMatchedCandidate: Boolean, + val refusal: ExtractionRefusal?, +) : RefactoringPlan { + val isEmpty: Boolean get() = candidates.isEmpty() + + companion object { + fun refused( + refusal: ExtractionRefusal, + fileText: String = "", + documentVersion: Int = -1, + ) = ExtractMethodPlan(fileText, documentVersion, emptyList(), selectionMatchedCandidate = false, refusal = refusal) + } +} + +/** + * The signature exactly as [buildExtractMethodRewrites] emits it. The sheet's preview calls this, so + * there is one derivation and the preview cannot drift from the declaration (R11). + */ +fun ExtractMethodCandidate.signatureText(name: String): String = + buildString { + annotations.forEach { append(it).append(' ') } + modifiers.forEach { append(it).append(' ') } + append("fun ") + receiverTypeText?.let { append(it).append('.') } + append(name) + append('(') + append(parameters.joinToString(", ") { "${it.name}: ${it.typeText}" }) + append(')') + returnTypeText?.let { append(": ").append(it) } + } +``` + +- [ ] **Step 4: Write the edit builder** + +Create `ExtractMethodEdit.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** + * The two replacements an extraction performs: the new function, then the call that replaces the + * region. + * + * **The order is mandatory, not stylistic.** `IDELanguageClientImpl.applyActionEdits` iterates the + * list and applies each edit with line/column ranges against whatever the text is at that moment. + * The insertion point sits after the region, so emitting the call first would shift it and corrupt + * the file. Descending document order is the only safe order. + * + * Nothing on that path calls `beginBatchEdit`, so this costs the user **two** undo steps and the + * intermediate state does not compile. ADFA-5081 fixes that by batching the edit loop; until it + * lands the two-step undo is a stated limitation. + * + * The region is the only site rewritten (R13). Exact-duplicate matching would almost never fire, and + * near-duplicate matching needs anti-unification plus a per-site parameter mapping. + * + * Returns null when the offsets cannot be honoured, which the caller reports rather than applying. + */ +fun buildExtractMethodRewrites( + fileText: String, + candidate: ExtractMethodCandidate, + name: String, +): List? { + val span = candidate.span + if (span.end > fileText.length) return null + if (candidate.insertOffset > fileText.length || candidate.insertOffset < span.end) return null + + val newline = detectNewline(fileText) + val indent = candidate.insertIndent + val bodyIndent = indent + detectIndentUnit(fileText) + val regionText = fileText.substring(span.start, span.end) + val baseIndent = leadingIndentAt(fileText, span.start) + + val bodyLines = + when (val body = candidate.body) { + is ExtractedBody.ExpressionBody -> { + val lines = reindent(regionText, baseIndent, newline) + if (body.needsReturn) listOf("return " + lines.first()) + lines.drop(1) else lines + } + + is ExtractedBody.StatementBody -> + reindent(regionText, baseIndent, newline) + listOfNotNull(body.trailingReturn) + } + + val declaration = + buildString { + // A blank line separates the new function from the declaration it follows. + append(newline).append(newline) + append(indent).append(candidate.signatureText(name)).append(" {").append(newline) + bodyLines.forEach { append(bodyIndent).append(it).append(newline) } + append(indent).append('}') + } + + val call = "$name(${candidate.parameters.joinToString(", ") { it.name }})" + val callText = + when (val form = candidate.callSite) { + CallSiteForm.Call -> call + is CallSiteForm.AssignOutput -> "val ${form.name} = $call" + CallSiteForm.Return -> "return $call" + } + + return listOf( + RewriteSpan(TextSpan(candidate.insertOffset, candidate.insertOffset), declaration), + RewriteSpan(span, callText), + ) +} + +/** + * Splits the region into lines with its original base indentation removed, so the caller can prefix + * each with the new function's body indentation. Lines nested deeper than the base keep the extra + * depth; the first line never carries indentation, since the span starts at the code itself. + */ +private fun reindent( + text: String, + baseIndent: String, + newline: String, +): List = + text.split(newline).mapIndexed { index, line -> + if (index == 0) line else line.removePrefix(baseIndent) + } +``` + +- [ ] **Step 5: Run the test to verify it passes** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest"` +Expected: PASS, 8 tests. If an assertion on exact text fails, fix the *implementation*, not the expectation, unless the expectation itself has a wrong tab count. + +- [ ] **Step 6: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt +git commit -m "ADFA-5080: Add the extract-method plan model and its two rewrites" +``` + +--- + +## Task 4: The analysis -- signature derivation and refusals + +The only analysis-dependent part. This is the largest task; compile early with `:lsp:kotlin:compileV7DebugKotlin` rather than waiting for the tests. + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt` +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt` +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt` (the private `makeUnique`, around line 147) +- Create: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: everything from Tasks 2 and 3, plus `renderName`, `suggestVariableName`, `collapseForLabel`, `leadingIndentAt`, `analyzeMaybeDangling`, `env.project.read`, `env.ktSymbolIndex.getCurrentKtFile`. +- Produces: + - `internal fun uniqueName(base: String, takenNames: Set): String` (in `NameSuggestion.kt`) + - `internal fun KaSession.buildCandidate(elements: List, isExpression: Boolean, fileText: String): SignatureResult` + - `internal sealed interface SignatureResult { data class Success(val candidate: ExtractMethodCandidate); data class Refused(val refusal: ExtractionRefusal) }` + - `internal fun buildExtractMethodPlan(env: AbstractCompilationEnvironment, nioPath: Path, selectionStart: Int, selectionEnd: Int, documentVersion: Int, cancelChecker: ScheduledCancelChecker): ExtractMethodPlan` + +- [ ] **Step 1: Expose `uniqueName`** + +In `NameSuggestion.kt`, rename the private helper and make it internal. Change + +```kotlin +/** `size` -> `size1` -> `size2` until nothing in [takenNames] matches. */ +private fun makeUnique( + base: String, + takenNames: Set, +): String { +``` + +to + +```kotlin +/** `size` -> `size1` -> `size2` until nothing in [takenNames] matches. */ +internal fun uniqueName( + base: String, + takenNames: Set, +): String { +``` + +and update the one call site inside `suggestVariableName` from `makeUnique(sanitised, takenNames)` to `uniqueName(sanitised, takenNames)`. + +- [ ] **Step 2: Write the failing test** + +Create `ExtractMethodPlanEndToEndTest.kt`. One case per rule, plus one per refusal reason. + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The parts of the plan that need real resolution: the parameter set, the return type and call-site + * form, the modifiers, and one case per refusal reason. + * + * Where a rewrite is produced the assertion is on the resulting file text, which is the only + * assertion that catches an indentation or off-by-one error. + */ +class ExtractMethodPlanEndToEndTest : KtLspTest() { + private fun plan( + content: String, + start: Int, + end: Int = start, + ): ExtractMethodPlan { + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + return buildExtractMethodPlan(env, path, start, end, documentVersion = 1, cancelChecker = noopCancelChecker()) + } + + private fun apply( + text: String, + rewrites: List, + ): String = + rewrites.fold(text) { current, rewrite -> + current.substring(0, rewrite.span.start) + rewrite.newText + current.substring(rewrite.span.end) + } + + private fun selection( + content: String, + from: String, + to: String, + ): Pair = content.indexOf(from) to (content.indexOf(to) + to.length) + + @Test + fun `an expression region parameterises the locals it uses, in first-use order`() { + val content = + """ + package p + fun demo(a: Int, b: Int): Int { + return b * a + a + } + """.trimIndent() + + val result = plan(content, content.indexOf("b * a") + 1) + val candidate = result.candidates.first { it.label == "b * a" } + + assertEquals(listOf("b" to "Int", "a" to "Int"), candidate.parameters.map { it.name to it.typeText }) + assertEquals("Int", candidate.returnTypeText) + assertEquals(listOf("private"), candidate.modifiers) + } + + @Test + fun `a statement range with no output returns Unit and calls as a statement`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(a: Int) { + log(a) + log(a + 1) + } + """.trimIndent() + val (start, end) = selection(content, "log(a)", "log(a + 1)") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + assertNull(candidate.returnTypeText) + assertEquals(CallSiteForm.Call, candidate.callSite) + assertEquals(listOf("a"), candidate.parameters.map { it.name }) + assertEquals("extracted", candidate.suggestedName) + } + + @Test + fun `a single output becomes the return value and a val at the call site`() { + val content = + """ + package p + fun demo(a: Int): Int { + val doubled = a * 2 + return doubled + 1 + } + """.trimIndent() + val (start, end) = selection(content, "val doubled", "val doubled = a * 2") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + assertEquals(CallSiteForm.AssignOutput("doubled"), candidate.callSite) + assertEquals("Int", candidate.returnTypeText) + } + + @Test + fun `two outputs are declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + val x = a * 2 + val y = a * 3 + return x + y + } + """.trimIndent() + val (start, end) = selection(content, "val x", "val y = a * 3") + + val refusal = plan(content, start, end).refusal + + assertTrue(refusal is ExtractionRefusal.MultipleOutputs) + assertEquals(listOf("x", "y"), (refusal as ExtractionRefusal.MultipleOutputs).names) + } + + @Test + fun `a reassigned outer var is declined and names the variable`() { + val content = + """ + package p + fun demo(items: List): Int { + var total = 0 + for (item in items) { + total += item + } + return total + } + """.trimIndent() + val (start, end) = selection(content, "for (item in items)", "\t}") + + val refusal = plan(content, start, end).refusal + + assertEquals(ExtractionRefusal.ReassignsOuterVar("total"), refusal) + } + + @Test + fun `a tail return keeps the return and returns the call`() { + val content = + """ + package p + fun demo(a: Int): Int { + val doubled = a * 2 + return doubled + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return doubled", "return doubled + 1") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + assertEquals(CallSiteForm.Return, candidate.callSite) + assertEquals("Int", candidate.returnTypeText) + assertEquals( + """ + package p + fun demo(a: Int): Int { + val doubled = a * 2 + return finish(doubled) + } + + private fun finish(doubled: Int): Int { + return doubled + 1 + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "finish")!!), + ) + } + + @Test + fun `a return in the middle of the range is declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + if (a > 0) return a + val b = a * 2 + return b + } + """.trimIndent() + val (start, end) = selection(content, "if (a > 0) return a", "val b = a * 2") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a break targeting an outer loop is declined`() { + val content = + """ + package p + fun demo(items: List) { + for (item in items) { + if (item < 0) break + println(item) + } + } + """.trimIndent() + val (start, end) = selection(content, "if (item < 0) break", "println(item)") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `an extension receiver is copied onto the new function`() { + val content = + """ + package p + class Foo(val n: Int) + fun Foo.bar(): Int { + return n * 2 + } + """.trimIndent() + + val result = plan(content, content.indexOf("n * 2") + 1) + val candidate = result.candidates.first { it.label == "n * 2" } + + assertEquals("Foo", candidate.receiverTypeText) + // `this` is a Foo at the call site, so nothing is passed and nothing is captured. + assertEquals(emptyList(), candidate.parameters) + } + + @Test + fun `an inner with receiver is declined and names the construct`() { + val content = + """ + package p + class Foo { val n: Int = 1 } + fun demo(f: Foo): Int { + with(f) { + return n * 2 + } + } + """.trimIndent() + + val refusal = plan(content, content.indexOf("n * 2") + 1).refusal + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("with"), refusal) + } + + @Test + fun `a suspend call adds the suspend modifier`() { + val content = + """ + package p + suspend fun load(): Int = 1 + suspend fun demo(): Int { + return load() + 1 + } + """.trimIndent() + + val result = plan(content, content.indexOf("load() + 1") + 1) + val candidate = result.candidates.first { it.label == "load() + 1" } + + assertEquals(listOf("private", "suspend"), candidate.modifiers) + } + + @Test + fun `a Composable call adds the Composable annotation`() { + createSourceFile( + "Composable.kt", + """ + package androidx.compose.runtime + annotation class Composable + """.trimIndent(), + ) + val content = + """ + package p + import androidx.compose.runtime.Composable + @Composable fun Label(text: String) {} + @Composable fun Demo(name: String) { + Label(name) + } + """.trimIndent() + val (start, end) = selection(content, "Label(name)", "Label(name)") + + val candidate = plan(content, start, end).candidates.single() + + assertEquals(listOf("@Composable"), candidate.annotations) + } + + @Test + fun `a function-level type parameter is declined and names it`() { + val content = + """ + package p + fun demo(value: T): String { + val held: T = value + return held.toString() + } + """.trimIndent() + val (start, end) = selection(content, "val held", "val held: T = value") + + assertEquals(ExtractionRefusal.UsesTypeParameter("T"), plan(content, start, end).refusal) + } + + @Test + fun `taken names include inherited members`() { + val content = + """ + package p + open class Base { fun helper(): Int = 1 } + class Child : Base() { + fun demo(a: Int): Int { + return a * 2 + } + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("a * 2") + 1).candidates.first { it.label == "a * 2" } + + // A private member matching an inherited name is an accidental-override compile error. + assertTrue("helper" in candidate.takenNames) + assertTrue("demo" in candidate.takenNames) + } + + @Test + fun `a selection spanning two blocks is declined as not a single region`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(c: Boolean, a: Int) { + if (c) { + log(a) + } + log(a + 1) + } + """.trimIndent() + val (start, end) = selection(content, "log(a)", "log(a + 1)") + + assertEquals(ExtractionRefusal.NotASingleRegion, plan(content, start, end).refusal) + } + + @Test + fun `an expression extraction rewrites the call site and adds a member function`() { + val content = + """ + package p + class C { + fun demo(a: Int, b: Int): Int { + return a + b + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("a + b") + 1) + val candidate = result.candidates.first { it.label == "a + b" } + + assertEquals( + """ + package p + class C { + fun demo(a: Int, b: Int): Int { + return total(a, b) + } + + private fun total(a: Int, b: Int): Int { + return a + b + } + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "total")!!), + ) + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest"` +Expected: compilation failure -- `Unresolved reference: buildExtractMethodPlan`. + +- [ ] **Step 4: Write `MethodSignature.kt`** + +This derives one candidate from one region. Every resolution call is wrapped in `runCatching` -- resolution over broken code throws, and a throw here must read as a refusal, not a crash. + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.utils.renderName +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.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.builtins.StandardNames +import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtAnonymousInitializer +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtBreakExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtContinueExpression +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtLoopExpression +import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPropertyAccessor +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtReturnExpression +import org.jetbrains.kotlin.psi.KtSecondaryConstructor +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtTypeReference +import org.jetbrains.kotlin.psi.KtUnaryExpression + +/** The name of the statement-range suggestion; there is no expression to read a name from (R12). */ +private const val STATEMENT_RANGE_NAME = "extracted" + +private const val COMPOSABLE_FQ_NAME = "androidx.compose.runtime.Composable" + +/** + * Receiver-binding scoping functions. `let`, `also` and `forEach` are absent on purpose: they bind + * `it`, which is a captured declaration and becomes an ordinary parameter (R5). + */ +private val RECEIVER_SCOPING_FUNCTIONS = + setOf("with", "apply", "run", "buildString", "buildList", "buildMap", "buildSet") + +/** Either a derived candidate or the reason there is not one. */ +internal sealed interface SignatureResult { + data class Success( + val candidate: ExtractMethodCandidate, + ) : SignatureResult + + data class Refused( + val refusal: ExtractionRefusal, + ) : SignatureResult +} + +/** + * Derives one candidate from [elements] -- a single expression, or the statement range. + * + * Ordered so the cheapest refusals come first and nothing expensive runs for a region that is going + * to be declined anyway. MUST be called inside an analysis session. + */ +internal fun KaSession.buildCandidate( + elements: List, + isExpression: Boolean, + fileText: String, +): SignatureResult { + val first = elements.first() + val last = elements.last() + val span = TextSpan(first.textRange.startOffset, last.textRange.endOffset) + val enclosing = enclosingDeclaration(first) ?: return refuse(ExtractionRefusal.NotASingleRegion) + + typeParameterIn(enclosing, elements)?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } + innerImplicitReceiver(enclosing, elements, span)?.let { return refuse(ExtractionRefusal.InnerImplicitReceiver(it)) } + reassignedOuterVar(enclosing, elements, span)?.let { return refuse(ExtractionRefusal.ReassignsOuterVar(it)) } + + val tailReturn = !isExpression && isTailReturn(elements, span) + if (!tailReturn && hasExit(elements, span)) return refuse(ExtractionRefusal.ExitsRegion) + + val outputs = if (isExpression) emptyList() else outputsOf(enclosing, elements, span) + if (outputs.size > 1) { + return refuse(ExtractionRefusal.MultipleOutputs(outputs.mapNotNull { it.name })) + } + // The tail-return exception holds only when nothing else flows out (R8). + if (tailReturn && outputs.isNotEmpty()) return refuse(ExtractionRefusal.ExitsRegion) + + val parameters = capturedParameters(enclosing, elements, span) ?: return refuse(ExtractionRefusal.UnrenderableType) + + val returnTypeText = + when { + isExpression -> renderedTypeOrNull(first) ?: return refuse(ExtractionRefusal.UnrenderableType) + tailReturn -> enclosingReturnType(enclosing) ?: return refuse(ExtractionRefusal.UnrenderableType) + outputs.size == 1 -> + renderedDeclarationType(outputs.single()) ?: return refuse(ExtractionRefusal.UnrenderableType) + + else -> null + }.takeUnless { it == "Unit" } + + val body = + when { + isExpression -> ExtractedBody.ExpressionBody(needsReturn = returnTypeText != null) + outputs.size == 1 -> ExtractedBody.StatementBody(trailingReturn = "return ${outputs.single().name.orEmpty()}") + else -> ExtractedBody.StatementBody(trailingReturn = null) + } + + val callSite = + when { + tailReturn -> CallSiteForm.Return + outputs.size == 1 -> CallSiteForm.AssignOutput(outputs.single().name.orEmpty()) + else -> CallSiteForm.Call + } + + val takenNames = takenNamesFor(enclosing) + + return SignatureResult.Success( + ExtractMethodCandidate( + label = collapseForLabel(fileText.substring(span.start, span.end)), + span = span, + suggestedName = + if (isExpression) { + suggestVariableName(first, renderedTypeOrNull(first), takenNames) + } else { + uniqueName(STATEMENT_RANGE_NAME, takenNames) + }, + takenNames = takenNames, + annotations = if (usesComposable(elements)) listOf("@Composable") else emptyList(), + modifiers = if (usesSuspend(elements)) listOf("private", "suspend") else listOf("private"), + receiverTypeText = (enclosing as? KtNamedFunction)?.receiverTypeReference?.text, + parameters = parameters, + returnTypeText = returnTypeText, + body = body, + callSite = callSite, + insertOffset = enclosing.textRange.endOffset, + insertIndent = leadingIndentAt(fileText, enclosing.textRange.startOffset), + ), + ) +} + +private fun refuse(refusal: ExtractionRefusal): SignatureResult = SignatureResult.Refused(refusal) + +/** + * The named function, accessor, `init` block or constructor whose body holds [element]. Lambdas are + * skipped: the new function is a sibling of the enclosing *named* declaration (R4), and the lambda's + * captures become parameters. + */ +private fun enclosingDeclaration(element: PsiElement): KtDeclaration? { + var current: PsiElement? = element.parent + while (current != null) { + when (current) { + is KtNamedFunction, is KtPropertyAccessor, is KtAnonymousInitializer, is KtSecondaryConstructor -> + return current as KtDeclaration + + is KtClassOrObject -> return null + } + current = current.parent + } + return null +} + +/** Whether [element] is inside the region's span. */ +private fun inRegion( + element: PsiElement, + span: TextSpan, +): Boolean = element.textRange.startOffset >= span.start && element.textRange.endOffset <= span.end + +private fun simpleNamesIn(elements: List): List = + elements.flatMap { PsiTreeUtil.collectElementsOfType(it, KtSimpleNameExpression::class.java) } + +private fun descendantsOf( + elements: List, + type: Class, +): List = elements.flatMap { PsiTreeUtil.collectElementsOfType(it, type) } + +/** + * A captured declaration is one the region references whose PSI lies inside the enclosing + * declaration but outside the region itself. Anything else -- a class member, a top-level + * declaration, an import -- resolves unchanged from the new function's body (R5). + * + * Returns null when a type cannot be rendered as source, which declines the extraction rather than + * emitting text that will not compile. + */ +private fun KaSession.capturedParameters( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): List? { + val parameters = mutableListOf() + val seen = mutableSetOf() + + for (reference in simpleNamesIn(elements).sortedBy { it.textRange.startOffset }) { + val symbol = + runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaCallableSymbol + ?: continue + val declarationPsi = runCatching { symbol.psi }.getOrNull() + + val key: Any = + when { + declarationPsi != null -> { + if (!PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue + if (inRegion(declarationPsi, span)) continue + declarationPsi + } + + // `it` has no source PSI, so it would otherwise read as "not captured" and be dropped. + symbol is KaValueParameterSymbol && + reference.getReferencedName() == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() -> "it" + + else -> continue + } + if (!seen.add(key)) continue + + val typeText = renderedSymbolType(symbol) ?: return null + parameters += MethodParameter(name = reference.getReferencedName(), typeText = typeText) + } + return parameters +} + +/** A type that cannot be written out as source -- anonymous, intersection, or a resolution error. */ +private fun isUnrenderable(text: String): Boolean = + text.isBlank() || + text.contains("anonymous") || + text.contains("ERROR") || + text.contains(" & ") + +private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = + runCatching { renderName(symbol.returnType) }.getOrNull()?.takeUnless(::isUnrenderable) + +private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = + runCatching { expression.expressionType?.let { renderName(it) } }.getOrNull()?.takeUnless(::isUnrenderable) + +private fun KaSession.renderedDeclarationType(property: KtProperty): String? = + runCatching { (property.symbol as? KaCallableSymbol)?.returnType?.let { renderName(it) } } + .getOrNull() + ?.takeUnless(::isUnrenderable) + +private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = + runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType?.let { renderName(it) } } + .getOrNull() + ?.takeUnless(::isUnrenderable) + +/** + * Locals declared inside the region and read after it (R7). Exactly one is supported. + * + * "Read after it" is a textual-offset test inside the enclosing declaration, which is sound because + * a local is only in scope after its own declaration in the same block. + */ +private fun KaSession.outputsOf( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): List { + val declared = descendantsOf(elements, KtProperty::class.java) + if (declared.isEmpty()) return emptyList() + + val laterReads = + PsiTreeUtil + .collectElementsOfType(enclosing, KtSimpleNameExpression::class.java) + .filter { it.textRange.startOffset >= span.end } + .mapNotNull { runCatching { it.mainReference?.resolveToSymbols()?.firstOrNull()?.psi }.getOrNull() } + .toSet() + + return declared.filter { it in laterReads } +} + +/** + * A `var` declared inside the enclosing declaration but outside the region, assigned inside it. + * Kotlin has no `out` parameters, so the faithful emission would shadow a name (R7, ADR 0012). + */ +private fun KaSession.reassignedOuterVar( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): String? { + for (reference in simpleNamesIn(elements)) { + if (!reference.isWriteTarget()) continue + val symbol = + runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaVariableSymbol + ?: continue + if (symbol.isVal) continue + val declarationPsi = runCatching { symbol.psi }.getOrNull() ?: continue + if (!PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue + if (inRegion(declarationPsi, span)) continue + return reference.getReferencedName() + } + return null +} + +private fun KtSimpleNameExpression.isWriteTarget(): Boolean { + val parent = parent + if (parent is KtBinaryExpression && parent.left === this && parent.operationToken in ASSIGNMENT_TOKENS) return true + if (parent is KtUnaryExpression && parent.operationToken in INCREMENT_TOKENS) return true + return false +} + +private val ASSIGNMENT_TOKENS = + setOf(KtTokens.EQ, KtTokens.PLUSEQ, KtTokens.MINUSEQ, KtTokens.MULTEQ, KtTokens.DIVEQ, KtTokens.PERCEQ) + +private val INCREMENT_TOKENS = setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) + +/** + * The tail-return exception (R8): the region's last statement is a `return`, and it is the region's + * only `return`, `break` or `continue`. Purely syntactic, which is why it is worth having. + */ +private fun isTailReturn( + elements: List, + span: TextSpan, +): Boolean { + if (elements.last() !is KtReturnExpression) return false + val returns = descendantsOf(elements, KtReturnExpression::class.java) + if (returns.size != 1 || returns.single() !== elements.last()) return false + return !hasLoopExit(elements, span) +} + +/** Any `return`, `break` or `continue` whose target lies outside the region (R8). */ +private fun hasExit( + elements: List, + span: TextSpan, +): Boolean { + for (returnExpression in descendantsOf(elements, KtReturnExpression::class.java)) { + // An unlabelled `return` always targets the enclosing named declaration, which is outside the + // region by construction. A labelled one is fine only when its lambda is inside the region. + if (returnExpression.getLabelName() == null) return true + val lambda = PsiTreeUtil.getParentOfType(returnExpression, KtFunctionLiteral::class.java, true) + if (lambda == null || !inRegion(lambda, span)) return true + } + return hasLoopExit(elements, span) +} + +private fun hasLoopExit( + elements: List, + span: TextSpan, +): Boolean { + val jumps = + descendantsOf(elements, KtBreakExpression::class.java) + + descendantsOf(elements, KtContinueExpression::class.java) + return jumps.any { jump -> + val loop = PsiTreeUtil.getParentOfType(jump, KtLoopExpression::class.java, true) + loop == null || !inRegion(loop, span) + } +} + +/** + * The name of the enclosing function's type parameter the region uses, or null. A filtered copy of + * the type-parameter list with its bounds is the alternative, and deciding "is `T` referenced" from + * rendered type text is exactly the fragility that rules it out (R10). + */ +private fun typeParameterIn( + enclosing: KtDeclaration, + elements: List, +): String? { + val names = (enclosing as? KtNamedFunction)?.typeParameters?.mapNotNull { it.name }.orEmpty() + if (names.isEmpty()) return null + + val typeTexts = + descendantsOf(elements, KtTypeReference::class.java).map { it.text } + + simpleNamesIn(elements).map { it.getReferencedName() } + return names.firstOrNull { name -> typeTexts.any { it == name || it.containsWord(name) } } +} + +/** Whole-word containment, so `T` does not match `Type`. */ +private fun String.containsWord(word: String): Boolean = + Regex("(^|[^A-Za-z0-9_])" + Regex.escape(word) + "($|[^A-Za-z0-9_])").containsMatchIn(this) + +/** + * The scoping construct whose implicit receiver the region uses unqualified, or null (R9). + * + * Turning that receiver into a parameter would mean qualifying every unqualified member access + * inside the extracted body -- editing the interior of the moved code, which this refactoring does + * not do. Android code leans on `with`/`apply` heavily, so the message names the construct. + */ +private fun KaSession.innerImplicitReceiver( + enclosing: KtDeclaration, + elements: List, + span: TextSpan, +): String? { + val construct = enclosingScopingCall(elements.first(), enclosing) ?: return null + val enclosingClass = PsiTreeUtil.getParentOfType(enclosing, KtClassOrObject::class.java, true) + + for (reference in simpleNamesIn(elements)) { + val parent = reference.parent + if (parent is KtQualifiedExpression && parent.selectorExpression === reference) continue + if (parent is KtCallExpression && parent.calleeExpression !== reference) continue + + val symbol = + runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaCallableSymbol + ?: continue + val declarationPsi = runCatching { symbol.psi }.getOrNull() ?: continue + + // A local or a member of the class the new function joins needs nothing. + if (PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue + if (enclosingClass != null && PsiTreeUtil.isAncestor(enclosingClass, declarationPsi, true)) continue + // A top-level declaration resolves unchanged from anywhere in the file. + if (declarationPsi.parent is KtFile) continue + // Anything else reached without a qualifier came in through the scoping receiver. + if (inRegion(declarationPsi, span)) continue + return construct + } + return null +} + +/** The callee name of the nearest receiver-binding scoping call between [element] and [enclosing]. */ +private fun enclosingScopingCall( + element: PsiElement, + enclosing: KtDeclaration, +): String? { + var current: PsiElement? = element + while (current != null && current !== enclosing) { + if (current is KtFunctionLiteral) { + val call = PsiTreeUtil.getParentOfType(current, KtCallExpression::class.java, true) + val callee = (call?.calleeExpression as? KtNameReferenceExpression)?.getReferencedName() + if (callee != null && callee in RECEIVER_SCOPING_FUNCTIONS) return callee + } + current = current.parent + } + return null +} + +/** `suspend` is added when the region calls one, or touches `coroutineContext` (R10). */ +private fun KaSession.usesSuspend(elements: List): Boolean { + if (simpleNamesIn(elements).any { it.getReferencedName() == "coroutineContext" }) return true + return descendantsOf(elements, KtCallExpression::class.java).any { call -> + runCatching { + (call.resolveToCall()?.successfulFunctionCallOrNull()?.symbol as? KaNamedFunctionSymbol)?.isSuspend + }.getOrNull() == true + } +} + +/** + * `@Composable` is added when the region calls one. Not polish: CoGo users write Compose apps on the + * device, and an extracted composable without the annotation does not compile (R10). + */ +private fun KaSession.usesComposable(elements: List): Boolean = + descendantsOf(elements, KtCallExpression::class.java).any { call -> + runCatching { + call + .resolveToCall() + ?.successfulFunctionCallOrNull() + ?.symbol + ?.annotations + ?.any { it.classId?.asFqNameString() == COMPOSABLE_FQ_NAME } + }.getOrNull() == true + } + +/** + * Names the new function must avoid (R12). + * + * For a class target this is the whole member scope, **including inherited members**: a private + * function accidentally matching a supertype member is an accidental-override compile error. + * Rejecting any name match rather than only a signature match also means the refactoring never + * creates an overload the user did not ask for. + */ +private fun KaSession.takenNamesFor(enclosing: KtDeclaration): Set { + val containingClass = PsiTreeUtil.getParentOfType(enclosing, KtClassOrObject::class.java, true) + if (containingClass != null) { + val fromScope = + runCatching { + (containingClass.symbol as? KaClassSymbol) + ?.memberScope + ?.callables + ?.mapNotNull { (it as? KaNamedSymbol)?.name?.asString() } + ?.toSet() + }.getOrNull().orEmpty() + val declared = containingClass.declarations.mapNotNull { it.name } + return fromScope + declared + } + + // A local `fun` target: the enclosing block's own declarations. Otherwise the file's top level. + val block = enclosing.parent + if (block is KtBlockExpression) { + return PsiTreeUtil + .collectElementsOfType(block, KtDeclaration::class.java) + .mapNotNull { it.name } + .toSet() + } + return enclosing.containingKtFile.declarations.mapNotNull { it.name }.toSet() +} +``` + +- [ ] **Step 5: Write `ExtractMethodPlanner.kt`** + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment +import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling +import com.itsaky.androidide.lsp.kotlin.compiler.read +import org.slf4j.LoggerFactory +import java.nio.file.Path + +private val logger = LoggerFactory.getLogger("ExtractMethodPlanner") + +/** + * Computes the whole [ExtractMethodPlan] in one background analysis pass. + * + * The current `KtFile` is fetched *before* entering [read] -- blocking on `getCurrentKtFile(...).get()` + * inside `project.read` deadlocks. + * + * Anything thrown in this pipeline degrades to a refusal plus a log line: the action framework + * catches only `IllegalArgumentException` and this runs on a scope with no exception handler, so an + * uncaught throw would crash the app (R16). + */ +internal fun buildExtractMethodPlan( + env: AbstractCompilationEnvironment, + nioPath: Path, + selectionStart: Int, + selectionEnd: Int, + documentVersion: Int, + cancelChecker: ScheduledCancelChecker, +): ExtractMethodPlan = + runCatching { + val ktFile = + env.ktSymbolIndex.getCurrentKtFile(nioPath).get() + ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + + env.project.read { + val fileText = ktFile.text + val region = + resolveExtractionRegion(ktFile, selectionStart, selectionEnd) + ?: return@read ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion, fileText, documentVersion) + + analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { + val results = + when (region) { + is ExtractionRegion.Expressions -> + region.candidates.map { buildCandidate(listOf(it), isExpression = true, fileText = fileText) } + + is ExtractionRegion.Statements -> + listOf(buildCandidate(region.statements, isExpression = false, fileText = fileText)) + } + + val candidates = results.filterIsInstance().map { it.candidate } + if (candidates.isEmpty()) { + // The innermost region is the one the user pointed at, so its reason is the one to show. + val refusal = + results.filterIsInstance().firstOrNull()?.refusal + ?: ExtractionRefusal.NotASingleRegion + return@analyzeMaybeDangling ExtractMethodPlan.refused(refusal, fileText, documentVersion) + } + + ExtractMethodPlan( + fileText = fileText, + documentVersion = documentVersion, + candidates = candidates, + // Only meaningful while the innermost candidate survived: otherwise the selection no + // longer corresponds to the first option shown. + selectionMatchedCandidate = + region is ExtractionRegion.Expressions && + region.selectionMatchedInnermost && + candidates.first().span == region.span, + refusal = null, + ) + } + } + }.getOrElse { error -> + logger.warn("Failed to build extract-method plan for {}", nioPath, error) + ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + } +``` + +- [ ] **Step 6: Compile before running the tests** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:compileV7DebugKotlin` +Expected: BUILD SUCCESSFUL. Analysis API symbol names drift between Kotlin versions; if `annotations`, `memberScope`, `isSuspend` or `symbol` do not resolve, find the equivalent by grepping `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubs.kt` and `completion/KotlinCompletions.kt`, which already use them. Do not add a dependency. + +- [ ] **Step 7: Run the test to verify it passes** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest"` +Expected: PASS, 16 tests. + +If a refusal test reports the wrong reason, check the ordering in `buildCandidate` -- the checks are ordered deliberately and a case can be caught by an earlier one. If the `@Composable` test cannot resolve the annotation, confirm the second `createSourceFile` call registers the file with the symbol index (`KtLspTest.createSourceFile` does this itself). + +- [ ] **Step 8: Run the whole module's tests, then format and commit** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` +Expected: PASS. + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt +git commit -m "ADFA-5080: Derive the extracted signature, or a typed refusal" +``` + +--- + +## Task 5: Strings, shared sheet components, and the ViewModel + +**Files:** +- Modify: `resources/src/main/res/values/strings.xml` (after the extract-variable block, currently ending at `msg_extract_variable_file_changed`) +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt` +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt` (delete lines 138-200: `LabelledSection`, `OptionList`, `messageRes`) +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt` +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt` +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt` + +**Interfaces:** +- Consumes: `ExtractMethodPlan`, `ExtractMethodCandidate`, `signatureText`, `validateVariableName`, `NameProblem`. +- Produces: + - `internal @Composable fun LabelledSection(label: String, content: @Composable () -> Unit)` + - `internal @Composable fun OptionList(options: List, selected: Int, monospace: Boolean, onSelect: (Int) -> Unit)` + - `internal fun NameProblem.messageRes(): Int` + - `data class ExtractMethodUiState(candidateLabels, selectedCandidate, showCandidatePicker, name, nameProblem, signaturePreview)` with `canConfirm` + - `sealed interface ExtractMethodUiEvent` with `CandidateSelected(index)`, `NameChanged(name)`, `Confirmed`, `Dismissed` + - `data class ExtractMethodChoice(val candidate: ExtractMethodCandidate, val name: String)` + - `class ExtractMethodViewModel(plan: ExtractMethodPlan)` with `uiState: StateFlow`, `onEvent(event)`, `choice(): ExtractMethodChoice?`, `companion object { fun factory(plan): ViewModelProvider.Factory }` + +- [ ] **Step 1: Add the strings** + +In `resources/src/main/res/values/strings.xml`, immediately after the line +`The file changed. Try extracting again.` +insert: + +```xml + + + Extract method + Extract method + Signature + The file changed. Try extracting again. + Select an expression, or whole statements inside one block + The selection produces more than one value: %1$s + The selection assigns to %1$s, which is declared outside it + The selection jumps out of itself with return, break or continue + The selection uses members of the enclosing %1$s receiver + The selection uses type parameter %1$s + A type in the selection cannot be written out +``` + +Reuse the existing `action_extract`, `label_extract_variable_expression`, `label_extract_variable_name` and the four `msg_extract_variable_name_*` messages -- R12 keeps name validation identical, so no new error strings. + +- [ ] **Step 2: Promote the shared sheet components** + +Create `SheetComponents.kt` with the three declarations moved **verbatim** from `ExtractVariableSheetContent.kt` (lines 138-200), changing `private` to `internal`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.selection.selectable +import androidx.compose.foundation.selection.selectableGroup +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.RadioButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.semantics.Role +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.resources.R + +/** Shared by the extract-variable and extract-method sheets; neither owns them. */ +@Composable +internal fun LabelledSection( + label: String, + content: @Composable () -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { + Text(text = label, style = MaterialTheme.typography.labelLarge) + content() + } +} + +/** A radio group. Expression text is monospaced so a candidate reads as the code it is. */ +@Composable +internal fun OptionList( + options: List, + selected: Int, + monospace: Boolean, + onSelect: (Int) -> Unit, +) { + Column( + modifier = Modifier.selectableGroup(), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + options.forEachIndexed { index, option -> + Row( + verticalAlignment = Alignment.CenterVertically, + modifier = + Modifier + .fillMaxWidth() + .selectable( + selected = index == selected, + role = Role.RadioButton, + onClick = { onSelect(index) }, + ), + ) { + RadioButton( + selected = index == selected, + onClick = null, + ) + + Text( + text = option, + style = + if (monospace) { + MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) + } else { + MaterialTheme.typography.bodyMedium + }, + modifier = Modifier.padding(start = 8.dp), + ) + } + } + } +} + +/** The message shown under a name field for each way a name can be unusable. */ +internal fun NameProblem.messageRes(): Int = + when (this) { + NameProblem.Blank -> R.string.msg_extract_variable_name_blank + NameProblem.NotAnIdentifier -> R.string.msg_extract_variable_name_invalid + NameProblem.Keyword -> R.string.msg_extract_variable_name_keyword + NameProblem.AlreadyTaken -> R.string.msg_extract_variable_name_taken + } +``` + +Then delete those three declarations from `ExtractVariableSheetContent.kt` and remove the imports they alone used (`selectable`, `selectableGroup`, `RadioButton`, `FontFamily`, `Role` stays -- it is used by the replace-all `toggleable`). Let the compiler tell you which imports are now unused. + +- [ ] **Step 3: Write the failing ViewModel test** + +Create `ExtractMethodViewModelTest.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.CallSiteForm +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodCandidate +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractedBody +import com.itsaky.androidide.lsp.kotlin.utils.refactor.MethodParameter +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem +import com.itsaky.androidide.lsp.kotlin.utils.refactor.TextSpan +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** The sheet's derivation logic, tested without Compose, a fragment or an activity. */ +class ExtractMethodViewModelTest { + private fun candidate( + label: String, + suggestedName: String, + parameters: List = listOf(MethodParameter("a", "Int")), + returnTypeText: String? = "Int", + modifiers: List = listOf("private"), + takenNames: Set = emptySet(), + ) = ExtractMethodCandidate( + label = label, + span = TextSpan(0, 5), + suggestedName = suggestedName, + takenNames = takenNames, + annotations = emptyList(), + modifiers = modifiers, + receiverTypeText = null, + parameters = parameters, + returnTypeText = returnTypeText, + body = ExtractedBody.ExpressionBody(needsReturn = true), + callSite = CallSiteForm.Call, + insertOffset = 100, + insertIndent = "\t", + ) + + private fun plan( + candidates: List, + selectionMatched: Boolean = false, + ) = ExtractMethodPlan( + fileText = "unused", + documentVersion = 1, + candidates = candidates, + selectionMatchedCandidate = selectionMatched, + refusal = null, + ) + + @Test + fun `the initial state takes the first candidate's suggestion`() { + val model = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) + + assertEquals("total", model.uiState.value.name) + assertEquals(0, model.uiState.value.selectedCandidate) + assertNull(model.uiState.value.nameProblem) + } + + @Test + fun `the chooser is hidden for one candidate and for an exact selection match`() { + val single = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) + assertFalse(single.uiState.value.showCandidatePicker) + + val many = listOf(candidate("a + b", "total"), candidate("a + b + c", "total1")) + assertTrue(ExtractMethodViewModel(plan(many)).uiState.value.showCandidatePicker) + assertFalse(ExtractMethodViewModel(plan(many, selectionMatched = true)).uiState.value.showCandidatePicker) + } + + @Test + fun `the preview is the signature as it will be emitted`() { + val model = + ExtractMethodViewModel( + plan( + listOf( + candidate( + "load() + 1", + "total", + parameters = listOf(MethodParameter("id", "String")), + returnTypeText = "User", + modifiers = listOf("private", "suspend"), + ), + ), + ), + ) + + assertEquals("private suspend fun total(id: String): User", model.uiState.value.signaturePreview) + + model.onEvent(ExtractMethodUiEvent.NameChanged("loadUser")) + + assertEquals("private suspend fun loadUser(id: String): User", model.uiState.value.signaturePreview) + } + + @Test + fun `a name matching an inherited member is rejected`() { + val model = + ExtractMethodViewModel(plan(listOf(candidate("a + b", "total", takenNames = setOf("helper"))))) + + model.onEvent(ExtractMethodUiEvent.NameChanged("helper")) + + assertEquals(NameProblem.AlreadyTaken, model.uiState.value.nameProblem) + assertFalse(model.uiState.value.canConfirm) + assertNull(model.choice()) + } + + @Test + fun `switching candidate re-suggests the name`() { + val model = + ExtractMethodViewModel( + plan(listOf(candidate("a + b", "total"), candidate("a + b + c", "sum"))), + ) + model.onEvent(ExtractMethodUiEvent.NameChanged("mine")) + + model.onEvent(ExtractMethodUiEvent.CandidateSelected(1)) + + assertEquals("sum", model.uiState.value.name) + assertEquals(1, model.uiState.value.selectedCandidate) + } + + @Test + fun `the choice carries the selected candidate and the typed name`() { + val model = + ExtractMethodViewModel( + plan(listOf(candidate("a + b", "total"), candidate("a + b + c", "sum"))), + ) + model.onEvent(ExtractMethodUiEvent.CandidateSelected(1)) + model.onEvent(ExtractMethodUiEvent.NameChanged("combined")) + + val choice = model.choice() + + assertNotNull(choice) + assertEquals("a + b + c", choice!!.candidate.label) + assertEquals("combined", choice.name) + } + + @Test + fun `a blank name blocks confirmation`() { + val model = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) + + model.onEvent(ExtractMethodUiEvent.NameChanged("")) + + assertEquals(NameProblem.Blank, model.uiState.value.nameProblem) + assertNull(model.choice()) + } +} +``` + +- [ ] **Step 4: Run the test to verify it fails** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodViewModelTest"` +Expected: compilation failure -- `Unresolved reference: ExtractMethodViewModel`. + +- [ ] **Step 5: Write the state and the ViewModel** + +Create `ExtractMethodUiState.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodCandidate +import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem + +/** + * Everything the extract-method sheet renders. + * + * There is no scope chooser (the new function is always a sibling of the enclosing declaration) and + * no replace-all checkbox (the region is the only site rewritten), so the sheet is a chooser, a name + * field and a preview. + * + * [signaturePreview] is the signature exactly as it will be emitted -- the one derived artefact, and + * the one place the derivation can surprise the user. The body is the code they selected and can see + * behind the sheet, so previewing it says nothing new. + */ +data class ExtractMethodUiState( + val candidateLabels: List, + val selectedCandidate: Int, + val showCandidatePicker: Boolean, + val name: String, + val nameProblem: NameProblem?, + val signaturePreview: String, +) { + val canConfirm: Boolean get() = nameProblem == null +} + +/** What the sheet reports back up; the ViewModel never touches the document itself. */ +sealed interface ExtractMethodUiEvent { + data class CandidateSelected( + val index: Int, + ) : ExtractMethodUiEvent + + data class NameChanged( + val name: String, + ) : ExtractMethodUiEvent + + data object Confirmed : ExtractMethodUiEvent + + data object Dismissed : ExtractMethodUiEvent +} + +/** + * The user's finished decision, handed to the action to turn into edits. Free of offsets and text so + * the sheet stays a pure chooser. + */ +data class ExtractMethodChoice( + val candidate: ExtractMethodCandidate, + val name: String, +) +``` + +Create `ExtractMethodViewModel.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.signatureText +import com.itsaky.androidide.lsp.kotlin.utils.refactor.validateVariableName +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.asStateFlow + +/** + * Derives the sheet's state from an [ExtractMethodPlan] and nothing else -- no analysis, no PSI, no + * I/O -- which is what lets it hold all the sheet's logic and still be a plain unit test. + * + * A plain [ViewModelProvider.Factory] rather than a Koin definition, for the same reason as + * `ExtractVariableViewModel`: sheet-scoped, injects nothing, takes the plan as a runtime argument. + */ +class ExtractMethodViewModel( + private val plan: ExtractMethodPlan, +) : ViewModel() { + private val _uiState = MutableStateFlow(stateFor(candidateIndex = 0, name = null)) + val uiState: StateFlow = _uiState.asStateFlow() + + fun onEvent(event: ExtractMethodUiEvent) { + val current = _uiState.value + when (event) { + is ExtractMethodUiEvent.CandidateSelected -> { + if (event.index == current.selectedCandidate) return + // A different expression means a different signature and suggested name, so the name is + // re-suggested rather than carried over -- the old one described the old expression. + _uiState.value = stateFor(event.index, name = null) + } + + is ExtractMethodUiEvent.NameChanged -> { + _uiState.value = stateFor(current.selectedCandidate, name = event.name) + } + + ExtractMethodUiEvent.Confirmed, ExtractMethodUiEvent.Dismissed -> Unit + } + } + + /** The user's decision, or null when the name is unusable. */ + fun choice(): ExtractMethodChoice? { + val state = _uiState.value + if (!state.canConfirm) return null + return ExtractMethodChoice(candidate(state.selectedCandidate), state.name) + } + + private fun candidate(index: Int) = plan.candidates[index.coerceIn(plan.candidates.indices)] + + private fun stateFor( + candidateIndex: Int, + name: String?, + ): ExtractMethodUiState { + val bounded = candidateIndex.coerceIn(plan.candidates.indices) + val candidate = candidate(bounded) + val resolvedName = name ?: candidate.suggestedName + + return ExtractMethodUiState( + candidateLabels = plan.candidates.map { it.label }, + selectedCandidate = bounded, + showCandidatePicker = plan.candidates.size > 1 && !plan.selectionMatchedCandidate, + name = resolvedName, + nameProblem = validateVariableName(resolvedName, candidate.takenNames), + // The same call the edit builder makes, so the preview cannot drift from the declaration. + signaturePreview = candidate.signatureText(resolvedName), + ) + } + + companion object { + fun factory(plan: ExtractMethodPlan): ViewModelProvider.Factory = + object : ViewModelProvider.Factory { + @Suppress("UNCHECKED_CAST") + override fun create(modelClass: Class): T = ExtractMethodViewModel(plan) as T + } + } +} +``` + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` +Expected: PASS, including the untouched `ExtractVariableViewModelTest` -- the component promotion must not have changed extract-variable behaviour. + +- [ ] **Step 7: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add resources/src/main/res/values/strings.xml \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt +git commit -m "ADFA-5080: Add the extract-method sheet state and strings" +``` + +--- + +## Task 6: The sheet + +Compose UI is not unit-testable in this module (`lsp/kotlin` has no `androidTest` source set and none is added). Verification is a compile plus the on-device QA in Task 7. + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt` +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt` + +**Interfaces:** +- Consumes: `ExtractMethodUiState`, `ExtractMethodUiEvent`, `ExtractMethodChoice`, `ExtractMethodViewModel`, `LabelledSection`, `OptionList`, `messageRes()`, `IdeTheme`, `findFragmentActivity` (already in `ExtractVariableSheet.kt`). +- Produces: `fun ExtractMethodSheet.Companion.show(activity: FragmentActivity, plan: ExtractMethodPlan, onChoice: (ExtractMethodChoice) -> Unit): Boolean`. + +- [ ] **Step 1: Write the content** + +Create `ExtractMethodSheetContent.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.navigationBarsPadding +import androidx.compose.foundation.layout.padding +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedTextField +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.font.FontFamily +import androidx.compose.ui.unit.dp +import com.itsaky.androidide.resources.R + +/** + * The extract-method sheet: the expression chooser (when there is a choice), the name, and the + * signature exactly as it will be emitted. + * + * A sibling of the extract-variable sheet rather than a generalisation of it: a single shared sheet + * would need a state class where half the fields are meaningless to either caller (ADR 0011). + * + * Stateless: all state arrives in [state] and every interaction leaves as an [ExtractMethodUiEvent]. + */ +@Composable +fun ExtractMethodSheetContent( + state: ExtractMethodUiState, + onEvent: (ExtractMethodUiEvent) -> Unit, + modifier: Modifier = Modifier, +) { + Column( + modifier = + modifier + .fillMaxWidth() + .navigationBarsPadding() + .padding(horizontal = 24.dp, vertical = 16.dp), + verticalArrangement = Arrangement.spacedBy(16.dp), + ) { + Text( + text = stringResource(R.string.title_extract_method), + style = MaterialTheme.typography.titleLarge, + ) + + if (state.showCandidatePicker) { + LabelledSection(stringResource(R.string.label_extract_variable_expression)) { + OptionList( + options = state.candidateLabels, + selected = state.selectedCandidate, + monospace = true, + onSelect = { onEvent(ExtractMethodUiEvent.CandidateSelected(it)) }, + ) + } + } + + OutlinedTextField( + value = state.name, + onValueChange = { onEvent(ExtractMethodUiEvent.NameChanged(it)) }, + label = { Text(stringResource(R.string.label_extract_variable_name)) }, + isError = state.nameProblem != null, + singleLine = true, + supportingText = state.nameProblem?.let { problem -> { Text(stringResource(problem.messageRes())) } }, + modifier = Modifier.fillMaxWidth(), + ) + + LabelledSection(stringResource(R.string.label_extract_method_signature)) { + Text( + text = state.signaturePreview, + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onEvent(ExtractMethodUiEvent.Dismissed) }) { + Text(stringResource(android.R.string.cancel)) + } + Button( + onClick = { onEvent(ExtractMethodUiEvent.Confirmed) }, + enabled = state.canConfirm, + modifier = Modifier.padding(start = 8.dp), + ) { + Text(stringResource(R.string.action_extract)) + } + } + } +} +``` + +Note: the preview **wraps rather than truncating** (R11), which a plain `Text` with no `maxLines` does by default. Do not make it horizontally scrollable. + +- [ ] **Step 2: Write the sheet** + +Create `ExtractMethodSheet.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.refactor.ui + +import android.os.Bundle +import android.view.LayoutInflater +import android.view.View +import android.view.ViewGroup +import androidx.compose.runtime.getValue +import androidx.compose.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.FragmentActivity +import androidx.fragment.app.viewModels +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.itsaky.androidide.common.compose.IdeTheme +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan + +/** + * Hosts [ExtractMethodSheetContent]. + * + * The plan is handed in directly rather than through fragment arguments: it carries the file's text + * and offset spans, which is neither `Parcelable` nor meaningful to restore -- after process death + * the document may be entirely different. So [plan] is null on a recreated instance and the sheet + * dismisses itself, the same outcome the action's document-version guard would reach anyway. + */ +class ExtractMethodSheet : BottomSheetDialogFragment() { + private var plan: ExtractMethodPlan? = null + private var onChoice: ((ExtractMethodChoice) -> Unit)? = null + + private val viewModel: ExtractMethodViewModel by viewModels { + ExtractMethodViewModel.factory(requireNotNull(plan) { "sheet shown without a plan" }) + } + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + if (plan == null) { + dismissAllowingStateLoss() + return null + } + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + IdeTheme { + val state by viewModel.uiState.collectAsStateWithLifecycle() + ExtractMethodSheetContent( + state = state, + onEvent = ::handleEvent, + ) + } + } + } + } + + private fun handleEvent(event: ExtractMethodUiEvent) { + when (event) { + ExtractMethodUiEvent.Confirmed -> { + viewModel.choice()?.let { choice -> onChoice?.invoke(choice) } + dismiss() + } + + ExtractMethodUiEvent.Dismissed -> dismiss() + + else -> viewModel.onEvent(event) + } + } + + companion object { + private const val TAG = "extract_method_sheet" + + /** + * Shows the sheet on [activity], calling [onChoice] once if the user confirms. Returns false + * when it could not be shown, so the caller can report a failure rather than doing nothing. + */ + fun show( + activity: FragmentActivity, + plan: ExtractMethodPlan, + onChoice: (ExtractMethodChoice) -> Unit, + ): Boolean { + val manager = activity.supportFragmentManager + if (manager.isStateSaved || manager.isDestroyed) return false + ExtractMethodSheet() + .apply { + this.plan = plan + this.onChoice = onChoice + }.show(manager, TAG) + return true + } + } +} +``` + +- [ ] **Step 3: Compile and run the tests** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:compileV7DebugKotlin :lsp:kotlin:testV7DebugUnitTest` +Expected: BUILD SUCCESSFUL, tests PASS. + +- [ ] **Step 4: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt +git commit -m "ADFA-5080: Add the extract-method Compose sheet" +``` + +--- + +## Task 7: Wire up the code action + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt` +- Modify: `idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt` (next to `EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE`, around line 92) +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt` +- Modify: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt` +- Modify: `docs/features/kotlin-extract-method.md` (the `Status:` line, line 4) + +**Interfaces:** +- Consumes: `buildExtractMethodPlan`, `ExtractMethodPlan`, `ExtractionRefusal`, `buildExtractMethodRewrites`, `toTextEdit`, `ExtractMethodSheet.show`, `ExtractMethodChoice`, `findFragmentActivity`. +- Produces: `class ExtractMethodAction : BaseKotlinCodeAction()` with `companion object { const val ID = "ide.editor.lsp.kt.extractMethod" }`. + +- [ ] **Step 1: Add the tooltip tag** + +In `TooltipTag.kt`, directly below the extract-variable constant: + +```kotlin + const val EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD = "editor.codeactions.kotlin.extractmethod" +``` + +- [ ] **Step 2: Write the failing tooltip-tag test change** + +In `KotlinCodeActionTooltipTagTest.kt`, add the import `com.itsaky.androidide.lsp.kotlin.actions.ExtractMethodAction` and add the row to the `expected` map, next to the extract-variable row: + +```kotlin + ExtractMethodAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD, +``` + +Also add `ExtractMethodAction()` to the action list this test builds, mirroring how `ExtractVariableAction()` appears there. + +- [ ] **Step 3: Run the test to verify it fails** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.KotlinCodeActionTooltipTagTest"` +Expected: compilation failure -- `Unresolved reference: ExtractMethodAction`. + +- [ ] **Step 4: Write the action** + +Create `ExtractMethodAction.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.actions + +import android.content.Context +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireEditor +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodChoice +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodSheet +import com.itsaky.androidide.lsp.kotlin.refactor.ui.findFragmentActivity +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionRefusal +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractMethodPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractMethodRewrites +import com.itsaky.androidide.lsp.kotlin.utils.refactor.toTextEdit +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.projects.FileManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tasks.createJobCancelChecker +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo +import java.nio.file.Path + +/** + * Moves the expression at the cursor, or a selected range of statements, into a new `private fun`. + * + * [execAction] runs one background analysis pass and returns a plain-data [ExtractMethodPlan]; + * [postExec] shows the sheet and turns the user's choice into two text edits with pure offset + * arithmetic. Where the region cannot be moved faithfully the plan carries a typed refusal, which + * postExec renders as a specific message rather than a generic failure (ADR 0012). + */ +class ExtractMethodAction : BaseKotlinCodeAction() { + companion object { + const val ID = "ide.editor.lsp.kt.extractMethod" + } + + override var titleTextRes: Int = R.string.action_extract_method + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD + + override val id: String = ID + override var label: String = "" + + // Analysis must not run on the UI thread, so the selection is read at the top of execAction on a + // background thread. A torn read while the user is mid-edit can only produce a plan the + // document-version guard then refuses to apply. + override var requiresUIThread: Boolean = false + + // Intentionally no prepare() visibility gate: deciding whether anything is extractable needs a K2 + // analysis session, far too costly for prepare(). The action stays visible on any Kotlin file and + // reports a refusal instead. + + override suspend fun execAction(data: ActionData): ExtractMethodPlan { + val server = + data.get() + ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + val nioPath = data.requireFile().toPath() + val env = + server.compilationEnvironmentFor(nioPath) + ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + + val cursor = data.requireEditor().cursor + return buildExtractMethodPlan( + env = env, + nioPath = nioPath, + selectionStart = minOf(cursor.left, cursor.right), + selectionEnd = maxOf(cursor.left, cursor.right), + documentVersion = documentVersionOf(nioPath), + // Ties the analysis to this action's coroutine: cancelling the action aborts the analysis. + cancelChecker = ScheduledCancelChecker(createJobCancelChecker()), + ) + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + super.postExec(data, result) + if (result !is ExtractMethodPlan) return + + val context = data.requireContext() + if (result.isEmpty) { + flashInfo(refusalMessage(context, result.refusal ?: ExtractionRefusal.NotASingleRegion)) + return + } + + val activity = + context.findFragmentActivity() + ?: run { + // A wiring problem rather than a user path: the editor is always hosted by one. + logger.warn("No FragmentActivity for the editor context. Cannot show the extract sheet.") + flashError(R.string.msg_cannot_perform_fix) + return + } + + val shown = ExtractMethodSheet.show(activity, result) { choice -> applyChoice(data, result, choice) } + if (!shown) { + logger.warn("Fragment manager unavailable. Cannot show the extract sheet.") + } + } + + /** + * Turns the user's choice into the two edits and hands them to the language client. + * + * The document version is re-read here rather than trusted from the plan: the editor stays + * reachable while the sheet is open, and applying spans computed against older text would corrupt + * the file. Refusing is always safe; the user can invoke the action again. + */ + private fun applyChoice( + data: ActionData, + plan: ExtractMethodPlan, + choice: ExtractMethodChoice, + ) { + val file = data.requireFile() + val nioPath = file.toPath() + if (documentVersionOf(nioPath) != plan.documentVersion) { + flashInfo(R.string.msg_extract_method_file_changed) + return + } + + val rewrites = + buildExtractMethodRewrites(plan.fileText, choice.candidate, choice.name) ?: run { + logger.warn("Could not build an extract-method rewrite for '{}'", choice.candidate.label) + flashError(R.string.msg_cannot_perform_fix) + return + } + + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot extract method.") + return + } + + client.performCodeAction( + CodeActionItem( + title = label, + changes = + listOf( + DocumentChange( + file = nioPath, + // Descending document order: applyActionEdits applies these in list order with + // line/column ranges, so the call site must not shift the insertion point. + edits = rewrites.map { it.toTextEdit(plan.fileText) }, + ), + ), + kind = CodeActionKind.QuickFix, + // The rewrites are emitted fully indented; CMD_FORMAT_CODE is a no-op for Kotlin anyway. + command = Command("", ""), + ), + ) + } + + /** Each refusal names the construct in the way; a generic message reads as a broken feature. */ + private fun refusalMessage( + context: Context, + refusal: ExtractionRefusal, + ): String = + when (refusal) { + ExtractionRefusal.NotASingleRegion -> context.getString(R.string.msg_extract_method_not_single_region) + is ExtractionRefusal.MultipleOutputs -> + context.getString(R.string.msg_extract_method_multiple_outputs, refusal.names.joinToString(", ")) + + is ExtractionRefusal.ReassignsOuterVar -> + context.getString(R.string.msg_extract_method_reassigns_outer_var, refusal.name) + + ExtractionRefusal.ExitsRegion -> context.getString(R.string.msg_extract_method_exits_region) + is ExtractionRefusal.InnerImplicitReceiver -> + context.getString(R.string.msg_extract_method_inner_implicit_receiver, refusal.construct) + + is ExtractionRefusal.UsesTypeParameter -> + context.getString(R.string.msg_extract_method_uses_type_parameter, refusal.name) + + ExtractionRefusal.UnrenderableType -> context.getString(R.string.msg_extract_method_unrenderable_type) + } + + /** -1 when the document is not open, which never matches a real version and so fails the guard. */ + private fun documentVersionOf(path: Path): Int = FileManager.getActiveDocument(path)?.version ?: -1 +} +``` + +- [ ] **Step 5: Register the action** + +In `KotlinCodeActionsMenu.kt`, add the import `com.itsaky.androidide.lsp.kotlin.actions.ExtractMethodAction` and add `ExtractMethodAction(),` to the action list, directly after `ExtractVariableAction(),`. + +- [ ] **Step 6: Run the tests to verify they pass** + +Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` +Expected: PASS, all tests including `KotlinCodeActionTooltipTagTest`. + +- [ ] **Step 7: Update the feature doc's status** + +In `docs/features/kotlin-extract-method.md`, change line 4 from + +```markdown +- **Status:** Requirements only - not implemented +``` + +to + +```markdown +- **Status:** Implemented +``` + +- [ ] **Step 8: Build the app end to end** + +Run: `flox activate -d flox/local -- ./gradlew :app:assembleV8Debug --parallel --max-workers=6` +Expected: BUILD SUCCESSFUL. This is slow (multi-minute) but it is the only check that the resource strings, the tooltip module and the LSP module all agree. + +- [ ] **Step 9: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git status +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt \ + idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt \ + docs/features/kotlin-extract-method.md +git commit -m "ADFA-5080: Wire up the extract-method code action" +``` + +`git status` must show `docs/superpowers/plans/2026-08-10-kotlin-extract-method.md` as untracked and it must stay that way. + +--- + +## On-device QA (not unit-testable) + +The sheet, `prepare()`/`ActionData`, the two-step undo and the new tooltip row are covered by manual QA. Record these in ADFA-5080's "Steps to QA" field (`customfield_10250`), taken from the spec's acceptance criteria: + +1. "Extract method" appears in the code-actions menu of a Kotlin file and is absent in a non-Kotlin file. +2. A cursor inside an expression offers innermost-first candidates; extracting one replaces it with a call and adds a `private fun` below the enclosing function. +3. Selecting two adjacent statements that use two locals produces a function with those two locals as parameters, in first-use order. +4. A ragged selection snaps outward to whole statements. +5. A selection spanning two blocks reports "Select an expression, or whole statements inside one block". +6. A range declaring a local read afterwards produces `val x = extracted(...)`. +7. A range declaring two such locals is declined as producing more than one value. +8. A loop accumulating into an outer `var` is declined, naming that variable. +9. Selecting a tail ending in `return x` produces `return extracted(...)`. +10. A `return` mid-range is declined; a `break` targeting an outer loop is declined. +11. Inside `fun Foo.bar()`, a region touching `Foo`'s members produces `private fun Foo.extracted(...)` with an unchanged call site. +12. Inside `with(x) { ... }`, a region using `x`'s members is declined, naming the construct. +13. A region calling a suspend function produces a `suspend fun`; one calling a `@Composable` produces a `@Composable` function that compiles. +14. A region using an enclosing function's type parameter is declined, naming it. +15. A name matching an inherited member is rejected with "That name is already used". +16. The signature preview matches the emitted declaration exactly. +17. Editing the file while the sheet is open, then confirming, reports the file-changed message and leaves the file untouched. +18. Undo restores the file; it currently takes **two** undo steps (ADFA-5081) and the intermediate state does not compile. +19. A space-indented file receives space-indented output; a CRLF file keeps CRLF. +20. The tooltip long-press on the menu item resolves `editor.codeactions.kotlin.extractmethod`. diff --git a/docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md b/docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md new file mode 100644 index 0000000000..660aa96bae --- /dev/null +++ b/docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md @@ -0,0 +1,1779 @@ +# Extract Variable Defect Fixes 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:** Fix the five defects found in the Kotlin extract-variable code action so every extraction it offers produces code that compiles, then hand ADFA-4826 to QA. + +**Architecture:** All five fixes stay inside the existing analysis/UI split: the background pass produces a plain-data `ExtractionPlan`, and the rewrite is pure text and offset arithmetic on it. Two of the fixes add data to `AnchorForm` so the rewrite can honour the scope the user picked and can tell a one-line block from a multi-line one; one adds a rendered return type to the expression-body conversion; two are guard/label corrections in the syntactic layer. + +**Tech Stack:** Kotlin, K2 Analysis API (`org.jetbrains.kotlin.analysis.api`), Kotlin PSI, JUnit 4, Gradle (flox-wrapped), `gh stack` for the PR stack. + +## Global Constraints + +- **Branch:** all five fix commits go on `feat/ADFA-4826-extract-variable` (PR #1654). The stack is `stage` <- `feat/ADFA-4826-common-compose-theme` (#1653) <- `feat/ADFA-4826-extract-variable` (#1654) <- `feat/ADFA-5080-extract-method` (#1655), tracked as `gh stack` Stack #1656. +- **Worktree:** `/var/mnt/data/dev/work/adfa/cogo/code-on-the-go/.claude/worktrees/ADFA-4826`. It currently has `feat/ADFA-5080-extract-method` checked out; Task 1 switches it. +- **Gradle:** every invocation is wrapped: `flox activate -d flox/local -- ./gradlew `. +- **Unit test task:** `:lsp:kotlin:testV7DebugUnitTest` (V7 flavour; there is no flavourless `test`). +- **Formatting:** tabs for indentation, LF endings, ktlint via Spotless. Run `flox activate -d flox/local -- ./gradlew spotlessApply` before each commit. +- **Code comments:** comment the non-obvious *why* only. No separator or decorative comments. ASCII only in code and comments (`->`, `-`, straight quotes). +- **Commits:** subject `ADFA-4826: ` (`ADFA-5080: ...` for the one commit on the 5080 branch). No `Co-Authored-By` trailer. Never `git add .` - stage named paths. Never commit anything under `docs/superpowers/plans/`. +- **Invariants that must not regress:** exactly one `TextEdit` per code action; the plan carries no PSI; nothing in `prepare()`; no I/O on the main thread. +- **Jira:** ADFA-4826, field `customfield_10250` ("Steps to QA") is ADF, cloudId `bb66613e-967d-4549-a8d6-d9166759f2d2`. + +## File Structure + +**Created** + +- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt` - the shared type-text layer: render a `KaType` as source-shaped text, reject what cannot be written out, and shorten qualified names that already resolve in the file. Extracted here (rather than left private in `MethodSignature.kt`, which lives one PR further up the stack) so both refactorings share one renderer. + +**Modified** + +- `.../utils/refactor/CandidateExpressions.kt` - `isLegalExtractionTarget` also rejects `KtLambdaExpression` (Task 1). +- `.../utils/refactor/ScopeChain.kt` - `blockLabel` unwraps the control-structure container node (Task 2); `frameFor` fills the new `ExistingBlock` fields (Task 4). +- `.../utils/refactor/ExtractionPlan.kt` - `AnchorForm.ExistingBlock` becomes a data class carrying `contentSpan` + `statementSpans` (Task 4); `AnchorForm.ConvertExpressionBody` gains `returnTypeText` (Task 3). +- `.../utils/refactor/ExtractVariablePlanner.kt` - computes `returnTypeText` and declines the rung when the type cannot be written (Task 3). +- `.../utils/refactor/ExtractVariableEdit.kt` - anchors on the chosen scope's statement (Task 4), expands a one-line block (Task 5), emits the return type (Task 3). +- `.../utils/refactor/MethodSignature.kt` - drops its private renderer copies in favour of `TypeText.kt` (Task 6, on the 5080 branch). +- `docs/features/kotlin-extract-variable.md` - R2, R5, R9, the acceptance criteria and the stale Status line, one delta per fix commit. + +**Tests modified** + +- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt` - pure `shortenTypeText` rules (Task 3). +- `.../utils/refactor/ExtractVariableEditTest.kt` - `ExistingBlock` fixtures, the outer-rung anchor, the one-line block, the return-type header (Tasks 3-5). +- `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` - lambda exclusion, rung labels, inferred return type, outer-rung text, one-line lambda text (Tasks 1-5). +- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt` - one `ExistingBlock` fixture (Task 4). + +**Test facts worth knowing before writing any test** + +- `ExtractVariablePlanEndToEndTest` extends `KtLspTest` and has two private helpers already: `plan(content, start, end = start)` (writes `Main.kt` into the test source root and returns the `ExtractionPlan`) and `apply(text, rewrite)` (applies a `RewriteSpan` to a string). Use them; do not add new ones. +- Every analysis-backed test writes the **same** file name `Main.kt`, which overwrites the previous test's file. Do not give each test a unique file name: several files in one source root share package `p`, and duplicate top-level declarations across them silently break symbol resolution - which shows up as wrong `needsReturn`/type results rather than as a test error. +- `RefactorPrimitivesTest` and `ExtractVariableEditTest` are plain JUnit with no PSI and no analysis session. Keep them that way. + +--- + +### Task 1: Stop offering the lambda that wraps the expression + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt:190-210` +- Modify: `docs/features/kotlin-extract-variable.md:4` (Status), `:80` (R2 illegal-target list) +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: nothing from other tasks. +- Produces: nothing other tasks depend on. `isLegalExtractionTarget(): Boolean` keeps its signature. + +**Why:** `isLegalExtractionTarget` rejects `KtFunctionLiteral`, but the candidate walk sees the `KtLambdaExpression` that wraps it, so `{ it.length + 1 }` is offered as a candidate. Extracting it emits `val value = { it.length + 1 }`, where `it` has no source, and R2 already says a lambda literal is not a legal target. + +- [ ] **Step 1: Put the worktree on the right branch** + +```bash +cd /var/mnt/data/dev/work/adfa/cogo/code-on-the-go/.claude/worktrees/ADFA-4826 +gh stack checkout feat/ADFA-4826-extract-variable +git log --oneline -1 +``` + +Expected: `617ed6f39 ADFA-4826: Document the extract-variable requirements` (the untracked plan file under `docs/superpowers/plans/` survives the switch; leave it untracked). + +- [ ] **Step 2: Write the failing test** + +Append to `ExtractVariablePlanEndToEndTest`: + +```kotlin + @Test + fun `does not offer the lambda that wraps the expression`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { + it.length + 1 + } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + // `{ it.length + 1 }` must not appear between the two: a hoisted lambda loses the `it` the call + // site was supplying. + assertEquals( + listOf("it.length + 1", "items.map { it.length + 1 }"), + result.candidates.map { it.label }, + ) + } +``` + +- [ ] **Step 3: Run it and watch it fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" +``` + +Expected: FAIL on this test, with the actual list containing `{ it.length + 1 }` as its second entry. + +- [ ] **Step 4: Exclude lambda expressions** + +In `CandidateExpressions.kt`, add the import (keep the import block alphabetical - it goes directly after `KtFunctionLiteral`): + +```kotlin +import org.jetbrains.kotlin.psi.KtLambdaExpression +``` + +and in `isLegalExtractionTarget`, directly after the `KtFunctionLiteral` line: + +```kotlin + if (this is KtFunctionLiteral) return false + // The wrapper around the literal. A hoisted lambda loses the parameter types its call site was + // supplying, so `{ it.length + 1 }` becomes uncompilable the moment it leaves the call. + if (this is KtLambdaExpression) return false +``` + +Also extend the KDoc bullet above the function: + +```kotlin + * - blocks, loops, `return`/`throw`/`break`/`continue` -- no useful value to bind; + * - lambdas, literal and wrapper alike -- outside their call site the parameter types are gone; +``` + +- [ ] **Step 5: Run it and watch it pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" +``` + +Expected: PASS, all tests in the class. + +- [ ] **Step 6: Update the feature doc** + +In `docs/features/kotlin-extract-variable.md`, replace the Status line (line 4): + +```markdown +- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename (the sealed `RefactoringPlan` supertype it will sit under has landed). +``` + +and in R2, in the illegal-target sentence, replace `a lambda literal` with: + +```markdown +a lambda (the `{ ... }` expression and the literal inside it -- outside its call site the parameter types are gone, so `val v = { it.length + 1 }` does not compile) +``` + +- [ ] **Step 7: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ + docs/features/kotlin-extract-variable.md +git commit -m "ADFA-4826: Stop offering the lambda that wraps the expression" +``` + +--- + +### Task 2: Name the construct that owns a braced block + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt:172-186` (`blockLabel`) +- Modify: `docs/features/kotlin-extract-variable.md` (R5, after the anchor-form table) +- Test: `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: nothing. +- Produces: rung labels seen by the sheet's `Declare in` list. Task 4's tests assert `"fun demo"` and `"if block"`. + +**Why:** a braced `if` branch's PSI is `KtIfExpression -> KtContainerNodeForControlStructureBody -> KtBlockExpression`, so `blockLabel`'s `when` sees the container node and falls through to the generic `"block"`. The doc promises `if block`. Same for braced loop bodies. + +- [ ] **Step 1: Write the failing test** + +Append to `ExtractVariablePlanEndToEndTest`: + +```kotlin + @Test + fun `labels a braced if branch by its owner`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b * 2 + } + return 0 + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + assertEquals(listOf("if block", "fun demo"), result.candidates.first().scopes.map { it.label }) + } +``` + +- [ ] **Step 2: Run it and watch it fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" +``` + +Expected: FAIL, actual `[block, fun demo]`. + +- [ ] **Step 3: Unwrap the container node** + +Replace `blockLabel` in `ScopeChain.kt`: + +```kotlin +/** + * The name shown for a block rung. + * + * A braceless *or* braced control-structure body is wrapped in a container node, so the `if`/loop is + * the block's grandparent; without unwrapping, every braced branch reads as a generic "block". The + * container is also what `then`/`else` point at, so the branch check compares against it. + */ +private fun blockLabel(block: KtBlockExpression): String { + val parent = block.parent + val container = parent as? KtContainerNodeForControlStructureBody + val branch = container ?: block + return when (val owner = container?.parent ?: parent) { + is KtNamedFunction -> "fun ${owner.name ?: ""}" + is KtPropertyAccessor -> if (owner.isGetter) "getter" else "setter" + is KtAnonymousInitializer -> "init block" + is KtFunctionLiteral -> "lambda" + is KtIfExpression -> if (owner.then === branch) "if block" else "else block" + is KtForExpression -> "for loop" + is KtWhileExpression -> "while loop" + is KtDoWhileExpression -> "do-while loop" + is KtWhenEntry -> "when branch" + else -> "block" + } +} +``` + +- [ ] **Step 4: Run it and watch it pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" +``` + +Expected: PASS, all tests in the class. + +- [ ] **Step 5: Document the labels** + +In `docs/features/kotlin-extract-variable.md`, immediately after the R5 anchor-form table, insert: + +```markdown +Each rung is labelled with the construct that owns it -- `fun name`, `getter`, `setter`, `init block`, +`lambda`, `if block`, `else block`, `for loop`, `while loop`, `do-while loop`, `when branch` -- so the +`Declare in` list reads as a place rather than as a nesting level. A braced control-structure body is +wrapped in a container node, so the owner is the block's grandparent, not its parent. +``` + +- [ ] **Step 6: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ + docs/features/kotlin-extract-variable.md +git commit -m "ADFA-4826: Label a block rung by the construct that owns it" +``` + +--- + +### Task 3: Write out the return type when converting an expression body + +**Files:** +- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt` +- Modify: `.../utils/refactor/ExtractionPlan.kt:44-57` (`ConvertExpressionBody`) +- Modify: `.../utils/refactor/ExtractVariablePlanner.kt:77-129` +- Modify: `.../utils/refactor/ExtractVariableEdit.kt:104-125` +- Modify: `docs/features/kotlin-extract-variable.md` (R5 table row, acceptance criteria 10-11) +- Test: `.../utils/refactor/RefactorPrimitivesTest.kt`, `.../utils/refactor/ExtractVariableEditTest.kt`, `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: nothing from Tasks 1-2. +- Produces: + - `internal fun KaSession.renderedTypeTextOrNull(type: KaType): String?` + - `internal fun isUnrenderableTypeText(text: String): Boolean` + - `internal fun shortenTypeText(rendered: String, importedNames: Set, starImportedPackages: Set): String` + - `internal fun importedNamesOf(file: KtFile): Set` and `internal fun starImportedPackagesOf(file: KtFile): Set` + - `AnchorForm.ConvertExpressionBody` gains `val returnTypeText: String?` (null = insert nothing). Task 6 reuses the first three from `MethodSignature.kt`. + +**Why:** `fun area(r: Int) = r * r` has no declared return type. Converting it to a block body with `return squared` leaves a Unit-returning function returning an `Int`, which does not compile. The type has to be written into the signature, and it is only safe to shorten a qualified name when that short name already resolves in the file. + +- [ ] **Step 1: Write the failing pure tests for the shortening rule** + +Append to `RefactorPrimitivesTest`: + +```kotlin + @Test + fun `shortens types from Kotlin's default-imported packages`() { + assertEquals("Int", shortenTypeText("kotlin.Int", emptySet(), emptySet())) + assertEquals( + "List", + shortenTypeText("kotlin.collections.List", emptySet(), emptySet()), + ) + } + + @Test + fun `keeps a type qualified when its short name would not resolve`() { + assertEquals("java.util.Date", shortenTypeText("java.util.Date", emptySet(), emptySet())) + // An import of the enclosing class is not an import of the nested one. + assertEquals( + "com.example.Outer.Inner", + shortenTypeText("com.example.Outer.Inner", setOf("com.example.Outer"), emptySet()), + ) + } + + @Test + fun `shortens a type the file already imports, by name or by star`() { + assertEquals("Date", shortenTypeText("java.util.Date", setOf("java.util.Date"), emptySet())) + assertEquals("Date", shortenTypeText("java.util.Date", emptySet(), setOf("java.util"))) + assertEquals( + "Flow", + shortenTypeText( + "kotlinx.coroutines.flow.Flow", + setOf("kotlinx.coroutines.flow.Flow", "com.example.Widget"), + emptySet(), + ), + ) + } + + @Test + fun `unrenderable type text is recognised`() { + assertTrue(isUnrenderableTypeText("")) + assertTrue(isUnrenderableTypeText("kotlin.collections.List")) + assertTrue(isUnrenderableTypeText("")) + assertTrue(isUnrenderableTypeText("ERROR CLASS: unresolved")) + assertTrue(isUnrenderableTypeText("kotlin.Any & kotlin.Comparable<*>")) + assertFalse(isUnrenderableTypeText("kotlin.Int")) + } +``` + +Add the two imports the class does not have yet: + +```kotlin +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +``` + +- [ ] **Step 2: Run them and watch them fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.RefactorPrimitivesTest" +``` + +Expected: compilation failure - `Unresolved reference: shortenTypeText` and `isUnrenderableTypeText`. + +- [ ] **Step 3: Create the shared type-text layer** + +Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.utils.renderName +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.psi.KtFile + +/** + * Types are rendered **fully qualified** and only then shortened against what the file can resolve. + * + * A short name resolves only when the file imports it or it comes from a default-imported package, and + * a refactoring that adds imports would be a much larger change -- so qualified is the safe starting + * point and [shortenTypeText] gives back readability where it provably costs nothing. + */ +@OptIn(KaExperimentalApi::class) +private val QUALIFIED_TYPE_RENDERER = KaTypeRendererForSource.WITH_QUALIFIED_NAMES + +/** Packages whose simple names resolve with no import at all on the JVM/Android target. */ +private val DEFAULT_IMPORTED_PACKAGES = + setOf( + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.jvm", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "java.lang", + ) + +/** A dotted run of identifiers -- one qualified name inside rendered type text. */ +private val QUALIFIED_NAME = Regex("""[\p{L}_][\p{L}\p{Nd}_]*(?:\.[\p{L}_][\p{L}\p{Nd}_]*)+""") + +/** + * A type that cannot be written out as source -- anonymous, intersection, a resolution error, or a + * platform type the renderer could not reduce (`List`, where the `!` is on a type argument). + * `!` is not Kotlin syntax anywhere, so its presence alone settles it. + */ +internal fun isUnrenderableTypeText(text: String): Boolean = + text.isBlank() || + text.contains("anonymous") || + text.contains("ERROR") || + text.contains(" & ") || + text.contains('!') + +/** + * One type as source text, fully qualified, or null when it cannot be written out. + * + * A platform type is unwrapped to its lower bound first: the renderer prints `String!`, which does not + * parse. Only the outermost bound is unwrapped, so a `!` on a type argument still reaches + * [isUnrenderableTypeText]. + */ +@OptIn(KaExperimentalApi::class) +internal fun KaSession.renderedTypeTextOrNull(type: KaType): String? = + runCatching { renderName((type as? KaFlexibleType)?.lowerBound ?: type, QUALIFIED_TYPE_RENDERER) } + .getOrNull() + ?.takeUnless(::isUnrenderableTypeText) + +/** + * Replaces each qualified name in [rendered] with its simple name when that name already resolves in + * the file -- because the file imports it exactly, star-imports its package, or it comes from a + * default-imported package. Everything else stays qualified: verbose, but it always compiles. + * + * Purely textual, so it needs no analysis session and is unit-testable on its own. A nested class + * (`com.example.Outer.Inner`) is only shortened by an import of the nested name itself; an import of + * the outer class leaves it alone rather than emitting an unresolvable `Inner`. + */ +internal fun shortenTypeText( + rendered: String, + importedNames: Set, + starImportedPackages: Set, +): String = + QUALIFIED_NAME.replace(rendered) { match -> + val qualified = match.value + val container = qualified.substringBeforeLast('.') + val resolvable = + qualified in importedNames || + container in DEFAULT_IMPORTED_PACKAGES || + container in starImportedPackages + if (resolvable) qualified.substringAfterLast('.') else qualified + } + +/** The fully qualified names [file] imports by name. Syntactic: no analysis session needed. */ +internal fun importedNamesOf(file: KtFile): Set = + file.importDirectives + .filterNot { it.isAllUnder } + .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } + +/** The packages [file] star-imports (`import com.example.*`). */ +internal fun starImportedPackagesOf(file: KtFile): Set = + file.importDirectives + .filter { it.isAllUnder } + .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } +``` + +- [ ] **Step 4: Run the pure tests and watch them pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.RefactorPrimitivesTest" +``` + +Expected: PASS, all tests in the class. + +- [ ] **Step 5: Write the failing rewrite test for the emitted header** + +Append to `ExtractVariableEditTest`: + +```kotlin + @Test + fun `writes the return type into the signature when the declaration has none`() { + val text = "fun area(r: Int) = r * r" + val candidate = spanOf(text, "r * r") + val form = + AnchorForm.ConvertExpressionBody( + assignStart = text.indexOf('='), + bodyStart = candidate.start, + bodyEnd = text.length, + indent = "", + innerIndent = "\t", + needsReturn = true, + returnTypeText = "Int", + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "squared", replaceAll = false)!! + + assertEquals( + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(text, result), + ) + } +``` + +- [ ] **Step 6: Run it and watch it fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" +``` + +Expected: compilation failure - `ConvertExpressionBody` has no `returnTypeText` parameter. + +- [ ] **Step 7: Add the field and emit it** + +In `ExtractionPlan.kt`, replace the `ConvertExpressionBody` declaration and its KDoc: + +```kotlin + /** + * An expression-bodied function or property accessor -- `fun area(r: Int) = r * r`. The `=` and + * the body are replaced by a block body. [needsReturn] is false only when the declaration + * returns `Unit`, where `return` is both unnecessary and wrong for a non-`Unit` expression. + * + * [returnTypeText] is the type to write into the signature, or null when there is nothing to write + * -- the declaration already spells its type out, or the block body infers `Unit` anyway. A block + * body with no declared type returns `Unit`, so `return ` without this would not compile. + */ + data class ConvertExpressionBody( + val assignStart: Int, + val bodyStart: Int, + val bodyEnd: Int, + val indent: String, + val innerIndent: String, + val needsReturn: Boolean, + val returnTypeText: String? = null, + ) : AnchorForm +``` + +In `ExtractVariableEdit.kt`, replace `convertExpressionBodyRewrite` and add the helper below it: + +```kotlin +/** Converts `= expr` into a block body holding the declaration and a `return` of the rewritten body. */ +private fun convertExpressionBodyRewrite( + fileText: String, + form: AnchorForm.ConvertExpressionBody, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + val bodySpan = TextSpan(form.bodyStart, form.bodyEnd) + val newline = detectNewline(fileText) + val body = replaceOccurrences(fileText, bodySpan, targets, name) + val returned = if (form.needsReturn) "return $body" else body + + // Writing a type means rewriting from the end of the signature, not from the `=`: starting at the + // `=` would leave the space in front of it and emit `fun area(r: Int) : Int {`. + val spanStart = + if (form.returnTypeText == null) form.assignStart else startOfWhitespaceBefore(fileText, form.assignStart) + val header = form.returnTypeText?.let { ": $it " } ?: "" + + val newText = + buildString { + append(header).append('{').append(newline) + append(form.innerIndent).append(declaration).append(newline) + append(form.innerIndent).append(returned).append(newline) + append(form.indent).append('}') + } + return RewriteSpan(TextSpan(spanStart, form.bodyEnd), newText) +} + +/** The offset where the run of whitespace ending at [offset] begins. */ +private fun startOfWhitespaceBefore( + text: String, + offset: Int, +): Int { + var index = offset.coerceIn(0, text.length) + while (index > 0 && text[index - 1].isWhitespace()) index-- + return index +} +``` + +- [ ] **Step 8: Run the rewrite tests and watch them pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" +``` + +Expected: PASS, all tests in the class (the two pre-existing `ConvertExpressionBody` tests pass `returnTypeText` implicitly as null and must be unchanged). + +- [ ] **Step 9: Write the failing plan tests for the three signature shapes** + +Append to `ExtractVariablePlanEndToEndTest`: + +```kotlin + @Test + fun `converting an inferred-type expression body writes the type out`() { + val content = + """ + package p + fun area(r: Int) = r * r + """.trimIndent() + + val target = "r * r" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "squared", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `a declared return type is not written twice`() { + val content = + """ + package p + fun area(r: Int): Int = r * r + """.trimIndent() + + val target = "r * r" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "squared", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `a Unit-returning expression body gets neither a type nor a return`() { + val content = + """ + package p + fun report(value: Int) { + println(value) + } + fun show(text: String) = report(text.length + 1) + """.trimIndent() + + val target = "text.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun report(value: Int) {\n" + + "\tprintln(value)\n" + + "}\n" + + "fun show(text: String) {\n" + + "\tval length = text.length + 1\n" + + "\treport(length)\n" + + "}", + apply(content, rewrite), + ) + } +``` + +- [ ] **Step 10: Run them and watch the first one fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" +``` + +Expected: `converting an inferred-type expression body writes the type out` FAILS (actual has `fun area(r: Int) {`). The other two PASS. + +- [ ] **Step 11: Compute the type in the planner, and decline when it cannot be written** + +In `ExtractVariablePlanner.kt`, add these imports: + +```kotlin +import org.jetbrains.kotlin.psi.KtCallableDeclaration +import org.jetbrains.kotlin.psi.KtPropertyAccessor +``` + +Replace `candidateFor`'s scope-building lines so a rung can be declined: + +```kotlin + val span = TextSpan(expression.textRange.startOffset, expression.textRange.endOffset) + val file = expression.containingKtFile + val scopes = frames.mapNotNull { scopeOptionFor(expression, span, it, file) } + if (scopes.isEmpty()) return null + val takenNames = visibleNamesAt(expression) +``` + +Replace `scopeOptionFor` with: + +```kotlin +/** + * Builds one scope option, resolving its occurrence set and fixing up expression-body details. + * + * Returns null when the rung cannot be honoured: converting an expression body whose return type is + * neither declared nor renderable would emit a block body that does not compile, and declining is + * always safe (ADR 0013). + */ +private fun KaSession.scopeOptionFor( + expression: KtExpression, + span: TextSpan, + frame: ScopeFrame, + file: KtFile, +): ScopeOption? { + val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) + val writes = writeOffsetsFor(expression, frame.scopeElement) + val occurrences = excludeUnsoundOccurrences(matches, span, writes) + + val anchorForm = + when (val form = frame.anchorForm) { + is AnchorForm.ConvertExpressionBody -> { + val declaration = frame.scopeElement.parent as? KtDeclarationWithBody + val needsReturn = expressionBodyNeedsReturn(frame.scopeElement) + val returnTypeText = + if (needsReturn && declaration != null && !declaration.declaresReturnType()) { + returnTypeTextOf(declaration, file) ?: return null + } else { + null + } + form.copy(needsReturn = needsReturn, returnTypeText = returnTypeText) + } + + else -> form + } + + return ScopeOption(label = frame.label, anchorForm = anchorForm, occurrences = occurrences) +} + +/** Whether the declaration spells its return type out, in which case nothing needs writing. */ +private fun KtDeclarationWithBody.declaresReturnType(): Boolean = + when (this) { + is KtPropertyAccessor -> returnTypeReference != null + is KtCallableDeclaration -> typeReference != null + else -> false + } + +/** The declaration's return type as source text, shortened where the file can resolve it. */ +private fun KaSession.returnTypeTextOf( + declaration: KtDeclarationWithBody, + file: KtFile, +): String? { + val type = runCatching { ((declaration as? KtDeclaration)?.symbol as? KaCallableSymbol)?.returnType }.getOrNull() ?: return null + val rendered = renderedTypeTextOrNull(type) ?: return null + return shortenTypeText(rendered, importedNamesOf(file), starImportedPackagesOf(file)) +} +``` + +- [ ] **Step 12: Run the whole module's tests and watch them pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest +``` + +Expected: PASS for the whole module, including all four refactor test classes. + +- [ ] **Step 13: Update the feature doc** + +In the R5 anchor-form table, replace the `ConvertExpressionBody` row's "Emitted as" cell: + +```markdown +| `ConvertExpressionBody` | an expression-bodied function or accessor, `fun area(r: Int) = r * r` | `=` and the body become a block body; `return` is added unless the declaration returns `Unit`; the return type is written into the signature when the declaration does not spell one out, because a block body with no declared type returns `Unit` | +``` + +Immediately after the table, add: + +```markdown +A written-out return type is rendered fully qualified and then shortened to its simple name only where +that name already resolves in the file -- an exact import, a star import of its package, or a +default-imported package such as `kotlin.collections`. Everything else stays qualified: verbose, but it +compiles, and this refactoring adds no imports. When the type cannot be written as source at all +(anonymous, intersection, an unresolved type, or a platform type the renderer cannot reduce) the rung +is declined rather than emitting a block body that does not compile. +``` + +Replace acceptance criteria 10 and 11: + +```markdown +10. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. +11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. +``` + +- [ ] **Step 14: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ + docs/features/kotlin-extract-variable.md +git commit -m "ADFA-4826: Write the return type when converting an expression body" +``` + +--- + +### Task 4: Anchor the declaration in the scope the user picked + +**Files:** +- Modify: `.../utils/refactor/ExtractionPlan.kt:21-31` (`AnchorForm.ExistingBlock`) +- Modify: `.../utils/refactor/ScopeChain.kt:108-116` (`frameFor`'s block branch), plus a new `contentSpanOf` +- Modify: `.../utils/refactor/ExtractVariableEdit.kt:46-78` (`buildExtractVariableRewrite`, `existingBlockRewrite`) +- Modify: `docs/features/kotlin-extract-variable.md` (R5 anchor-point paragraph, acceptance criteria) +- Test: `.../utils/refactor/ExtractVariableEditTest.kt`, `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt`, `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt` + +**Interfaces:** +- Consumes: `AnchorForm` from Task 3 (unchanged by this task); rung labels from Task 2. +- Produces: `AnchorForm.ExistingBlock(contentSpan: TextSpan, statementSpans: List)` - `contentSpan` is the region *inside* the block's braces, `statementSpans` are the block's direct child statements, ascending. Task 5 consumes `contentSpan`. + +**Why:** `existingBlockRewrite` inserts before the line of the first *occurrence*, so every block rung produces byte-identical output and the sheet's `Declare in` choice does nothing. R5 defines the anchor point as the first statement *within the anchor scope* that contains a replaced occurrence, which needs that scope's statement list in the plan. + +- [ ] **Step 1: Write the failing rewrite tests for both rungs** + +Append to `ExtractVariableEditTest`: + +```kotlin + @Test + fun `the inner rung declares inside the if block`() { + val text = + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\treturn a + b * 2\n" + + "\t}\n" + + "\treturn 0\n" + + "}" + val candidate = spanOf(text, "a + b * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, "\n\t\treturn a + b * 2\n\t"), + statementSpans = listOf(spanOf(text, "return a + b * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! + + assertEquals( + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\tval total = a + b * 2\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `the outer rung declares above the enclosing statement`() { + val text = + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\treturn a + b * 2\n" + + "\t}\n" + + "\treturn 0\n" + + "}" + val candidate = spanOf(text, "a + b * 2") + // The function block's rung: its statements are the whole `if` and the trailing `return 0`. + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = + listOf( + spanOf(text, "if (flag) {\n\t\treturn a + b * 2\n\t}"), + spanOf(text, "return 0"), + ), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! + + assertEquals( + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tval total = a + b * 2\n" + + "\tif (flag) {\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(text, result), + ) + } +``` + +- [ ] **Step 2: Run them and watch them fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" +``` + +Expected: compilation failure - `ExistingBlock` is an object and takes no arguments. + +- [ ] **Step 3: Give `ExistingBlock` its data** + +In `ExtractionPlan.kt`, replace the `ExistingBlock` declaration and its KDoc: + +```kotlin + /** + * The scope already has a `{ ... }` body (function body, `if` block, lambda body, ...), so the + * declaration is a new statement line inside it. + * + * [statementSpans] are the block's direct child statements, ascending. The anchor point is the + * first of them containing the first served occurrence -- which is what makes an outer rung differ + * from an inner one. Anchoring on the occurrence's own line instead would make every rung of a + * chain produce the same edit. + * + * [contentSpan] is the region *inside* the braces. It tells a block written on one line + * (`items.map { it.length + 1 }`) from a multi-line one, where inserting at the statement's line + * start would put the declaration outside the braces. + */ + data class ExistingBlock( + val contentSpan: TextSpan, + val statementSpans: List, + ) : AnchorForm +``` + +- [ ] **Step 4: Fill the fields in the scope chain** + +In `ScopeChain.kt`, replace the `parent is KtBlockExpression` branch of `frameFor`: + +```kotlin + if (parent is KtBlockExpression) { + val lineStart = lineStartOffset(text, inner.textRange.startOffset) + return ScopeFrame( + label = blockLabel(parent), + scopeElement = parent, + searchRange = parent.textRange.let { TextSpan(it.startOffset, it.endOffset) }, + statementSpan = TextSpan(lineStart, inner.textRange.endOffset), + anchorForm = + AnchorForm.ExistingBlock( + contentSpan = contentSpanOf(parent), + statementSpans = + parent.statements.map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) }, + ), + ) + } +``` + +and add, next to `lineStartOffset`: + +```kotlin +/** + * The region inside a block's braces. + * + * A function, `if` or loop body owns its braces, so they are trimmed off. A lambda body block does not + * -- the braces and any `param ->` header belong to the enclosing function literal -- so its own range + * already *is* the content, which is what keeps the header on the brace line when the block is + * expanded. Deriving this from the block's text rather than from brace PSI keeps one code path for + * both shapes. + */ +internal fun contentSpanOf(block: KtBlockExpression): TextSpan { + val range = block.textRange + val text = block.text + return if (text.length >= 2 && text.startsWith("{") && text.endsWith("}")) { + TextSpan(range.startOffset + 1, range.endOffset - 1) + } else { + TextSpan(range.startOffset, range.endOffset) + } +} +``` + +- [ ] **Step 5: Anchor the rewrite on the chosen scope's statement** + +In `ExtractVariableEdit.kt`, change the `ExistingBlock` dispatch line in `buildExtractVariableRewrite`: + +```kotlin + is AnchorForm.ExistingBlock -> existingBlockRewrite(fileText, form, targets, declaration, name) +``` + +and replace `existingBlockRewrite`: + +```kotlin +/** + * Inserts the declaration as its own line before the anchor statement, and rewrites everything from + * there through the last occurrence. + * + * The anchor is the statement *of this scope* that holds the first served occurrence, so picking an + * outer rung hoists the declaration above the enclosing statement rather than leaving it where the + * inner rung would have put it. The rewritten span starts at that statement's line start so the + * declaration lands on a line of its own at the right indentation, and ends at the last occurrence so + * untouched trailing code is left alone. + * + * Null when no statement of the scope contains the occurrence, which would mean the plan and the text + * disagree; the caller reports that rather than guessing. + */ +private fun existingBlockRewrite( + fileText: String, + form: AnchorForm.ExistingBlock, + targets: List, + declaration: String, + name: String, +): RewriteSpan? { + val first = targets.first() + val last = targets.last() + val anchor = form.statementSpans.firstOrNull { it.start <= first.start && first.end <= it.end } ?: return null + val lineStart = lineStartOffset(fileText, anchor.start) + val indent = leadingIndentAt(fileText, anchor.start) + val newline = detectNewline(fileText) + + val span = TextSpan(lineStart, last.end) + val body = replaceOccurrences(fileText, span, targets, name) + return RewriteSpan(span = span, newText = indent + declaration + newline + body) +} +``` + +- [ ] **Step 6: Update the existing `ExistingBlock` fixtures** + +In `ExtractVariableEditTest`, add this helper directly below `allSpansOf`: + +```kotlin + /** + * The block rung of a single-block fixture: content is everything between the first `{` and the + * last `}`, and [statements] are the block's direct child statements in source order. + */ + private fun existingBlock( + text: String, + vararg statements: String, + ) = AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = statements.map { spanOf(text, it) }, + ) +``` + +Then replace each `AnchorForm.ExistingBlock` usage: + +- `inserts the declaration above the statement and replaces the selected occurrence`: + +```kotlin + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! +``` + +- `replace-all rewrites every occurrence and anchors above the first`: + +```kotlin + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)", "log(items.size * 2)", "use(items.size * 2)"), + occurrences, + "size", + replaceAll = true, + )!! +``` + +- `replace-all off leaves the other occurrences alone`: + +```kotlin + val result = + rewrite( + text, + occurrences[0], + existingBlock(text, "println(items.size * 2)", "log(items.size * 2)"), + occurrences, + "size", + replaceAll = false, + )!! +``` + +- `matches the file's space indentation rather than assuming tabs`, `keeps CRLF line endings when the file uses them` and `deeper indentation is preserved` (each has one statement): + +```kotlin + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! +``` + +- `null when there is nothing to replace` and `null when an occurrence lies outside the file` (text is `"fun f() {}"`, so the block is empty): + +```kotlin + scope = ScopeOption("scope", AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), emptyList()), +``` + +```kotlin + scope = + ScopeOption( + "scope", + AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), + listOf(TextSpan(0, text.length + 5)), + ), +``` + +In `ExtractVariableViewModelTest`, replace the `anchorForm` line of the `scope` helper: + +```kotlin + anchorForm = AnchorForm.ExistingBlock(contentSpan = TextSpan(0, 100), statementSpans = emptyList()), +``` + +- [ ] **Step 7: Run the rewrite and view-model tests and watch them pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" \ + --tests "com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractVariableViewModelTest" +``` + +Expected: PASS in both classes. + +- [ ] **Step 8: Write the failing end-to-end test for the outer rung** + +Append to `ExtractVariablePlanEndToEndTest`: + +```kotlin + @Test + fun `picking the outer rung hoists the declaration above the enclosing statement`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b * 2 + } + return 0 + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + assertEquals(listOf("if block", "fun demo"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes[1], + name = "total", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tval total = a + b * 2\n" + + "\tif (flag) {\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(content, rewrite), + ) + } +``` + +- [ ] **Step 9: Run the whole module's tests and watch them pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest +``` + +Expected: PASS for the whole module. If `picking the outer rung ...` fails on the *inner* rung's output instead, the plan is handing both rungs the same statement list - check `contentSpanOf` and `parent.statements` in `frameFor`. + +- [ ] **Step 10: Update the feature doc** + +In R5, replace the anchor-point sentence in the Language section's `Anchor point` entry with: + +```markdown +The exact insertion offset - the start of the line holding the first statement *within the anchor +scope* that contains a replaced occurrence. Recorded per rung in the plan (`ExistingBlock`'s +`statementSpans`), because it is the only thing that makes an outer rung differ from an inner one. +``` + +In R9, after the first paragraph, add: + +```markdown +The span is anchored on the chosen rung's statement, not on the occurrence: for an outer rung the +declaration goes above the whole enclosing statement, at that statement's indentation. +``` + +Add an acceptance criterion after 9: + +```markdown +9a. With a candidate inside a braced `if` inside a function, picking `fun name` in `Declare in` puts the declaration above the `if`, and picking `if block` puts it inside the branch. +``` + +- [ ] **Step 11: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt \ + docs/features/kotlin-extract-variable.md +git commit -m "ADFA-4826: Anchor the declaration in the scope the user picked" +``` + +--- + +### Task 5: Expand a block written on one line + +**Files:** +- Modify: `.../utils/refactor/ExtractVariableEdit.kt` (`existingBlockRewrite`, plus a new `oneLineBlockRewrite`) +- Modify: `docs/features/kotlin-extract-variable.md` (R9, acceptance criteria) +- Test: `.../utils/refactor/ExtractVariableEditTest.kt`, `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: `AnchorForm.ExistingBlock.contentSpan` from Task 4. +- Produces: nothing new; `buildExtractVariableRewrite` keeps its signature. + +**Why:** when the anchor statement shares its line with the block's `{`, inserting at the line start puts the declaration *outside* the block: `return items.map { it.length + 1 }` becomes a `val` above the `return` with an unresolved `it`, and `fun f(n: Int): Int { return n * 2 }` puts the `val` above the function signature. Both are uncompilable. + +- [ ] **Step 1: Write the failing rewrite tests** + +Append to `ExtractVariableEditTest`: + +```kotlin + @Test + fun `expands a one-line lambda so the declaration lands inside the braces`() { + val text = "fun f(items: List): List {\n\treturn items.map { it.length + 1 }\n}" + val candidate = spanOf(text, "it.length + 1") + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, " it.length + 1 "), + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval length = it.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `expanding a one-line lambda keeps its parameter header on the brace line`() { + val text = "fun f(items: List): List {\n\treturn items.map { item -> item.length + 1 }\n}" + val candidate = spanOf(text, "item.length + 1") + // A lambda body block excludes the `item ->` header, so the header is outside the content span. + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, " item.length + 1 "), + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map { item ->\n" + + "\t\tval length = item.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `expands a one-line function body`() { + val text = "fun f(n: Int): Int { return n * 2 }" + val candidate = spanOf(text, "n * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = listOf(spanOf(text, "return n * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "doubled", replaceAll = false)!! + + assertEquals( + "fun f(n: Int): Int {\n" + + "\tval doubled = n * 2\n" + + "\treturn doubled\n" + + "}", + apply(text, result), + ) + } +``` + +- [ ] **Step 2: Run them and watch them fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" +``` + +Expected: all three FAIL, each with the declaration emitted on its own line *before* `return`. + +- [ ] **Step 3: Branch to an expansion when the statement shares its line with the brace** + +In `ExtractVariableEdit.kt`, insert these four lines in `existingBlockRewrite` directly **after** its existing `val lineStart = lineStartOffset(fileText, anchor.start)` line and before `val indent = ...`: + +```kotlin + // The statement shares its line with the block's opening brace (a one-line lambda or body). The + // line start is then *outside* the block, so the declaration has to go inside the braces instead. + if (lineStart < form.contentSpan.start) { + return oneLineBlockRewrite(fileText, form, targets, declaration, name) + } +``` + +Add below the function: + +```kotlin +/** + * Puts the declaration inside a block written on one line, moving the block's content and its closing + * brace onto their own lines. + * + * Only the content between the braces is rewritten: the braces, and a lambda's `param ->` header, + * stay exactly where they are, so the expansion cannot disturb the call around it. + */ +private fun oneLineBlockRewrite( + fileText: String, + form: AnchorForm.ExistingBlock, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + val content = form.contentSpan + val newline = detectNewline(fileText) + val indent = leadingIndentAt(fileText, content.start) + val innerIndent = indent + detectIndentUnit(fileText) + val body = replaceOccurrences(fileText, content, targets, name).trim() + + val newText = + buildString { + append(newline) + append(innerIndent).append(declaration).append(newline) + append(innerIndent).append(body).append(newline) + append(indent) + } + return RewriteSpan(span = content, newText = newText) +} +``` + +- [ ] **Step 4: Run the rewrite tests and watch them pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" +``` + +Expected: PASS, all tests in the class - the six multi-line `ExistingBlock` tests included, since their statement lines start after the brace. + +- [ ] **Step 5: Write the failing end-to-end test** + +Append to `ExtractVariablePlanEndToEndTest`: + +```kotlin + @Test + fun `extracting from a one-line lambda stays inside the lambda`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { it.length + 1 } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + // `it` is lambda-scoped, so the lambda is the ceiling: there is no outer rung to choose. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval length = it.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } +``` + +- [ ] **Step 6: Run the whole module's tests and watch them pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest +``` + +Expected: PASS for the whole module. + +- [ ] **Step 7: Update the feature doc** + +In R9, after the paragraph added in Task 4, add: + +```markdown +A block written on one line -- `items.map { it.length + 1 }`, `fun f(n: Int): Int { return n * 2 }`, +a one-line `if` body -- is expanded instead: the content between the braces moves onto its own line +with the declaration above it and the closing brace below. Anchoring on the statement's line start +there would place the declaration *before* the `{`, outside the scope the value belongs to, which +leaves a lambda's `it` unresolved. The braces themselves and a lambda's `param ->` header are left +where they are. +``` + +Add an acceptance criterion after 9a: + +```markdown +9b. Extracting from `return items.map { it.length + 1 }` puts the declaration inside the lambda and expands the block over three lines; the same holds for a one-line function body. +``` + +- [ ] **Step 8: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ + docs/features/kotlin-extract-variable.md +git commit -m "ADFA-4826: Expand a block written on one line" +``` + +--- + +### Task 6: Restack ADFA-5080 onto the fixes + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:404-470` (on branch `feat/ADFA-5080-extract-method`) + +**Interfaces:** +- Consumes: `renderedTypeTextOrNull`, `isUnrenderableTypeText` from Task 3's `TypeText.kt`. +- Produces: a rebased, pushed stack; no API change. + +**Why:** the fixes are three commits below #1655 in the stack, and `MethodSignature.kt` now carries private copies of the renderer that `TypeText.kt` owns. + +- [ ] **Step 1: Verify the branch is green and push it** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest :lsp:kotlin:compileV8DebugKotlin +git log --oneline stage..HEAD | head -8 +git push --force-with-lease origin feat/ADFA-4826-extract-variable +``` + +Expected: tests pass, five new `ADFA-4826:` commits on top of `617ed6f39`, push accepted. + +- [ ] **Step 2: Rebase the stack** + +```bash +gh stack rebase +gh stack view +``` + +Expected: `feat/ADFA-5080-extract-method` replays onto the new tip with no conflicts (its commits touch different files). If a conflict does appear in `ExtractionPlan.kt`, keep both changes: `ExistingBlock`'s new fields *and* whatever 5080 added. + +- [ ] **Step 3: Point `MethodSignature` at the shared renderer** + +```bash +git checkout feat/ADFA-5080-extract-method +``` + +In `MethodSignature.kt`, delete `isUnrenderable`, `SIGNATURE_TYPE_RENDERER` and `renderTypeText` together with their KDoc (they are the block from the `/** A type that cannot be written out as source ... */` comment through the `renderTypeText` body), and replace the four helpers that used them with: + +```kotlin +private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = renderedTypeTextOrNull(symbol.returnType) + +private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = + runCatching { expression.expressionType }.getOrNull()?.let { renderedTypeTextOrNull(it) } + +private fun KaSession.renderedDeclarationType(property: KtProperty): String? = + runCatching { (property.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } + +private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = + runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } +``` + +`renderedTypeTextOrNull` already wraps its own rendering in `runCatching` and applies the unrenderable filter, so the `?.takeUnless(::isUnrenderable)` suffixes go away with it. + +Then find the remaining `renderTypeText(` call in `usedTypeOf` (around line 404): + +```bash +grep -n "renderTypeText\|isUnrenderable\|SIGNATURE_TYPE_RENDERER" \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +``` + +and replace each surviving `renderTypeText(x)` with `renderedTypeTextOrNull(x)`, keeping the `?: return UsedType.Absent` guard exactly as it is. Re-run that grep until it prints nothing. + +Finally drop the imports that are now unused - `KaTypeRendererForSource`, and `KaFlexibleType` / `renderName` if nothing else in the file references them (check with `grep -n "KaFlexibleType\|renderName" `). ktlint fails the build on an unused import, so this is not optional. + +- [ ] **Step 4: Verify and commit on the 5080 branch** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest :lsp:kotlin:compileV8DebugKotlin +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +git commit -m "ADFA-5080: Use the shared type-text helpers" +``` + +Expected: tests pass on the rebased 5080 branch. + +- [ ] **Step 5: Push the stack** + +```bash +gh stack push +gh stack view +``` + +Expected: #1654 and #1655 both updated, bases unchanged (`#1653` <- `#1654` <- `#1655`). + +--- + +### Task 7: Probe extract method for the same class of defect + +**Files:** +- Create (temporarily): `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScratchOneLineAnchorTest.kt` - deleted again in the last step, never committed. + +**Interfaces:** +- Consumes: `buildExtractMethodRewrites`, `ExtractMethodCandidate` from the 5080 branch. +- Produces: a Jira comment on ADFA-5080. No code change. + +**Why:** `MethodSignature` sets `insertOffset = anchor.textRange.startOffset` for a local-function target, which has the same line-sharing exposure Task 5 fixed for extract variable: if the anchor declaration shares its line with other code, a multi-line function is inserted mid-line. + +- [ ] **Step 1: Write the probe** + +On `feat/ADFA-5080-extract-method`, create `ScratchOneLineAnchorTest.kt`: + +```kotlin +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.junit.Test + +/** Scratch probe: what does extract method emit when the anchor shares its line with other code? */ +class ScratchOneLineAnchorTest : KtLspTest() { + @Test + fun probe() { + val cases = + listOf( + "package p\nfun outer(n: Int): Int { return n * 2 }", + "package p\nfun outer(n: Int): Int {\n\tfun inner(): Int { return n * 2 }\n\treturn inner()\n}", + "package p\nfun outer(items: List) { items.forEach { println(it.length + 1) } }", + ) + cases.forEachIndexed { index, content -> + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + val target = "n * 2".takeIf { content.contains("n * 2") } ?: "it.length + 1" + val start = content.indexOf(target) + println("### case $index") + println( + buildExtractMethodPlan( + env = env, + nioPath = path, + selectionStart = start, + selectionEnd = start + target.length, + documentVersion = 1, + cancelChecker = noopCancelChecker(), + ), + ) + } + } +} +``` + +Before running, check the real entry point and its parameter names: + +```bash +grep -n "^internal fun buildExtractMethodPlan\|^fun buildExtractMethodPlan" -A 10 \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt +``` + +Adjust the call to match exactly what that signature says. + +- [ ] **Step 2: Run the probe and read the output** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ScratchOneLineAnchorTest" -i 2>&1 \ + | grep -vE "DEBUG|Took org" | sed -n '/### case 0/,$p' | head -60 +``` + +Expected: for each case either a refusal (fine - extract method declines) or a candidate whose `insertOffset` sits mid-line. Apply the rewrites by hand in the output if needed to judge whether the emitted text would compile. + +- [ ] **Step 3: Delete the probe** + +```bash +rm lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScratchOneLineAnchorTest.kt +git status --short +``` + +Expected: no changes to tracked files. + +- [ ] **Step 4: Report the finding on ADFA-5080** + +Only if the probe showed a real defect. Write the comment body to the scratchpad first, then: + +```bash +jira issue comment add ADFA-5080 --template /tmp/claude-1000/adfa-5080-probe.md +``` + +Comment body, with the bracketed parts filled in from the probe output: + +```markdown +Probe while fixing the extract-variable defects on ADFA-4826 (PR #1654): extract method's insertion +anchor has the same line-sharing exposure. + +`MethodSignature` sets `insertOffset = anchor.textRange.startOffset` for a local-function target, and +`buildExtractMethodRewrites` emits the declaration at that offset. When the anchor declaration shares +its line with other code, the multi-line function lands mid-line. + +Case: `[the source that failed]` +Emitted: `[the text the rewrite produces]` +Compiles: no. + +Not fixed here - ADFA-4826's fix is confined to the extract-variable rewrite (`ExtractVariableEdit`), +which now expands a one-line block instead of anchoring outside it. Same shape of fix would apply. +``` + +If the probe found nothing, skip the comment and record "extract method declines / anchors soundly in all three shapes" in the execution notes instead. + +--- + +### Task 8: Hand ADFA-4826 to QA + +**Files:** +- Modify: `/tmp/claude-1000/-var-mnt-data-dev-work-adfa-cogo-code-on-the-go--claude-worktrees-ADFA-4826/93ae2f82-68a1-4eca-8a27-23fbfdc5f395/scratchpad/ADFA-4826-steps-to-qa.md` (two expectation edits) + +**Interfaces:** +- Consumes: the shipped behaviour from Tasks 1-5. +- Produces: ADFA-4826's `customfield_10250`, a Jira comment, and a status transition. + +**Why:** the QA draft was written against correct behaviour, so two of its cases describe what only Tasks 3 and 5 make true, and QA should not receive steps before the build can pass them. + +- [ ] **Step 1: Comment the findings on the ticket and move it out of QA** + +Write this to `/tmp/claude-1000/adfa-4826-findings.md`: + +```markdown +Five defects found while drafting the Steps to QA, each reproduced through the module's own test +fixture. Moving back to In Progress; all five are fixed on PR #1654 with tests. + +1. `Declare in` was ignored for block anchors. The rewrite anchored on the first *occurrence's* line, + so every rung of the scope chain produced a byte-identical edit. Now anchored on the statement of + the chosen scope, per R5. +2. A block written on one line put the declaration outside the braces. `return items.map { it.length + + 1 }` produced a `val` above the `return` with an unresolved `it`; `fun f(n: Int): Int { return n + * 2 }` put it above the signature. The block is now expanded over three lines instead. +3. An expression body with an inferred return type converted without writing the type, so + `fun area(r: Int) = r * r` became a Unit-returning function with `return squared` - uncompilable. + The type is now written into the signature, shortened only where the short name already resolves. +4. The `{ ... }` lambda expression was offered as a candidate (only the literal inside it was + excluded), which contradicts R2 and yields `val v = { it.length + 1 }`. Now excluded. +5. A braced `if` branch's rung was labelled `block` rather than `if block`, because the label lookup + saw the control-structure container node. Fixed. + +Steps to QA follows once the on-device pass over the three affected cases is done. +``` + +Then: + +```bash +jira issue comment add ADFA-4826 --template /tmp/claude-1000/adfa-4826-findings.md +jira issue move ADFA-4826 "In Progress" +jira issue view ADFA-4826 --plain | head -3 +``` + +Expected: status reads `In Progress`. If `jira issue move` rejects the target, list what is reachable with `jira issue move ADFA-4826` (no argument prints the available transitions) and pick the In Progress one - a subtask's workflow does not always allow every hop directly. + +- [ ] **Step 2: Build and install the debug APK** + +```bash +adb devices -l | grep -v offline +flox activate -d flox/local -- ./gradlew :app:assembleV8Debug --parallel --max-workers=6 +``` + +Expected: `BUILD SUCCESSFUL` and at least one arm device or arm-translation emulator listed. The app is arm-only, so an x86_64 emulator cannot run it - if only that is available, stop here and report that the device pass needs hardware. + +- [ ] **Step 3: Run the three device cases** + +Install the APK, open a Kotlin file containing the setup from the QA draft, and run: + +- **QA-12** - `fun squaredInferred(r: Int) = r * r`: select `r * r`, name it `squared`, extract. Expected `fun squaredInferred(r: Int): Int {` with `return squared` inside, and no new error underline. +- **QA-14** - `fun nested`: select `a + b * 2`, confirm `Declare in` lists `if block` then `fun nested`, extract once with each rung, and confirm the declaration lands inside the branch and above the `if` respectively. +- **QA-15** - `fun oneLineLambda`: select `it.length + 1` in `return items.map { it.length + 1 }`, extract, and confirm the block expands with the declaration inside the braces and no error underline. + +Record what each one actually produced. + +- [ ] **Step 4: Correct the two QA expectations** + +In `ADFA-4826-steps-to-qa.md`: + +- QA-12 step 3's Expected becomes: `the return type is added - fun squaredInferred(r: Int): Int { - and the file compiles.` and its "Fails if" becomes: `the signature is left as fun squaredInferred(r: Int) { with return squared inside, which does not compile.` +- QA-14's Expected for `Declare in` becomes: `Declare in lists two rungs, innermost first: if block, then fun nested.` (the label is no longer a generic `block`). + +- [ ] **Step 5: Post the Steps to QA field** + +The source is the corrected `ADFA-4826-steps-to-qa.md` from Step 4. Convert it to ADF - `heading` nodes for the section titles, `orderedList`/`bulletList` for the steps, `codeBlock` with `language: "kotlin"` for the fixture and expected-output snippets, `paragraph` elsewhere - and set the field with: + +``` +mcp__claude_ai_Atlassian_Rovo__editJiraIssue + cloudId: bb66613e-967d-4549-a8d6-d9166759f2d2 + issueIdOrKey: ADFA-4826 + fields: { "customfield_10250": { "type": "doc", "version": 1, "content": [ ... ] } } +``` + +A plain string is rejected for this field; it must be an ADF `doc` object. Load the tool schema first with `ToolSearch("select:mcp__claude_ai_Atlassian_Rovo__editJiraIssue")`. + +- [ ] **Step 6: Confirm what landed** + +```bash +jira issue view ADFA-4826 --raw | python3 -c "import json,sys; print(bool(json.load(sys.stdin)['fields']['customfield_10250']))" +``` + +Expected: `True`. Then report the device-pass results and the five commits to the user; whether the ticket moves on to Code review is theirs to call. + +--- + +## Notes for whoever executes this + +- **Do not** run `:app:assembleV8Debug` between tasks; it is multi-minute. `:lsp:kotlin:testV7DebugUnitTest` is the loop, and the assemble happens once, in Task 8. +- Tasks 4 and 5 both touch `existingBlockRewrite`. Task 4 deliberately leaves the one-line block broken (its line start is unchanged), so do not "fix" it early - Task 5's tests are what pin the expansion down. +- If a test in `ExtractVariablePlanEndToEndTest` reports a type or `needsReturn` that makes no sense, check that the test wrote `Main.kt` and not a uniquely named file: duplicate top-level declarations across files in package `p` break resolution silently. diff --git a/docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md b/docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md new file mode 100644 index 0000000000..249ec1d1e3 --- /dev/null +++ b/docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md @@ -0,0 +1,1008 @@ +# Extract-Method Review Fixes 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:** Fix the four confirmed correctness defects John Andrés Trujillo found in PR #1655 (Kotlin extract method), each with a regression test and the matching feature-doc update. + +**Architecture:** Four independent, small changes inside `lsp/kotlin`. Tasks 1-3 each rewrite one private function in `MethodSignature.kt` (the analysis layer that derives a candidate). Task 4 adds one field to `ExtractMethodCandidate`, populates it in `MethodSignature.kt`, and reshapes `reindent` in `ExtractMethodEdit.kt` (the pure-text emission layer) to honour it. Tasks are ordered smallest-blast-radius first so a compile or test failure localises to the task that caused it. + +**Tech Stack:** Kotlin, Kotlin K2 Analysis API (2.3.20) + Kotlin PSI, JUnit 4 + Robolectric, Gradle with `v7`/`v8` ABI flavors, Spotless/ktlint. + +**Spec:** +- John's review on PR #1655: (review id `4928006446`, inline comments `3776099257`, `3776099272`, `3776099279`, `3776099288`) +- `docs/features/kotlin-extract-method.md` - the feature spec these fixes must keep true (R4, R8, R10, R15) +- `docs/adr/0013-refactorings-decline-rather-than-rewrite.md` - "an interactive refactoring moves the user's code; it does not edit the interior of what it moved" + +## Global Constraints + +- **Indentation: tabs. Line endings: LF.** Enforced by Spotless; ktlint formats Kotlin. The `ratchetFrom = origin/stage` ratchet is file-level, so any touched file is reformatted in full. +- **ASCII only** in code and code comments. No em dashes, no curly quotes, no arrow glyphs. +- **Comments:** no `//` line comments outside function bodies - use `/* ... */` or KDoc. No separator or banner comments. Comment the non-obvious *why* only. No references to this plan, task numbers, or phases in any comment or commit message. +- **Commit subject only**, format `ADFA-5080: `. No `Co-Authored-By` trailer, no AI-attribution trailers. Never `git add .` - stage the exact paths listed in each task. +- **Never commit this plan file** or any planning/status document. `docs/features/kotlin-extract-method.md` is a checked-in artifact and *is* committed. +- **Branch:** `feat/ADFA-5080-extract-method`, already checked out in the worktree at `/var/mnt/data/dev/work/adfa/cogo/code-on-the-go/.claude/worktrees/ADFA-4826`. Do not branch, rebase or push unless asked. +- **Test task:** `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`. There is no flavorless `testDebugUnitTest`. A `local.properties` with `sdk.dir` may be required (git-ignored, safe to create, never commit). Run test tasks in the foreground; they take minutes. +- **No new dependencies.** `@Composable` is exercised by declaring `package androidx.compose.runtime; annotation class Composable` in a test source file, as the existing test already does. + +--- + +## File Structure + +**Modified - production:** + +- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt` (882 lines) - derives one `ExtractMethodCandidate` from a region inside an analysis session. Tasks 1, 2, 3 and part of 4 each change exactly one private function here. +- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt` (95 lines) - turns a candidate into the two `RewriteSpan`s. Task 4 only. +- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt` (187 lines) - the PSI-free data carried from the analysis layer to the sheet and the edit builder. Task 4 adds one field to `ExtractMethodCandidate`. + +**Modified - tests:** + +- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` - analysis-backed, real PSI and resolution. Tasks 1, 2, 3, 4. +- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt` - pure text, candidates built by hand. Task 4. + +**Modified - docs:** + +- `docs/features/kotlin-extract-method.md` - R4 target table (Task 1), R10 `@Composable` bullet + acceptance criterion 15 (Task 2), R8 declined list (Task 3), R15 emission paragraph (Task 4), Verification section (Task 6). + +No new files. No module, DI, Compose or string-resource changes, so no architecture surface is touched. + +--- + +### Task 1: An anonymous function is never an insertion anchor + +**Why:** `enclosingDeclaration` matches `is KtNamedFunction`, and Kotlin PSI represents an anonymous function expression (`fun(v: Int) { }` used as a value) as a `KtNamedFunction` whose `name` is null (see `KtNamedFunction.isAnonymous = name == null && isLocal`). `enclosingExecutableBody` already accepts it, so nothing upstream declines. The anonymous function then becomes both `enclosing` and `anchor`; its parent is a `KtValueArgument` or a `KtProperty`, so `isLocalTarget` is false, `private` is added, and `insertOffset` lands at the anonymous function's own end - inside an argument list or a property initializer. The emitted file does not parse. `ExtractMethodEdit.kt:32` only rejects an `insertOffset` strictly *inside* the region, so it does not catch this. + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:223-243` (`enclosingDeclaration` and its KDoc) +- Modify: `docs/features/kotlin-extract-method.md:70-78` (R4 target table) +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: no signature change. `enclosingDeclaration(element: PsiElement): KtDeclaration?` keeps its name, parameters and return type; only which node it returns changes. + +- [ ] **Step 1: Write the two failing tests** + +Append to `ExtractMethodPlanEndToEndTest.kt`, inside the class: + +```kotlin + @Test + fun `a region inside an anonymous function argument anchors on the enclosing member`() { + val content = + """ + package p + class C { + fun demo() { + register(fun(v: Int) { + work(v) + }) + } + fun register(h: (Int) -> Unit) {} + fun work(n: Int) {} + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("work(v)") + 1).candidates.first { it.label == "work(v)" } + + // The anonymous function is a value, not a declaration a sibling can follow: an insertion at its + // own end lands before the closing `)` of `register(...)` and the file stops parsing. + val callEnd = content.indexOf("})") + "})".length + assertTrue( + "insertOffset ${candidate.insertOffset} must be past the enclosing call at $callEnd", + candidate.insertOffset >= callEnd, + ) + assertEquals("\t", candidate.insertIndent) + assertEquals(listOf("private"), candidate.modifiers) + assertEquals(listOf("v" to "kotlin.Int"), candidate.parameters.map { it.name to it.typeText }) + } + + @Test + fun `a region inside an anonymous function initializer anchors on the enclosing function`() { + val content = + """ + package p + fun demo() { + val f = fun(): Int { + return compute() + } + f() + } + fun compute(): Int = 1 + """.trimIndent() + + val candidate = plan(content, content.indexOf("compute()") + 1).candidates.first { it.label == "compute()" } + + assertEquals("", candidate.insertIndent) + assertTrue( + "insertOffset ${candidate.insertOffset} must be past the property initializer", + candidate.insertOffset >= content.indexOf("\tf()"), + ) + assertEquals(listOf("private"), candidate.modifiers) + } +``` + +`assertTrue`, `assertEquals` and `Test` are already imported in this file. + +- [ ] **Step 2: Run the tests to verify they fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: both new tests FAIL. The first on `insertOffset ... must be past the enclosing call` (the offset sits one character before the closing `)`); the second on the `insertIndent` assertion (`expected:<> but was:<\t>`). + +- [ ] **Step 3: Skip a nameless `KtNamedFunction` and keep walking** + +Replace `enclosingDeclaration` and its KDoc in `MethodSignature.kt` with: + +```kotlin +/** + * The named function, accessor, `init` block or constructor whose body holds [element]. Lambdas and + * anonymous functions are skipped: the new function is a sibling of the enclosing *named* declaration + * (R4), and their captures become parameters. + */ +private fun enclosingDeclaration(element: PsiElement): KtDeclaration? { + var current: PsiElement? = element.parent + while (current != null) { + when (current) { + is KtNamedFunction -> { + // PSI gives an anonymous `fun(...) { }` the same node type as a named function, with a null + // name. It is a value, not a declaration a sibling can follow: anchoring on it inserts the + // new function into an argument list or a property initializer, and the file stops parsing. + if (current.name != null) return current + } + + is KtPropertyAccessor, is KtAnonymousInitializer, is KtSecondaryConstructor -> { + return current + } + + is KtClassOrObject -> { + return null + } + } + current = current.parent + } + return null +} +``` + +`current.name` smart-casts because `is KtNamedFunction` is now its own branch. Nothing else needs an import. + +- [ ] **Step 4: Run the tests to verify they pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: PASS, the whole class. If a pre-existing test now fails, stop and report it rather than editing the assertion. + +- [ ] **Step 5: Add the R4 target-table row** + +In `docs/features/kotlin-extract-method.md`, add this row to the R4 table immediately after the existing lambda row (`| a lambda inside either of the above | ... |`): + +```markdown +| an anonymous `fun(...) { }` used as a value | still a sibling of the enclosing *named* declaration, exactly as for a lambda; PSI gives it the same node type as a named function, but it is a value and nothing can be inserted after it | +``` + +- [ ] **Step 6: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ + docs/features/kotlin-extract-method.md +git commit -m "ADFA-5080: Stop anchoring an extraction on an anonymous function" +``` + +--- + +### Task 2: `@Composable` property getters count as Composable use + +**Why:** `usesComposable` walks only `KtCallExpression` descendants and resolves them with `successfulFunctionCallOrNull`. `MaterialTheme.colorScheme`, `MaterialTheme.typography` and `LocalDensity.current` are `@Composable @ReadOnlyComposable` *property getters* reached through a `KtDotQualifiedExpression`, not calls. Extracting such a region emits a function with no `@Composable`, which is exactly the compile failure R10 exists to prevent, on an everyday Compose shape. + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:825-839` (`usesComposable` and its KDoc), plus two imports +- Modify: `docs/features/kotlin-extract-method.md:122` (R10 `@Composable` bullet) and `:210` (acceptance criterion 15) +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: nothing from Task 1. +- Produces: `private fun KaAnnotatedSymbol.hasComposableAnnotation(): Boolean` - file-private, used only by `usesComposable`. `usesComposable(elements: List): Boolean` keeps its signature. + +- [ ] **Step 1: Write the failing test and its negative guard** + +Append to `ExtractMethodPlanEndToEndTest.kt`, inside the class: + +```kotlin + @Test + fun `reading a Composable property getter adds the Composable annotation`() { + createSourceFile( + "Composable.kt", + """ + package androidx.compose.runtime + annotation class Composable + """.trimIndent(), + ) + val content = + """ + package p + import androidx.compose.runtime.Composable + object Palette { + val accent: Int + @Composable get() = 1 + } + fun use(n: Int) {} + @Composable fun Demo() { + use(Palette.accent) + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("Palette.accent") + 1).candidates.first { it.label == "Palette.accent" } + + assertEquals(listOf("@Composable"), candidate.annotations) + } + + @Test + fun `reading a plain property getter adds no annotation`() { + createSourceFile( + "Composable.kt", + """ + package androidx.compose.runtime + annotation class Composable + """.trimIndent(), + ) + val content = + """ + package p + import androidx.compose.runtime.Composable + object Palette { + val accent: Int + get() = 1 + } + fun use(n: Int) {} + @Composable fun Demo() { + use(Palette.accent) + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("Palette.accent") + 1).candidates.first { it.label == "Palette.accent" } + + assertEquals(emptyList(), candidate.annotations) + } +``` + +The second test is not redundant: it rules out the naive fix of treating any `@Composable` anywhere in the file, or on the enclosing function, as a reason to annotate. + +- [ ] **Step 2: Run the tests to verify the first fails and the second passes** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: `reading a Composable property getter adds the Composable annotation` FAILS with `expected:<[@Composable]> but was:<[]>`. `reading a plain property getter adds no annotation` PASSES already. + +- [ ] **Step 3: Resolve simple names to property symbols too** + +Add these two imports to `MethodSignature.kt`, each in its existing alphabetical run: + +```kotlin +import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaAnnotatedSymbol +``` + +Replace `usesComposable` and its KDoc with: + +```kotlin +/** + * `@Composable` is added when the region uses one. Not polish: CoGo users write Compose apps on the + * device, and an extracted composable without the annotation does not compile (R10). + * + * Property *getters* count, not only calls. `MaterialTheme.colorScheme` and `LocalDensity.current` are + * annotated getters reached through a name reference, and they are as common in Compose code as any + * composable call. + */ +private fun KaSession.usesComposable(elements: List): Boolean = + descendantsOf(elements, KtCallExpression::class.java).any { call -> + runCatching { + call + .resolveToCall() + ?.successfulFunctionCallOrNull() + ?.symbol + ?.hasComposableAnnotation() + }.getOrNull() == true + } || + simpleNamesIn(elements).any { reference -> + runCatching { + val property = reference.mainReference?.resolveToSymbols()?.firstOrNull() as? KaPropertySymbol + property?.hasComposableAnnotation() == true || property?.getter?.hasComposableAnnotation() == true + }.getOrNull() == true + } + +/** Whether [this] carries `@Composable`. */ +private fun KaAnnotatedSymbol.hasComposableAnnotation(): Boolean = + annotations.any { it.classId?.asFqNameString() == COMPOSABLE_FQ_NAME } +``` + +The property symbol itself is checked as well as its getter because a use-site-free `@Composable` on a `val` targets whichever of the two the annotation declares. + +- [ ] **Step 4: Run the tests to verify both pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: PASS, the whole class - including the pre-existing `a Composable call adds the Composable annotation`, which must keep passing through the call branch. + +- [ ] **Step 5: Update R10 and acceptance criterion 15** + +In `docs/features/kotlin-extract-method.md`, replace the `@Composable` bullet under R10: + +```markdown +- **`@Composable`** - added when the region uses one: any call resolving to a `@Composable`-annotated function, **or any name reference resolving to a property whose getter is annotated**. The second half is not an edge case - `MaterialTheme.colorScheme` and `LocalDensity.current` are annotated getters, not calls. This is not polish: CoGo users write Compose apps on the device, and an extracted composable without the annotation does not compile. +``` + +And replace acceptance criterion 15: + +```markdown +15. A region calling a `@Composable` function, or reading a `@Composable` property such as `MaterialTheme.colorScheme`, produces a `@Composable` function that compiles. +``` + +- [ ] **Step 6: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ + docs/features/kotlin-extract-method.md +git commit -m "ADFA-5080: Detect Composable property getters when annotating" +``` + +--- + +### Task 3: A return belonging to a declaration inside the region is not an exit + +**Why:** `hasExit` and `isTailReturn` both use `descendantsOf(elements, KtReturnExpression::class.java)`, which collects every `return` in the subtree - including ones inside a local `fun`, an anonymous `fun`, or an anonymous-object override *declared within the region*. Those returns have no label, so `hasExit` returns true and the region is refused as `ExitsRegion` ("The selection jumps out of itself with return, break or continue") even though the jump never leaves the region. `isTailReturn` is skewed the same way through `returns.size != 1`. The `object : Listener { override fun onX() { ... return ... } }` shape makes this common in Android code, and the message describes something the user did not write. + +The nested-owner search must skip `KtFunctionLiteral`: a lambda is transparent to an unlabelled `return`, which targets the enclosing function declaration, so a non-local return out of a lambda inside the region is still a genuine exit and must stay refused. An anonymous `fun` is *not* transparent, and is not a literal, so the same walk handles it correctly. `hasLoopExit` already checks `inRegion(loop, span)` and needs no change. + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:511-538` (`isTailReturn`, `hasExit`, plus one new private helper), plus one import +- Modify: `docs/features/kotlin-extract-method.md:110` (R8 declined list) +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` + +**Interfaces:** +- Consumes: nothing from Tasks 1-2. +- Produces: `private fun returnTargetInRegion(returnExpression: KtReturnExpression, span: TextSpan): Boolean` - file-private, used by both `isTailReturn` and `hasExit`. Neither of those changes signature. + +- [ ] **Step 1: Write the four failing/guard tests** + +Append to `ExtractMethodPlanEndToEndTest.kt`, inside the class: + +```kotlin + @Test + fun `a return inside a local function declared in the region is not an exit`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun helper(): Int { + return a * 2 + } + val x = helper() + return x + } + """.trimIndent() + val (start, end) = selection(content, "fun helper", "val x = helper()") + + val result = plan(content, start, end) + + assertNull(result.refusal) + val candidate = result.candidates.single() + assertEquals(CallSiteForm.AssignOutput("x"), candidate.callSite) + assertEquals(listOf("a"), candidate.parameters.map { it.name }) + } + + @Test + fun `a return inside an anonymous object override in the region is not an exit`() { + val content = + """ + package p + interface Runner { fun run() } + fun work() {} + fun use(r: Runner) {} + fun demo(flag: Boolean) { + val r = object : Runner { + override fun run() { + if (flag) return + work() + } + } + use(r) + } + """.trimIndent() + val (start, end) = selection(content, "val r = object", "use(r)") + + val result = plan(content, start, end) + + assertNull(result.refusal) + val candidate = result.candidates.single() + assertEquals(listOf("flag"), candidate.parameters.map { it.name }) + assertEquals(CallSiteForm.Call, candidate.callSite) + } + + @Test + fun `a tail return is recognised when a nested function in the region also returns`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun helper(): Int { + return a * 2 + } + return helper() + } + """.trimIndent() + val (start, end) = selection(content, "fun helper", "return helper()") + + val candidate = plan(content, start, end).candidates.single() + + assertEquals(CallSiteForm.Return, candidate.callSite) + assertEquals("kotlin.Int", candidate.returnTypeText) + } + + @Test + fun `a non-local return from a lambda in the region is still an exit`() { + // A lambda is transparent to an unlabelled `return`, so this one really does leave the region. + val content = + """ + package p + fun demo(items: List): Int { + items.forEach { item -> + if (item > 0) return item + } + return 0 + } + """.trimIndent() + val (start, end) = selection(content, "items.forEach", "\t}") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } +``` + +`assertNull` and the `selection` helper are already available in this file. + +- [ ] **Step 2: Run the tests to verify the first three fail and the fourth passes** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: the first two FAIL on `assertNull(result.refusal)` (the refusal is `ExitsRegion`), the third FAILS with `NoSuchElementException` / empty candidate list, and `a non-local return from a lambda in the region is still an exit` PASSES already. + +- [ ] **Step 3: Filter out returns owned by a declaration inside the region** + +Add this import to `MethodSignature.kt`, in its existing alphabetical run: + +```kotlin +import org.jetbrains.kotlin.psi.KtDeclarationWithBody +``` + +Insert this helper immediately above `isTailReturn`: + +```kotlin +/** + * Whether [returnExpression] returns from a function declared *inside* the region, so its jump never + * crosses the region boundary and it is not an exit (R8). + * + * A `KtFunctionLiteral` is skipped rather than accepted: a lambda is transparent to an unlabelled + * `return`, which targets the enclosing function declaration, so a non-local return out of a lambda in + * the region really does leave it. An anonymous `fun` is not transparent and is not a literal, so the + * same walk stops on it correctly. + */ +private fun returnTargetInRegion( + returnExpression: KtReturnExpression, + span: TextSpan, +): Boolean { + var owner = PsiTreeUtil.getParentOfType(returnExpression, KtDeclarationWithBody::class.java, true) + while (owner is KtFunctionLiteral) { + owner = PsiTreeUtil.getParentOfType(owner, KtDeclarationWithBody::class.java, true) + } + return owner != null && inRegion(owner, span) +} +``` + +Replace the body of `isTailReturn` so the nested returns are filtered before it counts: + +```kotlin +private fun isTailReturn( + elements: List, + span: TextSpan, +): Boolean { + if (elements.last() !is KtReturnExpression) return false + val returns = + descendantsOf(elements, KtReturnExpression::class.java) + .filterNot { returnTargetInRegion(it, span) } + if (returns.size != 1 || returns.single() !== elements.last()) return false + return !hasLoopExit(elements, span) +} +``` + +And add the same skip as the first statement of `hasExit`'s loop: + +```kotlin + for (returnExpression in descendantsOf(elements, KtReturnExpression::class.java)) { + if (returnTargetInRegion(returnExpression, span)) continue + // An unlabelled `return` always targets the enclosing named declaration, which is outside the + // region by construction. A labelled one targets the lambda carrying that label, which is not + // necessarily the nearest one -- `return@outer` from a nested lambda still leaves the region. + val label = returnExpression.getLabelName() ?: return true + val target = labelledLambdaFor(returnExpression, label) ?: return true + if (!inRegion(target, span)) return true + } +``` + +- [ ] **Step 4: Run the tests to verify all four pass** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: PASS, the whole class. The pre-existing mid-region-`return` and `break`-out-of-region refusal tests must still pass; if one does not, the filter is too broad - check whether its `return` sits in a lambda rather than a nested declaration. + +- [ ] **Step 5: Update the R8 declined list** + +In `docs/features/kotlin-extract-method.md`, replace the "Declined:" paragraph under R8 with: + +```markdown +Declined: a `return` anywhere but the tail position, a `break`/`continue` whose target loop is outside the region, a labelled `return@` whose target is outside it, and a non-local return from an inlined lambda. Each would silently change meaning, since a `return` in the extracted body returns from *it*. + +Not an exit: a `return` belonging to a function **declared inside** the region - a local `fun`, an anonymous `fun`, or an anonymous-object override. It moves with its own declaration and its jump never crosses the region boundary, so counting it would refuse a perfectly good extraction with a message describing something the user did not write. +``` + +- [ ] **Step 6: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ + docs/features/kotlin-extract-method.md +git commit -m "ADFA-5080: Stop counting a nested declaration's return as a region exit" +``` + +--- + +### Task 4: Re-indentation leaves multi-line string literals verbatim + +**Why:** `reindent` strips `baseIndent` from every line of the region and `buildExtractMethodRewrites` then prefixes `bodyIndent` to every line, with no awareness of string literals. Two ways that changes a raw string's value: + +1. `bodyIndent != baseIndent` - the normal case for a region nested inside an `if` or a lambda - shifts every continuation line by the difference. +2. `bodyIndent == baseIndent` still breaks a literal whose lines are indented *less* than the base: `removePrefix(baseIndent)` is a no-op on `line one`, but `bodyIndent` is prefixed anyway, so the literal gains an indent level on the ordinary member-function path. + +The result compiles but the runtime string differs, contradicting ADR 0013's "it does not edit the interior of what it moved". A literal followed by `.trimIndent()` is unaffected by case 1; nothing is safe from case 2. + +The fix carries the literal spans from the analysis layer (which has PSI) to the edit layer (which does not), and folds the `bodyIndent` prefixing into `reindent` so a protected line can be emitted untouched. The closing delimiter line is protected too - its position sets `trimIndent`'s margin, so moving it changes the value just as much. + +**Files:** +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt:53-67` (add `rawStringSpans` to `ExtractMethodCandidate`) +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:195-217` (populate it) and add one private helper, plus one import +- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt:40-57` and `:83-95` (`reindent` becomes `indentedBodyLines`) +- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt`, `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` +- Modify: `docs/features/kotlin-extract-method.md:172` (R15 emission paragraph) + +**Interfaces:** +- Consumes: nothing from Tasks 1-3. +- Produces: + - `ExtractMethodCandidate.rawStringSpans: List`, defaulted to `emptyList()` so the four existing construction sites keep compiling unchanged. + - `private fun indentedBodyLines(regionText: String, regionStart: Int, baseIndent: String, bodyIndent: String, newline: String, protectedSpans: List): List` in `ExtractMethodEdit.kt`, replacing `reindent`. It returns **fully indented** lines, so the declaration builder no longer prefixes anything. + - `private fun multiLineStringSpans(elements: List): List` in `MethodSignature.kt`. + +- [ ] **Step 1: Write the failing text-level tests** + +Append to `ExtractMethodEditTest.kt`, inside the class: + +```kotlin + @Test + fun `a raw string keeps its interior lines when the body indent differs from the base`() { + val quotes = "\"\"\"" + val nested = + "package p\n" + + "class C {\n" + + "\tfun demo() {\n" + + "\t\tif (true) {\n" + + "\t\t\tsend($quotes\n" + + "line one\n" + + "\t\t\t\tline two\n" + + "$quotes)\n" + + "\t\t}\n" + + "\t}\n" + + "}\n" + val span = TextSpan(nested.indexOf("send("), nested.indexOf("$quotes)") + "$quotes)".length) + val rewrites = + buildExtractMethodRewrites( + nested, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Call, + ).copy( + insertOffset = nested.indexOf("\t}\n}") + 2, + insertIndent = "\t", + rawStringSpans = listOf(TextSpan(nested.indexOf(quotes), nested.indexOf("$quotes)") + quotes.length)), + ), + "emit", + ) + + val text = apply(nested, rewrites!!) + + assertTrue("the first line takes the body indent", text.contains("\n\t\tsend($quotes\n")) + assertTrue("an unindented literal line stays unindented", text.contains("\nline one\n")) + assertTrue("an indented literal line keeps its own indent", text.contains("\n\t\t\t\tline two\n")) + assertTrue("the closing delimiter line is untouched", text.contains("\n$quotes)\n")) + } + + @Test + fun `a raw string is left alone when the body and base indents match`() { + // The base indent is not a prefix of an unindented literal line, so stripping it is a no-op while + // the body indent is still prefixed. Equal indents are not a safe case. + val quotes = "\"\"\"" + val flat = + "package p\n" + + "class C {\n" + + "\tfun demo() {\n" + + "\t\tsend($quotes\n" + + "line one\n" + + "$quotes)\n" + + "\t}\n" + + "}\n" + val span = TextSpan(flat.indexOf("send("), flat.indexOf("$quotes)") + "$quotes)".length) + val rewrites = + buildExtractMethodRewrites( + flat, + candidate( + span, + ExtractedBody.StatementBody(trailingReturn = null), + CallSiteForm.Call, + ).copy( + insertOffset = flat.indexOf("\t}\n}") + 2, + insertIndent = "\t", + rawStringSpans = listOf(TextSpan(flat.indexOf(quotes), flat.indexOf("$quotes)") + quotes.length)), + ), + "emit", + ) + + val text = apply(flat, rewrites!!) + + assertTrue("an unindented literal line stays unindented", text.contains("\nline one\n")) + assertTrue("the closing delimiter line is untouched", text.contains("\n$quotes)\n")) + } +``` + +Then append to `ExtractMethodPlanEndToEndTest.kt`, inside the class, so the spans are proven to be *populated*, not just honoured: + +```kotlin + @Test + fun `a multi-line string in the region is recorded and emitted verbatim`() { + val quotes = "\"\"\"" + val content = + "package p\n" + + "fun send(s: String) {}\n" + + "fun demo() {\n" + + "\tif (true) {\n" + + "\t\tsend($quotes\n" + + "line one\n" + + "$quotes)\n" + + "\t}\n" + + "}\n" + val (start, end) = selection(content, "send($quotes", "$quotes)") + + val candidate = plan(content, start, end).candidates.single() + + assertEquals(1, candidate.rawStringSpans.size) + assertEquals(content.indexOf(quotes), candidate.rawStringSpans.single().start) + assertEquals(content.indexOf("$quotes)") + quotes.length, candidate.rawStringSpans.single().end) + + val text = apply(content, buildExtractMethodRewrites(content, candidate, "emit")!!) + assertTrue("the literal must not gain an indent level", text.contains("\nline one\n")) + } +``` + +- [ ] **Step 2: Run both test classes to verify the new tests fail** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest" \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: the two `ExtractMethodEditTest` cases fail to **compile** first (`rawStringSpans` is not a parameter of `ExtractMethodCandidate` yet). That counts as red. Add the field in Step 3, then re-run this command before Step 4 and expect assertion failures instead: `line one` comes out as `\tline one` / `\t\tline one`. + +- [ ] **Step 3: Add the field to the candidate** + +In `ExtractMethodPlan.kt`, add the last parameter of `ExtractMethodCandidate` and extend its KDoc with one paragraph: + +```kotlin + * [rawStringSpans] are the multi-line string literals inside the region, in file offsets. Their + * interior is whitespace-sensitive, so re-indentation must leave those lines byte-for-byte (ADR 0013). + */ +data class ExtractMethodCandidate( + val label: String, + val span: TextSpan, + val suggestedName: String, + val takenNames: Set, + val annotations: List, + val modifiers: List, + val receiverTypeText: String?, + val parameters: List, + val returnTypeText: String?, + val body: ExtractedBody, + val callSite: CallSiteForm, + val insertOffset: Int, + val insertIndent: String, + val rawStringSpans: List = emptyList(), +) +``` + +- [ ] **Step 4: Populate it in the analysis layer** + +Add this import to `MethodSignature.kt`, in its existing alphabetical run: + +```kotlin +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +``` + +Add `rawStringSpans = multiLineStringSpans(elements),` to the `ExtractMethodCandidate(...)` construction in `buildCandidate`, immediately after `insertIndent = ...`. + +Add this helper next to the other `descendantsOf` users, above `localTypeNameIn`: + +```kotlin +/** + * The multi-line string literals inside [elements], in file offsets. A single-line literal needs no + * protection: `\n` inside it is an escape, not a line break the re-indentation can reach. + */ +private fun multiLineStringSpans(elements: List): List = + descendantsOf(elements, KtStringTemplateExpression::class.java) + .filter { it.text.contains('\n') } + .map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) } +``` + +- [ ] **Step 5: Make the emission honour the spans** + +In `ExtractMethodEdit.kt`, replace the `bodyLines` block and the declaration builder (lines 40-57) with: + +```kotlin + val bodyLines = + when (val body = candidate.body) { + is ExtractedBody.ExpressionBody -> { + val lines = + indentedBodyLines(regionText, span.start, baseIndent, bodyIndent, newline, candidate.rawStringSpans) + // The first line is never inside a literal's interior -- the region starts at the code + // itself -- so it always carries bodyIndent and `return ` goes straight after it. + if (body.needsReturn) { + listOf(bodyIndent + "return " + lines.first().substring(bodyIndent.length)) + lines.drop(1) + } else { + lines + } + } + + is ExtractedBody.StatementBody -> { + indentedBodyLines(regionText, span.start, baseIndent, bodyIndent, newline, candidate.rawStringSpans) + + listOfNotNull(body.trailingReturn?.let { bodyIndent + it }) + } + } + + val declaration = + buildString { + append(indent).append(candidate.signatureText(name)).append(" {").append(newline) + bodyLines.forEach { append(it).append(newline) } + append(indent).append('}') + } +``` + +And replace `reindent` and its KDoc (lines 83-95) with: + +```kotlin +/** + * The region's lines at the new function's body indentation: the original base indentation removed and + * [bodyIndent] put in its place. Lines nested deeper than the base keep the extra depth; the first line + * only gains the indent, since the span starts at the code itself. + * + * A line inside one of [protectedSpans] is emitted byte-for-byte. Those are multi-line string literals, + * whose interior whitespace is part of their value, and whose closing delimiter sets `trimIndent`'s + * margin -- moving either edits the interior of the moved code (ADR 0013). + */ +private fun indentedBodyLines( + regionText: String, + regionStart: Int, + baseIndent: String, + bodyIndent: String, + newline: String, + protectedSpans: List, +): List { + var offset = regionStart + return regionText.split(newline).mapIndexed { index, line -> + val lineStart = offset + offset += line.length + newline.length + when { + index == 0 -> bodyIndent + line + protectedSpans.any { lineStart > it.start && lineStart < it.end } -> line + else -> bodyIndent + line.removePrefix(baseIndent) + } + } +} +``` + +- [ ] **Step 6: Run both test classes to verify everything passes** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest" \ + --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" +``` + +Expected: PASS, both classes. Every pre-existing `ExtractMethodEditTest` case - indentation, blank-line separation, CRLF preservation, the three call-site forms - must still pass unchanged; they all leave `rawStringSpans` at its default, and the refactored `indentedBodyLines` must produce byte-identical output for them. + +- [ ] **Step 7: Update the R15 emission paragraph** + +In `docs/features/kotlin-extract-method.md`, replace the paragraph beginning "The new function is emitted **fully indented**" with: + +```markdown +The new function is emitted **fully indented** at the enclosing declaration's own indentation, separated by one blank line, reusing `detectIndentUnit`, `detectNewline`, `leadingIndentAt` and `positionAt`. Code-action edits bypass the editor's auto-indent and `CMD_FORMAT_CODE` is a no-op for Kotlin. + +One exception to re-indenting every line: the interior and closing delimiter of a **multi-line string literal** are emitted byte-for-byte. Their whitespace is part of the literal's value, and the closing delimiter's column sets `trimIndent`'s margin, so shifting either would edit the interior of the moved code (ADR 0013). The candidate carries those literals' spans so the text layer can skip them without needing PSI. +``` + +- [ ] **Step 8: Commit** + +```bash +git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ + lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt \ + lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ + docs/features/kotlin-extract-method.md +git commit -m "ADFA-5080: Keep multi-line string literals verbatim when re-indenting" +``` + +--- + +### Task 5: File the type-text shortening follow-up + +**Why:** John's fifth point - extract-method signatures print `kotlin.Int` while `ExtractVariablePlanner.kt:162` runs the same rendering through `shortenTypeText` - is real but is a *documented* decision, not an oversight: R5 and R11 of `docs/features/kotlin-extract-method.md` both specify fully-qualified rendering, and `ExtractMethodPlanEndToEndTest` asserts it in several places. Changing it means a doc change, a code change and a test sweep, which does not belong in a review-fix commit. It gets a ticket instead. + +**Files:** none. No repository change; this task creates a Jira issue and leaves the working tree clean. + +**Interfaces:** +- Consumes: nothing. +- Produces: a ticket id, quoted in Task 6's PR reply. + +- [ ] **Step 1: Confirm no equivalent ticket exists** + +```bash +jira issue list -q 'project = ADFA AND text ~ "shorten type text" AND statusCategory != Done' --plain +``` + +If a matching ticket already exists, note its id and skip Step 2. + +- [ ] **Step 2: Create the ticket** + +Write the body to a tempfile first - the description contains backticks, which break inline heredocs: + +```bash +cat > /tmp/adfa-shorten-body.md <<'BODY' +Extract method renders every signature type fully qualified (`private fun total(a: kotlin.Int): kotlin.Int`). +Extract variable renders the same types through `shortenTypeText`, which drops the qualifier whenever the +file already resolves the simple name. Two refactorings in the same family read differently for no reason +the user can see. + +Raised by John Andrés Trujillo in review on PR #1655 as a follow-up, not a correctness issue: fully +qualified text always compiles. + +Scope: +- Run the derived parameter, return and receiver type text through `shortenTypeText` in the extract-method + path, using `importedNamesOf` / `starImportedPackagesOf` on the enclosing `KtFile`. +- Update R5 and R11 of `docs/features/kotlin-extract-method.md`, which currently specify fully-qualified + rendering as deliberate. +- Update the `ExtractMethodPlanEndToEndTest` assertions that expect `kotlin.Int`, and the signature-preview + assertions in `ExtractMethodViewModelTest`. +BODY + +jira issue create --type Task --project ADFA \ + --summary "Shorten extract-method signature types to match extract variable" \ + --template /tmp/adfa-shorten-body.md \ + --no-input +``` + +- [ ] **Step 3: Link it from the feature doc's Related list** + +In `docs/features/kotlin-extract-method.md`, add one bullet to the `## Related` list, after the ADFA-5082 line, substituting the real ticket id: + +```markdown +- ADFA- - shorten signature type text to match extract variable (revisits R5's fully-qualified rendering) +``` + +- [ ] **Step 4: Commit** + +```bash +git add docs/features/kotlin-extract-method.md +git commit -m "ADFA-5080: Link the type-text shortening follow-up" +``` + +--- + +### Task 6: Verify the whole module, format, and answer the review + +**Why:** Each earlier task ran only the classes it touched. This task proves the four fixes hold together, that nothing else in `:lsp:kotlin` regressed, and that the branch is formatted for push. Then it closes the loop with the reviewer and the ticket, which the project asks for explicitly. + +**Files:** +- Modify: `docs/features/kotlin-extract-method.md` (Verification section - the new coverage) +- No source changes expected; Spotless may reformat touched files. + +**Interfaces:** +- Consumes: the four commits from Tasks 1-4 and the ticket id from Task 5. +- Produces: nothing consumed by a later task. + +- [ ] **Step 1: Run the full module test suite** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest +``` + +Expected: PASS. Report the actual failure output if anything fails; do not weaken an assertion to make it green. + +- [ ] **Step 2: Compile the app flavor the fixes ship in** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:compileV8DebugKotlin +``` + +Expected: `BUILD SUCCESSFUL`. This catches anything that compiles under `v7` but not `v8`. + +- [ ] **Step 3: Update the Verification section's coverage list** + +In `docs/features/kotlin-extract-method.md`, replace the `ExtractMethodPlanEndToEndTest` and `ExtractMethodEditTest` bullets under `## Verification` with: + +```markdown +- **`ExtractMethodPlanEndToEndTest`** - analysis-backed, one case per rule: the parameter set, order and types (R5), the single output and the `Unit` case (R6, R7), the tail return and the nested-declaration `return` that is not an exit (R8), the extension receiver (R9), `suspend`, a `@Composable` call and a `@Composable` property getter (R10), the anonymous-function anchor (R4), the recorded multi-line-string spans (R15), and **one case per refusal reason** (R14). +- **`ExtractMethodEditTest`** - pure text: the two edits and their descending order, the three call-site forms, indentation, multi-line string literals left verbatim, the blank-line separation, and CRLF preservation (R15). +``` + +- [ ] **Step 4: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git status --short +``` + +Review what Spotless changed. The file-level ratchet reformats any file differing from `origin/stage` in full, so an unrelated whole-file reindent may appear - if it does, keep it in its own commit: + +```bash +git add docs/features/kotlin-extract-method.md +git commit -m "ADFA-5080: Record the new extract-method test coverage" +``` + +- [ ] **Step 5: Reply in each review thread** + +Reply in the thread, not as a top-level PR comment. One reply per comment id, each naming the commit and what changed: + +```bash +gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099257/replies -f body="Fixed. \`enclosingDeclaration\` now skips a nameless \`KtNamedFunction\` and keeps walking, so the anchor is the enclosing named declaration and the anonymous function's parameters become captures. Two end-to-end tests cover the argument and property-initializer shapes, and R4's target table has a row for it." +gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099272/replies -f body="Fixed. \`usesComposable\` now also resolves simple names to \`KaPropertySymbol\` and checks the property and its getter for the annotation. Tests cover an annotated getter and a plain one, so the negative case is pinned too." +gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099279/replies -f body="Fixed. Both \`hasExit\` and \`isTailReturn\` now skip a \`return\` whose nearest enclosing \`KtDeclarationWithBody\` is inside the region. The walk skips \`KtFunctionLiteral\` so a non-local return out of a lambda is still refused, and there is a test holding that line. Worth noting the same fault hit \`object : Runner { override fun run() { ... return ... } }\`, which is the more common shape - that case is tested." +gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099288/replies -f body="Fixed. The candidate carries the multi-line string spans and the text layer emits those lines byte-for-byte, closing delimiter included. One correction to the scope: it also fired when \`bodyIndent == baseIndent\`, because \`removePrefix(baseIndent)\` is a no-op on a literal line indented less than the base while \`bodyIndent\` was still prefixed - so an unindented literal gained an indent level on the ordinary member-function path. Both cases are tested." +``` + +- [ ] **Step 6: Comment on the ticket** + +```bash +jira issue comment add ADFA-5080 "Addressed John's review on PR #1655: anonymous-function insertion anchor, @Composable property getters, nested-declaration returns wrongly counted as region exits, and raw-string interiors being re-indented. Each has a regression test and the feature doc is updated. The fully-qualified-vs-shortened type text point is tracked separately as ADFA-." +``` + +Substitute the ticket id from Task 5. + +--- + +## Self-Review + +**Spec coverage.** All five items in the review analysis have a task: HIGH anonymous anchor (Task 1), MEDIUM `@Composable` getters (Task 2), LOW nested returns (Task 3), LOW raw-string re-indentation (Task 4), the qualified-type-text follow-up (Task 5). The "missing tests" row of the analysis is folded into Tasks 1-4 rather than deferred, and the doc-consistency requirement from `CLAUDE.md` is satisfied per task rather than in a sweep at the end. + +**Placeholder scan.** No TBDs. Every code step carries the actual replacement text; every doc step carries the actual markdown; every command is runnable as written. The only substitution is the Jira ticket id created in Task 5 and quoted in Task 6, which cannot be known ahead of time and is flagged at both use sites. + +**Type consistency.** `returnTargetInRegion` (Task 3) is used by both `isTailReturn` and `hasExit` with the same `(KtReturnExpression, TextSpan)` signature. `rawStringSpans: List` is named identically in `ExtractMethodPlan.kt`, `MethodSignature.kt`, `ExtractMethodEdit.kt` and both test files. `indentedBodyLines` replaces `reindent` at all three call sites in Task 4 Step 5, and its parameter order matches both invocations. `hasComposableAnnotation` is declared on `KaAnnotatedSymbol`, which both `KaDeclarationSymbol` (the function-call branch) and `KaPropertyGetterSymbol` (the property branch) satisfy. + +**Known risk.** Task 4 Step 2 goes red by failing to compile rather than by failing an assertion, because the test needs a field the fix introduces. The step says so and requires a second red run after the field exists, so the assertions themselves are still proven to fail before the behaviour changes. diff --git a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt index b4b8c1c753..22761c2ccd 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -95,6 +95,7 @@ object TooltipTag { const val EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH = "editor.codeactions.kotlin.trycatch" const val EDITOR_CODE_ACTIONS_KT_GOTO_DEF = "editor.codeactions.kotlin.gotodef" const val EDITOR_CODE_ACTIONS_KT_FIND_REFS = "editor.codeactions.kotlin.findrefs" + const val EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE = "editor.codeactions.kotlin.extractvariable" const val EXIT_TO_MAIN = "exit.to.main" diff --git a/lsp/kotlin/build.gradle.kts b/lsp/kotlin/build.gradle.kts index 27f92b80a7..d25dd4a40a 100644 --- a/lsp/kotlin/build.gradle.kts +++ b/lsp/kotlin/build.gradle.kts @@ -28,7 +28,7 @@ android { namespace = "${BuildConfig.PACKAGE_NAME}.lsp.kotlin" // The refactoring bottom sheets are Compose (ADR 0009); they live here rather than in a UI - // module because `editor` depends on this module, not the reverse (ADR 0011). + // module because `editor` depends on this module, not the reverse (ADR 0012). buildFeatures { compose = true } 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 288e2d095d..7990d4b8b5 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 @@ -7,6 +7,7 @@ import com.itsaky.androidide.lsp.actions.IActionsMenuProvider import com.itsaky.androidide.lsp.actions.SurroundWithTryCatchAction import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.ExtractVariableAction import com.itsaky.androidide.lsp.kotlin.actions.FindReferencesAction import com.itsaky.androidide.lsp.kotlin.actions.GoToDefinitionAction import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction @@ -48,5 +49,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { ), NullSafetyAction(), ImplementMembersAction(), + ExtractVariableAction(), ) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt new file mode 100644 index 0000000000..086c8060f7 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt @@ -0,0 +1,155 @@ +package com.itsaky.androidide.lsp.kotlin.actions + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.get +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireEditor +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractVariableSheet +import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractionChoice +import com.itsaky.androidide.lsp.kotlin.refactor.ui.findFragmentActivity +import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractVariableRewrite +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractionPlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.toTextEdit +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.projects.FileManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.tasks.createJobCancelChecker +import com.itsaky.androidide.utils.flashError +import com.itsaky.androidide.utils.flashInfo +import java.nio.file.Path + +/** + * Extracts the expression at the cursor, or the selected one, into a local `val`. + * + * The work is split so nothing heavy touches the UI thread: [execAction] runs one background analysis + * pass and returns a plain-data [ExtractionPlan] covering every candidate, then [postExec] shows the + * sheet and turns the user's choice into a single text edit with pure offset arithmetic. + */ +class ExtractVariableAction : BaseKotlinCodeAction() { + companion object { + const val ID = "ide.editor.lsp.kt.extractVariable" + } + + override var titleTextRes: Int = R.string.action_extract_variable + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE + + override val id: String = ID + override var label: String = "" + + // Analysis must not run on the UI thread. The selection is therefore read at the top of + // execAction on a background thread, as ImplementMembersAction does; a torn read while the user + // is mid-edit can only produce a plan the document-version guard then refuses to apply. + override var requiresUIThread: Boolean = false + + // Intentionally no prepare() visibility gate: deciding whether anything is extractable needs a K2 + // analysis session, far too costly for prepare() (UI thread). The action stays visible on any + // Kotlin file and reports "nothing to extract" instead. Matches OrganizeImportsAction and + // ImplementMembersAction. + + override suspend fun execAction(data: ActionData): ExtractionPlan { + val server = data.get() ?: return ExtractionPlan.empty() + val nioPath = data.requireFile().toPath() + val env = server.compilationEnvironmentFor(nioPath) ?: return ExtractionPlan.empty() + + val cursor = data.requireEditor().cursor + val selectionStart = minOf(cursor.left, cursor.right) + val selectionEnd = maxOf(cursor.left, cursor.right) + + return buildExtractionPlan( + env = env, + nioPath = nioPath, + selectionStart = selectionStart, + selectionEnd = selectionEnd, + documentVersion = documentVersionOf(nioPath), + // Ties the analysis to this action's coroutine: cancelling the action aborts the analysis. + cancelChecker = ScheduledCancelChecker(createJobCancelChecker()), + ) + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + super.postExec(data, result) + if (result !is ExtractionPlan) return + + if (result.isEmpty) { + flashInfo(R.string.msg_extract_variable_nothing_to_extract) + return + } + + val activity = + data.requireContext().findFragmentActivity() + ?: run { + // A wiring problem rather than a user path: the editor is always hosted by one. + logger.warn("No FragmentActivity for the editor context. Cannot show the extract sheet.") + flashError(R.string.msg_cannot_perform_fix) + return + } + + val shown = ExtractVariableSheet.show(activity, result) { choice -> applyChoice(data, result, choice) } + if (!shown) { + logger.warn("Fragment manager unavailable. Cannot show the extract sheet.") + } + } + + /** + * Turns the user's choice into one edit and hands it to the language client. + * + * The document version is re-read here rather than trusted from the plan: the editor stays + * reachable while the sheet is open, and applying spans computed against older text would corrupt + * the file. Refusing is always safe; the user can invoke the action again. + */ + private fun applyChoice( + data: ActionData, + plan: ExtractionPlan, + choice: ExtractionChoice, + ) { + val file = data.requireFile() + val nioPath = file.toPath() + if (documentVersionOf(nioPath) != plan.documentVersion) { + flashInfo(R.string.msg_extract_variable_file_changed) + return + } + + val rewrite = + buildExtractVariableRewrite( + fileText = plan.fileText, + candidateSpan = choice.candidate.span, + scope = choice.scope, + name = choice.name, + replaceAll = choice.replaceAll, + ) ?: run { + logger.warn("Could not build an extract-variable rewrite for '{}'", choice.candidate.label) + flashError(R.string.msg_cannot_perform_fix) + return + } + + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot extract variable.") + return + } + + client.performCodeAction( + CodeActionItem( + title = label, + changes = listOf(DocumentChange(file = nioPath, edits = listOf(rewrite.toTextEdit(plan.fileText)))), + kind = CodeActionKind.QuickFix, + // The rewrite is emitted fully indented; CMD_FORMAT_CODE is a no-op for Kotlin anyway. + command = Command("", ""), + ), + ) + } + + /** -1 when the document is not open, which never matches a real version and so fails the guard. */ + private fun documentVersionOf(path: Path): Int = FileManager.getActiveDocument(path)?.version ?: -1 +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt index 79acdcc998..4b3905fd4b 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt @@ -6,6 +6,7 @@ import com.itsaky.androidide.lsp.actions.SurroundWithTryCatchAction import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.KotlinCodeActionsMenu.KT_LANG import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.ExtractVariableAction import com.itsaky.androidide.lsp.kotlin.actions.FindReferencesAction import com.itsaky.androidide.lsp.kotlin.actions.GoToDefinitionAction import com.itsaky.androidide.lsp.kotlin.actions.ImplementMembersAction @@ -42,6 +43,7 @@ class KotlinCodeActionTooltipTagTest { ImplementMembersAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS, SurroundWithTryCatchAction.idFor(KT_LANG) to TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, + ExtractVariableAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE, ) assertEquals(expected, actualTags) } From 9c94ed01ab9c88c39a167f8ada701a31efcc187d Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:19:21 +0000 Subject: [PATCH 05/25] ADFA-4826: Document the extract-variable requirements Requirements, scope, non-goals, acceptance criteria and the test split, following the kotlin-goto-definition.md template. Also carries the Language section for the whole refactoring family - extract method, inline variable and rename all reuse this vocabulary rather than restating it. --- docs/features/kotlin-extract-variable.md | 229 +++++++++++++++++++++++ 1 file changed, 229 insertions(+) create mode 100644 docs/features/kotlin-extract-variable.md diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md new file mode 100644 index 0000000000..bc45813e51 --- /dev/null +++ b/docs/features/kotlin-extract-variable.md @@ -0,0 +1,229 @@ +# Kotlin extract variable (K2 LSP) + +- **Ticket:** ADFA-4826 (subtask of ADFA-3317; split out of the closed ADFA-3324 "Refactoring"). Extract method was originally part of this subtask and is now ADFA-5080. +- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename and the sealed `RefactoringPlan` supertype shared with ADFA-5080 (see [Design](#design)). +- **Module:** `lsp/kotlin` + +Bind the expression at the cursor, or the selected one, to a new local `val`, and replace the occurrences of that expression with the new name. + +This is the first *interactive* Kotlin code action: the user chooses an expression, a name, a target scope and whether to replace other occurrences, so it needs a real UI surface rather than a fire-and-forget edit. Where that UI lives is [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md); what it refuses to do is the decline-rather-than-rewrite principle, recorded as ADR 0013 alongside extract method (ADFA-5080). + +## Language + +This section is the glossary for the whole refactoring family - extract variable, extract method (ADFA-5080), inline variable (ADFA-4827), rename (ADFA-4825). Prefer these terms over ad-hoc synonyms in code, tests, docs and review comments. + +**Selection**: +The user's raw offsets from the editor caret, before any processing. A cursor is the degenerate selection where start equals end. Trimmed and snapped before it becomes an extraction region, so it is *not* interchangeable with one. +_Avoid_: range (that's `Range`, the LSP line/column type), region. + +**Extraction region**: +The contiguous text an extraction reads its body from. For extract variable it is always an expression candidate; extract method adds statement ranges. +_Avoid_: target (overloaded with go-to-definition's target and with the insertion site), extent, fragment. + +**Expression candidate**: +A `KtExpression` at the selection that is a legal extraction target. Ordered innermost-first, at most `MAX_CANDIDATES` (3) of them, so the chooser stays scannable on a phone. +_Avoid_: candidate expression when naming code (the type is `CandidateExpression`, but the term is "expression candidate"), match, option. + +**Text span**: +A half-open offset range `[start, end)` into the analysed file's text - the type `TextSpan`. Purely positional; it carries no meaning about what it covers. +_Avoid_: range, offset pair. + +**Legal scope chain**: +The ordered anchors available for the new declaration, innermost first: outward from the candidate's own statement through enclosing blocks, crossing a lambda boundary only when nothing lambda-scoped is referenced, and stopping at the enclosing named function, accessor or `init` body. +_Avoid_: scope list, parent chain. + +**Anchor scope**: +The chain member the user picked. The `val` is declared inside it. + +**Anchor form**: +How the declaration is woven into an anchor scope, since not all Kotlin scopes are blocks: `ExistingBlock`, `WrapInBraces`, or `ConvertExpressionBody`. + +**Anchor point**: +The exact insertion offset - immediately before the first statement *within the anchor scope* that contains a replaced occurrence. + +**Occurrence**: +A site inside the anchor scope that is structurally equal to the candidate *and* whose every name reference resolves to the same declaration. Sites made unsound by an intervening write are excluded, so an occurrence set is always safe to replace wholesale. +_Avoid_: duplicate, match, usage. + +**Refactoring plan**: +The complete result of the background analysis pass - the sealed `RefactoringPlan`, carrying the analysed `fileText` and its `documentVersion`. Plain data: no PSI, no symbols, no session. `ExtractVariablePlan` is this refactoring's subtype. +_Avoid_: model, result, context. + +**Rewrite span**: +The single text replacement an extraction performs - a `TextSpan` plus its replacement text (`RewriteSpan`), converted to one `TextEdit` at the boundary. + +## Scope + +### In scope + +An expression inside any executable body: a function body, a property accessor, an `init` block, a constructor, or a lambda. Both a bare cursor and a selection, since a cursor is just the selection where start equals end. + +### Out of scope + +Positions where no `val` can precede the expression, all rejected up front by `isExtractionPosition`: + +- **Annotation arguments** - must be compile-time constants. +- **Default parameter values** - evaluated per call, and a hoisted local would not be in scope. +- **Super-constructor delegation arguments** - nothing can precede them. +- **Anything outside an executable body**, notably a class-body property initializer. Converting one to a getter would turn compute-once into compute-per-access, so it is declined rather than silently changing evaluation semantics. + +## Requirements + +**R1 - Trigger.** An "Extract variable" item (`action_extract_variable`) appears in the editor code-actions menu for Kotlin files, id `ide.editor.lsp.kt.extractVariable`, tooltip tag `EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE = "editor.codeactions.kotlin.extractvariable"`. Tooltip *content* is keyed by tag in the out-of-repo tooltips database, so the tag shows no text until a row exists for it - a hand-off item, not code. + +There is deliberately **no `prepare()` visibility gate**. Deciding whether anything is extractable needs a K2 analysis session, which is far too costly for `prepare()` (UI thread, per menu item). The action stays visible on any Kotlin file and reports "nothing to extract" instead, matching `OrganizeImportsAction` and `ImplementMembersAction`. `requiresUIThread = false`, so the selection is read on a background thread; a torn read while the user is mid-edit can only produce a plan the version guard (R3) then refuses. + +**R2 - Region.** The selection is whitespace-trimmed first, because a touch-screen selection routinely carries a leading or trailing space; a whitespace-only selection yields nothing. For a cursor, the element is looked up at the offset and then at `offset - 1`, so a caret resting just past a token still resolves. + +From the innermost element the parent chain is walked outwards, collecting legal targets and stopping at the enclosing declaration. Illegal nodes along the way are **skipped rather than terminating the walk**, so `if (c) a else b` is still offered from inside one of its branches. At most 3 candidates, innermost first, deduplicated by range. + +An expression is not a legal target when it is: a block, a loop, `return`/`throw`/`break`/`continue`, an operation reference, `super`, a lambda literal, the selector of a qualified expression (`b` in `a.b`), a call's callee (`foo` in `foo(x)`), the left side of an assignment, or a **bare literal**. Excluding bare literals removes the only case where omitting the type annotation could change meaning - an `Int` literal where a `Long` is expected, or a bare `null` inferring `Nothing?`. + +When the trimmed selection exactly equals the innermost candidate's range, the user has already said which expression they mean and the chooser is not shown (`selectionMatchedCandidate`). + +**R3 - Live offsets and the version guard.** Analysis runs against `ktSymbolIndex.getCurrentKtFile(path)`, PSI refreshed to the open document's current version - an offset resolved against stale text points at the wrong element. The `KtFile` is fetched *before* entering `project.read`: the refresh needs `project.write`, and awaiting it under the read lock deadlocks. + +The plan records the document version it was computed against. On confirm, the version is re-read and the edit is **refused** if it has moved on (`msg_extract_variable_file_changed`) - the editor stays reachable while the sheet is open, and applying spans computed against older text would corrupt the file. Refusing is always safe; the user can invoke the action again. + +**R4 - Value filter.** A candidate whose type is `Unit` or `Nothing` is dropped: `val u = println(x)` compiles but is pointless. A candidate whose legal scope chain is empty is dropped too - a candidate with no legal anchor is not a candidate. + +**R5 - Scope chain.** Anchors are enumerated outward from the candidate's own statement, each one of three anchor forms: + +| Anchor form | When | Emitted as | +|---|---|---| +| `ExistingBlock` | the scope already has a `{ ... }` body | a new statement line | +| `WrapInBraces` | a braceless statement position: `if (c) foo()`, a `when` entry, a braceless loop body | the statement is replaced by a braced block holding the declaration and the original statement | +| `ConvertExpressionBody` | an expression-bodied function or accessor, `fun area(r: Int) = r * r` | `=` and the body become a block body; `return` is added unless the declaration returns `Unit` | + +The walk stops after the enclosing named function, accessor or `init` body. A class body or file is never an anchor. Lambda boundaries are crossed during the syntactic walk, then **truncated afterwards** by the innermost scope holding a declaration the candidate references - so a candidate using `it` or a lambda parameter can never be hoisted out of that lambda. `it` needs its own case: it has no source PSI, so a value-parameter symbol with no PSI referenced by the name `it` is taken to be the innermost enclosing lambda's implicit parameter. That is a property of the language, not a guess about the text. + +Braceless control-structure bodies are wrapped in a container node, so the `if`/loop is the grandparent; without unwrapping, no braceless body is ever detected and the declaration silently hoists to the enclosing block instead of braces being added. + +**R6 - Occurrences.** Two sites are the same expression when they are structurally identical (whitespace and comments ignored) *and* every name reference in them resolves to the same declaration. The symbol check is the point: text or structure alone would match `config.timeout` inside a nested lambda where `config` is a different `config`. ADFA-3324 states the standard outright - text-based matching breaks things. + +Source declarations are compared by PSI identity, which is exactly the question being asked ("the same `val`?"); symbols without source PSI fall back to symbol equality. A resolution failure reads as "not the same" rather than propagating. + +Matches must themselves be legal targets - in `a.a`, a candidate of `a` matches the selector too, and rewriting it would produce `v.v`. Overlapping matches are dropped so no site is rewritten twice. + +An occurrence set is then restricted to a contiguous run around the candidate that **no write to a referenced mutable interrupts**: + +```kotlin +var limit = 1 +foo(limit + 1) // occurrence +limit = 5 +foo(limit + 1) // same expression, different value +``` + +Unsound sites are excluded rather than warned about, so "Replace all N occurrences" can never produce wrong code and N is always achievable. The walk grows outward from the candidate - never dropping the site the user selected - and stops in each direction at the first write it would cross. Writes counted: plain assignment, the augmented forms, and `++`/`--`, against any `var` the candidate reads. + +Occurrence sets are ascending by offset and always contain the candidate's own span, so `occurrences.size` is the count shown in "Replace all N occurrences". Narrowing to an inner scope can only shrink the set, never grow it. + +**R7 - Name.** The suggestion is derived from the expression's shape first (`items.size` -> `size`, `getFoo()` -> `foo`, an interpolated string -> `text`), then its rendered type (`List` -> `list`), then `"value"`; shape beats type because `size`, `count` and `name` are far better names than `int` and `string`. It is then uniquified with a numeric suffix. + +Validation returns a `NameProblem` - `Blank`, `NotAnIdentifier`, `Keyword`, `AlreadyTaken` - rather than throwing, since the input is a text field. Only Kotlin's **hard** keywords are rejected; soft and modifier keywords (`by`, `data`, `it`) are legal names. Backtick-quoted names are rejected: legal Kotlin, but a poor generated local, and accepting them would mean validating the quoted form too. + +Taken names are every declaration name in the file - deliberately conservative rather than scope-exact. Being over-broad costs a `size1` where `size` would have done; being under-broad generates code that shadows something. It is also purely syntactic, so it needs no analysis and is unit-testable. + +**R8 - Sheet.** One surface holding every choice, with no navigation between steps: expression chooser, name field, scope chooser, replace-all checkbox, Cancel/Extract. The four are interdependent - a different expression changes the scope list and the occurrence count - so they are shown together where that relationship is visible, rather than across sequential dialogs the user would have to back out of to explore. + +Each chooser is hidden when it has nothing to ask: the expression chooser when there is one candidate or the selection already matched one, the scope chooser when the chain has one rung, the replace-all checkbox at an occurrence count of one. Changing the expression re-suggests the name, because the old one described the old expression. + +**R9 - Edit.** Exactly **one** `TextEdit`, built as a `RewriteSpan` covering one contiguous span. `IDELanguageClientImpl.applyActionEdits` applies each edit in its own `runOnUiThread` with no `beginBatchEdit`, and every range is interpreted against the *current* text - so a list of N edits would be applied against positions already shifted by its predecessors and would cost N undo steps with a typing window between each. Occurrences are substituted right-to-left within the span so an earlier substitution cannot shift a later offset. + +The emitted text is **fully indented**: code-action edits bypass the editor's auto-indent (raw `Content.replace`), and `CMD_FORMAT_CODE` is a no-op for Kotlin. The indent unit is inferred from the file's own lines (a tab if any line is tab-indented, else the smallest positive run of leading spaces, defaulting to a tab), mirroring `ImplementMembersAction`; CRLF is used only when the file already contains it, so the edit never mixes line endings. + +**R10 - Responsiveness.** One background analysis pass produces the plan for *all* candidates at once; the sheet then performs pure string and offset arithmetic on it. Nothing re-enters analysis on confirm, which keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation unit-testable without an editor, an activity or Compose. Analysis runs at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine, so cancelling the action aborts the analysis. + +**R11 - Failure isolation.** Anything thrown in the analysis pipeline degrades to an empty plan and a log line. The action framework catches only `IllegalArgumentException` and this runs on a scope with no exception handler, so an uncaught throw would crash the app; reporting "nothing to extract" is always safe. A missing `FragmentActivity` or fragment manager logs and flashes `msg_cannot_perform_fix` rather than failing silently. + +## Non-goals + +- **Extract to a `val` outside an executable body** - a class property or a top-level `val`. That is a different refactoring with different scope rules. +- **Extract `var`, `lateinit`, or a property with accessors.** Always a `val`. +- **An explicit type annotation** on the generated declaration. Bare literals are excluded (R2) precisely so inference cannot change meaning. +- **Occurrences outside the anchor scope**, or across files. +- **Renaming the declaration in place after the edit** - ADFA-4825. +- **Formatting the result.** `CMD_FORMAT_CODE` is a no-op for Kotlin; R9 emits indented text instead. +- **Extract method** - ADFA-5080, which shares this vocabulary and these primitives. + +## Acceptance criteria + +1. "Extract variable" appears in the code-actions menu of a Kotlin file and is absent in a non-Kotlin file. +2. A cursor inside `a + b * c` offers the innermost-first candidates and extracting the selected one produces `val = ...` on its own line above, correctly indented. +3. A selection that exactly matches an expression skips the expression chooser. +4. A caret immediately after an identifier resolves the same as one inside it. +5. A cursor on a bare literal, on whitespace, in a comment, or in an annotation argument reports "No expression to extract here". +6. An expression appearing three times in the same block reports "Replace all 3 occurrences" and rewrites all three. +7. The same expression with an intervening reassignment of a `var` it reads offers only the contiguous sound run. +8. An expression using `it` inside a lambda offers no anchor outside that lambda. +9. Extracting from `if (c) foo(x + 1)` wraps the branch in braces with the declaration inside. +10. Extracting from `fun area(r: Int) = r * r` converts it to a block body with `return`. +11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return`. +12. A name that is blank, not an identifier, a hard keyword, or already used disables Extract and shows the matching message. +13. Editing the file while the sheet is open, then confirming, reports "The file changed. Try extracting again." and leaves the file untouched. +14. One undo restores the file exactly. +15. A file indented with spaces receives space-indented output; a CRLF file keeps CRLF. + +## Design + +Per [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md), `lsp/kotlin` owns its refactoring UI, and the analysis/UI split is enforced **by data rather than by module boundaries**: the background pass produces a plain-data plan, and the sheet holds no PSI and performs no analysis. + +``` +ExtractVariableAction.execAction (background) lsp/kotlin/actions + server.compilationEnvironmentFor(path) ?: empty plan + cursor -> [selectionStart, selectionEnd) + -> buildExtractionPlan(...) utils/refactor/ExtractVariablePlanner.kt + ktFile = env.ktSymbolIndex.getCurrentKtFile(path).get() [R3: before project.read] + env.project.read { + candidateExpressionsAt(ktFile, start, end) utils/refactor/CandidateExpressions.kt [R2] + analyzeMaybeDangling(INTERACTIVE, cancelChecker) { [R10] + per candidate: type filter [R4] + enclosingScopeFrames + truncateAtCeiling ScopeChain.kt / Occurrences.kt [R5] + findOccurrences + excludeUnsoundOccurrences Occurrences.kt [R6] + suggestVariableName + visibleNamesAt NameSuggestion.kt / Occurrences.kt [R7] + } + } + <- ExtractVariablePlan (plain data, no PSI) + +ExtractVariableAction.postExec (UI thread) + empty -> flashInfo("No expression to extract here") [R11] + findFragmentActivity() -> ExtractVariableSheet.show refactor/ui [R8] + ExtractVariableViewModel: StateFlow, sealed UiEvent + on confirm -> ExtractionChoice + version re-read; mismatch -> refuse [R3] + buildExtractVariableRewrite -> RewriteSpan -> toTextEdit utils/refactor/ExtractVariableEdit.kt [R9] + client.performCodeAction(one DocumentChange, one TextEdit) +``` + +Components: + +- **`utils/refactor/ExtractionPlan.kt`** - `TextSpan`, `AnchorForm`, `ScopeOption`, `CandidateExpression`, the plan, `collapseForLabel`. To be renamed to `ExtractVariablePlan` under a sealed `RefactoringPlan` carrying `fileText`, `documentVersion` and the shared version guard, so ADFA-5080 adds a subtype rather than renaming this one. Both refactorings share these *primitives*, not the aggregate: extract method has no scope chain, so `ScopeOption`/`AnchorForm`/`CandidateExpression` are not shared. +- **`CandidateExpressions.kt`** - purely syntactic, no analysis session, hence unit-testable on its own (R2). +- **`ScopeChain.kt`** - the syntactic chain and the three anchor forms (R5); indentation and newline detection shared with the edit builder. +- **`Occurrences.kt`** - symbol-aware structural equality, the occurrence search, the unsoundness filter, the referenced-declaration ceiling, and `visibleNamesAt` (R5, R6, R7). +- **`NameSuggestion.kt`** - suggestion and validation, no analysis session (R7). +- **`ExtractVariableEdit.kt`** - `RewriteSpan`, the three anchor-form rewrites, `toTextEdit` (R9). Pure text and offsets. +- **`refactor/ui/`** - `ExtractVariableSheet` (a `BottomSheetDialogFragment` hosting a `ComposeView`), stateless `ExtractVariableSheetContent`, `ExtractVariableViewModel` + `ExtractVariableUiState` + sealed `ExtractVariableUiEvent`. `LabelledSection` and `OptionList` become shared with ADFA-5080. The ViewModel uses a plain `ViewModelProvider.Factory` rather than a Koin definition: it is sheet-scoped, injects nothing, and takes the plan as a runtime argument. +- **`ExtractVariableAction`** extending `BaseKotlinCodeAction`, registered in `KotlinCodeActionsMenu`; the only class that touches the editor, the document version or the language client. +- **`common-compose`** - `IdeTheme`/`IdeColorScheme`, shared with `profiler` and `floating-window` so the sheet matches the IDE's theme. + +## Verification + +Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`), split so a failure localises to one layer: + +- **`RefactorPrimitivesTest`** - no analysis session: selection trimming, candidate collection and the legal-target rules (R2), indent/newline detection, name suggestion and validation (R7), the unsoundness filter as a pure function (R6). +- **`ExtractVariablePlanEndToEndTest`** - analysis-backed: the value filter (R4), scope chains and the lambda ceiling (R5), occurrence sets including the `it` and same-name-different-symbol cases (R6). +- **`ExtractVariableEditTest`** - pure text: the three anchor forms, right-to-left substitution, indentation and CRLF (R9). +- **`ExtractVariableViewModelTest`** - state derivation: chooser visibility, candidate switching re-suggesting the name, replace-all clamping, `choice()` refusing an invalid name (R8). +- **`KotlinCodeActionTooltipTagTest`** - every action carries a tooltip tag (R1). + +`prepare()`/`ActionData` and the sheet itself are not unit-testable, consistent with the other Kotlin code actions. They are covered by on-device QA from the acceptance criteria, recorded in ADFA-4826's "Steps to QA" field. + +## Related + +- [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - refactoring UI lives in the owning LSP module +- ADR 0013 - refactorings decline rather than rewrite unselected code (lands with extract method, ADFA-5080) +- [ADR 0009](../adr/0009-jetpack-compose-for-new-ui.md) - Compose, UDF, `ViewModel` + `StateFlow` +- [ADR 0010](../adr/0010-navigation-resolves-via-analysis-api.md) - the K2 Analysis API as the Kotlin semantic source of truth +- ADFA-5080 - extract method, the sibling refactoring; it reuses this vocabulary and these primitives +- [ARCHITECTURE.md](../../ARCHITECTURE.md) From bc9cb1181f1c267aa0db676150aff16b1c7bb4fb Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 14:34:01 +0000 Subject: [PATCH 06/25] ADFA-4826: Stop offering the lambda that wraps the expression --- docs/features/kotlin-extract-variable.md | 4 ++-- .../utils/refactor/CandidateExpressions.kt | 5 ++++ .../ExtractVariablePlanEndToEndTest.kt | 23 +++++++++++++++++++ 3 files changed, 30 insertions(+), 2 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index bc45813e51..f6fe22d263 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -1,7 +1,7 @@ # Kotlin extract variable (K2 LSP) - **Ticket:** ADFA-4826 (subtask of ADFA-3317; split out of the closed ADFA-3324 "Refactoring"). Extract method was originally part of this subtask and is now ADFA-5080. -- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename and the sealed `RefactoringPlan` supertype shared with ADFA-5080 (see [Design](#design)). +- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename (the sealed `RefactoringPlan` supertype it will sit under has landed). - **Module:** `lsp/kotlin` Bind the expression at the cursor, or the selected one, to a new local `val`, and replace the occurrences of that expression with the new name. @@ -77,7 +77,7 @@ There is deliberately **no `prepare()` visibility gate**. Deciding whether anyth From the innermost element the parent chain is walked outwards, collecting legal targets and stopping at the enclosing declaration. Illegal nodes along the way are **skipped rather than terminating the walk**, so `if (c) a else b` is still offered from inside one of its branches. At most 3 candidates, innermost first, deduplicated by range. -An expression is not a legal target when it is: a block, a loop, `return`/`throw`/`break`/`continue`, an operation reference, `super`, a lambda literal, the selector of a qualified expression (`b` in `a.b`), a call's callee (`foo` in `foo(x)`), the left side of an assignment, or a **bare literal**. Excluding bare literals removes the only case where omitting the type annotation could change meaning - an `Int` literal where a `Long` is expected, or a bare `null` inferring `Nothing?`. +An expression is not a legal target when it is: a block, a loop, `return`/`throw`/`break`/`continue`, an operation reference, `super`, a lambda (the `{ ... }` expression and the literal inside it -- outside its call site the parameter types are gone, so `val v = { it.length + 1 }` does not compile), the selector of a qualified expression (`b` in `a.b`), a call's callee (`foo` in `foo(x)`), the left side of an assignment, or a **bare literal**. Excluding bare literals removes the only case where omitting the type annotation could change meaning - an `Int` literal where a `Long` is expected, or a bare `null` inferring `Nothing?`. When the trimmed selection exactly equals the innermost candidate's range, the user has already said which expression they mean and the chooser is not shown (`selectionMatchedCandidate`). diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt index 8c0510c27f..2f6b6fd3ef 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt @@ -17,6 +17,7 @@ import org.jetbrains.kotlin.psi.KtDeclarationWithBody import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtLiteralStringTemplateEntry import org.jetbrains.kotlin.psi.KtLoopExpression import org.jetbrains.kotlin.psi.KtOperationReferenceExpression @@ -179,6 +180,7 @@ private fun PsiElement.isAncestorOf(other: PsiElement): Boolean = PsiTreeUtil.is * * Excluded, and why: * - blocks, loops, `return`/`throw`/`break`/`continue` -- no useful value to bind; + * - lambdas, literal and wrapper alike -- outside their call site the parameter types are gone; * - operator tokens and call callees (`foo` in `foo(x)`) -- fragments, not expressions; * - the selector of a qualified expression (`b` in `a.b`) -- only meaningful with its receiver; * - the left side of an assignment -- a write target, not a value; @@ -195,6 +197,9 @@ internal fun KtExpression.isLegalExtractionTarget(): Boolean { if (this is KtOperationReferenceExpression) return false if (this is KtSuperExpression) return false if (this is KtFunctionLiteral) return false + // The wrapper around the literal. A hoisted lambda loses the parameter types its call site was + // supplying, so `{ it.length + 1 }` becomes uncompilable the moment it leaves the call. + if (this is KtLambdaExpression) return false if (isBareLiteral()) return false val parent = parent diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 11aff94443..e6c81822b1 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -386,4 +386,27 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { apply(content, rewrite!!), ) } + + @Test + fun `does not offer the lambda that wraps the expression`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { + it.length + 1 + } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + // `{ it.length + 1 }` must not appear between the two: a hoisted lambda loses the `it` the call + // site was supplying. + assertEquals( + listOf("it.length + 1", "items.map { it.length + 1 }"), + result.candidates.map { it.label }, + ) + } } From 42c231bc15b33bfb4b6d5307ebebc99e16f189c8 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 14:45:59 +0000 Subject: [PATCH 07/25] ADFA-4826: Label a block rung by the construct that owns it --- docs/features/kotlin-extract-variable.md | 5 ++++ .../lsp/kotlin/utils/refactor/ScopeChain.kt | 17 ++++++++++--- .../ExtractVariablePlanEndToEndTest.kt | 25 +++++++++++++++++++ 3 files changed, 44 insertions(+), 3 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index f6fe22d263..005439222f 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -95,6 +95,11 @@ The plan records the document version it was computed against. On confirm, the v | `WrapInBraces` | a braceless statement position: `if (c) foo()`, a `when` entry, a braceless loop body | the statement is replaced by a braced block holding the declaration and the original statement | | `ConvertExpressionBody` | an expression-bodied function or accessor, `fun area(r: Int) = r * r` | `=` and the body become a block body; `return` is added unless the declaration returns `Unit` | +Each rung is labelled with the construct that owns it -- `fun name`, `getter`, `setter`, `init block`, +`lambda`, `if block`, `else block`, `for loop`, `while loop`, `do-while loop`, `when branch` -- so the +`Declare in` list reads as a place rather than as a nesting level. A braced control-structure body is +wrapped in a container node, so the owner is the block's grandparent, not its parent. + The walk stops after the enclosing named function, accessor or `init` body. A class body or file is never an anchor. Lambda boundaries are crossed during the syntactic walk, then **truncated afterwards** by the innermost scope holding a declaration the candidate references - so a candidate using `it` or a lambda parameter can never be hoisted out of that lambda. `it` needs its own case: it has no source PSI, so a value-parameter symbol with no PSI referenced by the name `it` is taken to be the innermost enclosing lambda's implicit parameter. That is a property of the language, not a guess about the text. Braceless control-structure bodies are wrapped in a container node, so the `if`/loop is the grandparent; without unwrapping, no braceless body is ever detected and the declaration silently hoists to the enclosing block instead of braces being added. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt index 79ac67d2fe..64692923bf 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt @@ -170,19 +170,30 @@ private fun isCeilingBody(scopeElement: PsiElement): Boolean { } } -private fun blockLabel(block: KtBlockExpression): String = - when (val owner = block.parent) { +/** + * The name shown for a block rung. + * + * A braceless *or* braced control-structure body is wrapped in a container node, so the `if`/loop is + * the block's grandparent; without unwrapping, every braced branch reads as a generic "block". The + * container is also what `then`/`else` point at, so the branch check compares against it. + */ +private fun blockLabel(block: KtBlockExpression): String { + val parent = block.parent + val container = parent as? KtContainerNodeForControlStructureBody + val branch = container ?: block + return when (val owner = container?.parent ?: parent) { is KtNamedFunction -> "fun ${owner.name ?: ""}" is KtPropertyAccessor -> if (owner.isGetter) "getter" else "setter" is KtAnonymousInitializer -> "init block" is KtFunctionLiteral -> "lambda" - is KtIfExpression -> if (owner.then === block) "if block" else "else block" + is KtIfExpression -> if (owner.then === branch || owner.then?.parent === container) "if block" else "else block" is KtForExpression -> "for loop" is KtWhileExpression -> "while loop" is KtDoWhileExpression -> "do-while loop" is KtWhenEntry -> "when branch" else -> "block" } +} private fun declarationLabel(declaration: KtDeclarationWithBody): String = when (declaration) { diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index e6c81822b1..7c29f31919 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -409,4 +409,29 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { result.candidates.map { it.label }, ) } + + @Test + fun `labels a braced if branch by its owner`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b * 2 + } + return 0 + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + assertEquals( + listOf("if block", "fun demo"), + result.candidates + .first() + .scopes + .map { it.label }, + ) + } } From b84ad4046991cc0a0ec03888060a774a5bc057d6 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 14:54:58 +0000 Subject: [PATCH 08/25] ADFA-4826: Fix misleading KDoc and add else block test Remove dead code path (owner.then === branch can never be true). Correct the KDoc to accurately describe that getThen()/getElse() return unwrapped body expressions, not containers, so branch identity is checked via owner.then?.parent === container. Add test for braced else branch to prevent regression. --- .../lsp/kotlin/utils/refactor/ScopeChain.kt | 8 +++--- .../ExtractVariablePlanEndToEndTest.kt | 26 +++++++++++++++++++ 2 files changed, 31 insertions(+), 3 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt index 64692923bf..1361d45080 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt @@ -174,8 +174,10 @@ private fun isCeilingBody(scopeElement: PsiElement): Boolean { * The name shown for a block rung. * * A braceless *or* braced control-structure body is wrapped in a container node, so the `if`/loop is - * the block's grandparent; without unwrapping, every braced branch reads as a generic "block". The - * container is also what `then`/`else` point at, so the branch check compares against it. + * the block's grandparent; without unwrapping, every braced branch reads as a generic "block". + * `getThen()`/`getElse()` return the unwrapped body expression, never the container, so branch + * identity is decided by checking if the container's parent matches what `then`/`else` point at + * (by comparing `owner.then?.parent === container`). */ private fun blockLabel(block: KtBlockExpression): String { val parent = block.parent @@ -186,7 +188,7 @@ private fun blockLabel(block: KtBlockExpression): String { is KtPropertyAccessor -> if (owner.isGetter) "getter" else "setter" is KtAnonymousInitializer -> "init block" is KtFunctionLiteral -> "lambda" - is KtIfExpression -> if (owner.then === branch || owner.then?.parent === container) "if block" else "else block" + is KtIfExpression -> if (owner.then?.parent === container) "if block" else "else block" is KtForExpression -> "for loop" is KtWhileExpression -> "while loop" is KtDoWhileExpression -> "do-while loop" diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 7c29f31919..0db9c2cbb8 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -434,4 +434,30 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { .map { it.label }, ) } + + @Test + fun `labels a braced else branch by its owner`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return 0 + } else { + return a + b * 2 + } + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + + assertEquals( + listOf("else block", "fun demo"), + result.candidates + .first() + .scopes + .map { it.label }, + ) + } } From 79ed3fc1f8dfae03474c1fd96d1711018c6acfff Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 15:10:57 +0000 Subject: [PATCH 09/25] ADFA-4826: Write the return type when converting an expression body --- docs/features/kotlin-extract-variable.md | 13 ++- .../utils/refactor/ExtractVariableEdit.kt | 20 +++- .../utils/refactor/ExtractVariablePlanner.kt | 55 ++++++++++- .../kotlin/utils/refactor/ExtractionPlan.kt | 5 + .../lsp/kotlin/utils/refactor/TypeText.kt | 98 +++++++++++++++++++ .../utils/refactor/ExtractVariableEditTest.kt | 26 +++++ .../ExtractVariablePlanEndToEndTest.kt | 98 ++++++++++++++++++- .../utils/refactor/RefactorPrimitivesTest.kt | 45 +++++++++ 8 files changed, 349 insertions(+), 11 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index 005439222f..ffb53df5a6 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -93,7 +93,14 @@ The plan records the document version it was computed against. On confirm, the v |---|---|---| | `ExistingBlock` | the scope already has a `{ ... }` body | a new statement line | | `WrapInBraces` | a braceless statement position: `if (c) foo()`, a `when` entry, a braceless loop body | the statement is replaced by a braced block holding the declaration and the original statement | -| `ConvertExpressionBody` | an expression-bodied function or accessor, `fun area(r: Int) = r * r` | `=` and the body become a block body; `return` is added unless the declaration returns `Unit` | +| `ConvertExpressionBody` | an expression-bodied function or accessor, `fun area(r: Int) = r * r` | `=` and the body become a block body; `return` is added unless the declaration returns `Unit`; the return type is written into the signature when the declaration does not spell one out, because a block body with no declared type returns `Unit` | + +A written-out return type is rendered fully qualified and then shortened to its simple name only where +that name already resolves in the file -- an exact import, a star import of its package, or a +default-imported package such as `kotlin.collections`. Everything else stays qualified: verbose, but it +compiles, and this refactoring adds no imports. When the type cannot be written as source at all +(anonymous, intersection, an unresolved type, or a platform type the renderer cannot reduce) the rung +is declined rather than emitting a block body that does not compile. Each rung is labelled with the construct that owns it -- `fun name`, `getter`, `setter`, `init block`, `lambda`, `if block`, `else block`, `for loop`, `while loop`, `do-while loop`, `when branch` -- so the @@ -162,8 +169,8 @@ The emitted text is **fully indented**: code-action edits bypass the editor's au 7. The same expression with an intervening reassignment of a `var` it reads offers only the contiguous sound run. 8. An expression using `it` inside a lambda offers no anchor outside that lambda. 9. Extracting from `if (c) foo(x + 1)` wraps the branch in braces with the declaration inside. -10. Extracting from `fun area(r: Int) = r * r` converts it to a block body with `return`. -11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return`. +10. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. +11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. 12. A name that is blank, not an identifier, a hard keyword, or already used disables Extract and shows the matching message. 13. Editing the file while the sheet is open, then confirming, reports "The file changed. Try extracting again." and leaves the file untouched. 14. One undo restores the file exactly. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index da41a5e2fa..a4215500e6 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -114,14 +114,30 @@ private fun convertExpressionBodyRewrite( val body = replaceOccurrences(fileText, bodySpan, targets, name) val returned = if (form.needsReturn) "return $body" else body + // Writing a type means rewriting from the end of the signature, not from the `=`: starting at the + // `=` would leave the space in front of it and emit `fun area(r: Int) : Int {`. + val spanStart = + if (form.returnTypeText == null) form.assignStart else startOfWhitespaceBefore(fileText, form.assignStart) + val header = form.returnTypeText?.let { ": $it " } ?: "" + val newText = buildString { - append('{').append(newline) + append(header).append('{').append(newline) append(form.innerIndent).append(declaration).append(newline) append(form.innerIndent).append(returned).append(newline) append(form.indent).append('}') } - return RewriteSpan(TextSpan(form.assignStart, form.bodyEnd), newText) + return RewriteSpan(TextSpan(spanStart, form.bodyEnd), newText) +} + +/** The offset where the run of whitespace ending at [offset] begins. */ +private fun startOfWhitespaceBefore( + text: String, + offset: Int, +): Int { + var index = offset.coerceIn(0, text.length) + while (index > 0 && text[index - 1].isWhitespace()) index-- + return index } /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index bc39bde916..0b4a058c41 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -11,10 +11,12 @@ import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.com.intellij.psi.PsiElement +import org.jetbrains.kotlin.psi.KtCallableDeclaration import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtDeclarationWithBody import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.slf4j.LoggerFactory import java.nio.file.Path @@ -82,7 +84,9 @@ private fun KaSession.candidateFor(expression: KtExpression): CandidateExpressio if (frames.isEmpty()) return null val span = TextSpan(expression.textRange.startOffset, expression.textRange.endOffset) - val scopes = frames.map { scopeOptionFor(expression, span, it) } + val file = expression.containingKtFile + val scopes = frames.mapNotNull { scopeOptionFor(expression, span, it, file) } + if (scopes.isEmpty()) return null val takenNames = visibleNamesAt(expression) return CandidateExpression( @@ -94,25 +98,66 @@ private fun KaSession.candidateFor(expression: KtExpression): CandidateExpressio ) } -/** Builds one scope option, resolving its occurrence set and fixing up expression-body details. */ +/** + * Builds one scope option, resolving its occurrence set and fixing up expression-body details. + * + * Returns null when the rung cannot be honoured: converting an expression body whose return type is + * neither declared nor renderable would emit a block body that does not compile, and declining is + * always safe (ADR 0013). + */ private fun KaSession.scopeOptionFor( expression: KtExpression, span: TextSpan, frame: ScopeFrame, -): ScopeOption { + file: KtFile, +): ScopeOption? { val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) val writes = writeOffsetsFor(expression, frame.scopeElement) val occurrences = excludeUnsoundOccurrences(matches, span, writes) val anchorForm = when (val form = frame.anchorForm) { - is AnchorForm.ConvertExpressionBody -> form.copy(needsReturn = expressionBodyNeedsReturn(frame.scopeElement)) - else -> form + is AnchorForm.ConvertExpressionBody -> { + val declaration = frame.scopeElement.parent as? KtDeclarationWithBody + val needsReturn = expressionBodyNeedsReturn(frame.scopeElement) + val returnTypeText = + if (needsReturn && declaration != null && !declaration.declaresReturnType()) { + returnTypeTextOf(declaration, file) ?: return null + } else { + null + } + form.copy(needsReturn = needsReturn, returnTypeText = returnTypeText) + } + + else -> { + form + } } return ScopeOption(label = frame.label, anchorForm = anchorForm, occurrences = occurrences) } +/** Whether the declaration spells its return type out, in which case nothing needs writing. */ +private fun KtDeclarationWithBody.declaresReturnType(): Boolean = + when (this) { + // KtPropertyAccessor.returnTypeReference is deprecated in favour of the identical typeReference. + is KtPropertyAccessor -> typeReference != null + + is KtCallableDeclaration -> typeReference != null + + else -> false + } + +/** The declaration's return type as source text, shortened where the file can resolve it. */ +private fun KaSession.returnTypeTextOf( + declaration: KtDeclarationWithBody, + file: KtFile, +): String? { + val type = runCatching { ((declaration as? KtDeclaration)?.symbol as? KaCallableSymbol)?.returnType }.getOrNull() ?: return null + val rendered = renderedTypeTextOrNull(type) ?: return null + return shortenTypeText(rendered, importedNamesOf(file), starImportedPackagesOf(file)) +} + /** * Whether converting an expression body to a block body needs a `return`. * diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt index 47d1f43538..e96c76aefb 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -46,6 +46,10 @@ sealed interface AnchorForm { * An expression-bodied function or property accessor -- `fun area(r: Int) = r * r`. The `=` and * the body are replaced by a block body. [needsReturn] is false only when the declaration * returns `Unit`, where `return` is both unnecessary and wrong for a non-`Unit` expression. + * + * [returnTypeText] is the type to write into the signature, or null when there is nothing to write + * -- the declaration already spells its type out, or the block body infers `Unit` anyway. A block + * body with no declared type returns `Unit`, so `return ` without this would not compile. */ data class ConvertExpressionBody( val assignStart: Int, @@ -54,6 +58,7 @@ sealed interface AnchorForm { val indent: String, val innerIndent: String, val needsReturn: Boolean, + val returnTypeText: String? = null, ) : AnchorForm } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt new file mode 100644 index 0000000000..4db23f4256 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt @@ -0,0 +1,98 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.utils.renderName +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource +import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.psi.KtFile + +/** + * Types are rendered **fully qualified** and only then shortened against what the file can resolve. + * + * A short name resolves only when the file imports it or it comes from a default-imported package, and + * a refactoring that adds imports would be a much larger change -- so qualified is the safe starting + * point and [shortenTypeText] gives back readability where it provably costs nothing. + */ +@OptIn(KaExperimentalApi::class) +private val QUALIFIED_TYPE_RENDERER = KaTypeRendererForSource.WITH_QUALIFIED_NAMES + +/** Packages whose simple names resolve with no import at all on the JVM/Android target. */ +private val DEFAULT_IMPORTED_PACKAGES = + setOf( + "kotlin", + "kotlin.annotation", + "kotlin.collections", + "kotlin.comparisons", + "kotlin.io", + "kotlin.jvm", + "kotlin.ranges", + "kotlin.sequences", + "kotlin.text", + "java.lang", + ) + +/** A dotted run of identifiers -- one qualified name inside rendered type text. */ +private val QUALIFIED_NAME = Regex("""[\p{L}_][\p{L}\p{Nd}_]*(?:\.[\p{L}_][\p{L}\p{Nd}_]*)+""") + +/** + * A type that cannot be written out as source -- anonymous, intersection, a resolution error, or a + * platform type the renderer could not reduce (`List`, where the `!` is on a type argument). + * `!` is not Kotlin syntax anywhere, so its presence alone settles it. + */ +internal fun isUnrenderableTypeText(text: String): Boolean = + text.isBlank() || + text.contains("anonymous") || + text.contains("ERROR") || + text.contains(" & ") || + text.contains('!') + +/** + * One type as source text, fully qualified, or null when it cannot be written out. + * + * A platform type is unwrapped to its lower bound first: the renderer prints `String!`, which does not + * parse. Only the outermost bound is unwrapped, so a `!` on a type argument still reaches + * [isUnrenderableTypeText]. + */ +@OptIn(KaExperimentalApi::class) +internal fun KaSession.renderedTypeTextOrNull(type: KaType): String? = + runCatching { renderName((type as? KaFlexibleType)?.lowerBound ?: type, QUALIFIED_TYPE_RENDERER) } + .getOrNull() + ?.takeUnless(::isUnrenderableTypeText) + +/** + * Replaces each qualified name in [rendered] with its simple name when that name already resolves in + * the file -- because the file imports it exactly, star-imports its package, or it comes from a + * default-imported package. Everything else stays qualified: verbose, but it always compiles. + * + * Purely textual, so it needs no analysis session and is unit-testable on its own. A nested class + * (`com.example.Outer.Inner`) is only shortened by an import of the nested name itself; an import of + * the outer class leaves it alone rather than emitting an unresolvable `Inner`. + */ +internal fun shortenTypeText( + rendered: String, + importedNames: Set, + starImportedPackages: Set, +): String = + QUALIFIED_NAME.replace(rendered) { match -> + val qualified = match.value + val container = qualified.substringBeforeLast('.') + val resolvable = + qualified in importedNames || + container in DEFAULT_IMPORTED_PACKAGES || + container in starImportedPackages + if (resolvable) qualified.substringAfterLast('.') else qualified + } + +/** The fully qualified names [file] imports by name. Syntactic: no analysis session needed. */ +internal fun importedNamesOf(file: KtFile): Set = + file.importDirectives + .filterNot { it.isAllUnder } + .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } + +/** The packages [file] star-imports (`import com.example.*`). */ +internal fun starImportedPackagesOf(file: KtFile): Set = + file.importDirectives + .filter { it.isAllUnder } + .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt index 334146c19e..5f4c810269 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -245,6 +245,32 @@ class ExtractVariableEditTest { ) } + @Test + fun `writes the return type into the signature when the declaration has none`() { + val text = "fun area(r: Int) = r * r" + val candidate = spanOf(text, "r * r") + val form = + AnchorForm.ConvertExpressionBody( + assignStart = text.indexOf('='), + bodyStart = candidate.start, + bodyEnd = text.length, + indent = "", + innerIndent = "\t", + needsReturn = true, + returnTypeText = "Int", + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "squared", replaceAll = false)!! + + assertEquals( + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(text, result), + ) + } + @Test fun `null when there is nothing to replace`() { val text = "fun f() {}" diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 0db9c2cbb8..00a1c2c1b6 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -342,7 +342,7 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { assertEquals( """ package p - fun area(r: Int) { + fun area(r: Int): Int { val square = r * r return square + square } @@ -460,4 +460,100 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { .map { it.label }, ) } + + @Test + fun `converting an inferred-type expression body writes the type out`() { + val content = + """ + package p + fun area(r: Int) = r * r + """.trimIndent() + + val target = "r * r" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "squared", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `a declared return type is not written twice`() { + val content = + """ + package p + fun area(r: Int): Int = r * r + """.trimIndent() + + val target = "r * r" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "squared", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun area(r: Int): Int {\n" + + "\tval squared = r * r\n" + + "\treturn squared\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `a Unit-returning expression body gets neither a type nor a return`() { + val content = + """ + package p + fun report(value: Int) { + println(value) + } + fun show(text: String) = report(text.length + 1) + """.trimIndent() + + val target = "text.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun report(value: Int) {\n" + + "\tprintln(value)\n" + + "}\n" + + "fun show(text: String) {\n" + + "\tval length = text.length + 1\n" + + "\treport(length)\n" + + "}", + apply(content, rewrite), + ) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt index 1d212b8404..45bc4751ef 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt @@ -1,7 +1,9 @@ package com.itsaky.androidide.lsp.kotlin.utils.refactor import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue import org.junit.Test /** The analysis-free primitives the refactoring is built from: name rules, indentation, soundness. */ @@ -139,4 +141,47 @@ class RefactorPrimitivesTest { excludeUnsoundOccurrences(listOf(TextSpan(10, 20)), TextSpan(70, 80), writeOffsets = emptyList()), ) } + + @Test + fun `shortens types from Kotlin's default-imported packages`() { + assertEquals("Int", shortenTypeText("kotlin.Int", emptySet(), emptySet())) + assertEquals( + "List", + shortenTypeText("kotlin.collections.List", emptySet(), emptySet()), + ) + } + + @Test + fun `keeps a type qualified when its short name would not resolve`() { + assertEquals("java.util.Date", shortenTypeText("java.util.Date", emptySet(), emptySet())) + // An import of the enclosing class is not an import of the nested one. + assertEquals( + "com.example.Outer.Inner", + shortenTypeText("com.example.Outer.Inner", setOf("com.example.Outer"), emptySet()), + ) + } + + @Test + fun `shortens a type the file already imports, by name or by star`() { + assertEquals("Date", shortenTypeText("java.util.Date", setOf("java.util.Date"), emptySet())) + assertEquals("Date", shortenTypeText("java.util.Date", emptySet(), setOf("java.util"))) + assertEquals( + "Flow", + shortenTypeText( + "kotlinx.coroutines.flow.Flow", + setOf("kotlinx.coroutines.flow.Flow", "com.example.Widget"), + emptySet(), + ), + ) + } + + @Test + fun `unrenderable type text is recognised`() { + assertTrue(isUnrenderableTypeText("")) + assertTrue(isUnrenderableTypeText("kotlin.collections.List")) + assertTrue(isUnrenderableTypeText("")) + assertTrue(isUnrenderableTypeText("ERROR CLASS: unresolved")) + assertTrue(isUnrenderableTypeText("kotlin.Any & kotlin.Comparable<*>")) + assertFalse(isUnrenderableTypeText("kotlin.Int")) + } } From a69b67984914a051f43e9995f072070540c23e10 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 15:27:34 +0000 Subject: [PATCH 10/25] ADFA-4826: Anchor the declaration in the scope the user picked --- docs/features/kotlin-extract-variable.md | 8 +- .../utils/refactor/ExtractVariableEdit.kt | 33 ++-- .../kotlin/utils/refactor/ExtractionPlan.kt | 19 ++- .../lsp/kotlin/utils/refactor/ScopeChain.kt | 26 +++- .../ui/ExtractVariableViewModelTest.kt | 2 +- .../utils/refactor/ExtractVariableEditTest.kt | 146 +++++++++++++++++- .../ExtractVariablePlanEndToEndTest.kt | 40 +++++ 7 files changed, 243 insertions(+), 31 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index ffb53df5a6..5be55864f2 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -39,7 +39,9 @@ The chain member the user picked. The `val` is declared inside it. How the declaration is woven into an anchor scope, since not all Kotlin scopes are blocks: `ExistingBlock`, `WrapInBraces`, or `ConvertExpressionBody`. **Anchor point**: -The exact insertion offset - immediately before the first statement *within the anchor scope* that contains a replaced occurrence. +The exact insertion offset - the start of the line holding the first statement *within the anchor +scope* that contains a replaced occurrence. Recorded per rung in the plan (`ExistingBlock`'s +`statementSpans`), because it is the only thing that makes an outer rung differ from an inner one. **Occurrence**: A site inside the anchor scope that is structurally equal to the candidate *and* whose every name reference resolves to the same declaration. Sites made unsound by an intervening write are excluded, so an occurrence set is always safe to replace wholesale. @@ -142,6 +144,9 @@ Each chooser is hidden when it has nothing to ask: the expression chooser when t **R9 - Edit.** Exactly **one** `TextEdit`, built as a `RewriteSpan` covering one contiguous span. `IDELanguageClientImpl.applyActionEdits` applies each edit in its own `runOnUiThread` with no `beginBatchEdit`, and every range is interpreted against the *current* text - so a list of N edits would be applied against positions already shifted by its predecessors and would cost N undo steps with a typing window between each. Occurrences are substituted right-to-left within the span so an earlier substitution cannot shift a later offset. +The span is anchored on the chosen rung's statement, not on the occurrence: for an outer rung the +declaration goes above the whole enclosing statement, at that statement's indentation. + The emitted text is **fully indented**: code-action edits bypass the editor's auto-indent (raw `Content.replace`), and `CMD_FORMAT_CODE` is a no-op for Kotlin. The indent unit is inferred from the file's own lines (a tab if any line is tab-indented, else the smallest positive run of leading spaces, defaulting to a tab), mirroring `ImplementMembersAction`; CRLF is used only when the file already contains it, so the edit never mixes line endings. **R10 - Responsiveness.** One background analysis pass produces the plan for *all* candidates at once; the sheet then performs pure string and offset arithmetic on it. Nothing re-enters analysis on confirm, which keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation unit-testable without an editor, an activity or Compose. Analysis runs at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine, so cancelling the action aborts the analysis. @@ -169,6 +174,7 @@ The emitted text is **fully indented**: code-action edits bypass the editor's au 7. The same expression with an intervening reassignment of a `var` it reads offers only the contiguous sound run. 8. An expression using `it` inside a lambda offers no anchor outside that lambda. 9. Extracting from `if (c) foo(x + 1)` wraps the branch in braces with the declaration inside. +9a. With a candidate inside a braced `if` inside a function, picking `fun name` in `Declare in` puts the declaration above the `if`, and picking `if block` puts it inside the branch. 10. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. 11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. 12. A name that is blank, not an identifier, a hard keyword, or already used disables Extract and shows the matching message. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index a4215500e6..712703ddd8 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -44,37 +44,42 @@ fun buildExtractVariableRewrite( val declaration = "val $name = $expression" return when (val form = scope.anchorForm) { - AnchorForm.ExistingBlock -> existingBlockRewrite(fileText, targets, declaration, name) + is AnchorForm.ExistingBlock -> existingBlockRewrite(fileText, form, targets, declaration, name) is AnchorForm.WrapInBraces -> wrapInBracesRewrite(fileText, form, targets, declaration, name) is AnchorForm.ConvertExpressionBody -> convertExpressionBodyRewrite(fileText, form, targets, declaration, name) } } /** - * Inserts the declaration as its own line before the first served occurrence's line, and rewrites - * everything from there through the last occurrence. + * Inserts the declaration as its own line before the anchor statement, and rewrites everything from + * there through the last occurrence. * - * The rewritten span starts at that line's start (not at the occurrence) so the declaration lands on - * a line of its own at the right indentation, and ends at the last occurrence so untouched trailing - * code is left alone. + * The anchor is the statement *of this scope* that holds the first served occurrence, so picking an + * outer rung hoists the declaration above the enclosing statement rather than leaving it where the + * inner rung would have put it. The rewritten span starts at that statement's line start so the + * declaration lands on a line of its own at the right indentation, and ends at the last occurrence so + * untouched trailing code is left alone. + * + * Null when no statement of the scope contains the occurrence, which would mean the plan and the text + * disagree; the caller reports that rather than guessing. */ private fun existingBlockRewrite( fileText: String, + form: AnchorForm.ExistingBlock, targets: List, declaration: String, name: String, -): RewriteSpan { +): RewriteSpan? { val first = targets.first() val last = targets.last() - val lineStart = lineStartOffset(fileText, first.start) - val indent = leadingIndentAt(fileText, first.start) + val anchor = form.statementSpans.firstOrNull { it.start <= first.start && first.end <= it.end } ?: return null + val lineStart = lineStartOffset(fileText, anchor.start) + val indent = leadingIndentAt(fileText, anchor.start) val newline = detectNewline(fileText) - val body = replaceOccurrences(fileText, TextSpan(lineStart, last.end), targets, name) - return RewriteSpan( - span = TextSpan(lineStart, last.end), - newText = indent + declaration + newline + body, - ) + val span = TextSpan(lineStart, last.end) + val body = replaceOccurrences(fileText, span, targets, name) + return RewriteSpan(span = span, newText = indent + declaration + newline + body) } /** Wraps a braceless statement in a block containing the declaration and the original statement. */ diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt index e96c76aefb..379fe89960 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -21,14 +21,21 @@ data class TextSpan( sealed interface AnchorForm { /** * The scope already has a `{ ... }` body (function body, `if` block, lambda body, ...), so the - * declaration is simply a new statement line. + * declaration is a new statement line inside it. * - * Deliberately field-free: the insertion offset and indentation are both derived from the first - * occurrence being served, which is the candidate itself when replacing only one site and an - * earlier statement when replacing all. Storing a precomputed anchor would duplicate that and - * let the two drift apart. + * [statementSpans] are the block's direct child statements, ascending. The anchor point is the + * first of them containing the first served occurrence -- which is what makes an outer rung differ + * from an inner one. Anchoring on the occurrence's own line instead would make every rung of a + * chain produce the same edit. + * + * [contentSpan] is the region *inside* the braces. It tells a block written on one line + * (`items.map { it.length + 1 }`) from a multi-line one, where inserting at the statement's line + * start would put the declaration outside the braces. */ - data object ExistingBlock : AnchorForm + data class ExistingBlock( + val contentSpan: TextSpan, + val statementSpans: List, + ) : AnchorForm /** * A braceless statement position -- `if (c) foo()`, a `when` entry, a braceless loop body. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt index 1361d45080..97ec38ddf2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt @@ -112,7 +112,12 @@ private fun frameFor( scopeElement = parent, searchRange = parent.textRange.let { TextSpan(it.startOffset, it.endOffset) }, statementSpan = TextSpan(lineStart, inner.textRange.endOffset), - anchorForm = AnchorForm.ExistingBlock, + anchorForm = + AnchorForm.ExistingBlock( + contentSpan = contentSpanOf(parent), + statementSpans = + parent.statements.map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) }, + ), ) } @@ -241,6 +246,25 @@ private fun bracelessOwnerLabel( } } +/** + * The region inside a block's braces. + * + * A function, `if` or loop body owns its braces, so they are trimmed off. A lambda body block does not + * -- the braces and any `param ->` header belong to the enclosing function literal -- so its own range + * already *is* the content, which is what keeps the header on the brace line when the block is + * expanded. Deriving this from the block's text rather than from brace PSI keeps one code path for + * both shapes. + */ +internal fun contentSpanOf(block: KtBlockExpression): TextSpan { + val range = block.textRange + val text = block.text + return if (text.length >= 2 && text.startsWith("{") && text.endsWith("}")) { + TextSpan(range.startOffset + 1, range.endOffset - 1) + } else { + TextSpan(range.startOffset, range.endOffset) + } +} + /** Offset of the start of the line containing [offset]. */ internal fun lineStartOffset( text: String, diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt index 4f25b9a3aa..1b008a941b 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt @@ -25,7 +25,7 @@ class ExtractVariableViewModelTest { occurrences: Int, ) = ScopeOption( label = label, - anchorForm = AnchorForm.ExistingBlock, + anchorForm = AnchorForm.ExistingBlock(contentSpan = TextSpan(0, 100), statementSpans = emptyList()), occurrences = (0 until occurrences).map { TextSpan(it * 10, it * 10 + 5) }, ) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt index 5f4c810269..afb3b5cecf 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -42,6 +42,18 @@ class ExtractVariableEditTest { return spans } + /** + * The block rung of a single-block fixture: content is everything between the first `{` and the + * last `}`, and [statements] are the block's direct child statements in source order. + */ + private fun existingBlock( + text: String, + vararg statements: String, + ) = AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = statements.map { spanOf(text, it) }, + ) + private fun rewrite( text: String, candidate: TextSpan, @@ -62,7 +74,15 @@ class ExtractVariableEditTest { val text = "fun f(items: List) {\n\tprintln(items.size * 2)\n}" val candidate = spanOf(text, "items.size * 2") - val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! assertEquals( "fun f(items: List) {\n" + @@ -85,7 +105,15 @@ class ExtractVariableEditTest { // The user selected the middle one; the declaration must still hoist above the first. val candidate = occurrences[1] - val result = rewrite(text, candidate, AnchorForm.ExistingBlock, occurrences, "size", replaceAll = true)!! + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)", "log(items.size * 2)", "use(items.size * 2)"), + occurrences, + "size", + replaceAll = true, + )!! assertEquals( "fun f(items: List) {\n" + @@ -107,7 +135,15 @@ class ExtractVariableEditTest { "}" val occurrences = allSpansOf(text, "items.size * 2") - val result = rewrite(text, occurrences[0], AnchorForm.ExistingBlock, occurrences, "size", replaceAll = false)!! + val result = + rewrite( + text, + occurrences[0], + existingBlock(text, "println(items.size * 2)", "log(items.size * 2)"), + occurrences, + "size", + replaceAll = false, + )!! assertEquals( "fun f(items: List) {\n" + @@ -124,7 +160,15 @@ class ExtractVariableEditTest { val text = "fun f(items: List) {\n println(items.size * 2)\n}" val candidate = spanOf(text, "items.size * 2") - val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! assertEquals( "fun f(items: List) {\n" + @@ -140,7 +184,15 @@ class ExtractVariableEditTest { val text = "fun f(items: List) {\r\n\tprintln(items.size * 2)\r\n}" val candidate = spanOf(text, "items.size * 2") - val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! assertEquals( "fun f(items: List) {\r\n" + @@ -156,7 +208,15 @@ class ExtractVariableEditTest { val text = "class C {\n\tfun f(items: List) {\n\t\tprintln(items.size * 2)\n\t}\n}" val candidate = spanOf(text, "items.size * 2") - val result = rewrite(text, candidate, AnchorForm.ExistingBlock, listOf(candidate), "size", replaceAll = false)!! + val result = + rewrite( + text, + candidate, + existingBlock(text, "println(items.size * 2)"), + listOf(candidate), + "size", + replaceAll = false, + )!! assertEquals( "class C {\n" + @@ -278,7 +338,7 @@ class ExtractVariableEditTest { buildExtractVariableRewrite( fileText = text, candidateSpan = TextSpan(0, 3), - scope = ScopeOption("scope", AnchorForm.ExistingBlock, emptyList()), + scope = ScopeOption("scope", AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), emptyList()), name = "value", replaceAll = true, ), @@ -292,13 +352,83 @@ class ExtractVariableEditTest { buildExtractVariableRewrite( fileText = text, candidateSpan = TextSpan(0, 3), - scope = ScopeOption("scope", AnchorForm.ExistingBlock, listOf(TextSpan(0, text.length + 5))), + scope = + ScopeOption( + "scope", + AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), + listOf(TextSpan(0, text.length + 5)), + ), name = "value", replaceAll = true, ), ) } + @Test + fun `the inner rung declares inside the if block`() { + val text = + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\treturn a + b * 2\n" + + "\t}\n" + + "\treturn 0\n" + + "}" + val candidate = spanOf(text, "a + b * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, "\n\t\treturn a + b * 2\n\t"), + statementSpans = listOf(spanOf(text, "return a + b * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! + + assertEquals( + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\tval total = a + b * 2\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `the outer rung declares above the enclosing statement`() { + val text = + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tif (flag) {\n" + + "\t\treturn a + b * 2\n" + + "\t}\n" + + "\treturn 0\n" + + "}" + val candidate = spanOf(text, "a + b * 2") + // The function block's rung: its statements are the whole `if` and the trailing `return 0`. + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = + listOf( + spanOf(text, "if (flag) {\n\t\treturn a + b * 2\n\t}"), + spanOf(text, "return 0"), + ), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! + + assertEquals( + "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tval total = a + b * 2\n" + + "\tif (flag) {\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(text, result), + ) + } + @Test fun `position index line and column all agree`() { val text = "aa\nbbb\nc" diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 00a1c2c1b6..5801109cbc 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -521,6 +521,46 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `picking the outer rung hoists the declaration above the enclosing statement`() { + val content = + """ + package p + fun demo(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b * 2 + } + return 0 + } + """.trimIndent() + + val target = "a + b * 2" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + assertEquals(listOf("if block", "fun demo"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes[1], + name = "total", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(flag: Boolean, a: Int, b: Int): Int {\n" + + "\tval total = a + b * 2\n" + + "\tif (flag) {\n" + + "\t\treturn total\n" + + "\t}\n" + + "\treturn 0\n" + + "}", + apply(content, rewrite), + ) + } + @Test fun `a Unit-returning expression body gets neither a type nor a return`() { val content = From 1a89480b2ddfc646279920618f164e2fb2d2ce8c Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 16:08:05 +0000 Subject: [PATCH 11/25] ADFA-4826: Cover contentSpanOf and fix a nested-block fixture --- .../utils/refactor/ExtractVariableEditTest.kt | 12 +++- .../ExtractVariablePlanEndToEndTest.kt | 66 +++++++++++++++++++ 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt index afb3b5cecf..58fb0e672c 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -45,6 +45,9 @@ class ExtractVariableEditTest { /** * The block rung of a single-block fixture: content is everything between the first `{` and the * last `}`, and [statements] are the block's direct child statements in source order. + * + * Only correct for a fixture with exactly one brace pair -- a nested one (e.g. a class wrapping a + * function) needs its `AnchorForm.ExistingBlock` built by hand instead. */ private fun existingBlock( text: String, @@ -207,12 +210,19 @@ class ExtractVariableEditTest { fun `deeper indentation is preserved`() { val text = "class C {\n\tfun f(items: List) {\n\t\tprintln(items.size * 2)\n\t}\n}" val candidate = spanOf(text, "items.size * 2") + // Two brace pairs are nested here, so `existingBlock`'s "first { .. last }" heuristic would + // grab the class's braces instead of `fun f`'s -- built by hand for the inner pair instead. + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, "\n\t\tprintln(items.size * 2)\n\t"), + statementSpans = listOf(spanOf(text, "println(items.size * 2)")), + ) val result = rewrite( text, candidate, - existingBlock(text, "println(items.size * 2)"), + form, listOf(candidate), "size", replaceAll = false, diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 5801109cbc..d1f9559bec 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -1,6 +1,11 @@ package com.itsaky.androidide.lsp.kotlin.utils.refactor import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtIfExpression +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtNamedFunction import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull @@ -561,6 +566,67 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `contentSpanOf finds the region inside a block's braces`() { + val content = + """ + package p + fun functionBody(a: Int, b: Int): Int { + return a + b + } + fun ifBody(flag: Boolean, a: Int, b: Int): Int { + if (flag) { + return a + b + } + return 0 + } + fun lambdaWithHeader(items: List): List { + return items.map { x -> x + 1 } + } + fun lambdaWithoutHeader(items: List): List { + return items.map { it + 1 } + } + fun emptyBody() {} + """.trimIndent() + val ktFile = createSourceFile("Main.kt", content) + val functions = ktFile.declarations.filterIsInstance().associateBy { it.name } + + fun contentOf(block: KtBlockExpression): String { + val span = contentSpanOf(block) + return content.substring(span.start, span.end) + } + + assertEquals("\n\treturn a + b\n", contentOf(functions.getValue("functionBody").bodyBlockExpression!!)) + + val ifBody = functions.getValue("ifBody").bodyBlockExpression!! + val ifThen = PsiTreeUtil.findChildOfType(ifBody, KtIfExpression::class.java)!!.then as KtBlockExpression + assertEquals("\n\tif (flag) {\n\t\treturn a + b\n\t}\n\treturn 0\n", contentOf(ifBody)) + assertEquals("\n\t\treturn a + b\n\t", contentOf(ifThen)) + + val lambdaWithHeaderBody = + PsiTreeUtil + .findChildOfType( + functions.getValue("lambdaWithHeader").bodyBlockExpression, + KtLambdaExpression::class.java, + )!! + .bodyExpression!! + val lambdaWithHeaderContent = contentOf(lambdaWithHeaderBody) + // The `x ->` header belongs to the enclosing function literal, not to this block. + assertFalse(lambdaWithHeaderContent.contains("->")) + assertEquals("x + 1", lambdaWithHeaderContent.trim()) + + val lambdaWithoutHeaderBody = + PsiTreeUtil + .findChildOfType( + functions.getValue("lambdaWithoutHeader").bodyBlockExpression, + KtLambdaExpression::class.java, + )!! + .bodyExpression!! + assertEquals("it + 1", contentOf(lambdaWithoutHeaderBody).trim()) + + assertEquals("", contentOf(functions.getValue("emptyBody").bodyBlockExpression!!)) + } + @Test fun `a Unit-returning expression body gets neither a type nor a return`() { val content = From b9877f81de46777593cd4c0f0c0408db948c315c Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 16:20:30 +0000 Subject: [PATCH 12/25] ADFA-4826: Expand a block written on one line --- docs/features/kotlin-extract-variable.md | 8 +++ .../utils/refactor/ExtractVariableEdit.kt | 52 ++++++++++++++ .../utils/refactor/ExtractVariableEditTest.kt | 68 +++++++++++++++++++ .../ExtractVariablePlanEndToEndTest.kt | 37 ++++++++++ 4 files changed, 165 insertions(+) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index 5be55864f2..d58f7e2eba 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -147,6 +147,13 @@ Each chooser is hidden when it has nothing to ask: the expression chooser when t The span is anchored on the chosen rung's statement, not on the occurrence: for an outer rung the declaration goes above the whole enclosing statement, at that statement's indentation. +A block written on one line -- `items.map { it.length + 1 }`, `fun f(n: Int): Int { return n * 2 }`, +a one-line `if` body -- is expanded instead: the content between the braces moves onto its own line +with the declaration above it and the closing brace below. Anchoring on the statement's line start +there would place the declaration *before* the `{`, outside the scope the value belongs to, which +leaves a lambda's `it` unresolved. The braces themselves and a lambda's `param ->` header are left +where they are. + The emitted text is **fully indented**: code-action edits bypass the editor's auto-indent (raw `Content.replace`), and `CMD_FORMAT_CODE` is a no-op for Kotlin. The indent unit is inferred from the file's own lines (a tab if any line is tab-indented, else the smallest positive run of leading spaces, defaulting to a tab), mirroring `ImplementMembersAction`; CRLF is used only when the file already contains it, so the edit never mixes line endings. **R10 - Responsiveness.** One background analysis pass produces the plan for *all* candidates at once; the sheet then performs pure string and offset arithmetic on it. Nothing re-enters analysis on confirm, which keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation unit-testable without an editor, an activity or Compose. Analysis runs at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine, so cancelling the action aborts the analysis. @@ -175,6 +182,7 @@ The emitted text is **fully indented**: code-action edits bypass the editor's au 8. An expression using `it` inside a lambda offers no anchor outside that lambda. 9. Extracting from `if (c) foo(x + 1)` wraps the branch in braces with the declaration inside. 9a. With a candidate inside a braced `if` inside a function, picking `fun name` in `Declare in` puts the declaration above the `if`, and picking `if block` puts it inside the branch. +9b. Extracting from `return items.map { it.length + 1 }` puts the declaration inside the lambda and expands the block over three lines; the same holds for a one-line function body. 10. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. 11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. 12. A name that is blank, not an identifier, a hard keyword, or already used disables Extract and shows the matching message. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index 712703ddd8..e739ba8491 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -74,6 +74,13 @@ private fun existingBlockRewrite( val last = targets.last() val anchor = form.statementSpans.firstOrNull { it.start <= first.start && first.end <= it.end } ?: return null val lineStart = lineStartOffset(fileText, anchor.start) + + // The statement shares its line with the block's opening brace (a one-line lambda or body). The + // line start is then *outside* the block, so the declaration has to go inside the braces instead. + if (lineStart < form.contentSpan.start) { + return oneLineBlockRewrite(fileText, form, targets, declaration, name) + } + val indent = leadingIndentAt(fileText, anchor.start) val newline = detectNewline(fileText) @@ -82,6 +89,41 @@ private fun existingBlockRewrite( return RewriteSpan(span = span, newText = indent + declaration + newline + body) } +/** + * Puts the declaration inside a block written on one line, moving the block's content and its closing + * brace onto their own lines. + * + * Only the content between the braces is rewritten: the braces, and a lambda's `param ->` header, + * stay exactly where they are, so the expansion cannot disturb the call around it. + */ +private fun oneLineBlockRewrite( + fileText: String, + form: AnchorForm.ExistingBlock, + targets: List, + declaration: String, + name: String, +): RewriteSpan { + val content = form.contentSpan + val newline = detectNewline(fileText) + val indent = leadingIndentAt(fileText, content.start) + val innerIndent = indent + detectIndentUnit(fileText) + + // A block that does not own its braces (a lambda body) stops short of them, leaving a single + // space between the content span and the brace on each side. Widen the replaced span over that + // gap so it does not survive the rewrite as a stray "{ " or " }". + val span = TextSpan(startOfWhitespaceBefore(fileText, content.start), endOfWhitespaceAfter(fileText, content.end)) + val body = replaceOccurrences(fileText, content, targets, name).trim() + + val newText = + buildString { + append(newline) + append(innerIndent).append(declaration).append(newline) + append(innerIndent).append(body).append(newline) + append(indent) + } + return RewriteSpan(span = span, newText = newText) +} + /** Wraps a braceless statement in a block containing the declaration and the original statement. */ private fun wrapInBracesRewrite( fileText: String, @@ -145,6 +187,16 @@ private fun startOfWhitespaceBefore( return index } +/** The offset where the run of whitespace starting at [offset] ends. */ +private fun endOfWhitespaceAfter( + text: String, + offset: Int, +): Int { + var index = offset.coerceIn(0, text.length) + while (index < text.length && text[index].isWhitespace()) index++ + return index +} + /** * Returns `[span]`'s text with every occurrence inside it replaced by [name]. Substitutes * right-to-left so an earlier replacement cannot invalidate a later offset. diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt index 58fb0e672c..9214ad9cdd 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -447,4 +447,72 @@ class ExtractVariableEditTest { assertEquals(0, position.column) assertEquals(7, position.index) } + + @Test + fun `expands a one-line lambda so the declaration lands inside the braces`() { + val text = "fun f(items: List): List {\n\treturn items.map { it.length + 1 }\n}" + val candidate = spanOf(text, "it.length + 1") + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, " it.length + 1 "), + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval length = it.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `expanding a one-line lambda keeps its parameter header on the brace line`() { + val text = "fun f(items: List): List {\n\treturn items.map { item -> item.length + 1 }\n}" + val candidate = spanOf(text, "item.length + 1") + // A lambda body block excludes the `item ->` header, so the header is outside the content span. + val form = + AnchorForm.ExistingBlock( + contentSpan = spanOf(text, " item.length + 1 "), + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map { item ->\n" + + "\t\tval length = item.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `expands a one-line function body`() { + val text = "fun f(n: Int): Int { return n * 2 }" + val candidate = spanOf(text, "n * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = listOf(spanOf(text, "return n * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "doubled", replaceAll = false)!! + + assertEquals( + "fun f(n: Int): Int {\n" + + "\tval doubled = n * 2\n" + + "\treturn doubled\n" + + "}", + apply(text, result), + ) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index d1f9559bec..4e517ff9fe 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -662,4 +662,41 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { apply(content, rewrite), ) } + + @Test + fun `extracting from a one-line lambda stays inside the lambda`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { it.length + 1 } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + // `it` is lambda-scoped, so the lambda is the ceiling: there is no outer rung to choose. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval length = it.length + 1\n" + + "\t\tlength\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } } From f9de1e8bace7459b4f4f5da9e18cdb17954915aa Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 16:38:36 +0000 Subject: [PATCH 13/25] ADFA-4826: Expand only a block that is really written on one line --- docs/features/kotlin-extract-variable.md | 10 ++ .../utils/refactor/ExtractVariableEdit.kt | 14 ++- .../utils/refactor/ExtractVariableEditTest.kt | 44 +++++++ .../ExtractVariablePlanEndToEndTest.kt | 115 ++++++++++++++++++ 4 files changed, 180 insertions(+), 3 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index d58f7e2eba..be4e44bd58 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -154,6 +154,16 @@ there would place the declaration *before* the `{`, outside the scope the value leaves a lambda's `it` unresolved. The braces themselves and a lambda's `param ->` header are left where they are. +Whether a block counts as "one line" takes two conditions, not one. A single check against where the +block's content starts is not enough: a lambda body's block does not own its braces, so its content +span sits at the body's first token even when that token starts its own line, and comparing that alone +against the line start would wrongly expand an ordinary multi-line lambda. Both must hold: something +other than indentation already precedes the statement on its line (the brace, a header, or a prior +semicolon-separated statement), *and* the block's own content contains no newline (so re-emitting it +as a single line loses nothing). A multi-line lambda fails the first and keeps its shape; a multi-line +block with two semicolon-separated statements on one line satisfies the first but fails the second, so +it also keeps its shape, with the declaration hoisted above the whole line instead. + The emitted text is **fully indented**: code-action edits bypass the editor's auto-indent (raw `Content.replace`), and `CMD_FORMAT_CODE` is a no-op for Kotlin. The indent unit is inferred from the file's own lines (a tab if any line is tab-indented, else the smallest positive run of leading spaces, defaulting to a tab), mirroring `ImplementMembersAction`; CRLF is used only when the file already contains it, so the edit never mixes line endings. **R10 - Responsiveness.** One background analysis pass produces the plan for *all* candidates at once; the sheet then performs pure string and offset arithmetic on it. Nothing re-enters analysis on confirm, which keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation unit-testable without an editor, an activity or Compose. Analysis runs at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine, so cancelling the action aborts the analysis. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index e739ba8491..8ea546b4e7 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -75,9 +75,17 @@ private fun existingBlockRewrite( val anchor = form.statementSpans.firstOrNull { it.start <= first.start && first.end <= it.end } ?: return null val lineStart = lineStartOffset(fileText, anchor.start) - // The statement shares its line with the block's opening brace (a one-line lambda or body). The - // line start is then *outside* the block, so the declaration has to go inside the braces instead. - if (lineStart < form.contentSpan.start) { + // A block written on one line needs the declaration expanded inside the braces instead of hoisted + // above the line. `contentSpan.start` is not a reliable signal by itself: a lambda body's block + // does not own its braces, so `contentSpan.start` sits at the body's first token even when that + // token starts its own line -- comparing it to `lineStart` alone would misfire on an ordinary + // multi-line lambda. Two conditions together are what actually mean "one line": something other + // than indentation already precedes the statement on its line (the brace, a header, or a prior + // semicolon-separated statement), *and* the block's content itself contains no newline (so + // re-emitting it as a single line loses nothing). + val linePrefix = fileText.substring(lineStart, anchor.start) + val contentIsOneLine = !fileText.substring(form.contentSpan.start, form.contentSpan.end).contains('\n') + if (linePrefix.isNotBlank() && contentIsOneLine) { return oneLineBlockRewrite(fileText, form, targets, declaration, name) } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt index 9214ad9cdd..a670badf17 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -515,4 +515,48 @@ class ExtractVariableEditTest { apply(text, result), ) } + + @Test + fun `widening is a no-op when a one-line lambda has no interior spaces`() { + val text = "fun f(items: List): List {\n\treturn items.map {it + 1}\n}" + val candidate = spanOf(text, "it + 1") + val form = + AnchorForm.ExistingBlock( + contentSpan = candidate, + statementSpans = listOf(candidate), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "value", replaceAll = false)!! + + assertEquals( + "fun f(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval value = it + 1\n" + + "\t\tvalue\n" + + "\t}\n" + + "}", + apply(text, result), + ) + } + + @Test + fun `keeps CRLF line endings when expanding a one-line block`() { + val text = "fun f(n: Int): Int { return n * 2 }\r\nval x = 1" + val candidate = spanOf(text, "n * 2") + val form = + AnchorForm.ExistingBlock( + contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), + statementSpans = listOf(spanOf(text, "return n * 2")), + ) + + val result = rewrite(text, candidate, form, listOf(candidate), "doubled", replaceAll = false)!! + + assertEquals( + "fun f(n: Int): Int {\r\n" + + "\tval doubled = n * 2\r\n" + + "\treturn doubled\r\n" + + "}\r\nval x = 1", + apply(text, result), + ) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 4e517ff9fe..c20f219957 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -699,4 +699,119 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { apply(content, rewrite), ) } + + @Test + fun `extracting from a multi-line lambda with a header on its own line is not collapsed`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { x -> + x + 1 + } + } + """.trimIndent() + + val target = "x + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + // `x` is the lambda's own parameter, so the lambda is still the ceiling. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "next", + replaceAll = false, + )!! + + // The body already starts its own line, so this is the normal path, not the one-line + // expansion: the header and the closing brace are left exactly where they were. + assertEquals( + "package p\n" + + "fun demo(items: List): List {\n" + + "\treturn items.map { x ->\n" + + "\t\tval next = x + 1\n" + + "\t\tnext\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `extracting from a multi-line lambda without a header is not collapsed`() { + val content = + """ + package p + fun demo(items: List): List { + return items.map { + it + 1 + } + } + """.trimIndent() + + val target = "it + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "next", + replaceAll = false, + )!! + + assertEquals( + "package p\n" + + "fun demo(items: List): List {\n" + + "\treturn items.map {\n" + + "\t\tval next = it + 1\n" + + "\t\tnext\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } + + @Test + fun `extracting from a semicolon-joined statement leaves the block multi-line`() { + val content = + """ + package p + fun demo(a: Int, b: Int): Int { + val x = a + 1; return x + b + } + """.trimIndent() + + val target = "x + b" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "sum", + replaceAll = false, + )!! + + // A statement already precedes the candidate on this line, but the block itself spans several + // lines, so this is not a one-line block: the declaration hoists above the whole line instead + // of expanding it, and the two semicolon-joined statements stay together. + assertEquals( + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + "\tval sum = x + b\n" + + "\tval x = a + 1; return sum\n" + + "}", + apply(content, rewrite), + ) + } } From 75944bdb19207e23c769a9c02ff937c4155a08ea Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 17:31:46 +0000 Subject: [PATCH 14/25] ADFA-4826: Split the type-text renderer from its catching form --- .../androidide/lsp/kotlin/utils/refactor/TypeText.kt | 12 ++++++++---- 1 file changed, 8 insertions(+), 4 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt index 4db23f4256..810caa658a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt @@ -54,12 +54,16 @@ internal fun isUnrenderableTypeText(text: String): Boolean = * A platform type is unwrapped to its lower bound first: the renderer prints `String!`, which does not * parse. Only the outermost bound is unwrapped, so a `!` on a type argument still reaches * [isUnrenderableTypeText]. + * + * Lets a failure from the renderer itself propagate, so a caller that must tell "the renderer threw" + * from "the type is unrenderable" can. [renderedTypeTextOrNull] is the catching form most callers want. */ @OptIn(KaExperimentalApi::class) -internal fun KaSession.renderedTypeTextOrNull(type: KaType): String? = - runCatching { renderName((type as? KaFlexibleType)?.lowerBound ?: type, QUALIFIED_TYPE_RENDERER) } - .getOrNull() - ?.takeUnless(::isUnrenderableTypeText) +internal fun KaSession.typeTextOrNull(type: KaType): String? = + renderName((type as? KaFlexibleType)?.lowerBound ?: type, QUALIFIED_TYPE_RENDERER) + .takeUnless(::isUnrenderableTypeText) + +internal fun KaSession.renderedTypeTextOrNull(type: KaType): String? = runCatching { typeTextOrNull(type) }.getOrNull() /** * Replaces each qualified name in [rendered] with its simple name when that name already resolves in From 9d8ca53200512428f199821a97e704eb520ea69e Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 18:27:35 +0000 Subject: [PATCH 15/25] ADFA-4826: Decline a block whose statement shares the brace line A block whose first served statement shares the opening-brace line but whose content spans several lines fell through the one-line-expansion check into the normal hoist path, anchoring above the block's own opening delimiter -- outside the scope the user picked. For a lambda this put the declaration where `it` is unresolved, emitting Kotlin that does not compile. Also fix contentSpanOf: it decided brace ownership by sniffing the block's own text for a leading `{` and trailing `}`, which misreads a lambda whose sole statement is itself a lambda literal (`{ x -> { x + 1 } }`) as owning its braces, returning the inner lambda's interior instead of the outer body's content. Ownership is now decided structurally, from the block's parent. --- docs/features/kotlin-extract-variable.md | 6 +++ .../utils/refactor/ExtractVariableEdit.kt | 11 ++++ .../lsp/kotlin/utils/refactor/ScopeChain.kt | 13 ++--- .../ExtractVariablePlanEndToEndTest.kt | 50 +++++++++++++++++++ 4 files changed, 74 insertions(+), 6 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index be4e44bd58..12b3753625 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -164,6 +164,12 @@ as a single line loses nothing). A multi-line lambda fails the first and keeps i block with two semicolon-separated statements on one line satisfies the first but fails the second, so it also keeps its shape, with the declaration hoisted above the whole line instead. +A block that fails *both* conditions -- something besides indentation precedes the statement on its +line, but the block's own content spans more than one line, as in `items.forEach { log(x)\n\tlog(y) }` +-- is **declined** rather than hoisted. Hoisting would anchor before the block's own opening delimiter, +outside the scope the user picked, which is unsound whenever anything inside that scope (a lambda's +`it`, say) is not visible there. + The emitted text is **fully indented**: code-action edits bypass the editor's auto-indent (raw `Content.replace`), and `CMD_FORMAT_CODE` is a no-op for Kotlin. The indent unit is inferred from the file's own lines (a tab if any line is tab-indented, else the smallest positive run of leading spaces, defaulting to a tab), mirroring `ImplementMembersAction`; CRLF is used only when the file already contains it, so the edit never mixes line endings. **R10 - Responsiveness.** One background analysis pass produces the plan for *all* candidates at once; the sheet then performs pure string and offset arithmetic on it. Nothing re-enters analysis on confirm, which keeps PSI off the UI thread, removes the stale-PSI window, and makes the whole derivation unit-testable without an editor, an activity or Compose. Analysis runs at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine, so cancelling the action aborts the analysis. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index 8ea546b4e7..ba2822a54f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -89,6 +89,17 @@ private fun existingBlockRewrite( return oneLineBlockRewrite(fileText, form, targets, declaration, name) } + // A lambda body's content starts right at its first token with no owned whitespace, so `lineStart` + // sits before `contentSpan.start` on plain indentation alone -- that gap must not trigger a + // decline. What does mean "outside the block" is *real code* in that gap: the block's own opening + // delimiter (a call and its brace, a header) sharing the anchor's line, which only happens for the + // multi-line case the one-line check above did not catch. Anchoring there would put the + // declaration before that delimiter, outside the scope the user picked. Declining is safe; hoisting + // is not. + if (form.contentSpan.start > lineStart && fileText.substring(lineStart, form.contentSpan.start).isNotBlank()) { + return null + } + val indent = leadingIndentAt(fileText, anchor.start) val newline = detectNewline(fileText) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt index 97ec38ddf2..73c7336284 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt @@ -252,16 +252,17 @@ private fun bracelessOwnerLabel( * A function, `if` or loop body owns its braces, so they are trimmed off. A lambda body block does not * -- the braces and any `param ->` header belong to the enclosing function literal -- so its own range * already *is* the content, which is what keeps the header on the brace line when the block is - * expanded. Deriving this from the block's text rather than from brace PSI keeps one code path for - * both shapes. + * expanded. Ownership is decided structurally, by the block's parent, rather than by sniffing the + * block's own text for a leading `{` and trailing `}`: a lambda body whose sole statement is itself a + * lambda literal (`{ x -> { x + 1 } }`) has text that looks brace-owned, and sniffing it would trim off + * that inner lambda's own braces and return its interior instead of the outer body's full content. */ internal fun contentSpanOf(block: KtBlockExpression): TextSpan { val range = block.textRange - val text = block.text - return if (text.length >= 2 && text.startsWith("{") && text.endsWith("}")) { - TextSpan(range.startOffset + 1, range.endOffset - 1) - } else { + return if (block.parent is KtFunctionLiteral) { TextSpan(range.startOffset, range.endOffset) + } else { + TextSpan(range.startOffset + 1, range.endOffset - 1) } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index c20f219957..d118ba1244 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -9,6 +9,7 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -587,6 +588,9 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { return items.map { it + 1 } } fun emptyBody() {} + fun nestedLambda(items: List): List<() -> Int> { + return items.map { x -> { x + 1 } } + } """.trimIndent() val ktFile = createSourceFile("Main.kt", content) val functions = ktFile.declarations.filterIsInstance().associateBy { it.name } @@ -625,6 +629,20 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { assertEquals("it + 1", contentOf(lambdaWithoutHeaderBody).trim()) assertEquals("", contentOf(functions.getValue("emptyBody").bodyBlockExpression!!)) + + // The outer lambda's sole statement is itself a lambda literal, so its text alone (`{ x + 1 }`) + // looks brace-owned; the content must still be that whole statement, not the inner lambda's + // interior. + val nestedOuterLambda = + PsiTreeUtil.findChildOfType( + functions.getValue("nestedLambda").bodyBlockExpression, + KtLambdaExpression::class.java, + )!! + val nestedOuterBody = nestedOuterLambda.bodyExpression!! + assertEquals("{ x + 1 }", contentOf(nestedOuterBody).trim()) + + val nestedInnerLambda = PsiTreeUtil.findChildOfType(nestedOuterBody, KtLambdaExpression::class.java)!! + assertEquals("x + 1", contentOf(nestedInnerLambda.bodyExpression!!).trim()) } @Test @@ -779,6 +797,38 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `declines a lambda whose first statement shares the brace line but the block spans several lines`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(items: List) { + items.forEach { log(it.length + 1) + log(it) } + } + """.trimIndent() + + val target = "it.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + // `it` is lambda-scoped, so the lambda body is the only legal anchor. + assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) + + // The statement shares the opening-brace line, but the block itself spans two lines, so this is + // not the one-line expansion case. Anchoring at the line start would put the declaration before + // the lambda's `{`, where `it` is out of scope -- declining is the only safe outcome here. + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + ) + assertNull(rewrite) + } + @Test fun `extracting from a semicolon-joined statement leaves the block multi-line`() { val content = From d00742dfc1abc2b66d8fca71c26a5d6259bf5042 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 18:28:09 +0000 Subject: [PATCH 16/25] ADFA-4826: Tidy the expression-body conversion and its docs Nothing was folded into the Unit case when deciding whether an expression-body conversion needs a `return`, so a Nothing-returning function (`fun boom() = error(...)`) lost both its `return` and its inferred return type, silently narrowing it to Unit and breaking a caller that uses it in a Nothing position (`x ?: boom()`). Only Unit is excluded now; Nothing goes through the normal return-type-writing path. Also: - Dedupe the symbol-to-return-type lookup into one KaSession.returnTypeOf, dropping the always-succeeding `as? KtDeclaration` cast. - ScopeChain: drop the unread ScopeFrame.statementSpan field and the dead `branch` local. - TypeText: document that the "anonymous"/"ERROR" substring checks in isUnrenderableTypeText are ambiguous but fail safe, and stop shortening a star-imported type when the file also imports a different type of the same simple name. - docs/features/kotlin-extract-variable.md: reword the Status line, the "Refactoring plan" glossary entry and a code comment that referenced the RefactoringPlan supertype and ADR 0013 as already landed -- both arrive with extract method (ADFA-5080); fix the "Anchor point" glossary entry to match the current anchoring behaviour; renumber the 9a/9b acceptance criteria into real ordered items. --- docs/features/kotlin-extract-variable.md | 20 +++++------ .../utils/refactor/ExtractVariableEdit.kt | 3 ++ .../utils/refactor/ExtractVariablePlanner.kt | 21 +++++++----- .../kotlin/utils/refactor/ExtractionPlan.kt | 5 +-- .../lsp/kotlin/utils/refactor/ScopeChain.kt | 9 +---- .../lsp/kotlin/utils/refactor/TypeText.kt | 14 ++++++-- .../ExtractVariablePlanEndToEndTest.kt | 34 +++++++++++++++++++ .../utils/refactor/RefactorPrimitivesTest.kt | 15 ++++++++ 8 files changed, 91 insertions(+), 30 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index 12b3753625..44e27107ef 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -1,7 +1,7 @@ # Kotlin extract variable (K2 LSP) - **Ticket:** ADFA-4826 (subtask of ADFA-3317; split out of the closed ADFA-3324 "Refactoring"). Extract method was originally part of this subtask and is now ADFA-5080. -- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename (the sealed `RefactoringPlan` supertype it will sit under has landed). +- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename under a sealed `RefactoringPlan` supertype, which arrives with extract method (ADFA-5080). - **Module:** `lsp/kotlin` Bind the expression at the cursor, or the selected one, to a new local `val`, and replace the occurrences of that expression with the new name. @@ -48,7 +48,7 @@ A site inside the anchor scope that is structurally equal to the candidate *and* _Avoid_: duplicate, match, usage. **Refactoring plan**: -The complete result of the background analysis pass - the sealed `RefactoringPlan`, carrying the analysed `fileText` and its `documentVersion`. Plain data: no PSI, no symbols, no session. `ExtractVariablePlan` is this refactoring's subtype. +The complete result of the background analysis pass, carrying the analysed `fileText` and its `documentVersion`. Plain data: no PSI, no symbols, no session. Currently `ExtractionPlan`; extract method (ADFA-5080) adds a sealed `RefactoringPlan` supertype and renames this to `ExtractVariablePlan`, its subtype. _Avoid_: model, result, context. **Rewrite span**: @@ -197,14 +197,14 @@ The emitted text is **fully indented**: code-action edits bypass the editor's au 7. The same expression with an intervening reassignment of a `var` it reads offers only the contiguous sound run. 8. An expression using `it` inside a lambda offers no anchor outside that lambda. 9. Extracting from `if (c) foo(x + 1)` wraps the branch in braces with the declaration inside. -9a. With a candidate inside a braced `if` inside a function, picking `fun name` in `Declare in` puts the declaration above the `if`, and picking `if block` puts it inside the branch. -9b. Extracting from `return items.map { it.length + 1 }` puts the declaration inside the lambda and expands the block over three lines; the same holds for a one-line function body. -10. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. -11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. -12. A name that is blank, not an identifier, a hard keyword, or already used disables Extract and shows the matching message. -13. Editing the file while the sheet is open, then confirming, reports "The file changed. Try extracting again." and leaves the file untouched. -14. One undo restores the file exactly. -15. A file indented with spaces receives space-indented output; a CRLF file keeps CRLF. +10. With a candidate inside a braced `if` inside a function, picking `fun name` in `Declare in` puts the declaration above the `if`, and picking `if block` puts it inside the branch. +11. Extracting from `return items.map { it.length + 1 }` puts the declaration inside the lambda and expands the block over three lines; the same holds for a one-line function body. +12. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. +13. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. +14. A name that is blank, not an identifier, a hard keyword, or already used disables Extract and shows the matching message. +15. Editing the file while the sheet is open, then confirming, reports "The file changed. Try extracting again." and leaves the file untouched. +16. One undo restores the file exactly. +17. A file indented with spaces receives space-indented output; a CRLF file keeps CRLF. ## Design diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index ba2822a54f..2eed7a334d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -38,6 +38,9 @@ fun buildExtractVariableRewrite( (if (replaceAll) scope.occurrences else listOf(candidateSpan)) .sortedBy { it.start } .takeIf { it.isNotEmpty() } ?: return null + // Only targets are bounds-checked against fileText; contentSpan/statementSpans are trusted + // unchecked. That is safe only because fileText is the plan's own text, not the live document -- + // if a caller ever passed live text here instead, those spans would need the same check. if (targets.any { it.end > fileText.length }) return null val expression = fileText.substring(candidateSpan.start, candidateSpan.end) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index 0b4a058c41..5f25af3e5c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -12,7 +12,6 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.psi.KtCallableDeclaration -import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtDeclarationWithBody import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtFile @@ -103,7 +102,8 @@ private fun KaSession.candidateFor(expression: KtExpression): CandidateExpressio * * Returns null when the rung cannot be honoured: converting an expression body whose return type is * neither declared nor renderable would emit a block body that does not compile, and declining is - * always safe (ADR 0013). + * always safe -- the decline-rather-than-rewrite principle that ADR 0013 records, landing alongside + * extract method (ADFA-5080). */ private fun KaSession.scopeOptionFor( expression: KtExpression, @@ -148,12 +148,16 @@ private fun KtDeclarationWithBody.declaresReturnType(): Boolean = else -> false } +/** The declaration's resolved return type, or null when it cannot be resolved. */ +private fun KaSession.returnTypeOf(declaration: KtDeclarationWithBody): KaType? = + runCatching { (declaration.symbol as? KaCallableSymbol)?.returnType }.getOrNull() + /** The declaration's return type as source text, shortened where the file can resolve it. */ private fun KaSession.returnTypeTextOf( declaration: KtDeclarationWithBody, file: KtFile, ): String? { - val type = runCatching { ((declaration as? KtDeclaration)?.symbol as? KaCallableSymbol)?.returnType }.getOrNull() ?: return null + val type = returnTypeOf(declaration) ?: return null val rendered = renderedTypeTextOrNull(type) ?: return null return shortenTypeText(rendered, importedNamesOf(file), starImportedPackagesOf(file)) } @@ -162,15 +166,16 @@ private fun KaSession.returnTypeTextOf( * Whether converting an expression body to a block body needs a `return`. * * False only for a `Unit`-returning function, where `return expr` on a non-`Unit` expression would - * not compile and is unnecessary anyway. Defaults to true, which is right for everything else + * not compile and is unnecessary anyway. `Nothing` is deliberately not folded in here even though + * [isValuelessType] treats it like `Unit` for the R4 candidate filter -- a `Nothing`-returning + * function needs its `return` and its written-out type kept, or a caller using it in a `Nothing` + * position (`x ?: boom()`) stops compiling. Defaults to true, which is right for everything else * including property accessors. */ private fun KaSession.expressionBodyNeedsReturn(bodyExpression: PsiElement): Boolean { val declaration = bodyExpression.parent as? KtDeclarationWithBody ?: return true - val returnType = - runCatching { ((declaration as? KtDeclaration)?.symbol as? KaCallableSymbol)?.returnType }.getOrNull() - ?: return true - return !isValuelessType(returnType) + val returnType = returnTypeOf(declaration) ?: return true + return !runCatching { returnType.isUnitType }.getOrDefault(false) } /** `Unit` and `Nothing` carry no value worth binding to a `val`. */ diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt index 379fe89960..be948e179b 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -115,8 +115,9 @@ data class CandidateExpression( * candidate's own statement through enclosing blocks, crossing a lambda boundary only when nothing * lambda-scoped is referenced, and stopping at the enclosing method body. * - **Anchor scope** -- the chain member the user picked. The `val` is declared inside it. - * - **Anchor point** -- the exact insertion offset: immediately before the first statement *within the - * anchor scope* that contains a replaced occurrence. + * - **Anchor point** -- the exact insertion offset: the start of the line holding the first statement + * *within the anchor scope* that contains a replaced occurrence, or inside the braces when that + * statement shares its line with a block written on one line. * - **Occurrence** -- a site inside the anchor scope that is structurally equal to the candidate *and* * whose every name reference resolves to the same symbol. Sites made unsound by an intervening * reassignment are excluded, so an occurrence set is always safe to replace wholesale. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt index 73c7336284..d89c570e5a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt @@ -22,14 +22,12 @@ import org.jetbrains.kotlin.psi.KtWhileExpression * * [scopeElement] is the PSI node that *is* the scope, used to decide whether a referenced * declaration lives inside it (see [truncateAtCeiling]). [searchRange] bounds the occurrence search - * for this rung. [statementSpan] is the statement within this scope that contains the candidate -- - * the fallback anchor when only the selected occurrence is replaced. + * for this rung. */ data class ScopeFrame( val label: String, val scopeElement: PsiElement, val searchRange: TextSpan, - val statementSpan: TextSpan, val anchorForm: AnchorForm, ) @@ -106,12 +104,10 @@ private fun frameFor( val controlOwner = (parent as? KtContainerNodeForControlStructureBody)?.parent if (parent is KtBlockExpression) { - val lineStart = lineStartOffset(text, inner.textRange.startOffset) return ScopeFrame( label = blockLabel(parent), scopeElement = parent, searchRange = parent.textRange.let { TextSpan(it.startOffset, it.endOffset) }, - statementSpan = TextSpan(lineStart, inner.textRange.endOffset), anchorForm = AnchorForm.ExistingBlock( contentSpan = contentSpanOf(parent), @@ -130,7 +126,6 @@ private fun frameFor( label = bracelessLabel, scopeElement = inner, searchRange = span, - statementSpan = span, anchorForm = AnchorForm.WrapInBraces( bodyStart = span.start, @@ -149,7 +144,6 @@ private fun frameFor( label = declarationLabel(parent), scopeElement = inner, searchRange = span, - statementSpan = span, anchorForm = AnchorForm.ConvertExpressionBody( assignStart = assign.textRange.startOffset, @@ -187,7 +181,6 @@ private fun isCeilingBody(scopeElement: PsiElement): Boolean { private fun blockLabel(block: KtBlockExpression): String { val parent = block.parent val container = parent as? KtContainerNodeForControlStructureBody - val branch = container ?: block return when (val owner = container?.parent ?: parent) { is KtNamedFunction -> "fun ${owner.name ?: ""}" is KtPropertyAccessor -> if (owner.isGetter) "getter" else "setter" diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt index 810caa658a..b2b3efbae1 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt @@ -40,6 +40,11 @@ private val QUALIFIED_NAME = Regex("""[\p{L}_][\p{L}\p{Nd}_]*(?:\.[\p{L}_][\p{L} * A type that cannot be written out as source -- anonymous, intersection, a resolution error, or a * platform type the renderer could not reduce (`List`, where the `!` is on a type argument). * `!` is not Kotlin syntax anywhere, so its presence alone settles it. + * + * The `"anonymous"` and `"ERROR"` substring checks are not unambiguous -- a real type named + * `com.example.AnonymousUser` or `p.ERRORS` would also match. Both fail safe: a false positive only + * declines the rung instead of emitting a block body that does not compile, so the heuristic is left + * as-is rather than made precise. */ internal fun isUnrenderableTypeText(text: String): Boolean = text.isBlank() || @@ -73,6 +78,10 @@ internal fun KaSession.renderedTypeTextOrNull(type: KaType): String? = runCatchi * Purely textual, so it needs no analysis session and is unit-testable on its own. A nested class * (`com.example.Outer.Inner`) is only shortened by an import of the nested name itself; an import of * the outer class leaves it alone rather than emitting an unresolvable `Inner`. + * + * A star import is trusted only when nothing else in the file imports the same simple name from a + * different package -- that explicit import would resolve first, so writing the short name here would + * silently name the wrong type. */ internal fun shortenTypeText( rendered: String, @@ -82,11 +91,12 @@ internal fun shortenTypeText( QUALIFIED_NAME.replace(rendered) { match -> val qualified = match.value val container = qualified.substringBeforeLast('.') + val simpleName = qualified.substringAfterLast('.') val resolvable = qualified in importedNames || container in DEFAULT_IMPORTED_PACKAGES || - container in starImportedPackages - if (resolvable) qualified.substringAfterLast('.') else qualified + (container in starImportedPackages && importedNames.none { it.endsWith(".$simpleName") }) + if (resolvable) simpleName else qualified } /** The fully qualified names [file] imports by name. Syntactic: no analysis session needed. */ diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index d118ba1244..08430fc2a7 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -681,6 +681,40 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `converting a Nothing-returning expression body preserves the signature`() { + val content = + """ + package p + fun boom(name: String) = error("bad " + name) + fun demo(x: Int?): Int = x ?: boom("missing") + """.trimIndent() + + val target = "\"bad \" + name" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "message", + replaceAll = false, + )!! + + // `boom`'s inferred return type is `Nothing`; folding it into the `Unit` case would drop both + // the `return` and the written-out `: Nothing`, and `x ?: boom(...)` would stop compiling. + assertEquals( + "package p\n" + + "fun boom(name: String): Nothing {\n" + + "\tval message = \"bad \" + name\n" + + "\treturn error(message)\n" + + "}\n" + + "fun demo(x: Int?): Int = x ?: boom(\"missing\")", + apply(content, rewrite), + ) + } + @Test fun `extracting from a one-line lambda stays inside the lambda`() { val content = diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt index 45bc4751ef..6303290b14 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt @@ -175,6 +175,21 @@ class RefactorPrimitivesTest { ) } + @Test + fun `a star import is skipped when a colliding name is imported from elsewhere`() { + // An explicit import of a different `Date` shadows the star import, so shortening would + // resolve to the wrong type. + assertEquals( + "java.util.Date", + shortenTypeText("java.util.Date", setOf("com.example.Date"), setOf("java.util")), + ) + // With nothing colliding, the star import still shortens as before. + assertEquals( + "Date", + shortenTypeText("java.util.Date", emptySet(), setOf("java.util")), + ) + } + @Test fun `unrenderable type text is recognised`() { assertTrue(isUnrenderableTypeText("")) From cb43457073f70fe2d39dfa7088efc43b548ba133 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 12:13:57 +0000 Subject: [PATCH 17/25] chore: remove plan docs Signed-off-by: Akash Yadav --- ...t-plugin-coordinates-localmvnrepository.md | 601 ---- .../plans/2026-08-10-kotlin-extract-method.md | 3043 ----------------- ...026-08-12-extract-variable-defect-fixes.md | 1779 ---------- .../2026-08-18-extract-method-review-fixes.md | 1008 ------ 4 files changed, 6431 deletions(-) delete mode 100644 docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md delete mode 100644 docs/superpowers/plans/2026-08-10-kotlin-extract-method.md delete mode 100644 docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md delete mode 100644 docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md diff --git a/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md b/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md deleted file mode 100644 index 194c959950..0000000000 --- a/docs/superpowers/plans/2026-07-28-inject-plugin-coordinates-localmvnrepository.md +++ /dev/null @@ -1,601 +0,0 @@ -# Inject plugin-api + builder coordinates into localMvnRepository — 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:** During CoGo onboarding, materialize plugin-api (fat compile jar), plugin-builder, and the `com.itsaky.androidide.plugins.build` marker into the on-device `localMvnRepository` as real Maven coordinates, so plugins resolve them by coordinate, offline, with no `libs/*.jar`. - -**Architecture:** Build-time, the host CoGo build assembles a small Maven-layout zip (`plugin-maven-repo.zip`): a fat `com.itsaky.androidide:plugin-api:1.0.0` jar (merged classes of plugin-api + common + eventbus-events + idetooltips, dependency-free POM) plus the builder impl + POM + marker emitted by real `maven-publish`. On-device, the two installers extract that zip into `LOCAL_MAVEN_DIR` **inside** the existing `localMvnRepository` branch (after its wipe+extract, via the non-wiping `extractZipToDir`) to avoid a wipe/concurrency race. - -**Tech Stack:** Gradle Kotlin DSL, `maven-publish` + `java-gradle-plugin`, AGP `com.android.library`, Kotlin, brotli4j, java.nio zip. Build wrapped in `flox activate -d flox/local -- ./gradlew`. - -## Global Constraints - -- **Build wrapper:** every Gradle call is `flox activate -d flox/local -- ./gradlew `. -- **Worktree:** work in `~/src/cogo/ADFA-4911` (branch `ADFA-4911-inject-plugin-jars-localmvn`); `app/google-services.json` already copied in. -- **Coordinates:** `com.itsaky.androidide:plugin-api:1.0.0` (jar), `com.itsaky.androidide.plugins:plugin-builder:1.0.0`, marker `com.itsaky.androidide.plugins.build:com.itsaky.androidide.plugins.build.gradle.plugin:1.0.0`. Version `1.0.0` everywhere. -- **Do NOT** add `plugin-maven-repo.zip` to `AssetsInstallationHelper.expectedEntries` — it must not become a concurrent install job (would race the `LOCAL_MAVEN_DIR` wipe). It is applied inside the `localMvnRepository` branch only. -- **Do NOT** touch the `plugin-artifacts.zip → .cg/plugin-api/` flow (still feeds `isPluginProject` until ADFA-4913) or the harvest pipeline. The plugin-api / common / eventbus-events / idetooltips module build files **are** edited — pinned to Kotlin `languageVersion`/`apiVersion` 2.0 so their metadata is readable by the on-device Kotlin 1.9.22 compiler. -- **Fat-jar harvest paths:** plugin-api `intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar`; the other three (v7/v8 flavored) `intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar`. -- **Code style:** tabs, LF; run `spotlessApply` before any commit that touches Kotlin/gradle.kts. Branch name already matches `ADFA-#####`. -- **Links:** the Maven POM `xmlns="http://maven.apache.org/POM/4.0.0"` is a standard XML **namespace identifier**, never dereferenced (no network) — it is required for a well-formed POM and is the one allowed http string. - ---- - -### Task 1: Publish plugin-builder to a build-dir Maven repo (impl POM + marker) - -**Files:** -- Modify: `plugin-api/plugin-builder/build.gradle.kts` - -**Interfaces:** -- Produces: a Maven layout under `plugin-api/plugin-builder/build/plugin-maven-repo/` containing - `com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.{jar,pom}` and - `com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/*.pom`. -- Produces: publish task `publishAllPublicationsToPluginMavenRepoRepository` (referenced by Task 3). - -- [ ] **Step 1: Add `maven-publish`, a build-dir repo, and disable module metadata** - -Edit `plugin-api/plugin-builder/build.gradle.kts`: - -```kotlin -plugins { - `kotlin-dsl` - `maven-publish` -} - -group = "com.itsaky.androidide.plugins" -version = "1.0.0" - -dependencies { - // compileOnly so the published POM stays dependency-free; the on-device build - // provides AGP (agp-tooling 8.11.0, as shipped in localMvnRepository). - compileOnly("com.android.tools.build:gradle:8.11.0") -} - -gradlePlugin { - plugins { - create("pluginBuilder") { - id = "com.itsaky.androidide.plugins.build" - implementationClass = "com.itsaky.androidide.plugins.build.PluginBuilder" - displayName = "Code on the Go Plugin Builder" - description = "Gradle plugin for building Code on the Go plugins" - } - } -} - -publishing { - repositories { - maven { - name = "pluginMavenRepo" - url = uri(layout.buildDirectory.dir("plugin-maven-repo")) - } - } -} - -// Ship POMs only (parity with the harvested repo); marker/plugin resolution works off POMs. -tasks.withType().configureEach { enabled = false } - -tasks.withType { - compilerOptions { - apiVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - languageVersion.set(org.jetbrains.kotlin.gradle.dsl.KotlinVersion.KOTLIN_2_1) - } -} -``` - -`java-gradle-plugin` (auto-applied by `kotlin-dsl`) auto-creates the `pluginMaven` (impl) and `pluginBuilderPluginMarkerMaven` (marker) publications; `maven-publish` adds the `publishAllPublicationsToPluginMavenRepoRepository` task. - -- [ ] **Step 2: Run the publish task and confirm the exact task name** - -Run: `flox activate -d flox/local -- ./gradlew -p plugin-api/plugin-builder tasks --all | grep -i publish` -Expected: a line `publishAllPublicationsToPluginMavenRepoRepository`. If the name differs, use the actual name in Task 3. - -- [ ] **Step 3: Publish and inspect the output layout** - -Run: -```bash -flox activate -d flox/local -- ./gradlew -p plugin-api/plugin-builder publishAllPublicationsToPluginMavenRepoRepository -find plugin-api/plugin-builder/build/plugin-maven-repo -type f | sort -``` -Expected files (no `.module`): -``` -.../com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/com.itsaky.androidide.plugins.build.gradle.plugin-1.0.0.pom -.../com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.jar -.../com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom -``` - -- [ ] **Step 4: Verify the POMs carry the right dependencies** - -Run: `grep -A3 -i "artifactId" plugin-api/plugin-builder/build/plugin-maven-repo/com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom` -Expected: the impl POM is **dependency-free** (AGP is `compileOnly`, so excluded from the published POM; the on-device build supplies it). The marker POM depends on `com.itsaky.androidide.plugins:plugin-builder:1.0.0`: -Run: `grep -i "plugin-builder" plugin-api/plugin-builder/build/plugin-maven-repo/com/itsaky/androidide/plugins/build/*/1.0.0/*.pom` -Expected: a `` on `plugin-builder` `1.0.0`. - -- [ ] **Step 5: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add plugin-api/plugin-builder/build.gradle.kts -git commit -m "ADFA-4911: Publish plugin-builder (impl POM + Gradle plugin marker) to a build-dir maven repo" -``` - ---- - -### Task 2: Assemble the fat plugin-api jar - -**Files:** -- Modify: `app/build.gradle.kts` (add task near the existing `createPluginArtifactsZip`, ~L435) - -**Interfaces:** -- Produces: `app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar` — a jar containing the merged main classes of `:plugin-api`, `:common`, `:eventbus-events`, `:idetooltips`. - -- [ ] **Step 1: Register the fat-jar task** - -Add to `app/build.gradle.kts` (after `createPluginArtifactsZip`, before `createAssetsZip`): - -```kotlin -// Fat compile-only jar published as com.itsaky.androidide:plugin-api:1.0.0. -// Merges the API surface plugins already compile against (plugin-api + common + -// eventbus-events + idetooltips) into one coordinate. The three add-ons are -// v7/v8-flavored (unlike plugin-api); their classes are ABI-neutral so v8 is used. -tasks.register("assemblePluginApiFatJar") { - dependsOn( - ":plugin-api:assembleRelease", - ":common:assembleV8Release", - ":eventbus-events:assembleV8Release", - ":idetooltips:assembleV8Release", - ) - archiveFileName.set("plugin-api-1.0.0.jar") - destinationDirectory.set(layout.buildDirectory.dir("plugin-maven-repo-staging")) - duplicatesStrategy = DuplicatesStrategy.EXCLUDE - - from(zipTree(project(":plugin-api").layout.buildDirectory - .file("intermediates/aar_main_jar/release/syncReleaseLibJars/classes.jar").get().asFile)) - from(zipTree(project(":common").layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) - from(zipTree(project(":eventbus-events").layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) - from(zipTree(project(":idetooltips").layout.buildDirectory - .file("intermediates/aar_main_jar/v8Release/syncV8ReleaseLibJars/classes.jar").get().asFile)) -} -``` - -- [ ] **Step 2: Build the fat jar** - -Run: `flox activate -d flox/local -- ./gradlew :app:assemblePluginApiFatJar` -Expected: BUILD SUCCESSFUL; `app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar` exists. If a `classes.jar` path is wrong, the build fails on a missing zip input — fix the path (verify with `find /build/intermediates/aar_main_jar -name classes.jar`). - -- [ ] **Step 3: Verify the jar contains a class from each of the 4 modules** - -Run: -```bash -unzip -l app/build/plugin-maven-repo-staging/plugin-api-1.0.0.jar | \ - grep -E "com/itsaky/androidide/(plugins/api|common|eventbus|idetooltips)" | head -``` -Expected: at least one `.class` under each of the four package roots (`plugins/api`, `common`, `eventbus`, `idetooltips`). If any is missing, that module's `classes.jar` path is wrong. - -- [ ] **Step 4: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add app/build.gradle.kts -git commit -m "ADFA-4911: Assemble fat plugin-api jar (plugin-api + common + eventbus-events + idetooltips)" -``` - ---- - -### Task 3: Write the plugin-api POM and assemble `plugin-maven-repo.zip` - -**Files:** -- Modify: `app/build.gradle.kts` (add `writePluginApiPom` + `createPluginMavenRepoZip` after Task 2's task) - -**Interfaces:** -- Consumes: Task 1's `publishAllPublicationsToPluginMavenRepoRepository`; Task 2's `assemblePluginApiFatJar`. -- Produces: `assets/plugin-maven-repo.zip` — a Maven layout with all three coordinates. - -- [ ] **Step 1: Register the POM writer and the zip assembler** - -Add to `app/build.gradle.kts` (after `assemblePluginApiFatJar`): - -```kotlin -// Dependency-free POM for the fat plugin-api coordinate: it is compile-only/provided, -// so it must NOT drag transitives that would need offline resolution. -tasks.register("writePluginApiPom") { - val pomFile = layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom") - outputs.file(pomFile) - doLast { - pomFile.get().asFile.writeText( - """ - - 4.0.0 - com.itsaky.androidide - plugin-api - 1.0.0 - jar - -""", - ) - } -} - -// Assembles the shippable Maven layout: the fat plugin-api coordinate + the -// builder impl/POM/marker published by the plugin-builder included build. -tasks.register("createPluginMavenRepoZip") { - dependsOn("assemblePluginApiFatJar", "writePluginApiPom") - dependsOn(gradle.includedBuild("plugin-builder") - .task(":publishAllPublicationsToPluginMavenRepoRepository")) - - archiveFileName.set("plugin-maven-repo.zip") - destinationDirectory.set(rootProject.file("assets")) - - into("com/itsaky/androidide/plugin-api/1.0.0") { - from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.jar")) - from(layout.buildDirectory.file("plugin-maven-repo-staging/plugin-api-1.0.0.pom")) - } - // Builder tree is already in Maven layout (com/itsaky/androidide/plugins/...). - from(rootProject.file("plugin-api/plugin-builder/build/plugin-maven-repo")) -} -``` - -- [ ] **Step 2: Build the zip** - -Run: `flox activate -d flox/local -- ./gradlew :app:createPluginMavenRepoZip` -Expected: BUILD SUCCESSFUL; `assets/plugin-maven-repo.zip` exists. - -- [ ] **Step 3: Verify the coordinate layout inside the zip** - -Run: `unzip -l assets/plugin-maven-repo.zip | grep -E "1.0.0/" | sort` -Expected exactly these artifact paths (order aside): -``` -com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar -com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.pom -com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.jar -com/itsaky/androidide/plugins/plugin-builder/1.0.0/plugin-builder-1.0.0.pom -com/itsaky/androidide/plugins/build/com.itsaky.androidide.plugins.build.gradle.plugin/1.0.0/com.itsaky.androidide.plugins.build.gradle.plugin-1.0.0.pom -``` - -- [ ] **Step 4: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add app/build.gradle.kts -git commit -m "ADFA-4911: Assemble plugin-maven-repo.zip (plugin-api coordinate + builder + marker)" -``` - ---- - -### Task 4: Register `plugin-maven-repo.zip` as a shipped asset (bundled `.br` + split zip) - -**Files:** -- Modify: `composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt` -- Modify: `composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt` -- Modify: `app/build.gradle.kts` (`createAssetsZip` file list ~L455-464; `assembleV8Assets`/`assembleV7Assets` deps ~L486-503) - -**Interfaces:** -- Consumes: Task 3's `assets/plugin-maven-repo.zip`. -- Produces: constant `PLUGIN_MAVEN_REPO_ZIP_NAME = "plugin-maven-repo.zip"` and `PLUGIN_MAVEN_REPO_ZIP_BR`; bundled common asset `data/common/plugin-maven-repo.zip.br`; split entry `plugin-maven-repo.zip` inside `assets-.zip`. (Task 5 consumes these.) - -- [ ] **Step 1: Add the asset-name constants** - -In `constants.kt`, after the Local-maven-repo block (`LOCAL_MAVEN_REPO_FOLDER_DEST`, ~L61): - -```kotlin -// Plugin maven-repo overlay (plugin-api + plugin-builder coordinates + marker) -const val PLUGIN_MAVEN_REPO_ZIP_NAME = "plugin-maven-repo.zip" -const val PLUGIN_MAVEN_REPO_ZIP_BR = "${PLUGIN_MAVEN_REPO_ZIP_NAME}.br" -``` - -- [ ] **Step 2: Register the per-build brotli copier for bundled builds** - -In `AndroidIDEAssetsPlugin.kt`, mirror `registerPluginArtifactsCopierTask` (~L80-107) with a new function, and call it from the `onVariants` block (after the plugin-artifacts copier registration, ~L75). The copier brotli-compresses `assets/plugin-maven-repo.zip` into `data/common/plugin-maven-repo.zip.br` when `hasBundledAssets(variant)`: - -```kotlin -private fun registerPluginMavenRepoCopierTask( - project: Project, - variant: Variant, -) { - val zip = project.rootProject.file("assets/plugin-maven-repo.zip") - val taskName = "copy${variant.name.replaceFirstChar { it.uppercase() }}PluginMavenRepo" - if (hasBundledAssets(variant)) { - val task = project.tasks.register(taskName, AddBrotliFileToAssetsTask::class.java) { - it.dependsOn(project.tasks.named("createPluginMavenRepoZip")) - it.inputFile.set(zip) - } - variant.sources.assets?.addGeneratedSourceDirectory(task, AddBrotliFileToAssetsTask::outputDirectory) - } else { - val task = project.tasks.register(taskName, AddFileToAssetsTask::class.java) { - it.dependsOn(project.tasks.named("createPluginMavenRepoZip")) - it.inputFile.set(zip) - } - variant.sources.assets?.addGeneratedSourceDirectory(task, AddFileToAssetsTask::outputDirectory) - } -} -``` - -Match the exact wiring of `registerPluginArtifactsCopierTask` (task property names, `baseAssetPath`/`data/common` default, `onVariants` call site). Call `registerPluginMavenRepoCopierTask(project, variant)` alongside the existing copier calls in `onVariants`. - -- [ ] **Step 3: Add the split entry + assemble deps in `app/build.gradle.kts`** - -In `createAssetsZip(arch)`, add `"plugin-maven-repo.zip"` to the `arrayOf(...)` file list (after `"plugin-artifacts.zip"`, ~L462). No `entryName` remap is needed (the `when` at ~L471 falls through to `else -> fileName`), so the entry name stays `plugin-maven-repo.zip`. - -Add a `dependsOn("createPluginMavenRepoZip")` to both `assembleV8Assets` and `assembleV7Assets` (~L486-503), so the file exists before `createAssetsZip` runs (it throws `FileNotFoundException` on a missing file, ~L466-468). - -- [ ] **Step 4: Verify the split asset packaging includes the new entry** - -Run: `flox activate -d flox/local -- ./gradlew :app:assembleV8Assets` -Then: `unzip -l app/build/outputs/assets/assets-arm64-v8a.zip | grep plugin-maven-repo` -Expected: `plugin-maven-repo.zip` is listed as an entry. - -- [ ] **Step 5: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add composite-builds/build-deps-common/constants/src/main/java/org/adfa/constants/constants.kt \ - composite-builds/build-logic/plugins/src/main/java/com/itsaky/androidide/plugins/AndroidIDEAssetsPlugin.kt \ - app/build.gradle.kts -git commit -m "ADFA-4911: Ship plugin-maven-repo.zip as a bundled (.br) and split asset" -``` - ---- - -### Task 5: Merge the overlay into LOCAL_MAVEN_DIR on-device (both installers) - -**Files:** -- Test: `app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt` (new) -- Modify: `app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt` (~L56-71) -- Modify: `app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt` (~L62-75) - -**Interfaces:** -- Consumes: `AssetsInstallationHelper.extractZipToDir(srcStream, destDir)` (existing, L241-271 — creates dirs and copies without wiping); constants `PLUGIN_MAVEN_REPO_ZIP_NAME`, `PLUGIN_MAVEN_REPO_ZIP_BR`; `ToolsManager.getCommonAsset` (prefixes `data/common/`). - -- [ ] **Step 1: Write the failing merge test** - -`extractZipToDir` is the merge primitive: it must add overlay entries into a dir that already has files, without deleting the existing ones, and reject path traversal. Create `ExtractZipToDirMergeTest.kt`: - -```kotlin -package com.itsaky.androidide.assets - -import io.mockk.mockkObject -import org.junit.Assert.assertEquals -import org.junit.Assert.assertThrows -import org.junit.Assert.assertTrue -import org.junit.Before -import org.junit.Test -import java.io.ByteArrayInputStream -import java.io.ByteArrayOutputStream -import java.nio.file.Files -import java.util.zip.ZipEntry -import java.util.zip.ZipOutputStream - -class ExtractZipToDirMergeTest { - @Before - fun setup() { - mockkObject(AssetsInstallationHelper) - } - - private fun zipOf(vararg entries: Pair): ByteArrayInputStream { - val bos = ByteArrayOutputStream() - ZipOutputStream(bos).use { zip -> - for ((name, body) in entries) { - zip.putNextEntry(ZipEntry(name)) - zip.write(body.toByteArray()) - zip.closeEntry() - } - } - return ByteArrayInputStream(bos.toByteArray()) - } - - @Test - fun `overlay merges without wiping existing files`() { - val dest = Files.createTempDirectory("mvn").also { - Files.createDirectories(it.resolve("com/foo/1.0")) - Files.writeString(it.resolve("com/foo/1.0/foo-1.0.jar"), "harvested") - } - - AssetsInstallationHelper.extractZipToDir( - zipOf("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar" to "fat"), - dest, - ) - - assertTrue("harvested file must survive the merge", - Files.exists(dest.resolve("com/foo/1.0/foo-1.0.jar"))) - assertEquals("fat", - Files.readString(dest.resolve("com/itsaky/androidide/plugin-api/1.0.0/plugin-api-1.0.0.jar"))) - } - - @Test - fun `rejects path traversal`() { - val dest = Files.createTempDirectory("mvn") - assertThrows(IllegalStateException::class.java) { - AssetsInstallationHelper.extractZipToDir(zipOf("../evil.jar" to "x"), dest) - } - } -} -``` - -- [ ] **Step 2: Run the test to confirm it passes against the existing primitive** - -Run: `flox activate -d flox/local -- ./gradlew :app:testV8DebugUnitTest --tests "com.itsaky.androidide.assets.ExtractZipToDirMergeTest"` -Expected: PASS both cases. (This pins the merge/no-wipe + traversal-guard contract the installers rely on. `extractZipToDir` already enforces the `..`/absolute-path check at L251-253.) - -- [ ] **Step 3: Add the overlay to `BundledAssetsInstaller`** - -Split `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` out of the shared archive arm (L56-71) into its own branch that extracts the harvested repo, then merges the plugin overlay in the same job: - -```kotlin -GRADLE_DISTRIBUTION_ARCHIVE_NAME, -ANDROID_SDK_ZIP, --> { - val destDir = destinationDirForArchiveEntry(entryName).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - val assetPath = ToolsManager.getCommonAsset("$entryName.br") - assets.open(assetPath).use { assetStream -> - BrotliInputStream(assetStream).use { srcStream -> - AssetsInstallationHelper.extractZipToDir(srcStream, destDir) - } - } -} - -LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { - val destDir = destinationDirForArchiveEntry(entryName).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - // 1) harvested repo - assets.open(ToolsManager.getCommonAsset("$entryName.br")).use { assetStream -> - BrotliInputStream(assetStream).use { srcStream -> - AssetsInstallationHelper.extractZipToDir(srcStream, destDir) - } - } - // 2) plugin coordinate overlay -- merged (no wipe) into the same repo - assets.open(ToolsManager.getCommonAsset(PLUGIN_MAVEN_REPO_ZIP_BR)).use { assetStream -> - BrotliInputStream(assetStream).use { srcStream -> - AssetsInstallationHelper.extractZipToDir(srcStream, destDir) - } - } - logger.debug("Merged plugin coordinates into {}", destDir) -} -``` - -Add imports: `import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_BR`. - -- [ ] **Step 4: Add the overlay to `SplitAssetsInstaller`** - -Split `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` out of the shared arm (L62-75). Extract the harvested repo from the entry stream, then read the `plugin-maven-repo.zip` entry from the already-open `zipFile` and merge: - -```kotlin -GRADLE_DISTRIBUTION_ARCHIVE_NAME, -ANDROID_SDK_ZIP, -GRADLE_API_NAME_JAR_ZIP, --> { - val destDir = destinationDirForArchiveEntry(entry.name).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - AssetsInstallationHelper.extractZipToDir(zipInput, destDir) -} - -LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME -> { - val destDir = destinationDirForArchiveEntry(entry.name).toPath() - if (Files.exists(destDir)) { - destDir.deleteRecursively() - } - Files.createDirectories(destDir) - // 1) harvested repo - AssetsInstallationHelper.extractZipToDir(zipInput, destDir) - // 2) plugin coordinate overlay from the split assets zip -- merged (no wipe) - val overlay = zipFile.getEntry(PLUGIN_MAVEN_REPO_ZIP_NAME) - ?: throw FileNotFoundException( - context.getString(R.string.err_asset_entry_not_found, PLUGIN_MAVEN_REPO_ZIP_NAME)) - zipFile.getInputStream(overlay).use { overlayInput -> - AssetsInstallationHelper.extractZipToDir(overlayInput, destDir) - } - logger.debug("Merged plugin coordinates into {}", destDir) -} -``` - -Add imports: `import org.adfa.constants.PLUGIN_MAVEN_REPO_ZIP_NAME`. (`GRADLE_API_NAME_JAR_ZIP` stays in the shared arm; only `LOCAL_MAVEN_REPO_ARCHIVE_ZIP_NAME` moves out.) - -- [ ] **Step 5: Build both installers' module to confirm compilation** - -Run: `flox activate -d flox/local -- ./gradlew :app:compileV8DebugKotlin` -Expected: BUILD SUCCESSFUL (constants resolve, imports correct). - -- [ ] **Step 6: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -flox activate -d flox/local -- ./gradlew spotlessApply -git add app/src/test/java/com/itsaky/androidide/assets/ExtractZipToDirMergeTest.kt \ - app/src/main/java/com/itsaky/androidide/assets/BundledAssetsInstaller.kt \ - app/src/main/java/com/itsaky/androidide/assets/SplitAssetsInstaller.kt -git commit -m "ADFA-4911: Merge plugin coordinate overlay into localMvnRepository during onboarding" -``` - ---- - -### Task 6: Document the coordinate + version - -**Files:** -- Modify: the Plugin API changelog added by ADFA-1713 (find with `git log --oneline | grep -i changelog`, or `find . -iname "*plugin*api*changelog*" -o -iname "CHANGELOG*" -path "*plugin*"`), or `plugin-api/README.md` if no changelog exists. - -**Interfaces:** none (docs). - -- [ ] **Step 1: Add the coordinate + build snippet** - -Document that on-device plugins resolve, offline, with no `libs/`: - -```kotlin -plugins { - id("com.itsaky.androidide.plugins.build") version "1.0.0" -} -dependencies { - compileOnly("com.itsaky.androidide:plugin-api:1.0.0") -} -``` - -Note the `plugin-api:1.0.0` coordinate is a fat jar (plugin-api + common + eventbus-events + idetooltips), injected into `localMvnRepository` at onboarding, and its version tracks the shipped jar. - -- [ ] **Step 2: Commit** - -```bash -cd ~/src/cogo/ADFA-4911 -git add -git commit -m "ADFA-4911: Document the plugin-api:1.0.0 coordinate and coordinate-based plugin build" -``` - ---- - -### Task 7: End-to-end on-device verification (acceptance criteria) - -**Files:** none (verification only). Requires an arm device/emulator (`adb devices -l | grep -v offline`; target `emulator-5554`). - -- [ ] **Step 1: Build + install the debug APK and its split assets** - -```bash -flox activate -d flox/local -- ./gradlew :app:assembleV8Debug :app:assembleV8Assets --parallel --max-workers=6 -adb -s emulator-5554 install -r app/build/outputs/apk/v8/debug/app-v8-debug.apk -adb -s emulator-5554 push app/build/outputs/assets/assets-arm64-v8a.zip /sdcard/Download/assets-arm64-v8a.zip -``` -Then launch the app and complete onboarding (asset installation). - -- [ ] **Step 2: Verify the coordinates landed (AC #1)** - -```bash -adb -s emulator-5554 shell "find /data/data/com.itsaky.androidide/files/home/maven/localMvnRepository -path '*plugin*' -name '*.pom' -o -path '*plugin*' -name '*.jar'" -``` -Expected: the plugin-api jar+pom, plugin-builder jar+pom, and the `com.itsaky.androidide.plugins.build` marker pom, at their coordinate paths. - -- [ ] **Step 3: Build a no-`libs/` plugin offline (AC #2)** - -On-device (or via a Termux/gradle harness), create a minimal plugin project with **no** `libs/` dir: -```kotlin -// settings.gradle.kts resolves via COTGSettingsPlugin (localMvnRepository injected) -plugins { id("com.itsaky.androidide.plugins.build") version "1.0.0" } -dependencies { compileOnly("com.itsaky.androidide:plugin-api:1.0.0") } -``` -Run `:assemblePluginDebug` with networking disabled. Expected: BUILD SUCCESSFUL, a `.cgp` produced, no network access. - -- [ ] **Step 4: Record results on the Jira ticket** - -`jira issue comment add ADFA-4911 ""` - ---- - -## Self-Review - -**Spec coverage:** the 3 coordinates (Task 1-3), fat-jar merge of all 4 modules (Task 2), dependency-free plugin-api POM + real builder POM/marker (Tasks 1,3), sibling-asset shipping bundled+split (Task 4), the wipe/concurrency-safe overlay inside the localMvnRepository branch (Task 5), the merge/traversal test (Task 5), docs (Task 6), and all three acceptance criteria (Task 7). No spec requirement is unmapped. - -**Placeholders:** none — every code/test/command step is concrete. The two empirically-risky names (the builder publish-task name; the `classes.jar` intermediate paths) each have an explicit discover/verify step (1.2, 2.2/2.3) that fails loudly on mismatch. - -**Type/name consistency:** constant names `PLUGIN_MAVEN_REPO_ZIP_NAME` / `PLUGIN_MAVEN_REPO_ZIP_BR` are defined in Task 4 and consumed by the split/bundled branches in Task 5; the coordinate paths asserted in 3.3 match those verified on-device in 7.2; `extractZipToDir` signature matches its existing definition. diff --git a/docs/superpowers/plans/2026-08-10-kotlin-extract-method.md b/docs/superpowers/plans/2026-08-10-kotlin-extract-method.md deleted file mode 100644 index 848689cbcc..0000000000 --- a/docs/superpowers/plans/2026-08-10-kotlin-extract-method.md +++ /dev/null @@ -1,3043 +0,0 @@ -# Kotlin Extract Method 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 "Extract method" code action to the Kotlin K2 LSP that moves an expression or a range of sibling statements into a new `private fun` and replaces it with a call, declining with a specific reason wherever it cannot do that faithfully. - -**Architecture:** Same shape as extract variable (ADFA-4826), which is already on this branch. One background analysis pass produces a plain-data `ExtractMethodPlan` (no PSI); a Compose bottom sheet does pure string arithmetic on it; confirming re-reads the document version and emits **two** `TextEdit`s in one `DocumentChange`, ordered new-function-first. Every hard case is a typed `ExtractionRefusal` rather than a clever rewrite (ADR 0012). - -**Tech Stack:** Kotlin, K2 Analysis API (`org.jetbrains.kotlin.analysis.api`), IntelliJ PSI (`org.jetbrains.kotlin.psi`), Jetpack Compose + Material3, JUnit4 + Robolectric. - -## Global Constraints - -- **Module:** everything lives in `lsp/kotlin`, except one constant in `idetooltips/.../TooltipTag.kt` and new strings in `resources/src/main/res/values/strings.xml`. No new module, no new dependency. -- **Vocabulary:** the term is **method** in user-facing text and type names, even though the output is a Kotlin `fun`. Internal vocabulary is fixed by the spec: *statement range*, *enclosing declaration*, *captured declaration*, *output*, *exit*, *refusal*. -- **Code style:** tabs for indentation, LF endings, ktlint via Spotless. ASCII only in code and comments (`->` not the arrow glyph, `--` not an em dash). No separator/banner comments. Comment the non-obvious *why*, never the what. -- **Ticket:** ADFA-5080. Commit subjects are `ADFA-5080: Short description` (colon, imperative). **Never** add a `Co-Authored-By` trailer. **Never** `git add .` / `git add -A` -- stage named files only. -- **Never commit this plan file** or anything under `docs/superpowers/`. `docs/features/kotlin-extract-method.md` IS a real project doc and does get committed. -- **Build wrapper:** every Gradle call is `flox activate -d flox/local -- ./gradlew `. -- **Test task:** `:lsp:kotlin:testV7DebugUnitTest` (V7 flavor; there is no flavorless `testDebugUnitTest`). Compile-only check: `:lsp:kotlin:compileV7DebugKotlin`. -- **Tooltip tag string is fixed:** `"editor.codeactions.kotlin.extractmethod"`. Tooltip content lives in an out-of-repo database keyed by that tag, so it cannot be renamed. -- **Action id is fixed:** `ide.editor.lsp.kt.extractMethod`. -- **No `prepare()` visibility gate** and `requiresUIThread = false` -- deciding extractability needs an analysis session, far too costly for the UI thread. Never do I/O or analysis on the main thread. -- **Edit ordering is mandatory:** `IDELanguageClientImpl.applyActionEdits` applies edits in list order using line/column ranges against the text as it is at that moment. Emit the function insertion **before** the call-site replacement (descending document order) or the file is corrupted. -- **Emitted text must be fully indented.** Code-action `TextEdit`s bypass the editor's auto-indent and `CMD_FORMAT_CODE` is a no-op for Kotlin. - ---- - -## Existing code you will reuse - -All in `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/`. Read these before starting -- the plan assumes their exact signatures. - -| Symbol | File | Signature | -|---|---|---| -| `TextSpan` | `utils/refactor/ExtractionPlan.kt` | `data class TextSpan(val start: Int, val end: Int)`, `.length`, `.overlaps(other)` | -| `collapseForLabel` | `utils/refactor/ExtractionPlan.kt` | `internal fun collapseForLabel(text: String, maxLength: Int = 80): String` | -| `candidateExpressionsAt` | `utils/refactor/CandidateExpressions.kt` | `fun candidateExpressionsAt(file: KtFile, selectionStart: Int, selectionEnd: Int): CandidateSyntax` | -| `CandidateSyntax` | `utils/refactor/CandidateExpressions.kt` | `data class CandidateSyntax(val expressions: List, val selectionMatchedInnermost: Boolean)` | -| `trimToCode` | `utils/refactor/CandidateExpressions.kt` | `internal fun trimToCode(text: String, start: Int, end: Int): Pair?` | -| `isExtractionPosition` | `utils/refactor/CandidateExpressions.kt` | `internal fun isExtractionPosition(element: PsiElement): Boolean` | -| `enclosingExecutableBody` | `utils/refactor/CandidateExpressions.kt` | `internal fun enclosingExecutableBody(element: PsiElement): PsiElement?` | -| `NameProblem`, `validateVariableName` | `utils/refactor/NameSuggestion.kt` | `fun validateVariableName(name: String, takenNames: Set): NameProblem?` | -| `suggestVariableName` | `utils/refactor/NameSuggestion.kt` | `fun suggestVariableName(expression: KtExpression, typeName: String?, takenNames: Set): String` | -| `detectIndentUnit`, `leadingIndentAt`, `lineStartOffset` | `utils/refactor/ScopeChain.kt` | `internal fun detectIndentUnit(text: String): String` etc. | -| `detectNewline`, `positionAt`, `RewriteSpan`, `toTextEdit` | `utils/refactor/ExtractVariableEdit.kt` | `data class RewriteSpan(val span: TextSpan, val newText: String)`, `fun RewriteSpan.toTextEdit(fileText: String): TextEdit` | -| `renderName` | `utils/TypeRendering.kt` | `internal fun KaSession.renderName(type: KaType, ...): String` | -| `analyzeMaybeDangling` | `compiler/modules/` | `analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { ... }` | -| `env.project.read { }` | `compiler/` | plain read lock; `getCurrentKtFile(...).get()` must be called **outside** it or it deadlocks | -| `KtLspTest` | `src/test/.../fixtures/KtLspTest.kt` | base class; `createSourceFile(name, content)`, `env`, `noopCancelChecker()` | - -Deliberately **not** reused: `ScopeOption`, `AnchorForm`, `CandidateExpression`, `Occurrences.kt`. Those are shaped by extract variable's legal scope chain, which this refactoring does not have. - -## File Structure - -**Created (all under `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/`):** - -| File | Responsibility | -|---|---| -| `utils/refactor/RefactoringPlan.kt` | The sealed supertype both plans share: `fileText`, `documentVersion` (R3). | -| `utils/refactor/ExtractionRegion.kt` | Resolving a selection to one region: expression candidates or a statement range (R2). Pure PSI. | -| `utils/refactor/ExtractMethodPlan.kt` | `ExtractMethodPlan`, `ExtractMethodCandidate`, `MethodParameter`, `ExtractedBody`, `CallSiteForm`, `ExtractionRefusal`, `signatureText` (R5-R6, R11, R14). Plain data. | -| `utils/refactor/ExtractMethodEdit.kt` | The two rewrites and their descending order (R15). Pure text and offsets. | -| `utils/refactor/MethodSignature.kt` | The analysis: captured declarations -> parameters, outputs, exits, receivers, modifiers, taken names, refusals (R5-R10, R12). | -| `utils/refactor/ExtractMethodPlanner.kt` | The single background pass (R3, R16). | -| `refactor/ui/SheetComponents.kt` | `LabelledSection`, `OptionList`, `NameProblem.messageRes()` promoted out of the extract-variable sheet (R11). | -| `refactor/ui/ExtractMethodUiState.kt` | `ExtractMethodUiState`, `ExtractMethodUiEvent`, `ExtractMethodChoice` (R11). | -| `refactor/ui/ExtractMethodViewModel.kt` | State derivation, name validation, signature preview (R11, R12). | -| `refactor/ui/ExtractMethodSheetContent.kt` | Stateless Compose content (R11). | -| `refactor/ui/ExtractMethodSheet.kt` | `BottomSheetDialogFragment` hosting a `ComposeView` (R11). | -| `actions/ExtractMethodAction.kt` | The only class touching the editor, the document version or the language client (R1, R3, R14, R15). | - -**Modified:** - -| File | Change | -|---|---| -| `utils/refactor/ExtractionPlan.kt` | `ExtractionPlan` implements `RefactoringPlan`. | -| `utils/refactor/NameSuggestion.kt` | Expose `uniqueName(base, taken)` (was the private `makeUnique`). | -| `refactor/ui/ExtractVariableSheetContent.kt` | Delete the local `LabelledSection`, `OptionList`, `messageRes()`; they move to `SheetComponents.kt`. | -| `KotlinCodeActionsMenu.kt` | Register `ExtractMethodAction()`. | -| `idetooltips/.../TooltipTag.kt` | `EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD`. | -| `resources/src/main/res/values/strings.xml` | Title, labels, and the seven refusal messages. | -| `docs/features/kotlin-extract-method.md` | Status line. | - -**Tests (under `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/`):** - -- `utils/refactor/ExtractMethodRegionTest.kt` -- PSI only (R2). -- `utils/refactor/ExtractMethodEditTest.kt` -- pure text (R6, R15). -- `utils/refactor/ExtractMethodPlanEndToEndTest.kt` -- analysis-backed (R5-R10, R12, R14). -- `refactor/ui/ExtractMethodViewModelTest.kt` -- state derivation (R11, R12). -- `KotlinCodeActionTooltipTagTest.kt` -- one new row. - ---- - -## Task 1: The shared `RefactoringPlan` supertype - -The spec (R3) says the version guard is "shared via the `RefactoringPlan` supertype", described as already introduced by the extract-variable PR. **It was not** -- `ExtractionPlan` is a standalone data class. This task introduces it so extract method is purely additive, exactly as the spec assumes. - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt` -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt` (the `data class ExtractionPlan(...)` declaration, around line 128) - -**Interfaces:** -- Consumes: nothing. -- Produces: `sealed interface RefactoringPlan { val fileText: String; val documentVersion: Int }`. Task 3's `ExtractMethodPlan` implements it. - -- [ ] **Step 1: Create the supertype** - -Create `RefactoringPlan.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -/** - * What every interactive refactoring's background pass returns. - * - * The two fields are what makes applying a plan safe long after it was computed: [fileText] is the - * text its offsets refer to, and [documentVersion] is re-read on confirm so a plan computed against - * text the user has since edited is discarded rather than applied against shifted offsets. - */ -sealed interface RefactoringPlan { - val fileText: String - val documentVersion: Int -} -``` - -- [ ] **Step 2: Make `ExtractionPlan` implement it** - -In `ExtractionPlan.kt`, change the declaration (keep the whole KDoc block above it untouched): - -```kotlin -data class ExtractionPlan( - override val fileText: String, - override val documentVersion: Int, - val candidates: List, - val selectionMatchedCandidate: Boolean, -) : RefactoringPlan { -``` - -- [ ] **Step 3: Verify the existing tests still pass** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` -Expected: PASS. This is a pure retrofit; a failure means something else broke. - -- [ ] **Step 4: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt -git commit -m "ADFA-5080: Hoist the shared refactoring plan supertype" -``` - ---- - -## Task 2: Region resolution - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt` -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt` - -**Interfaces:** -- Consumes: `TextSpan`, `trimToCode`, `candidateExpressionsAt`, `CandidateSyntax`, `isExtractionPosition`. -- Produces: - - `sealed interface ExtractionRegion { val span: TextSpan }` - - `data class ExtractionRegion.Expressions(val candidates: List, val selectionMatchedInnermost: Boolean)` - - `data class ExtractionRegion.Statements(val statements: List, val block: KtBlockExpression)` - - `fun resolveExtractionRegion(file: KtFile, selectionStart: Int, selectionEnd: Int): ExtractionRegion?` - -- [ ] **Step 1: Write the failing test** - -Create `ExtractMethodRegionTest.kt`. It extends `KtLspTest` for the PSI factory only -- it never opens an analysis session. - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test -import org.jetbrains.kotlin.psi.KtFile - -/** - * Region resolution is purely syntactic, so it is tested with no analysis session at all -- the same - * split `CandidateExpressions.kt` already has. - */ -class ExtractMethodRegionTest : KtLspTest() { - private fun file(content: String): KtFile = createSourceFile("Main.kt", content) - - private fun region( - content: String, - start: Int, - end: Int = start, - ): ExtractionRegion? = resolveExtractionRegion(file(content), start, end) - - private val twoStatements = - """ - package p - fun log(n: Int) {} - fun demo(a: Int, b: Int) { - val sum = a + b - log(sum) - } - """.trimIndent() - - @Test - fun `a bare cursor resolves to expression candidates`() { - val region = region(twoStatements, twoStatements.indexOf("a + b") + 1) - - assertTrue(region is ExtractionRegion.Expressions) - assertEquals("a + b", (region as ExtractionRegion.Expressions).candidates.first().text) - } - - @Test - fun `a selection over two whole statements resolves to a statement range`() { - val start = twoStatements.indexOf("val sum") - val end = twoStatements.indexOf("log(sum)") + "log(sum)".length - - val region = region(twoStatements, start, end) - - assertTrue(region is ExtractionRegion.Statements) - assertEquals( - listOf("val sum = a + b", "log(sum)"), - (region as ExtractionRegion.Statements).statements.map { it.text }, - ) - } - - @Test - fun `ragged boundaries snap outward to whole statements`() { - // Starts mid-`sum` and stops mid-`log(sum)`, as a touch drag routinely does. - val start = twoStatements.indexOf("sum = a + b") - val end = twoStatements.indexOf("log(sum)") + 3 - - val region = region(twoStatements, start, end) - - assertTrue(region is ExtractionRegion.Statements) - assertEquals( - listOf("val sum = a + b", "log(sum)"), - (region as ExtractionRegion.Statements).statements.map { it.text }, - ) - } - - @Test - fun `a selection inside a single statement stays an expression selection`() { - val start = twoStatements.indexOf("a + b") - - val region = region(twoStatements, start, start + "a + b".length) - - assertTrue(region is ExtractionRegion.Expressions) - assertEquals("a + b", (region as ExtractionRegion.Expressions).candidates.first().text) - assertTrue(region.selectionMatchedInnermost) - } - - @Test - fun `a selection spanning two different blocks resolves to nothing`() { - val content = - """ - package p - fun log(n: Int) {} - fun demo(c: Boolean, a: Int) { - if (c) { - log(a) - } - log(a + 1) - } - """.trimIndent() - val start = content.indexOf("log(a)") - val end = content.indexOf("log(a + 1)") + "log(a + 1)".length - - assertNull(region(content, start, end)) - } - - @Test - fun `the statement range span covers first to last statement`() { - val start = twoStatements.indexOf("val sum") - val end = twoStatements.indexOf("log(sum)") + "log(sum)".length - - val region = region(twoStatements, start, end) as ExtractionRegion.Statements - - assertEquals(TextSpan(start, end), region.span) - } - - @Test - fun `a whitespace-only selection resolves to nothing`() { - val start = twoStatements.indexOf("val sum") - 1 - - assertNull(region(twoStatements, start, start + 1)) - } - - @Test - fun `a property initializer outside an executable body resolves to nothing`() { - val content = - """ - package p - fun compute(): Int = 1 - class C { - val x = compute() + compute() - } - """.trimIndent() - - assertNull(region(content, content.indexOf("compute() + compute()") + 1)) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodRegionTest"` -Expected: compilation failure -- `Unresolved reference: resolveExtractionRegion`. - -- [ ] **Step 3: Write the implementation** - -Create `ExtractionRegion.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import org.jetbrains.kotlin.com.intellij.psi.PsiElement -import org.jetbrains.kotlin.psi.KtBlockExpression -import org.jetbrains.kotlin.psi.KtExpression -import org.jetbrains.kotlin.psi.KtFile - -/** - * What a selection resolved to. Exactly two kinds, which is the whole reason the hard cases never - * arise: a selection covering half an `if` and half its `else`, or straddling a lambda boundary, - * is neither, and is declined by construction rather than filtered out later. - */ -sealed interface ExtractionRegion { - /** The region's covering span in the file's text. */ - val span: TextSpan - - /** - * One or more nested expressions at the cursor, innermost first. The user picks between them in - * the sheet unless [selectionMatchedInnermost] says they already have. - */ - data class Expressions( - val candidates: List, - val selectionMatchedInnermost: Boolean, - ) : ExtractionRegion { - override val span: TextSpan - get() = candidates.first().textRange.let { TextSpan(it.startOffset, it.endOffset) } - } - - /** One or more sibling statements in a single [block]. */ - data class Statements( - val statements: List, - val block: KtBlockExpression, - ) : ExtractionRegion { - override val span: TextSpan - get() = - TextSpan( - statements.first().textRange.startOffset, - statements.last().textRange.endOffset, - ) - } -} - -/** - * Resolves `[selectionStart, selectionEnd)` to the one region the refactoring will act on, or null - * when it is neither kind. - * - * A bare cursor is always the expression path. A non-empty selection snaps **outward** to whole - * statements -- a touch selection will not land on a boundary -- but a selection that lies strictly - * inside one statement is still an expression selection: widening it to the whole statement would - * silently extract more than the user picked. - */ -fun resolveExtractionRegion( - file: KtFile, - selectionStart: Int, - selectionEnd: Int, -): ExtractionRegion? { - val (start, end) = trimToCode(file.text, selectionStart, selectionEnd) ?: return null - if (start == end) return expressionRegion(file, selectionStart, selectionEnd) - - val statements = snapToStatements(file, start, end) ?: return expressionRegion(file, selectionStart, selectionEnd) - - val only = statements.singleOrNull() - if (only != null && (start > only.textRange.startOffset || end < only.textRange.endOffset)) { - expressionRegion(file, selectionStart, selectionEnd)?.let { return it } - } - - val block = statements.first().parent as? KtBlockExpression ?: return null - return ExtractionRegion.Statements(statements, block) -} - -private fun expressionRegion( - file: KtFile, - selectionStart: Int, - selectionEnd: Int, -): ExtractionRegion.Expressions? { - val syntax = candidateExpressionsAt(file, selectionStart, selectionEnd) - if (syntax.expressions.isEmpty()) return null - return ExtractionRegion.Expressions(syntax.expressions, syntax.selectionMatchedInnermost) -} - -/** - * The whole statements `[start, end)` touches, when they are siblings in one [KtBlockExpression]. - * - * Null when the two ends land in different blocks, which is what rejects a selection spanning an - * `if` body and the code after it without needing to reason about the constructs involved. - */ -private fun snapToStatements( - file: KtFile, - start: Int, - end: Int, -): List? { - val first = statementContaining(file, start) ?: return null - val last = statementContaining(file, (end - 1).coerceAtLeast(start)) ?: return null - - val block = first.parent as? KtBlockExpression ?: return null - if (last.parent !== block) return null - if (!isExtractionPosition(first)) return null - - val statements = block.statements - val from = statements.indexOfFirst { it === first } - val to = statements.indexOfFirst { it === last } - if (from < 0 || to < from) return null - return statements.subList(from, to + 1).toList() -} - -/** - * The statement containing [offset]: the nearest ancestor that is a direct expression child of a - * block. Null for a position that is not inside one, such as a comment or a class body. - */ -private fun statementContaining( - file: KtFile, - offset: Int, -): KtExpression? { - var current: PsiElement? = file.findElementAt(offset) ?: return null - while (current != null && current !is KtFile) { - if (current is KtExpression && current.parent is KtBlockExpression) return current - current = current.parent - } - return null -} -``` - -- [ ] **Step 4: Run the test to verify it passes** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodRegionTest"` -Expected: PASS, 8 tests. - -- [ ] **Step 5: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt -git commit -m "ADFA-5080: Resolve a selection to an extraction region" -``` - ---- - -## Task 3: The plan data model and the two rewrites - -Pure data and pure text: no PSI, no analysis. Doing this before the analysis means the edit shape is pinned down and tested before anything has to derive it. - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt` -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt` -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt` - -**Interfaces:** -- Consumes: `RefactoringPlan` (Task 1), `TextSpan`, `RewriteSpan`, `detectNewline`, `detectIndentUnit`, `leadingIndentAt`. -- Produces, all used by Tasks 4-7: - - `data class MethodParameter(val name: String, val typeText: String)` - - `sealed interface ExtractedBody` with `ExpressionBody(needsReturn: Boolean)` and `StatementBody(trailingReturn: String?)` - - `sealed interface CallSiteForm` with `Call`, `AssignOutput(name: String)`, `Return` - - `data class ExtractMethodCandidate(label, span, suggestedName, takenNames, annotations, modifiers, receiverTypeText, parameters, returnTypeText, body, callSite, insertOffset, insertIndent)` - - `sealed interface ExtractionRefusal` with `NotASingleRegion`, `MultipleOutputs(names: List)`, `ReassignsOuterVar(name: String)`, `ExitsRegion`, `InnerImplicitReceiver(construct: String)`, `UsesTypeParameter(name: String)`, `UnrenderableType` - - `data class ExtractMethodPlan(fileText, documentVersion, candidates, selectionMatchedCandidate, refusal) : RefactoringPlan` with `.isEmpty` and `companion object { fun refused(refusal, fileText = "", documentVersion = -1) }` - - `fun ExtractMethodCandidate.signatureText(name: String): String` - - `fun buildExtractMethodRewrites(fileText: String, candidate: ExtractMethodCandidate, name: String): List?` - -- [ ] **Step 1: Write the failing test** - -Create `ExtractMethodEditTest.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * The emitted text, with every candidate built by hand -- no PSI, no analysis. Assertions are on the - * resulting file text, the only kind that catches an indentation or off-by-one error. - */ -class ExtractMethodEditTest { - private val file = - "package p\n" + - "class C {\n" + - "\tfun demo(a: Int, b: Int): Int {\n" + - "\t\tval sum = a + b\n" + - "\t\treturn sum\n" + - "\t}\n" + - "}\n" - - private val enclosingStart = file.indexOf("fun demo") - private val enclosingEnd = file.indexOf("\t}\n}") + 2 - - private fun candidate( - span: TextSpan, - body: ExtractedBody, - callSite: CallSiteForm, - parameters: List = emptyList(), - returnTypeText: String? = null, - modifiers: List = listOf("private"), - annotations: List = emptyList(), - receiverTypeText: String? = null, - ) = ExtractMethodCandidate( - label = "region", - span = span, - suggestedName = "extracted", - takenNames = emptySet(), - annotations = annotations, - modifiers = modifiers, - receiverTypeText = receiverTypeText, - parameters = parameters, - returnTypeText = returnTypeText, - body = body, - callSite = callSite, - insertOffset = enclosingEnd, - insertIndent = "\t", - ) - - /** Applies the rewrites in the order they are returned, exactly as the language client does. */ - private fun apply( - text: String, - rewrites: List, - ): String = - rewrites.fold(text) { current, rewrite -> - current.substring(0, rewrite.span.start) + rewrite.newText + current.substring(rewrite.span.end) - } - - @Test - fun `the function insertion comes before the call site`() { - val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) - val rewrites = - buildExtractMethodRewrites( - file, - candidate( - span, - ExtractedBody.ExpressionBody(needsReturn = true), - CallSiteForm.Call, - parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), - returnTypeText = "Int", - ), - "total", - ) - - assertNotNull(rewrites) - assertEquals(2, rewrites!!.size) - assertTrue( - "the insertion must be at a higher offset than the call site", - rewrites[0].span.start > rewrites[1].span.start, - ) - } - - @Test - fun `an expression region becomes a call and a returning function`() { - val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) - val rewrites = - buildExtractMethodRewrites( - file, - candidate( - span, - ExtractedBody.ExpressionBody(needsReturn = true), - CallSiteForm.Call, - parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), - returnTypeText = "Int", - ), - "total", - )!! - - assertEquals( - "package p\n" + - "class C {\n" + - "\tfun demo(a: Int, b: Int): Int {\n" + - "\t\tval sum = total(a, b)\n" + - "\t\treturn sum\n" + - "\t}\n" + - "\n" + - "\tprivate fun total(a: Int, b: Int): Int {\n" + - "\t\treturn a + b\n" + - "\t}\n" + - "}\n", - apply(file, rewrites), - ) - } - - @Test - fun `a statement range with one output assigns at the call site`() { - val span = TextSpan(file.indexOf("val sum"), file.indexOf("val sum") + "val sum = a + b".length) - val rewrites = - buildExtractMethodRewrites( - file, - candidate( - span, - ExtractedBody.StatementBody(trailingReturn = "return sum"), - CallSiteForm.AssignOutput("sum"), - parameters = listOf(MethodParameter("a", "Int"), MethodParameter("b", "Int")), - returnTypeText = "Int", - ), - "total", - )!! - - assertEquals( - "package p\n" + - "class C {\n" + - "\tfun demo(a: Int, b: Int): Int {\n" + - "\t\tval sum = total(a, b)\n" + - "\t\treturn sum\n" + - "\t}\n" + - "\n" + - "\tprivate fun total(a: Int, b: Int): Int {\n" + - "\t\tval sum = a + b\n" + - "\t\treturn sum\n" + - "\t}\n" + - "}\n", - apply(file, rewrites), - ) - } - - @Test - fun `a tail return region returns the call`() { - val span = TextSpan(file.indexOf("return sum"), file.indexOf("return sum") + "return sum".length) - val rewrites = - buildExtractMethodRewrites( - file, - candidate( - span, - ExtractedBody.StatementBody(trailingReturn = null), - CallSiteForm.Return, - parameters = listOf(MethodParameter("sum", "Int")), - returnTypeText = "Int", - ), - "finish", - )!! - - assertEquals( - "package p\n" + - "class C {\n" + - "\tfun demo(a: Int, b: Int): Int {\n" + - "\t\tval sum = a + b\n" + - "\t\treturn finish(sum)\n" + - "\t}\n" + - "\n" + - "\tprivate fun finish(sum: Int): Int {\n" + - "\t\treturn sum\n" + - "\t}\n" + - "}\n", - apply(file, rewrites), - ) - } - - @Test - fun `a multi-line statement range is reindented under the new function`() { - val text = - "package p\n" + - "fun demo(a: Int) {\n" + - "\tif (a > 0) {\n" + - "\t\tprintln(a)\n" + - "\t}\n" + - "}\n" - val start = text.indexOf("if (a > 0)") - val rewrites = - buildExtractMethodRewrites( - text, - ExtractMethodCandidate( - label = "region", - span = TextSpan(start, text.indexOf("\t}\n}") + 2), - suggestedName = "extracted", - takenNames = emptySet(), - annotations = emptyList(), - modifiers = listOf("private"), - receiverTypeText = null, - parameters = listOf(MethodParameter("a", "Int")), - returnTypeText = null, - body = ExtractedBody.StatementBody(trailingReturn = null), - callSite = CallSiteForm.Call, - insertOffset = text.length - 1, - insertIndent = "", - ), - "report", - )!! - - assertEquals( - "package p\n" + - "fun demo(a: Int) {\n" + - "\treport(a)\n" + - "}\n" + - "\n" + - "private fun report(a: Int) {\n" + - "\tif (a > 0) {\n" + - "\t\tprintln(a)\n" + - "\t}\n" + - "}\n", - apply(text, rewrites), - ) - } - - @Test - fun `a CRLF file keeps CRLF`() { - val text = - "package p\r\n" + - "fun demo(a: Int) {\r\n" + - "\tprintln(a)\r\n" + - "}\r\n" - val start = text.indexOf("println(a)") - val rewrites = - buildExtractMethodRewrites( - text, - ExtractMethodCandidate( - label = "region", - span = TextSpan(start, start + "println(a)".length), - suggestedName = "extracted", - takenNames = emptySet(), - annotations = emptyList(), - modifiers = listOf("private"), - receiverTypeText = null, - parameters = listOf(MethodParameter("a", "Int")), - returnTypeText = null, - body = ExtractedBody.StatementBody(trailingReturn = null), - callSite = CallSiteForm.Call, - insertOffset = text.length - 2, - insertIndent = "", - ), - "report", - )!! - - assertTrue(rewrites.all { !it.newText.contains("\n") || it.newText.contains("\r\n") }) - assertTrue(apply(text, rewrites).contains("\r\nprivate fun report(a: Int) {\r\n")) - } - - @Test - fun `the signature preview matches what is emitted`() { - val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) - val subject = - candidate( - span, - ExtractedBody.ExpressionBody(needsReturn = true), - CallSiteForm.Call, - parameters = listOf(MethodParameter("a", "Int")), - returnTypeText = "Int", - modifiers = listOf("private", "suspend"), - annotations = listOf("@Composable"), - receiverTypeText = "Foo", - ) - - assertEquals("@Composable private suspend fun Foo.total(a: Int): Int", subject.signatureText("total")) - assertTrue( - buildExtractMethodRewrites(file, subject, "total")!![0] - .newText - .contains("@Composable private suspend fun Foo.total(a: Int): Int {"), - ) - } - - @Test - fun `a span past the end of the text produces nothing`() { - val subject = - candidate( - TextSpan(file.length - 1, file.length + 10), - ExtractedBody.StatementBody(trailingReturn = null), - CallSiteForm.Call, - ) - - assertNull(buildExtractMethodRewrites(file, subject, "total")) - } -} -``` - -- [ ] **Step 2: Run the test to verify it fails** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest"` -Expected: compilation failure -- `Unresolved reference: ExtractMethodCandidate`. - -- [ ] **Step 3: Write the data model** - -Create `ExtractMethodPlan.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -/** One derived parameter of the new function. Names are the originals, unchanged (R5). */ -data class MethodParameter( - val name: String, - val typeText: String, -) - -/** What goes inside the new function's braces. */ -sealed interface ExtractedBody { - /** - * The region's expression text. [needsReturn] is false only for a `Unit`-valued expression, where - * the function returns `Unit` and a bare statement reads better than `return println(x)`. - */ - data class ExpressionBody( - val needsReturn: Boolean, - ) : ExtractedBody - - /** - * The statements verbatim. [trailingReturn] is the `return ` line appended for the - * single-output case, and null otherwise -- including the tail-return case, where the region - * already ends in a `return`. - */ - data class StatementBody( - val trailingReturn: String?, - ) : ExtractedBody -} - -/** How the region's own text is replaced (R6). */ -sealed interface CallSiteForm { - /** `extracted(args)` -- an expression in place, or a statement. */ - data object Call : CallSiteForm - - /** `val x = extracted(args)` for the single output [name]. */ - data class AssignOutput( - val name: String, - ) : CallSiteForm - - /** `return extracted(args)` for the tail-return case (R8). */ - data object Return : CallSiteForm -} - -/** - * One extractable region, fully derived: everything the sheet renders and the edit builder emits, - * with no PSI left in it. - * - * [span] is what the call site replaces. [insertOffset] is the end of the enclosing declaration -- - * the new function goes immediately after it (R4) -- and [insertIndent] is that declaration's own - * indentation, since nothing re-indents a code-action edit after it is applied. - * - * [returnTypeText] is null for a `Unit` function, where the `: Unit` is left off. - */ -data class ExtractMethodCandidate( - val label: String, - val span: TextSpan, - val suggestedName: String, - val takenNames: Set, - val annotations: List, - val modifiers: List, - val receiverTypeText: String?, - val parameters: List, - val returnTypeText: String?, - val body: ExtractedBody, - val callSite: CallSiteForm, - val insertOffset: Int, - val insertIndent: String, -) - -/** - * Why a region could not be extracted. A refusal is a designed outcome, not an error (ADR 0012): - * each reason gets its own message naming the construct in the way, because a generic one reads as - * the feature being broken. - */ -sealed interface ExtractionRefusal { - /** The selection is neither one expression nor whole statements inside one block (R2). */ - data object NotASingleRegion : ExtractionRefusal - - /** Two or more locals declared inside the region are read after it (R7). */ - data class MultipleOutputs( - val names: List, - ) : ExtractionRefusal - - /** A `var` declared outside the region is assigned inside it. ADFA-5082 lifts this (R7). */ - data class ReassignsOuterVar( - val name: String, - ) : ExtractionRefusal - - /** A `return`, `break` or `continue` whose target is outside the region (R8). */ - data object ExitsRegion : ExtractionRefusal - - /** Members of a `with`/`apply`/`run` receiver introduced inside the enclosing declaration (R9). */ - data class InnerImplicitReceiver( - val construct: String, - ) : ExtractionRefusal - - /** A type parameter declared on the enclosing function (R10). */ - data class UsesTypeParameter( - val name: String, - ) : ExtractionRefusal - - /** A parameter or return type that cannot be written out as source (R5). */ - data object UnrenderableType : ExtractionRefusal -} - -/** - * The complete result of the background pass. - * - * Unlike extract variable's plan this carries a [refusal] rather than merely being empty, because - * "why not" is most of what this refactoring has to say (ADR 0012). [candidates] and [refusal] are - * mutually exclusive in practice: a non-empty candidate list means at least one region survived. - */ -data class ExtractMethodPlan( - override val fileText: String, - override val documentVersion: Int, - val candidates: List, - val selectionMatchedCandidate: Boolean, - val refusal: ExtractionRefusal?, -) : RefactoringPlan { - val isEmpty: Boolean get() = candidates.isEmpty() - - companion object { - fun refused( - refusal: ExtractionRefusal, - fileText: String = "", - documentVersion: Int = -1, - ) = ExtractMethodPlan(fileText, documentVersion, emptyList(), selectionMatchedCandidate = false, refusal = refusal) - } -} - -/** - * The signature exactly as [buildExtractMethodRewrites] emits it. The sheet's preview calls this, so - * there is one derivation and the preview cannot drift from the declaration (R11). - */ -fun ExtractMethodCandidate.signatureText(name: String): String = - buildString { - annotations.forEach { append(it).append(' ') } - modifiers.forEach { append(it).append(' ') } - append("fun ") - receiverTypeText?.let { append(it).append('.') } - append(name) - append('(') - append(parameters.joinToString(", ") { "${it.name}: ${it.typeText}" }) - append(')') - returnTypeText?.let { append(": ").append(it) } - } -``` - -- [ ] **Step 4: Write the edit builder** - -Create `ExtractMethodEdit.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -/** - * The two replacements an extraction performs: the new function, then the call that replaces the - * region. - * - * **The order is mandatory, not stylistic.** `IDELanguageClientImpl.applyActionEdits` iterates the - * list and applies each edit with line/column ranges against whatever the text is at that moment. - * The insertion point sits after the region, so emitting the call first would shift it and corrupt - * the file. Descending document order is the only safe order. - * - * Nothing on that path calls `beginBatchEdit`, so this costs the user **two** undo steps and the - * intermediate state does not compile. ADFA-5081 fixes that by batching the edit loop; until it - * lands the two-step undo is a stated limitation. - * - * The region is the only site rewritten (R13). Exact-duplicate matching would almost never fire, and - * near-duplicate matching needs anti-unification plus a per-site parameter mapping. - * - * Returns null when the offsets cannot be honoured, which the caller reports rather than applying. - */ -fun buildExtractMethodRewrites( - fileText: String, - candidate: ExtractMethodCandidate, - name: String, -): List? { - val span = candidate.span - if (span.end > fileText.length) return null - if (candidate.insertOffset > fileText.length || candidate.insertOffset < span.end) return null - - val newline = detectNewline(fileText) - val indent = candidate.insertIndent - val bodyIndent = indent + detectIndentUnit(fileText) - val regionText = fileText.substring(span.start, span.end) - val baseIndent = leadingIndentAt(fileText, span.start) - - val bodyLines = - when (val body = candidate.body) { - is ExtractedBody.ExpressionBody -> { - val lines = reindent(regionText, baseIndent, newline) - if (body.needsReturn) listOf("return " + lines.first()) + lines.drop(1) else lines - } - - is ExtractedBody.StatementBody -> - reindent(regionText, baseIndent, newline) + listOfNotNull(body.trailingReturn) - } - - val declaration = - buildString { - // A blank line separates the new function from the declaration it follows. - append(newline).append(newline) - append(indent).append(candidate.signatureText(name)).append(" {").append(newline) - bodyLines.forEach { append(bodyIndent).append(it).append(newline) } - append(indent).append('}') - } - - val call = "$name(${candidate.parameters.joinToString(", ") { it.name }})" - val callText = - when (val form = candidate.callSite) { - CallSiteForm.Call -> call - is CallSiteForm.AssignOutput -> "val ${form.name} = $call" - CallSiteForm.Return -> "return $call" - } - - return listOf( - RewriteSpan(TextSpan(candidate.insertOffset, candidate.insertOffset), declaration), - RewriteSpan(span, callText), - ) -} - -/** - * Splits the region into lines with its original base indentation removed, so the caller can prefix - * each with the new function's body indentation. Lines nested deeper than the base keep the extra - * depth; the first line never carries indentation, since the span starts at the code itself. - */ -private fun reindent( - text: String, - baseIndent: String, - newline: String, -): List = - text.split(newline).mapIndexed { index, line -> - if (index == 0) line else line.removePrefix(baseIndent) - } -``` - -- [ ] **Step 5: Run the test to verify it passes** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest"` -Expected: PASS, 8 tests. If an assertion on exact text fails, fix the *implementation*, not the expectation, unless the expectation itself has a wrong tab count. - -- [ ] **Step 6: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt -git commit -m "ADFA-5080: Add the extract-method plan model and its two rewrites" -``` - ---- - -## Task 4: The analysis -- signature derivation and refusals - -The only analysis-dependent part. This is the largest task; compile early with `:lsp:kotlin:compileV7DebugKotlin` rather than waiting for the tests. - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt` -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt` -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt` (the private `makeUnique`, around line 147) -- Create: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: everything from Tasks 2 and 3, plus `renderName`, `suggestVariableName`, `collapseForLabel`, `leadingIndentAt`, `analyzeMaybeDangling`, `env.project.read`, `env.ktSymbolIndex.getCurrentKtFile`. -- Produces: - - `internal fun uniqueName(base: String, takenNames: Set): String` (in `NameSuggestion.kt`) - - `internal fun KaSession.buildCandidate(elements: List, isExpression: Boolean, fileText: String): SignatureResult` - - `internal sealed interface SignatureResult { data class Success(val candidate: ExtractMethodCandidate); data class Refused(val refusal: ExtractionRefusal) }` - - `internal fun buildExtractMethodPlan(env: AbstractCompilationEnvironment, nioPath: Path, selectionStart: Int, selectionEnd: Int, documentVersion: Int, cancelChecker: ScheduledCancelChecker): ExtractMethodPlan` - -- [ ] **Step 1: Expose `uniqueName`** - -In `NameSuggestion.kt`, rename the private helper and make it internal. Change - -```kotlin -/** `size` -> `size1` -> `size2` until nothing in [takenNames] matches. */ -private fun makeUnique( - base: String, - takenNames: Set, -): String { -``` - -to - -```kotlin -/** `size` -> `size1` -> `size2` until nothing in [takenNames] matches. */ -internal fun uniqueName( - base: String, - takenNames: Set, -): String { -``` - -and update the one call site inside `suggestVariableName` from `makeUnique(sanitised, takenNames)` to `uniqueName(sanitised, takenNames)`. - -- [ ] **Step 2: Write the failing test** - -Create `ExtractMethodPlanEndToEndTest.kt`. One case per rule, plus one per refusal reason. - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest -import org.junit.Assert.assertEquals -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** - * The parts of the plan that need real resolution: the parameter set, the return type and call-site - * form, the modifiers, and one case per refusal reason. - * - * Where a rewrite is produced the assertion is on the resulting file text, which is the only - * assertion that catches an indentation or off-by-one error. - */ -class ExtractMethodPlanEndToEndTest : KtLspTest() { - private fun plan( - content: String, - start: Int, - end: Int = start, - ): ExtractMethodPlan { - createSourceFile("Main.kt", content) - val path = env.sourceRoots.first().resolve("Main.kt") - return buildExtractMethodPlan(env, path, start, end, documentVersion = 1, cancelChecker = noopCancelChecker()) - } - - private fun apply( - text: String, - rewrites: List, - ): String = - rewrites.fold(text) { current, rewrite -> - current.substring(0, rewrite.span.start) + rewrite.newText + current.substring(rewrite.span.end) - } - - private fun selection( - content: String, - from: String, - to: String, - ): Pair = content.indexOf(from) to (content.indexOf(to) + to.length) - - @Test - fun `an expression region parameterises the locals it uses, in first-use order`() { - val content = - """ - package p - fun demo(a: Int, b: Int): Int { - return b * a + a - } - """.trimIndent() - - val result = plan(content, content.indexOf("b * a") + 1) - val candidate = result.candidates.first { it.label == "b * a" } - - assertEquals(listOf("b" to "Int", "a" to "Int"), candidate.parameters.map { it.name to it.typeText }) - assertEquals("Int", candidate.returnTypeText) - assertEquals(listOf("private"), candidate.modifiers) - } - - @Test - fun `a statement range with no output returns Unit and calls as a statement`() { - val content = - """ - package p - fun log(n: Int) {} - fun demo(a: Int) { - log(a) - log(a + 1) - } - """.trimIndent() - val (start, end) = selection(content, "log(a)", "log(a + 1)") - - val result = plan(content, start, end) - val candidate = result.candidates.single() - - assertNull(candidate.returnTypeText) - assertEquals(CallSiteForm.Call, candidate.callSite) - assertEquals(listOf("a"), candidate.parameters.map { it.name }) - assertEquals("extracted", candidate.suggestedName) - } - - @Test - fun `a single output becomes the return value and a val at the call site`() { - val content = - """ - package p - fun demo(a: Int): Int { - val doubled = a * 2 - return doubled + 1 - } - """.trimIndent() - val (start, end) = selection(content, "val doubled", "val doubled = a * 2") - - val result = plan(content, start, end) - val candidate = result.candidates.single() - - assertEquals(CallSiteForm.AssignOutput("doubled"), candidate.callSite) - assertEquals("Int", candidate.returnTypeText) - } - - @Test - fun `two outputs are declined`() { - val content = - """ - package p - fun demo(a: Int): Int { - val x = a * 2 - val y = a * 3 - return x + y - } - """.trimIndent() - val (start, end) = selection(content, "val x", "val y = a * 3") - - val refusal = plan(content, start, end).refusal - - assertTrue(refusal is ExtractionRefusal.MultipleOutputs) - assertEquals(listOf("x", "y"), (refusal as ExtractionRefusal.MultipleOutputs).names) - } - - @Test - fun `a reassigned outer var is declined and names the variable`() { - val content = - """ - package p - fun demo(items: List): Int { - var total = 0 - for (item in items) { - total += item - } - return total - } - """.trimIndent() - val (start, end) = selection(content, "for (item in items)", "\t}") - - val refusal = plan(content, start, end).refusal - - assertEquals(ExtractionRefusal.ReassignsOuterVar("total"), refusal) - } - - @Test - fun `a tail return keeps the return and returns the call`() { - val content = - """ - package p - fun demo(a: Int): Int { - val doubled = a * 2 - return doubled + 1 - } - """.trimIndent() - val (start, end) = selection(content, "return doubled", "return doubled + 1") - - val result = plan(content, start, end) - val candidate = result.candidates.single() - - assertEquals(CallSiteForm.Return, candidate.callSite) - assertEquals("Int", candidate.returnTypeText) - assertEquals( - """ - package p - fun demo(a: Int): Int { - val doubled = a * 2 - return finish(doubled) - } - - private fun finish(doubled: Int): Int { - return doubled + 1 - } - """.trimIndent(), - apply(content, buildExtractMethodRewrites(result.fileText, candidate, "finish")!!), - ) - } - - @Test - fun `a return in the middle of the range is declined`() { - val content = - """ - package p - fun demo(a: Int): Int { - if (a > 0) return a - val b = a * 2 - return b - } - """.trimIndent() - val (start, end) = selection(content, "if (a > 0) return a", "val b = a * 2") - - assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) - } - - @Test - fun `a break targeting an outer loop is declined`() { - val content = - """ - package p - fun demo(items: List) { - for (item in items) { - if (item < 0) break - println(item) - } - } - """.trimIndent() - val (start, end) = selection(content, "if (item < 0) break", "println(item)") - - assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) - } - - @Test - fun `an extension receiver is copied onto the new function`() { - val content = - """ - package p - class Foo(val n: Int) - fun Foo.bar(): Int { - return n * 2 - } - """.trimIndent() - - val result = plan(content, content.indexOf("n * 2") + 1) - val candidate = result.candidates.first { it.label == "n * 2" } - - assertEquals("Foo", candidate.receiverTypeText) - // `this` is a Foo at the call site, so nothing is passed and nothing is captured. - assertEquals(emptyList(), candidate.parameters) - } - - @Test - fun `an inner with receiver is declined and names the construct`() { - val content = - """ - package p - class Foo { val n: Int = 1 } - fun demo(f: Foo): Int { - with(f) { - return n * 2 - } - } - """.trimIndent() - - val refusal = plan(content, content.indexOf("n * 2") + 1).refusal - - assertEquals(ExtractionRefusal.InnerImplicitReceiver("with"), refusal) - } - - @Test - fun `a suspend call adds the suspend modifier`() { - val content = - """ - package p - suspend fun load(): Int = 1 - suspend fun demo(): Int { - return load() + 1 - } - """.trimIndent() - - val result = plan(content, content.indexOf("load() + 1") + 1) - val candidate = result.candidates.first { it.label == "load() + 1" } - - assertEquals(listOf("private", "suspend"), candidate.modifiers) - } - - @Test - fun `a Composable call adds the Composable annotation`() { - createSourceFile( - "Composable.kt", - """ - package androidx.compose.runtime - annotation class Composable - """.trimIndent(), - ) - val content = - """ - package p - import androidx.compose.runtime.Composable - @Composable fun Label(text: String) {} - @Composable fun Demo(name: String) { - Label(name) - } - """.trimIndent() - val (start, end) = selection(content, "Label(name)", "Label(name)") - - val candidate = plan(content, start, end).candidates.single() - - assertEquals(listOf("@Composable"), candidate.annotations) - } - - @Test - fun `a function-level type parameter is declined and names it`() { - val content = - """ - package p - fun demo(value: T): String { - val held: T = value - return held.toString() - } - """.trimIndent() - val (start, end) = selection(content, "val held", "val held: T = value") - - assertEquals(ExtractionRefusal.UsesTypeParameter("T"), plan(content, start, end).refusal) - } - - @Test - fun `taken names include inherited members`() { - val content = - """ - package p - open class Base { fun helper(): Int = 1 } - class Child : Base() { - fun demo(a: Int): Int { - return a * 2 - } - } - """.trimIndent() - - val candidate = plan(content, content.indexOf("a * 2") + 1).candidates.first { it.label == "a * 2" } - - // A private member matching an inherited name is an accidental-override compile error. - assertTrue("helper" in candidate.takenNames) - assertTrue("demo" in candidate.takenNames) - } - - @Test - fun `a selection spanning two blocks is declined as not a single region`() { - val content = - """ - package p - fun log(n: Int) {} - fun demo(c: Boolean, a: Int) { - if (c) { - log(a) - } - log(a + 1) - } - """.trimIndent() - val (start, end) = selection(content, "log(a)", "log(a + 1)") - - assertEquals(ExtractionRefusal.NotASingleRegion, plan(content, start, end).refusal) - } - - @Test - fun `an expression extraction rewrites the call site and adds a member function`() { - val content = - """ - package p - class C { - fun demo(a: Int, b: Int): Int { - return a + b - } - } - """.trimIndent() - - val result = plan(content, content.indexOf("a + b") + 1) - val candidate = result.candidates.first { it.label == "a + b" } - - assertEquals( - """ - package p - class C { - fun demo(a: Int, b: Int): Int { - return total(a, b) - } - - private fun total(a: Int, b: Int): Int { - return a + b - } - } - """.trimIndent(), - apply(content, buildExtractMethodRewrites(result.fileText, candidate, "total")!!), - ) - } -} -``` - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest"` -Expected: compilation failure -- `Unresolved reference: buildExtractMethodPlan`. - -- [ ] **Step 4: Write `MethodSignature.kt`** - -This derives one candidate from one region. Every resolution call is wrapped in `runCatching` -- resolution over broken code throws, and a throw here must read as a refusal, not a crash. - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import com.itsaky.androidide.lsp.kotlin.utils.renderName -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.KaClassSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaNamedFunctionSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol -import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol -import org.jetbrains.kotlin.builtins.StandardNames -import org.jetbrains.kotlin.com.intellij.psi.PsiElement -import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil -import org.jetbrains.kotlin.idea.references.mainReference -import org.jetbrains.kotlin.lexer.KtTokens -import org.jetbrains.kotlin.psi.KtAnonymousInitializer -import org.jetbrains.kotlin.psi.KtBinaryExpression -import org.jetbrains.kotlin.psi.KtBlockExpression -import org.jetbrains.kotlin.psi.KtBreakExpression -import org.jetbrains.kotlin.psi.KtCallExpression -import org.jetbrains.kotlin.psi.KtClassOrObject -import org.jetbrains.kotlin.psi.KtContinueExpression -import org.jetbrains.kotlin.psi.KtDeclaration -import org.jetbrains.kotlin.psi.KtExpression -import org.jetbrains.kotlin.psi.KtFile -import org.jetbrains.kotlin.psi.KtFunctionLiteral -import org.jetbrains.kotlin.psi.KtLoopExpression -import org.jetbrains.kotlin.psi.KtNameReferenceExpression -import org.jetbrains.kotlin.psi.KtNamedFunction -import org.jetbrains.kotlin.psi.KtProperty -import org.jetbrains.kotlin.psi.KtPropertyAccessor -import org.jetbrains.kotlin.psi.KtQualifiedExpression -import org.jetbrains.kotlin.psi.KtReturnExpression -import org.jetbrains.kotlin.psi.KtSecondaryConstructor -import org.jetbrains.kotlin.psi.KtSimpleNameExpression -import org.jetbrains.kotlin.psi.KtTypeReference -import org.jetbrains.kotlin.psi.KtUnaryExpression - -/** The name of the statement-range suggestion; there is no expression to read a name from (R12). */ -private const val STATEMENT_RANGE_NAME = "extracted" - -private const val COMPOSABLE_FQ_NAME = "androidx.compose.runtime.Composable" - -/** - * Receiver-binding scoping functions. `let`, `also` and `forEach` are absent on purpose: they bind - * `it`, which is a captured declaration and becomes an ordinary parameter (R5). - */ -private val RECEIVER_SCOPING_FUNCTIONS = - setOf("with", "apply", "run", "buildString", "buildList", "buildMap", "buildSet") - -/** Either a derived candidate or the reason there is not one. */ -internal sealed interface SignatureResult { - data class Success( - val candidate: ExtractMethodCandidate, - ) : SignatureResult - - data class Refused( - val refusal: ExtractionRefusal, - ) : SignatureResult -} - -/** - * Derives one candidate from [elements] -- a single expression, or the statement range. - * - * Ordered so the cheapest refusals come first and nothing expensive runs for a region that is going - * to be declined anyway. MUST be called inside an analysis session. - */ -internal fun KaSession.buildCandidate( - elements: List, - isExpression: Boolean, - fileText: String, -): SignatureResult { - val first = elements.first() - val last = elements.last() - val span = TextSpan(first.textRange.startOffset, last.textRange.endOffset) - val enclosing = enclosingDeclaration(first) ?: return refuse(ExtractionRefusal.NotASingleRegion) - - typeParameterIn(enclosing, elements)?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } - innerImplicitReceiver(enclosing, elements, span)?.let { return refuse(ExtractionRefusal.InnerImplicitReceiver(it)) } - reassignedOuterVar(enclosing, elements, span)?.let { return refuse(ExtractionRefusal.ReassignsOuterVar(it)) } - - val tailReturn = !isExpression && isTailReturn(elements, span) - if (!tailReturn && hasExit(elements, span)) return refuse(ExtractionRefusal.ExitsRegion) - - val outputs = if (isExpression) emptyList() else outputsOf(enclosing, elements, span) - if (outputs.size > 1) { - return refuse(ExtractionRefusal.MultipleOutputs(outputs.mapNotNull { it.name })) - } - // The tail-return exception holds only when nothing else flows out (R8). - if (tailReturn && outputs.isNotEmpty()) return refuse(ExtractionRefusal.ExitsRegion) - - val parameters = capturedParameters(enclosing, elements, span) ?: return refuse(ExtractionRefusal.UnrenderableType) - - val returnTypeText = - when { - isExpression -> renderedTypeOrNull(first) ?: return refuse(ExtractionRefusal.UnrenderableType) - tailReturn -> enclosingReturnType(enclosing) ?: return refuse(ExtractionRefusal.UnrenderableType) - outputs.size == 1 -> - renderedDeclarationType(outputs.single()) ?: return refuse(ExtractionRefusal.UnrenderableType) - - else -> null - }.takeUnless { it == "Unit" } - - val body = - when { - isExpression -> ExtractedBody.ExpressionBody(needsReturn = returnTypeText != null) - outputs.size == 1 -> ExtractedBody.StatementBody(trailingReturn = "return ${outputs.single().name.orEmpty()}") - else -> ExtractedBody.StatementBody(trailingReturn = null) - } - - val callSite = - when { - tailReturn -> CallSiteForm.Return - outputs.size == 1 -> CallSiteForm.AssignOutput(outputs.single().name.orEmpty()) - else -> CallSiteForm.Call - } - - val takenNames = takenNamesFor(enclosing) - - return SignatureResult.Success( - ExtractMethodCandidate( - label = collapseForLabel(fileText.substring(span.start, span.end)), - span = span, - suggestedName = - if (isExpression) { - suggestVariableName(first, renderedTypeOrNull(first), takenNames) - } else { - uniqueName(STATEMENT_RANGE_NAME, takenNames) - }, - takenNames = takenNames, - annotations = if (usesComposable(elements)) listOf("@Composable") else emptyList(), - modifiers = if (usesSuspend(elements)) listOf("private", "suspend") else listOf("private"), - receiverTypeText = (enclosing as? KtNamedFunction)?.receiverTypeReference?.text, - parameters = parameters, - returnTypeText = returnTypeText, - body = body, - callSite = callSite, - insertOffset = enclosing.textRange.endOffset, - insertIndent = leadingIndentAt(fileText, enclosing.textRange.startOffset), - ), - ) -} - -private fun refuse(refusal: ExtractionRefusal): SignatureResult = SignatureResult.Refused(refusal) - -/** - * The named function, accessor, `init` block or constructor whose body holds [element]. Lambdas are - * skipped: the new function is a sibling of the enclosing *named* declaration (R4), and the lambda's - * captures become parameters. - */ -private fun enclosingDeclaration(element: PsiElement): KtDeclaration? { - var current: PsiElement? = element.parent - while (current != null) { - when (current) { - is KtNamedFunction, is KtPropertyAccessor, is KtAnonymousInitializer, is KtSecondaryConstructor -> - return current as KtDeclaration - - is KtClassOrObject -> return null - } - current = current.parent - } - return null -} - -/** Whether [element] is inside the region's span. */ -private fun inRegion( - element: PsiElement, - span: TextSpan, -): Boolean = element.textRange.startOffset >= span.start && element.textRange.endOffset <= span.end - -private fun simpleNamesIn(elements: List): List = - elements.flatMap { PsiTreeUtil.collectElementsOfType(it, KtSimpleNameExpression::class.java) } - -private fun descendantsOf( - elements: List, - type: Class, -): List = elements.flatMap { PsiTreeUtil.collectElementsOfType(it, type) } - -/** - * A captured declaration is one the region references whose PSI lies inside the enclosing - * declaration but outside the region itself. Anything else -- a class member, a top-level - * declaration, an import -- resolves unchanged from the new function's body (R5). - * - * Returns null when a type cannot be rendered as source, which declines the extraction rather than - * emitting text that will not compile. - */ -private fun KaSession.capturedParameters( - enclosing: KtDeclaration, - elements: List, - span: TextSpan, -): List? { - val parameters = mutableListOf() - val seen = mutableSetOf() - - for (reference in simpleNamesIn(elements).sortedBy { it.textRange.startOffset }) { - val symbol = - runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaCallableSymbol - ?: continue - val declarationPsi = runCatching { symbol.psi }.getOrNull() - - val key: Any = - when { - declarationPsi != null -> { - if (!PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue - if (inRegion(declarationPsi, span)) continue - declarationPsi - } - - // `it` has no source PSI, so it would otherwise read as "not captured" and be dropped. - symbol is KaValueParameterSymbol && - reference.getReferencedName() == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() -> "it" - - else -> continue - } - if (!seen.add(key)) continue - - val typeText = renderedSymbolType(symbol) ?: return null - parameters += MethodParameter(name = reference.getReferencedName(), typeText = typeText) - } - return parameters -} - -/** A type that cannot be written out as source -- anonymous, intersection, or a resolution error. */ -private fun isUnrenderable(text: String): Boolean = - text.isBlank() || - text.contains("anonymous") || - text.contains("ERROR") || - text.contains(" & ") - -private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = - runCatching { renderName(symbol.returnType) }.getOrNull()?.takeUnless(::isUnrenderable) - -private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = - runCatching { expression.expressionType?.let { renderName(it) } }.getOrNull()?.takeUnless(::isUnrenderable) - -private fun KaSession.renderedDeclarationType(property: KtProperty): String? = - runCatching { (property.symbol as? KaCallableSymbol)?.returnType?.let { renderName(it) } } - .getOrNull() - ?.takeUnless(::isUnrenderable) - -private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = - runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType?.let { renderName(it) } } - .getOrNull() - ?.takeUnless(::isUnrenderable) - -/** - * Locals declared inside the region and read after it (R7). Exactly one is supported. - * - * "Read after it" is a textual-offset test inside the enclosing declaration, which is sound because - * a local is only in scope after its own declaration in the same block. - */ -private fun KaSession.outputsOf( - enclosing: KtDeclaration, - elements: List, - span: TextSpan, -): List { - val declared = descendantsOf(elements, KtProperty::class.java) - if (declared.isEmpty()) return emptyList() - - val laterReads = - PsiTreeUtil - .collectElementsOfType(enclosing, KtSimpleNameExpression::class.java) - .filter { it.textRange.startOffset >= span.end } - .mapNotNull { runCatching { it.mainReference?.resolveToSymbols()?.firstOrNull()?.psi }.getOrNull() } - .toSet() - - return declared.filter { it in laterReads } -} - -/** - * A `var` declared inside the enclosing declaration but outside the region, assigned inside it. - * Kotlin has no `out` parameters, so the faithful emission would shadow a name (R7, ADR 0012). - */ -private fun KaSession.reassignedOuterVar( - enclosing: KtDeclaration, - elements: List, - span: TextSpan, -): String? { - for (reference in simpleNamesIn(elements)) { - if (!reference.isWriteTarget()) continue - val symbol = - runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaVariableSymbol - ?: continue - if (symbol.isVal) continue - val declarationPsi = runCatching { symbol.psi }.getOrNull() ?: continue - if (!PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue - if (inRegion(declarationPsi, span)) continue - return reference.getReferencedName() - } - return null -} - -private fun KtSimpleNameExpression.isWriteTarget(): Boolean { - val parent = parent - if (parent is KtBinaryExpression && parent.left === this && parent.operationToken in ASSIGNMENT_TOKENS) return true - if (parent is KtUnaryExpression && parent.operationToken in INCREMENT_TOKENS) return true - return false -} - -private val ASSIGNMENT_TOKENS = - setOf(KtTokens.EQ, KtTokens.PLUSEQ, KtTokens.MINUSEQ, KtTokens.MULTEQ, KtTokens.DIVEQ, KtTokens.PERCEQ) - -private val INCREMENT_TOKENS = setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) - -/** - * The tail-return exception (R8): the region's last statement is a `return`, and it is the region's - * only `return`, `break` or `continue`. Purely syntactic, which is why it is worth having. - */ -private fun isTailReturn( - elements: List, - span: TextSpan, -): Boolean { - if (elements.last() !is KtReturnExpression) return false - val returns = descendantsOf(elements, KtReturnExpression::class.java) - if (returns.size != 1 || returns.single() !== elements.last()) return false - return !hasLoopExit(elements, span) -} - -/** Any `return`, `break` or `continue` whose target lies outside the region (R8). */ -private fun hasExit( - elements: List, - span: TextSpan, -): Boolean { - for (returnExpression in descendantsOf(elements, KtReturnExpression::class.java)) { - // An unlabelled `return` always targets the enclosing named declaration, which is outside the - // region by construction. A labelled one is fine only when its lambda is inside the region. - if (returnExpression.getLabelName() == null) return true - val lambda = PsiTreeUtil.getParentOfType(returnExpression, KtFunctionLiteral::class.java, true) - if (lambda == null || !inRegion(lambda, span)) return true - } - return hasLoopExit(elements, span) -} - -private fun hasLoopExit( - elements: List, - span: TextSpan, -): Boolean { - val jumps = - descendantsOf(elements, KtBreakExpression::class.java) + - descendantsOf(elements, KtContinueExpression::class.java) - return jumps.any { jump -> - val loop = PsiTreeUtil.getParentOfType(jump, KtLoopExpression::class.java, true) - loop == null || !inRegion(loop, span) - } -} - -/** - * The name of the enclosing function's type parameter the region uses, or null. A filtered copy of - * the type-parameter list with its bounds is the alternative, and deciding "is `T` referenced" from - * rendered type text is exactly the fragility that rules it out (R10). - */ -private fun typeParameterIn( - enclosing: KtDeclaration, - elements: List, -): String? { - val names = (enclosing as? KtNamedFunction)?.typeParameters?.mapNotNull { it.name }.orEmpty() - if (names.isEmpty()) return null - - val typeTexts = - descendantsOf(elements, KtTypeReference::class.java).map { it.text } + - simpleNamesIn(elements).map { it.getReferencedName() } - return names.firstOrNull { name -> typeTexts.any { it == name || it.containsWord(name) } } -} - -/** Whole-word containment, so `T` does not match `Type`. */ -private fun String.containsWord(word: String): Boolean = - Regex("(^|[^A-Za-z0-9_])" + Regex.escape(word) + "($|[^A-Za-z0-9_])").containsMatchIn(this) - -/** - * The scoping construct whose implicit receiver the region uses unqualified, or null (R9). - * - * Turning that receiver into a parameter would mean qualifying every unqualified member access - * inside the extracted body -- editing the interior of the moved code, which this refactoring does - * not do. Android code leans on `with`/`apply` heavily, so the message names the construct. - */ -private fun KaSession.innerImplicitReceiver( - enclosing: KtDeclaration, - elements: List, - span: TextSpan, -): String? { - val construct = enclosingScopingCall(elements.first(), enclosing) ?: return null - val enclosingClass = PsiTreeUtil.getParentOfType(enclosing, KtClassOrObject::class.java, true) - - for (reference in simpleNamesIn(elements)) { - val parent = reference.parent - if (parent is KtQualifiedExpression && parent.selectorExpression === reference) continue - if (parent is KtCallExpression && parent.calleeExpression !== reference) continue - - val symbol = - runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaCallableSymbol - ?: continue - val declarationPsi = runCatching { symbol.psi }.getOrNull() ?: continue - - // A local or a member of the class the new function joins needs nothing. - if (PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue - if (enclosingClass != null && PsiTreeUtil.isAncestor(enclosingClass, declarationPsi, true)) continue - // A top-level declaration resolves unchanged from anywhere in the file. - if (declarationPsi.parent is KtFile) continue - // Anything else reached without a qualifier came in through the scoping receiver. - if (inRegion(declarationPsi, span)) continue - return construct - } - return null -} - -/** The callee name of the nearest receiver-binding scoping call between [element] and [enclosing]. */ -private fun enclosingScopingCall( - element: PsiElement, - enclosing: KtDeclaration, -): String? { - var current: PsiElement? = element - while (current != null && current !== enclosing) { - if (current is KtFunctionLiteral) { - val call = PsiTreeUtil.getParentOfType(current, KtCallExpression::class.java, true) - val callee = (call?.calleeExpression as? KtNameReferenceExpression)?.getReferencedName() - if (callee != null && callee in RECEIVER_SCOPING_FUNCTIONS) return callee - } - current = current.parent - } - return null -} - -/** `suspend` is added when the region calls one, or touches `coroutineContext` (R10). */ -private fun KaSession.usesSuspend(elements: List): Boolean { - if (simpleNamesIn(elements).any { it.getReferencedName() == "coroutineContext" }) return true - return descendantsOf(elements, KtCallExpression::class.java).any { call -> - runCatching { - (call.resolveToCall()?.successfulFunctionCallOrNull()?.symbol as? KaNamedFunctionSymbol)?.isSuspend - }.getOrNull() == true - } -} - -/** - * `@Composable` is added when the region calls one. Not polish: CoGo users write Compose apps on the - * device, and an extracted composable without the annotation does not compile (R10). - */ -private fun KaSession.usesComposable(elements: List): Boolean = - descendantsOf(elements, KtCallExpression::class.java).any { call -> - runCatching { - call - .resolveToCall() - ?.successfulFunctionCallOrNull() - ?.symbol - ?.annotations - ?.any { it.classId?.asFqNameString() == COMPOSABLE_FQ_NAME } - }.getOrNull() == true - } - -/** - * Names the new function must avoid (R12). - * - * For a class target this is the whole member scope, **including inherited members**: a private - * function accidentally matching a supertype member is an accidental-override compile error. - * Rejecting any name match rather than only a signature match also means the refactoring never - * creates an overload the user did not ask for. - */ -private fun KaSession.takenNamesFor(enclosing: KtDeclaration): Set { - val containingClass = PsiTreeUtil.getParentOfType(enclosing, KtClassOrObject::class.java, true) - if (containingClass != null) { - val fromScope = - runCatching { - (containingClass.symbol as? KaClassSymbol) - ?.memberScope - ?.callables - ?.mapNotNull { (it as? KaNamedSymbol)?.name?.asString() } - ?.toSet() - }.getOrNull().orEmpty() - val declared = containingClass.declarations.mapNotNull { it.name } - return fromScope + declared - } - - // A local `fun` target: the enclosing block's own declarations. Otherwise the file's top level. - val block = enclosing.parent - if (block is KtBlockExpression) { - return PsiTreeUtil - .collectElementsOfType(block, KtDeclaration::class.java) - .mapNotNull { it.name } - .toSet() - } - return enclosing.containingKtFile.declarations.mapNotNull { it.name }.toSet() -} -``` - -- [ ] **Step 5: Write `ExtractMethodPlanner.kt`** - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment -import com.itsaky.androidide.lsp.kotlin.compiler.modules.AnalysisPriority -import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker -import com.itsaky.androidide.lsp.kotlin.compiler.modules.analyzeMaybeDangling -import com.itsaky.androidide.lsp.kotlin.compiler.read -import org.slf4j.LoggerFactory -import java.nio.file.Path - -private val logger = LoggerFactory.getLogger("ExtractMethodPlanner") - -/** - * Computes the whole [ExtractMethodPlan] in one background analysis pass. - * - * The current `KtFile` is fetched *before* entering [read] -- blocking on `getCurrentKtFile(...).get()` - * inside `project.read` deadlocks. - * - * Anything thrown in this pipeline degrades to a refusal plus a log line: the action framework - * catches only `IllegalArgumentException` and this runs on a scope with no exception handler, so an - * uncaught throw would crash the app (R16). - */ -internal fun buildExtractMethodPlan( - env: AbstractCompilationEnvironment, - nioPath: Path, - selectionStart: Int, - selectionEnd: Int, - documentVersion: Int, - cancelChecker: ScheduledCancelChecker, -): ExtractMethodPlan = - runCatching { - val ktFile = - env.ktSymbolIndex.getCurrentKtFile(nioPath).get() - ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) - - env.project.read { - val fileText = ktFile.text - val region = - resolveExtractionRegion(ktFile, selectionStart, selectionEnd) - ?: return@read ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion, fileText, documentVersion) - - analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { - val results = - when (region) { - is ExtractionRegion.Expressions -> - region.candidates.map { buildCandidate(listOf(it), isExpression = true, fileText = fileText) } - - is ExtractionRegion.Statements -> - listOf(buildCandidate(region.statements, isExpression = false, fileText = fileText)) - } - - val candidates = results.filterIsInstance().map { it.candidate } - if (candidates.isEmpty()) { - // The innermost region is the one the user pointed at, so its reason is the one to show. - val refusal = - results.filterIsInstance().firstOrNull()?.refusal - ?: ExtractionRefusal.NotASingleRegion - return@analyzeMaybeDangling ExtractMethodPlan.refused(refusal, fileText, documentVersion) - } - - ExtractMethodPlan( - fileText = fileText, - documentVersion = documentVersion, - candidates = candidates, - // Only meaningful while the innermost candidate survived: otherwise the selection no - // longer corresponds to the first option shown. - selectionMatchedCandidate = - region is ExtractionRegion.Expressions && - region.selectionMatchedInnermost && - candidates.first().span == region.span, - refusal = null, - ) - } - } - }.getOrElse { error -> - logger.warn("Failed to build extract-method plan for {}", nioPath, error) - ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) - } -``` - -- [ ] **Step 6: Compile before running the tests** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:compileV7DebugKotlin` -Expected: BUILD SUCCESSFUL. Analysis API symbol names drift between Kotlin versions; if `annotations`, `memberScope`, `isSuspend` or `symbol` do not resolve, find the equivalent by grepping `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/AbstractMemberStubs.kt` and `completion/KotlinCompletions.kt`, which already use them. Do not add a dependency. - -- [ ] **Step 7: Run the test to verify it passes** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest"` -Expected: PASS, 16 tests. - -If a refusal test reports the wrong reason, check the ordering in `buildCandidate` -- the checks are ordered deliberately and a case can be caught by an earlier one. If the `@Composable` test cannot resolve the annotation, confirm the second `createSourceFile` call registers the file with the symbol index (`KtLspTest.createSourceFile` does this itself). - -- [ ] **Step 8: Run the whole module's tests, then format and commit** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` -Expected: PASS. - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/NameSuggestion.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt -git commit -m "ADFA-5080: Derive the extracted signature, or a typed refusal" -``` - ---- - -## Task 5: Strings, shared sheet components, and the ViewModel - -**Files:** -- Modify: `resources/src/main/res/values/strings.xml` (after the extract-variable block, currently ending at `msg_extract_variable_file_changed`) -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt` -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt` (delete lines 138-200: `LabelledSection`, `OptionList`, `messageRes`) -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt` -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt` -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt` - -**Interfaces:** -- Consumes: `ExtractMethodPlan`, `ExtractMethodCandidate`, `signatureText`, `validateVariableName`, `NameProblem`. -- Produces: - - `internal @Composable fun LabelledSection(label: String, content: @Composable () -> Unit)` - - `internal @Composable fun OptionList(options: List, selected: Int, monospace: Boolean, onSelect: (Int) -> Unit)` - - `internal fun NameProblem.messageRes(): Int` - - `data class ExtractMethodUiState(candidateLabels, selectedCandidate, showCandidatePicker, name, nameProblem, signaturePreview)` with `canConfirm` - - `sealed interface ExtractMethodUiEvent` with `CandidateSelected(index)`, `NameChanged(name)`, `Confirmed`, `Dismissed` - - `data class ExtractMethodChoice(val candidate: ExtractMethodCandidate, val name: String)` - - `class ExtractMethodViewModel(plan: ExtractMethodPlan)` with `uiState: StateFlow`, `onEvent(event)`, `choice(): ExtractMethodChoice?`, `companion object { fun factory(plan): ViewModelProvider.Factory }` - -- [ ] **Step 1: Add the strings** - -In `resources/src/main/res/values/strings.xml`, immediately after the line -`The file changed. Try extracting again.` -insert: - -```xml - - - Extract method - Extract method - Signature - The file changed. Try extracting again. - Select an expression, or whole statements inside one block - The selection produces more than one value: %1$s - The selection assigns to %1$s, which is declared outside it - The selection jumps out of itself with return, break or continue - The selection uses members of the enclosing %1$s receiver - The selection uses type parameter %1$s - A type in the selection cannot be written out -``` - -Reuse the existing `action_extract`, `label_extract_variable_expression`, `label_extract_variable_name` and the four `msg_extract_variable_name_*` messages -- R12 keeps name validation identical, so no new error strings. - -- [ ] **Step 2: Promote the shared sheet components** - -Create `SheetComponents.kt` with the three declarations moved **verbatim** from `ExtractVariableSheetContent.kt` (lines 138-200), changing `private` to `internal`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.refactor.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.selection.selectable -import androidx.compose.foundation.selection.selectableGroup -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.RadioButton -import androidx.compose.material3.Text -import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment -import androidx.compose.ui.Modifier -import androidx.compose.ui.semantics.Role -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem -import com.itsaky.androidide.resources.R - -/** Shared by the extract-variable and extract-method sheets; neither owns them. */ -@Composable -internal fun LabelledSection( - label: String, - content: @Composable () -> Unit, -) { - Column(verticalArrangement = Arrangement.spacedBy(4.dp)) { - Text(text = label, style = MaterialTheme.typography.labelLarge) - content() - } -} - -/** A radio group. Expression text is monospaced so a candidate reads as the code it is. */ -@Composable -internal fun OptionList( - options: List, - selected: Int, - monospace: Boolean, - onSelect: (Int) -> Unit, -) { - Column( - modifier = Modifier.selectableGroup(), - verticalArrangement = Arrangement.spacedBy(8.dp), - ) { - options.forEachIndexed { index, option -> - Row( - verticalAlignment = Alignment.CenterVertically, - modifier = - Modifier - .fillMaxWidth() - .selectable( - selected = index == selected, - role = Role.RadioButton, - onClick = { onSelect(index) }, - ), - ) { - RadioButton( - selected = index == selected, - onClick = null, - ) - - Text( - text = option, - style = - if (monospace) { - MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace) - } else { - MaterialTheme.typography.bodyMedium - }, - modifier = Modifier.padding(start = 8.dp), - ) - } - } - } -} - -/** The message shown under a name field for each way a name can be unusable. */ -internal fun NameProblem.messageRes(): Int = - when (this) { - NameProblem.Blank -> R.string.msg_extract_variable_name_blank - NameProblem.NotAnIdentifier -> R.string.msg_extract_variable_name_invalid - NameProblem.Keyword -> R.string.msg_extract_variable_name_keyword - NameProblem.AlreadyTaken -> R.string.msg_extract_variable_name_taken - } -``` - -Then delete those three declarations from `ExtractVariableSheetContent.kt` and remove the imports they alone used (`selectable`, `selectableGroup`, `RadioButton`, `FontFamily`, `Role` stays -- it is used by the replace-all `toggleable`). Let the compiler tell you which imports are now unused. - -- [ ] **Step 3: Write the failing ViewModel test** - -Create `ExtractMethodViewModelTest.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.refactor.ui - -import com.itsaky.androidide.lsp.kotlin.utils.refactor.CallSiteForm -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodCandidate -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractedBody -import com.itsaky.androidide.lsp.kotlin.utils.refactor.MethodParameter -import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem -import com.itsaky.androidide.lsp.kotlin.utils.refactor.TextSpan -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull -import org.junit.Assert.assertTrue -import org.junit.Test - -/** The sheet's derivation logic, tested without Compose, a fragment or an activity. */ -class ExtractMethodViewModelTest { - private fun candidate( - label: String, - suggestedName: String, - parameters: List = listOf(MethodParameter("a", "Int")), - returnTypeText: String? = "Int", - modifiers: List = listOf("private"), - takenNames: Set = emptySet(), - ) = ExtractMethodCandidate( - label = label, - span = TextSpan(0, 5), - suggestedName = suggestedName, - takenNames = takenNames, - annotations = emptyList(), - modifiers = modifiers, - receiverTypeText = null, - parameters = parameters, - returnTypeText = returnTypeText, - body = ExtractedBody.ExpressionBody(needsReturn = true), - callSite = CallSiteForm.Call, - insertOffset = 100, - insertIndent = "\t", - ) - - private fun plan( - candidates: List, - selectionMatched: Boolean = false, - ) = ExtractMethodPlan( - fileText = "unused", - documentVersion = 1, - candidates = candidates, - selectionMatchedCandidate = selectionMatched, - refusal = null, - ) - - @Test - fun `the initial state takes the first candidate's suggestion`() { - val model = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) - - assertEquals("total", model.uiState.value.name) - assertEquals(0, model.uiState.value.selectedCandidate) - assertNull(model.uiState.value.nameProblem) - } - - @Test - fun `the chooser is hidden for one candidate and for an exact selection match`() { - val single = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) - assertFalse(single.uiState.value.showCandidatePicker) - - val many = listOf(candidate("a + b", "total"), candidate("a + b + c", "total1")) - assertTrue(ExtractMethodViewModel(plan(many)).uiState.value.showCandidatePicker) - assertFalse(ExtractMethodViewModel(plan(many, selectionMatched = true)).uiState.value.showCandidatePicker) - } - - @Test - fun `the preview is the signature as it will be emitted`() { - val model = - ExtractMethodViewModel( - plan( - listOf( - candidate( - "load() + 1", - "total", - parameters = listOf(MethodParameter("id", "String")), - returnTypeText = "User", - modifiers = listOf("private", "suspend"), - ), - ), - ), - ) - - assertEquals("private suspend fun total(id: String): User", model.uiState.value.signaturePreview) - - model.onEvent(ExtractMethodUiEvent.NameChanged("loadUser")) - - assertEquals("private suspend fun loadUser(id: String): User", model.uiState.value.signaturePreview) - } - - @Test - fun `a name matching an inherited member is rejected`() { - val model = - ExtractMethodViewModel(plan(listOf(candidate("a + b", "total", takenNames = setOf("helper"))))) - - model.onEvent(ExtractMethodUiEvent.NameChanged("helper")) - - assertEquals(NameProblem.AlreadyTaken, model.uiState.value.nameProblem) - assertFalse(model.uiState.value.canConfirm) - assertNull(model.choice()) - } - - @Test - fun `switching candidate re-suggests the name`() { - val model = - ExtractMethodViewModel( - plan(listOf(candidate("a + b", "total"), candidate("a + b + c", "sum"))), - ) - model.onEvent(ExtractMethodUiEvent.NameChanged("mine")) - - model.onEvent(ExtractMethodUiEvent.CandidateSelected(1)) - - assertEquals("sum", model.uiState.value.name) - assertEquals(1, model.uiState.value.selectedCandidate) - } - - @Test - fun `the choice carries the selected candidate and the typed name`() { - val model = - ExtractMethodViewModel( - plan(listOf(candidate("a + b", "total"), candidate("a + b + c", "sum"))), - ) - model.onEvent(ExtractMethodUiEvent.CandidateSelected(1)) - model.onEvent(ExtractMethodUiEvent.NameChanged("combined")) - - val choice = model.choice() - - assertNotNull(choice) - assertEquals("a + b + c", choice!!.candidate.label) - assertEquals("combined", choice.name) - } - - @Test - fun `a blank name blocks confirmation`() { - val model = ExtractMethodViewModel(plan(listOf(candidate("a + b", "total")))) - - model.onEvent(ExtractMethodUiEvent.NameChanged("")) - - assertEquals(NameProblem.Blank, model.uiState.value.nameProblem) - assertNull(model.choice()) - } -} -``` - -- [ ] **Step 4: Run the test to verify it fails** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodViewModelTest"` -Expected: compilation failure -- `Unresolved reference: ExtractMethodViewModel`. - -- [ ] **Step 5: Write the state and the ViewModel** - -Create `ExtractMethodUiState.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.refactor.ui - -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodCandidate -import com.itsaky.androidide.lsp.kotlin.utils.refactor.NameProblem - -/** - * Everything the extract-method sheet renders. - * - * There is no scope chooser (the new function is always a sibling of the enclosing declaration) and - * no replace-all checkbox (the region is the only site rewritten), so the sheet is a chooser, a name - * field and a preview. - * - * [signaturePreview] is the signature exactly as it will be emitted -- the one derived artefact, and - * the one place the derivation can surprise the user. The body is the code they selected and can see - * behind the sheet, so previewing it says nothing new. - */ -data class ExtractMethodUiState( - val candidateLabels: List, - val selectedCandidate: Int, - val showCandidatePicker: Boolean, - val name: String, - val nameProblem: NameProblem?, - val signaturePreview: String, -) { - val canConfirm: Boolean get() = nameProblem == null -} - -/** What the sheet reports back up; the ViewModel never touches the document itself. */ -sealed interface ExtractMethodUiEvent { - data class CandidateSelected( - val index: Int, - ) : ExtractMethodUiEvent - - data class NameChanged( - val name: String, - ) : ExtractMethodUiEvent - - data object Confirmed : ExtractMethodUiEvent - - data object Dismissed : ExtractMethodUiEvent -} - -/** - * The user's finished decision, handed to the action to turn into edits. Free of offsets and text so - * the sheet stays a pure chooser. - */ -data class ExtractMethodChoice( - val candidate: ExtractMethodCandidate, - val name: String, -) -``` - -Create `ExtractMethodViewModel.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.refactor.ui - -import androidx.lifecycle.ViewModel -import androidx.lifecycle.ViewModelProvider -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan -import com.itsaky.androidide.lsp.kotlin.utils.refactor.signatureText -import com.itsaky.androidide.lsp.kotlin.utils.refactor.validateVariableName -import kotlinx.coroutines.flow.MutableStateFlow -import kotlinx.coroutines.flow.StateFlow -import kotlinx.coroutines.flow.asStateFlow - -/** - * Derives the sheet's state from an [ExtractMethodPlan] and nothing else -- no analysis, no PSI, no - * I/O -- which is what lets it hold all the sheet's logic and still be a plain unit test. - * - * A plain [ViewModelProvider.Factory] rather than a Koin definition, for the same reason as - * `ExtractVariableViewModel`: sheet-scoped, injects nothing, takes the plan as a runtime argument. - */ -class ExtractMethodViewModel( - private val plan: ExtractMethodPlan, -) : ViewModel() { - private val _uiState = MutableStateFlow(stateFor(candidateIndex = 0, name = null)) - val uiState: StateFlow = _uiState.asStateFlow() - - fun onEvent(event: ExtractMethodUiEvent) { - val current = _uiState.value - when (event) { - is ExtractMethodUiEvent.CandidateSelected -> { - if (event.index == current.selectedCandidate) return - // A different expression means a different signature and suggested name, so the name is - // re-suggested rather than carried over -- the old one described the old expression. - _uiState.value = stateFor(event.index, name = null) - } - - is ExtractMethodUiEvent.NameChanged -> { - _uiState.value = stateFor(current.selectedCandidate, name = event.name) - } - - ExtractMethodUiEvent.Confirmed, ExtractMethodUiEvent.Dismissed -> Unit - } - } - - /** The user's decision, or null when the name is unusable. */ - fun choice(): ExtractMethodChoice? { - val state = _uiState.value - if (!state.canConfirm) return null - return ExtractMethodChoice(candidate(state.selectedCandidate), state.name) - } - - private fun candidate(index: Int) = plan.candidates[index.coerceIn(plan.candidates.indices)] - - private fun stateFor( - candidateIndex: Int, - name: String?, - ): ExtractMethodUiState { - val bounded = candidateIndex.coerceIn(plan.candidates.indices) - val candidate = candidate(bounded) - val resolvedName = name ?: candidate.suggestedName - - return ExtractMethodUiState( - candidateLabels = plan.candidates.map { it.label }, - selectedCandidate = bounded, - showCandidatePicker = plan.candidates.size > 1 && !plan.selectionMatchedCandidate, - name = resolvedName, - nameProblem = validateVariableName(resolvedName, candidate.takenNames), - // The same call the edit builder makes, so the preview cannot drift from the declaration. - signaturePreview = candidate.signatureText(resolvedName), - ) - } - - companion object { - fun factory(plan: ExtractMethodPlan): ViewModelProvider.Factory = - object : ViewModelProvider.Factory { - @Suppress("UNCHECKED_CAST") - override fun create(modelClass: Class): T = ExtractMethodViewModel(plan) as T - } - } -} -``` - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` -Expected: PASS, including the untouched `ExtractVariableViewModelTest` -- the component promotion must not have changed extract-variable behaviour. - -- [ ] **Step 7: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add resources/src/main/res/values/strings.xml \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableSheetContent.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt -git commit -m "ADFA-5080: Add the extract-method sheet state and strings" -``` - ---- - -## Task 6: The sheet - -Compose UI is not unit-testable in this module (`lsp/kotlin` has no `androidTest` source set and none is added). Verification is a compile plus the on-device QA in Task 7. - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt` -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt` - -**Interfaces:** -- Consumes: `ExtractMethodUiState`, `ExtractMethodUiEvent`, `ExtractMethodChoice`, `ExtractMethodViewModel`, `LabelledSection`, `OptionList`, `messageRes()`, `IdeTheme`, `findFragmentActivity` (already in `ExtractVariableSheet.kt`). -- Produces: `fun ExtractMethodSheet.Companion.show(activity: FragmentActivity, plan: ExtractMethodPlan, onChoice: (ExtractMethodChoice) -> Unit): Boolean`. - -- [ ] **Step 1: Write the content** - -Create `ExtractMethodSheetContent.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.refactor.ui - -import androidx.compose.foundation.layout.Arrangement -import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.navigationBarsPadding -import androidx.compose.foundation.layout.padding -import androidx.compose.material3.Button -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.OutlinedTextField -import androidx.compose.material3.Text -import androidx.compose.material3.TextButton -import androidx.compose.runtime.Composable -import androidx.compose.ui.Modifier -import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.font.FontFamily -import androidx.compose.ui.unit.dp -import com.itsaky.androidide.resources.R - -/** - * The extract-method sheet: the expression chooser (when there is a choice), the name, and the - * signature exactly as it will be emitted. - * - * A sibling of the extract-variable sheet rather than a generalisation of it: a single shared sheet - * would need a state class where half the fields are meaningless to either caller (ADR 0011). - * - * Stateless: all state arrives in [state] and every interaction leaves as an [ExtractMethodUiEvent]. - */ -@Composable -fun ExtractMethodSheetContent( - state: ExtractMethodUiState, - onEvent: (ExtractMethodUiEvent) -> Unit, - modifier: Modifier = Modifier, -) { - Column( - modifier = - modifier - .fillMaxWidth() - .navigationBarsPadding() - .padding(horizontal = 24.dp, vertical = 16.dp), - verticalArrangement = Arrangement.spacedBy(16.dp), - ) { - Text( - text = stringResource(R.string.title_extract_method), - style = MaterialTheme.typography.titleLarge, - ) - - if (state.showCandidatePicker) { - LabelledSection(stringResource(R.string.label_extract_variable_expression)) { - OptionList( - options = state.candidateLabels, - selected = state.selectedCandidate, - monospace = true, - onSelect = { onEvent(ExtractMethodUiEvent.CandidateSelected(it)) }, - ) - } - } - - OutlinedTextField( - value = state.name, - onValueChange = { onEvent(ExtractMethodUiEvent.NameChanged(it)) }, - label = { Text(stringResource(R.string.label_extract_variable_name)) }, - isError = state.nameProblem != null, - singleLine = true, - supportingText = state.nameProblem?.let { problem -> { Text(stringResource(problem.messageRes())) } }, - modifier = Modifier.fillMaxWidth(), - ) - - LabelledSection(stringResource(R.string.label_extract_method_signature)) { - Text( - text = state.signaturePreview, - style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), - modifier = Modifier.fillMaxWidth(), - ) - } - - Row( - modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.End, - ) { - TextButton(onClick = { onEvent(ExtractMethodUiEvent.Dismissed) }) { - Text(stringResource(android.R.string.cancel)) - } - Button( - onClick = { onEvent(ExtractMethodUiEvent.Confirmed) }, - enabled = state.canConfirm, - modifier = Modifier.padding(start = 8.dp), - ) { - Text(stringResource(R.string.action_extract)) - } - } - } -} -``` - -Note: the preview **wraps rather than truncating** (R11), which a plain `Text` with no `maxLines` does by default. Do not make it horizontally scrollable. - -- [ ] **Step 2: Write the sheet** - -Create `ExtractMethodSheet.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.refactor.ui - -import android.os.Bundle -import android.view.LayoutInflater -import android.view.View -import android.view.ViewGroup -import androidx.compose.runtime.getValue -import androidx.compose.ui.platform.ComposeView -import androidx.compose.ui.platform.ViewCompositionStrategy -import androidx.fragment.app.FragmentActivity -import androidx.fragment.app.viewModels -import androidx.lifecycle.compose.collectAsStateWithLifecycle -import com.google.android.material.bottomsheet.BottomSheetDialogFragment -import com.itsaky.androidide.common.compose.IdeTheme -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan - -/** - * Hosts [ExtractMethodSheetContent]. - * - * The plan is handed in directly rather than through fragment arguments: it carries the file's text - * and offset spans, which is neither `Parcelable` nor meaningful to restore -- after process death - * the document may be entirely different. So [plan] is null on a recreated instance and the sheet - * dismisses itself, the same outcome the action's document-version guard would reach anyway. - */ -class ExtractMethodSheet : BottomSheetDialogFragment() { - private var plan: ExtractMethodPlan? = null - private var onChoice: ((ExtractMethodChoice) -> Unit)? = null - - private val viewModel: ExtractMethodViewModel by viewModels { - ExtractMethodViewModel.factory(requireNotNull(plan) { "sheet shown without a plan" }) - } - - override fun onCreateView( - inflater: LayoutInflater, - container: ViewGroup?, - savedInstanceState: Bundle?, - ): View? { - if (plan == null) { - dismissAllowingStateLoss() - return null - } - - return ComposeView(requireContext()).apply { - setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) - setContent { - IdeTheme { - val state by viewModel.uiState.collectAsStateWithLifecycle() - ExtractMethodSheetContent( - state = state, - onEvent = ::handleEvent, - ) - } - } - } - } - - private fun handleEvent(event: ExtractMethodUiEvent) { - when (event) { - ExtractMethodUiEvent.Confirmed -> { - viewModel.choice()?.let { choice -> onChoice?.invoke(choice) } - dismiss() - } - - ExtractMethodUiEvent.Dismissed -> dismiss() - - else -> viewModel.onEvent(event) - } - } - - companion object { - private const val TAG = "extract_method_sheet" - - /** - * Shows the sheet on [activity], calling [onChoice] once if the user confirms. Returns false - * when it could not be shown, so the caller can report a failure rather than doing nothing. - */ - fun show( - activity: FragmentActivity, - plan: ExtractMethodPlan, - onChoice: (ExtractMethodChoice) -> Unit, - ): Boolean { - val manager = activity.supportFragmentManager - if (manager.isStateSaved || manager.isDestroyed) return false - ExtractMethodSheet() - .apply { - this.plan = plan - this.onChoice = onChoice - }.show(manager, TAG) - return true - } - } -} -``` - -- [ ] **Step 3: Compile and run the tests** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:compileV7DebugKotlin :lsp:kotlin:testV7DebugUnitTest` -Expected: BUILD SUCCESSFUL, tests PASS. - -- [ ] **Step 4: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt -git commit -m "ADFA-5080: Add the extract-method Compose sheet" -``` - ---- - -## Task 7: Wire up the code action - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt` -- Modify: `idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt` (next to `EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE`, around line 92) -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt` -- Modify: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt` -- Modify: `docs/features/kotlin-extract-method.md` (the `Status:` line, line 4) - -**Interfaces:** -- Consumes: `buildExtractMethodPlan`, `ExtractMethodPlan`, `ExtractionRefusal`, `buildExtractMethodRewrites`, `toTextEdit`, `ExtractMethodSheet.show`, `ExtractMethodChoice`, `findFragmentActivity`. -- Produces: `class ExtractMethodAction : BaseKotlinCodeAction()` with `companion object { const val ID = "ide.editor.lsp.kt.extractMethod" }`. - -- [ ] **Step 1: Add the tooltip tag** - -In `TooltipTag.kt`, directly below the extract-variable constant: - -```kotlin - const val EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD = "editor.codeactions.kotlin.extractmethod" -``` - -- [ ] **Step 2: Write the failing tooltip-tag test change** - -In `KotlinCodeActionTooltipTagTest.kt`, add the import `com.itsaky.androidide.lsp.kotlin.actions.ExtractMethodAction` and add the row to the `expected` map, next to the extract-variable row: - -```kotlin - ExtractMethodAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD, -``` - -Also add `ExtractMethodAction()` to the action list this test builds, mirroring how `ExtractVariableAction()` appears there. - -- [ ] **Step 3: Run the test to verify it fails** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.KotlinCodeActionTooltipTagTest"` -Expected: compilation failure -- `Unresolved reference: ExtractMethodAction`. - -- [ ] **Step 4: Write the action** - -Create `ExtractMethodAction.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.actions - -import android.content.Context -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.get -import com.itsaky.androidide.actions.requireContext -import com.itsaky.androidide.actions.requireEditor -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.kotlin.KotlinLanguageServer -import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker -import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodChoice -import com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractMethodSheet -import com.itsaky.androidide.lsp.kotlin.refactor.ui.findFragmentActivity -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlan -import com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionRefusal -import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractMethodPlan -import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildExtractMethodRewrites -import com.itsaky.androidide.lsp.kotlin.utils.refactor.toTextEdit -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.projects.FileManager -import com.itsaky.androidide.resources.R -import com.itsaky.androidide.tasks.createJobCancelChecker -import com.itsaky.androidide.utils.flashError -import com.itsaky.androidide.utils.flashInfo -import java.nio.file.Path - -/** - * Moves the expression at the cursor, or a selected range of statements, into a new `private fun`. - * - * [execAction] runs one background analysis pass and returns a plain-data [ExtractMethodPlan]; - * [postExec] shows the sheet and turns the user's choice into two text edits with pure offset - * arithmetic. Where the region cannot be moved faithfully the plan carries a typed refusal, which - * postExec renders as a specific message rather than a generic failure (ADR 0012). - */ -class ExtractMethodAction : BaseKotlinCodeAction() { - companion object { - const val ID = "ide.editor.lsp.kt.extractMethod" - } - - override var titleTextRes: Int = R.string.action_extract_method - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD - - override val id: String = ID - override var label: String = "" - - // Analysis must not run on the UI thread, so the selection is read at the top of execAction on a - // background thread. A torn read while the user is mid-edit can only produce a plan the - // document-version guard then refuses to apply. - override var requiresUIThread: Boolean = false - - // Intentionally no prepare() visibility gate: deciding whether anything is extractable needs a K2 - // analysis session, far too costly for prepare(). The action stays visible on any Kotlin file and - // reports a refusal instead. - - override suspend fun execAction(data: ActionData): ExtractMethodPlan { - val server = - data.get() - ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) - val nioPath = data.requireFile().toPath() - val env = - server.compilationEnvironmentFor(nioPath) - ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) - - val cursor = data.requireEditor().cursor - return buildExtractMethodPlan( - env = env, - nioPath = nioPath, - selectionStart = minOf(cursor.left, cursor.right), - selectionEnd = maxOf(cursor.left, cursor.right), - documentVersion = documentVersionOf(nioPath), - // Ties the analysis to this action's coroutine: cancelling the action aborts the analysis. - cancelChecker = ScheduledCancelChecker(createJobCancelChecker()), - ) - } - - override fun postExec( - data: ActionData, - result: Any, - ) { - super.postExec(data, result) - if (result !is ExtractMethodPlan) return - - val context = data.requireContext() - if (result.isEmpty) { - flashInfo(refusalMessage(context, result.refusal ?: ExtractionRefusal.NotASingleRegion)) - return - } - - val activity = - context.findFragmentActivity() - ?: run { - // A wiring problem rather than a user path: the editor is always hosted by one. - logger.warn("No FragmentActivity for the editor context. Cannot show the extract sheet.") - flashError(R.string.msg_cannot_perform_fix) - return - } - - val shown = ExtractMethodSheet.show(activity, result) { choice -> applyChoice(data, result, choice) } - if (!shown) { - logger.warn("Fragment manager unavailable. Cannot show the extract sheet.") - } - } - - /** - * Turns the user's choice into the two edits and hands them to the language client. - * - * The document version is re-read here rather than trusted from the plan: the editor stays - * reachable while the sheet is open, and applying spans computed against older text would corrupt - * the file. Refusing is always safe; the user can invoke the action again. - */ - private fun applyChoice( - data: ActionData, - plan: ExtractMethodPlan, - choice: ExtractMethodChoice, - ) { - val file = data.requireFile() - val nioPath = file.toPath() - if (documentVersionOf(nioPath) != plan.documentVersion) { - flashInfo(R.string.msg_extract_method_file_changed) - return - } - - val rewrites = - buildExtractMethodRewrites(plan.fileText, choice.candidate, choice.name) ?: run { - logger.warn("Could not build an extract-method rewrite for '{}'", choice.candidate.label) - flashError(R.string.msg_cannot_perform_fix) - return - } - - val client = - data.languageClient ?: run { - logger.warn("No language client set. Cannot extract method.") - return - } - - client.performCodeAction( - CodeActionItem( - title = label, - changes = - listOf( - DocumentChange( - file = nioPath, - // Descending document order: applyActionEdits applies these in list order with - // line/column ranges, so the call site must not shift the insertion point. - edits = rewrites.map { it.toTextEdit(plan.fileText) }, - ), - ), - kind = CodeActionKind.QuickFix, - // The rewrites are emitted fully indented; CMD_FORMAT_CODE is a no-op for Kotlin anyway. - command = Command("", ""), - ), - ) - } - - /** Each refusal names the construct in the way; a generic message reads as a broken feature. */ - private fun refusalMessage( - context: Context, - refusal: ExtractionRefusal, - ): String = - when (refusal) { - ExtractionRefusal.NotASingleRegion -> context.getString(R.string.msg_extract_method_not_single_region) - is ExtractionRefusal.MultipleOutputs -> - context.getString(R.string.msg_extract_method_multiple_outputs, refusal.names.joinToString(", ")) - - is ExtractionRefusal.ReassignsOuterVar -> - context.getString(R.string.msg_extract_method_reassigns_outer_var, refusal.name) - - ExtractionRefusal.ExitsRegion -> context.getString(R.string.msg_extract_method_exits_region) - is ExtractionRefusal.InnerImplicitReceiver -> - context.getString(R.string.msg_extract_method_inner_implicit_receiver, refusal.construct) - - is ExtractionRefusal.UsesTypeParameter -> - context.getString(R.string.msg_extract_method_uses_type_parameter, refusal.name) - - ExtractionRefusal.UnrenderableType -> context.getString(R.string.msg_extract_method_unrenderable_type) - } - - /** -1 when the document is not open, which never matches a real version and so fails the guard. */ - private fun documentVersionOf(path: Path): Int = FileManager.getActiveDocument(path)?.version ?: -1 -} -``` - -- [ ] **Step 5: Register the action** - -In `KotlinCodeActionsMenu.kt`, add the import `com.itsaky.androidide.lsp.kotlin.actions.ExtractMethodAction` and add `ExtractMethodAction(),` to the action list, directly after `ExtractVariableAction(),`. - -- [ ] **Step 6: Run the tests to verify they pass** - -Run: `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest` -Expected: PASS, all tests including `KotlinCodeActionTooltipTagTest`. - -- [ ] **Step 7: Update the feature doc's status** - -In `docs/features/kotlin-extract-method.md`, change line 4 from - -```markdown -- **Status:** Requirements only - not implemented -``` - -to - -```markdown -- **Status:** Implemented -``` - -- [ ] **Step 8: Build the app end to end** - -Run: `flox activate -d flox/local -- ./gradlew :app:assembleV8Debug --parallel --max-workers=6` -Expected: BUILD SUCCESSFUL. This is slow (multi-minute) but it is the only check that the resource strings, the tooltip module and the LSP module all agree. - -- [ ] **Step 9: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git status -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionsMenu.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/KotlinCodeActionTooltipTagTest.kt \ - idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt \ - docs/features/kotlin-extract-method.md -git commit -m "ADFA-5080: Wire up the extract-method code action" -``` - -`git status` must show `docs/superpowers/plans/2026-08-10-kotlin-extract-method.md` as untracked and it must stay that way. - ---- - -## On-device QA (not unit-testable) - -The sheet, `prepare()`/`ActionData`, the two-step undo and the new tooltip row are covered by manual QA. Record these in ADFA-5080's "Steps to QA" field (`customfield_10250`), taken from the spec's acceptance criteria: - -1. "Extract method" appears in the code-actions menu of a Kotlin file and is absent in a non-Kotlin file. -2. A cursor inside an expression offers innermost-first candidates; extracting one replaces it with a call and adds a `private fun` below the enclosing function. -3. Selecting two adjacent statements that use two locals produces a function with those two locals as parameters, in first-use order. -4. A ragged selection snaps outward to whole statements. -5. A selection spanning two blocks reports "Select an expression, or whole statements inside one block". -6. A range declaring a local read afterwards produces `val x = extracted(...)`. -7. A range declaring two such locals is declined as producing more than one value. -8. A loop accumulating into an outer `var` is declined, naming that variable. -9. Selecting a tail ending in `return x` produces `return extracted(...)`. -10. A `return` mid-range is declined; a `break` targeting an outer loop is declined. -11. Inside `fun Foo.bar()`, a region touching `Foo`'s members produces `private fun Foo.extracted(...)` with an unchanged call site. -12. Inside `with(x) { ... }`, a region using `x`'s members is declined, naming the construct. -13. A region calling a suspend function produces a `suspend fun`; one calling a `@Composable` produces a `@Composable` function that compiles. -14. A region using an enclosing function's type parameter is declined, naming it. -15. A name matching an inherited member is rejected with "That name is already used". -16. The signature preview matches the emitted declaration exactly. -17. Editing the file while the sheet is open, then confirming, reports the file-changed message and leaves the file untouched. -18. Undo restores the file; it currently takes **two** undo steps (ADFA-5081) and the intermediate state does not compile. -19. A space-indented file receives space-indented output; a CRLF file keeps CRLF. -20. The tooltip long-press on the menu item resolves `editor.codeactions.kotlin.extractmethod`. diff --git a/docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md b/docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md deleted file mode 100644 index 660aa96bae..0000000000 --- a/docs/superpowers/plans/2026-08-12-extract-variable-defect-fixes.md +++ /dev/null @@ -1,1779 +0,0 @@ -# Extract Variable Defect Fixes 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:** Fix the five defects found in the Kotlin extract-variable code action so every extraction it offers produces code that compiles, then hand ADFA-4826 to QA. - -**Architecture:** All five fixes stay inside the existing analysis/UI split: the background pass produces a plain-data `ExtractionPlan`, and the rewrite is pure text and offset arithmetic on it. Two of the fixes add data to `AnchorForm` so the rewrite can honour the scope the user picked and can tell a one-line block from a multi-line one; one adds a rendered return type to the expression-body conversion; two are guard/label corrections in the syntactic layer. - -**Tech Stack:** Kotlin, K2 Analysis API (`org.jetbrains.kotlin.analysis.api`), Kotlin PSI, JUnit 4, Gradle (flox-wrapped), `gh stack` for the PR stack. - -## Global Constraints - -- **Branch:** all five fix commits go on `feat/ADFA-4826-extract-variable` (PR #1654). The stack is `stage` <- `feat/ADFA-4826-common-compose-theme` (#1653) <- `feat/ADFA-4826-extract-variable` (#1654) <- `feat/ADFA-5080-extract-method` (#1655), tracked as `gh stack` Stack #1656. -- **Worktree:** `/var/mnt/data/dev/work/adfa/cogo/code-on-the-go/.claude/worktrees/ADFA-4826`. It currently has `feat/ADFA-5080-extract-method` checked out; Task 1 switches it. -- **Gradle:** every invocation is wrapped: `flox activate -d flox/local -- ./gradlew `. -- **Unit test task:** `:lsp:kotlin:testV7DebugUnitTest` (V7 flavour; there is no flavourless `test`). -- **Formatting:** tabs for indentation, LF endings, ktlint via Spotless. Run `flox activate -d flox/local -- ./gradlew spotlessApply` before each commit. -- **Code comments:** comment the non-obvious *why* only. No separator or decorative comments. ASCII only in code and comments (`->`, `-`, straight quotes). -- **Commits:** subject `ADFA-4826: ` (`ADFA-5080: ...` for the one commit on the 5080 branch). No `Co-Authored-By` trailer. Never `git add .` - stage named paths. Never commit anything under `docs/superpowers/plans/`. -- **Invariants that must not regress:** exactly one `TextEdit` per code action; the plan carries no PSI; nothing in `prepare()`; no I/O on the main thread. -- **Jira:** ADFA-4826, field `customfield_10250` ("Steps to QA") is ADF, cloudId `bb66613e-967d-4549-a8d6-d9166759f2d2`. - -## File Structure - -**Created** - -- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt` - the shared type-text layer: render a `KaType` as source-shaped text, reject what cannot be written out, and shorten qualified names that already resolve in the file. Extracted here (rather than left private in `MethodSignature.kt`, which lives one PR further up the stack) so both refactorings share one renderer. - -**Modified** - -- `.../utils/refactor/CandidateExpressions.kt` - `isLegalExtractionTarget` also rejects `KtLambdaExpression` (Task 1). -- `.../utils/refactor/ScopeChain.kt` - `blockLabel` unwraps the control-structure container node (Task 2); `frameFor` fills the new `ExistingBlock` fields (Task 4). -- `.../utils/refactor/ExtractionPlan.kt` - `AnchorForm.ExistingBlock` becomes a data class carrying `contentSpan` + `statementSpans` (Task 4); `AnchorForm.ConvertExpressionBody` gains `returnTypeText` (Task 3). -- `.../utils/refactor/ExtractVariablePlanner.kt` - computes `returnTypeText` and declines the rung when the type cannot be written (Task 3). -- `.../utils/refactor/ExtractVariableEdit.kt` - anchors on the chosen scope's statement (Task 4), expands a one-line block (Task 5), emits the return type (Task 3). -- `.../utils/refactor/MethodSignature.kt` - drops its private renderer copies in favour of `TypeText.kt` (Task 6, on the 5080 branch). -- `docs/features/kotlin-extract-variable.md` - R2, R5, R9, the acceptance criteria and the stale Status line, one delta per fix commit. - -**Tests modified** - -- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt` - pure `shortenTypeText` rules (Task 3). -- `.../utils/refactor/ExtractVariableEditTest.kt` - `ExistingBlock` fixtures, the outer-rung anchor, the one-line block, the return-type header (Tasks 3-5). -- `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` - lambda exclusion, rung labels, inferred return type, outer-rung text, one-line lambda text (Tasks 1-5). -- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt` - one `ExistingBlock` fixture (Task 4). - -**Test facts worth knowing before writing any test** - -- `ExtractVariablePlanEndToEndTest` extends `KtLspTest` and has two private helpers already: `plan(content, start, end = start)` (writes `Main.kt` into the test source root and returns the `ExtractionPlan`) and `apply(text, rewrite)` (applies a `RewriteSpan` to a string). Use them; do not add new ones. -- Every analysis-backed test writes the **same** file name `Main.kt`, which overwrites the previous test's file. Do not give each test a unique file name: several files in one source root share package `p`, and duplicate top-level declarations across them silently break symbol resolution - which shows up as wrong `needsReturn`/type results rather than as a test error. -- `RefactorPrimitivesTest` and `ExtractVariableEditTest` are plain JUnit with no PSI and no analysis session. Keep them that way. - ---- - -### Task 1: Stop offering the lambda that wraps the expression - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt:190-210` -- Modify: `docs/features/kotlin-extract-variable.md:4` (Status), `:80` (R2 illegal-target list) -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: nothing from other tasks. -- Produces: nothing other tasks depend on. `isLegalExtractionTarget(): Boolean` keeps its signature. - -**Why:** `isLegalExtractionTarget` rejects `KtFunctionLiteral`, but the candidate walk sees the `KtLambdaExpression` that wraps it, so `{ it.length + 1 }` is offered as a candidate. Extracting it emits `val value = { it.length + 1 }`, where `it` has no source, and R2 already says a lambda literal is not a legal target. - -- [ ] **Step 1: Put the worktree on the right branch** - -```bash -cd /var/mnt/data/dev/work/adfa/cogo/code-on-the-go/.claude/worktrees/ADFA-4826 -gh stack checkout feat/ADFA-4826-extract-variable -git log --oneline -1 -``` - -Expected: `617ed6f39 ADFA-4826: Document the extract-variable requirements` (the untracked plan file under `docs/superpowers/plans/` survives the switch; leave it untracked). - -- [ ] **Step 2: Write the failing test** - -Append to `ExtractVariablePlanEndToEndTest`: - -```kotlin - @Test - fun `does not offer the lambda that wraps the expression`() { - val content = - """ - package p - fun demo(items: List): List { - return items.map { - it.length + 1 - } - } - """.trimIndent() - - val target = "it.length + 1" - val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - - // `{ it.length + 1 }` must not appear between the two: a hoisted lambda loses the `it` the call - // site was supplying. - assertEquals( - listOf("it.length + 1", "items.map { it.length + 1 }"), - result.candidates.map { it.label }, - ) - } -``` - -- [ ] **Step 3: Run it and watch it fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" -``` - -Expected: FAIL on this test, with the actual list containing `{ it.length + 1 }` as its second entry. - -- [ ] **Step 4: Exclude lambda expressions** - -In `CandidateExpressions.kt`, add the import (keep the import block alphabetical - it goes directly after `KtFunctionLiteral`): - -```kotlin -import org.jetbrains.kotlin.psi.KtLambdaExpression -``` - -and in `isLegalExtractionTarget`, directly after the `KtFunctionLiteral` line: - -```kotlin - if (this is KtFunctionLiteral) return false - // The wrapper around the literal. A hoisted lambda loses the parameter types its call site was - // supplying, so `{ it.length + 1 }` becomes uncompilable the moment it leaves the call. - if (this is KtLambdaExpression) return false -``` - -Also extend the KDoc bullet above the function: - -```kotlin - * - blocks, loops, `return`/`throw`/`break`/`continue` -- no useful value to bind; - * - lambdas, literal and wrapper alike -- outside their call site the parameter types are gone; -``` - -- [ ] **Step 5: Run it and watch it pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" -``` - -Expected: PASS, all tests in the class. - -- [ ] **Step 6: Update the feature doc** - -In `docs/features/kotlin-extract-variable.md`, replace the Status line (line 4): - -```markdown -- **Status:** Implemented in `lsp/kotlin/utils/refactor/` and `lsp/kotlin/refactor/ui/`, pending on-device QA. Still to land in this PR: the `ExtractionPlan` -> `ExtractVariablePlan` rename (the sealed `RefactoringPlan` supertype it will sit under has landed). -``` - -and in R2, in the illegal-target sentence, replace `a lambda literal` with: - -```markdown -a lambda (the `{ ... }` expression and the literal inside it -- outside its call site the parameter types are gone, so `val v = { it.length + 1 }` does not compile) -``` - -- [ ] **Step 7: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ - docs/features/kotlin-extract-variable.md -git commit -m "ADFA-4826: Stop offering the lambda that wraps the expression" -``` - ---- - -### Task 2: Name the construct that owns a braced block - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt:172-186` (`blockLabel`) -- Modify: `docs/features/kotlin-extract-variable.md` (R5, after the anchor-form table) -- Test: `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: nothing. -- Produces: rung labels seen by the sheet's `Declare in` list. Task 4's tests assert `"fun demo"` and `"if block"`. - -**Why:** a braced `if` branch's PSI is `KtIfExpression -> KtContainerNodeForControlStructureBody -> KtBlockExpression`, so `blockLabel`'s `when` sees the container node and falls through to the generic `"block"`. The doc promises `if block`. Same for braced loop bodies. - -- [ ] **Step 1: Write the failing test** - -Append to `ExtractVariablePlanEndToEndTest`: - -```kotlin - @Test - fun `labels a braced if branch by its owner`() { - val content = - """ - package p - fun demo(flag: Boolean, a: Int, b: Int): Int { - if (flag) { - return a + b * 2 - } - return 0 - } - """.trimIndent() - - val target = "a + b * 2" - val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - - assertEquals(listOf("if block", "fun demo"), result.candidates.first().scopes.map { it.label }) - } -``` - -- [ ] **Step 2: Run it and watch it fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" -``` - -Expected: FAIL, actual `[block, fun demo]`. - -- [ ] **Step 3: Unwrap the container node** - -Replace `blockLabel` in `ScopeChain.kt`: - -```kotlin -/** - * The name shown for a block rung. - * - * A braceless *or* braced control-structure body is wrapped in a container node, so the `if`/loop is - * the block's grandparent; without unwrapping, every braced branch reads as a generic "block". The - * container is also what `then`/`else` point at, so the branch check compares against it. - */ -private fun blockLabel(block: KtBlockExpression): String { - val parent = block.parent - val container = parent as? KtContainerNodeForControlStructureBody - val branch = container ?: block - return when (val owner = container?.parent ?: parent) { - is KtNamedFunction -> "fun ${owner.name ?: ""}" - is KtPropertyAccessor -> if (owner.isGetter) "getter" else "setter" - is KtAnonymousInitializer -> "init block" - is KtFunctionLiteral -> "lambda" - is KtIfExpression -> if (owner.then === branch) "if block" else "else block" - is KtForExpression -> "for loop" - is KtWhileExpression -> "while loop" - is KtDoWhileExpression -> "do-while loop" - is KtWhenEntry -> "when branch" - else -> "block" - } -} -``` - -- [ ] **Step 4: Run it and watch it pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" -``` - -Expected: PASS, all tests in the class. - -- [ ] **Step 5: Document the labels** - -In `docs/features/kotlin-extract-variable.md`, immediately after the R5 anchor-form table, insert: - -```markdown -Each rung is labelled with the construct that owns it -- `fun name`, `getter`, `setter`, `init block`, -`lambda`, `if block`, `else block`, `for loop`, `while loop`, `do-while loop`, `when branch` -- so the -`Declare in` list reads as a place rather than as a nesting level. A braced control-structure body is -wrapped in a container node, so the owner is the block's grandparent, not its parent. -``` - -- [ ] **Step 6: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ - docs/features/kotlin-extract-variable.md -git commit -m "ADFA-4826: Label a block rung by the construct that owns it" -``` - ---- - -### Task 3: Write out the return type when converting an expression body - -**Files:** -- Create: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt` -- Modify: `.../utils/refactor/ExtractionPlan.kt:44-57` (`ConvertExpressionBody`) -- Modify: `.../utils/refactor/ExtractVariablePlanner.kt:77-129` -- Modify: `.../utils/refactor/ExtractVariableEdit.kt:104-125` -- Modify: `docs/features/kotlin-extract-variable.md` (R5 table row, acceptance criteria 10-11) -- Test: `.../utils/refactor/RefactorPrimitivesTest.kt`, `.../utils/refactor/ExtractVariableEditTest.kt`, `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: nothing from Tasks 1-2. -- Produces: - - `internal fun KaSession.renderedTypeTextOrNull(type: KaType): String?` - - `internal fun isUnrenderableTypeText(text: String): Boolean` - - `internal fun shortenTypeText(rendered: String, importedNames: Set, starImportedPackages: Set): String` - - `internal fun importedNamesOf(file: KtFile): Set` and `internal fun starImportedPackagesOf(file: KtFile): Set` - - `AnchorForm.ConvertExpressionBody` gains `val returnTypeText: String?` (null = insert nothing). Task 6 reuses the first three from `MethodSignature.kt`. - -**Why:** `fun area(r: Int) = r * r` has no declared return type. Converting it to a block body with `return squared` leaves a Unit-returning function returning an `Int`, which does not compile. The type has to be written into the signature, and it is only safe to shorten a qualified name when that short name already resolves in the file. - -- [ ] **Step 1: Write the failing pure tests for the shortening rule** - -Append to `RefactorPrimitivesTest`: - -```kotlin - @Test - fun `shortens types from Kotlin's default-imported packages`() { - assertEquals("Int", shortenTypeText("kotlin.Int", emptySet(), emptySet())) - assertEquals( - "List", - shortenTypeText("kotlin.collections.List", emptySet(), emptySet()), - ) - } - - @Test - fun `keeps a type qualified when its short name would not resolve`() { - assertEquals("java.util.Date", shortenTypeText("java.util.Date", emptySet(), emptySet())) - // An import of the enclosing class is not an import of the nested one. - assertEquals( - "com.example.Outer.Inner", - shortenTypeText("com.example.Outer.Inner", setOf("com.example.Outer"), emptySet()), - ) - } - - @Test - fun `shortens a type the file already imports, by name or by star`() { - assertEquals("Date", shortenTypeText("java.util.Date", setOf("java.util.Date"), emptySet())) - assertEquals("Date", shortenTypeText("java.util.Date", emptySet(), setOf("java.util"))) - assertEquals( - "Flow", - shortenTypeText( - "kotlinx.coroutines.flow.Flow", - setOf("kotlinx.coroutines.flow.Flow", "com.example.Widget"), - emptySet(), - ), - ) - } - - @Test - fun `unrenderable type text is recognised`() { - assertTrue(isUnrenderableTypeText("")) - assertTrue(isUnrenderableTypeText("kotlin.collections.List")) - assertTrue(isUnrenderableTypeText("")) - assertTrue(isUnrenderableTypeText("ERROR CLASS: unresolved")) - assertTrue(isUnrenderableTypeText("kotlin.Any & kotlin.Comparable<*>")) - assertFalse(isUnrenderableTypeText("kotlin.Int")) - } -``` - -Add the two imports the class does not have yet: - -```kotlin -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue -``` - -- [ ] **Step 2: Run them and watch them fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.RefactorPrimitivesTest" -``` - -Expected: compilation failure - `Unresolved reference: shortenTypeText` and `isUnrenderableTypeText`. - -- [ ] **Step 3: Create the shared type-text layer** - -Create `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import com.itsaky.androidide.lsp.kotlin.utils.renderName -import org.jetbrains.kotlin.analysis.api.KaExperimentalApi -import org.jetbrains.kotlin.analysis.api.KaSession -import org.jetbrains.kotlin.analysis.api.renderer.types.impl.KaTypeRendererForSource -import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType -import org.jetbrains.kotlin.analysis.api.types.KaType -import org.jetbrains.kotlin.psi.KtFile - -/** - * Types are rendered **fully qualified** and only then shortened against what the file can resolve. - * - * A short name resolves only when the file imports it or it comes from a default-imported package, and - * a refactoring that adds imports would be a much larger change -- so qualified is the safe starting - * point and [shortenTypeText] gives back readability where it provably costs nothing. - */ -@OptIn(KaExperimentalApi::class) -private val QUALIFIED_TYPE_RENDERER = KaTypeRendererForSource.WITH_QUALIFIED_NAMES - -/** Packages whose simple names resolve with no import at all on the JVM/Android target. */ -private val DEFAULT_IMPORTED_PACKAGES = - setOf( - "kotlin", - "kotlin.annotation", - "kotlin.collections", - "kotlin.comparisons", - "kotlin.io", - "kotlin.jvm", - "kotlin.ranges", - "kotlin.sequences", - "kotlin.text", - "java.lang", - ) - -/** A dotted run of identifiers -- one qualified name inside rendered type text. */ -private val QUALIFIED_NAME = Regex("""[\p{L}_][\p{L}\p{Nd}_]*(?:\.[\p{L}_][\p{L}\p{Nd}_]*)+""") - -/** - * A type that cannot be written out as source -- anonymous, intersection, a resolution error, or a - * platform type the renderer could not reduce (`List`, where the `!` is on a type argument). - * `!` is not Kotlin syntax anywhere, so its presence alone settles it. - */ -internal fun isUnrenderableTypeText(text: String): Boolean = - text.isBlank() || - text.contains("anonymous") || - text.contains("ERROR") || - text.contains(" & ") || - text.contains('!') - -/** - * One type as source text, fully qualified, or null when it cannot be written out. - * - * A platform type is unwrapped to its lower bound first: the renderer prints `String!`, which does not - * parse. Only the outermost bound is unwrapped, so a `!` on a type argument still reaches - * [isUnrenderableTypeText]. - */ -@OptIn(KaExperimentalApi::class) -internal fun KaSession.renderedTypeTextOrNull(type: KaType): String? = - runCatching { renderName((type as? KaFlexibleType)?.lowerBound ?: type, QUALIFIED_TYPE_RENDERER) } - .getOrNull() - ?.takeUnless(::isUnrenderableTypeText) - -/** - * Replaces each qualified name in [rendered] with its simple name when that name already resolves in - * the file -- because the file imports it exactly, star-imports its package, or it comes from a - * default-imported package. Everything else stays qualified: verbose, but it always compiles. - * - * Purely textual, so it needs no analysis session and is unit-testable on its own. A nested class - * (`com.example.Outer.Inner`) is only shortened by an import of the nested name itself; an import of - * the outer class leaves it alone rather than emitting an unresolvable `Inner`. - */ -internal fun shortenTypeText( - rendered: String, - importedNames: Set, - starImportedPackages: Set, -): String = - QUALIFIED_NAME.replace(rendered) { match -> - val qualified = match.value - val container = qualified.substringBeforeLast('.') - val resolvable = - qualified in importedNames || - container in DEFAULT_IMPORTED_PACKAGES || - container in starImportedPackages - if (resolvable) qualified.substringAfterLast('.') else qualified - } - -/** The fully qualified names [file] imports by name. Syntactic: no analysis session needed. */ -internal fun importedNamesOf(file: KtFile): Set = - file.importDirectives - .filterNot { it.isAllUnder } - .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } - -/** The packages [file] star-imports (`import com.example.*`). */ -internal fun starImportedPackagesOf(file: KtFile): Set = - file.importDirectives - .filter { it.isAllUnder } - .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } -``` - -- [ ] **Step 4: Run the pure tests and watch them pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.RefactorPrimitivesTest" -``` - -Expected: PASS, all tests in the class. - -- [ ] **Step 5: Write the failing rewrite test for the emitted header** - -Append to `ExtractVariableEditTest`: - -```kotlin - @Test - fun `writes the return type into the signature when the declaration has none`() { - val text = "fun area(r: Int) = r * r" - val candidate = spanOf(text, "r * r") - val form = - AnchorForm.ConvertExpressionBody( - assignStart = text.indexOf('='), - bodyStart = candidate.start, - bodyEnd = text.length, - indent = "", - innerIndent = "\t", - needsReturn = true, - returnTypeText = "Int", - ) - - val result = rewrite(text, candidate, form, listOf(candidate), "squared", replaceAll = false)!! - - assertEquals( - "fun area(r: Int): Int {\n" + - "\tval squared = r * r\n" + - "\treturn squared\n" + - "}", - apply(text, result), - ) - } -``` - -- [ ] **Step 6: Run it and watch it fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" -``` - -Expected: compilation failure - `ConvertExpressionBody` has no `returnTypeText` parameter. - -- [ ] **Step 7: Add the field and emit it** - -In `ExtractionPlan.kt`, replace the `ConvertExpressionBody` declaration and its KDoc: - -```kotlin - /** - * An expression-bodied function or property accessor -- `fun area(r: Int) = r * r`. The `=` and - * the body are replaced by a block body. [needsReturn] is false only when the declaration - * returns `Unit`, where `return` is both unnecessary and wrong for a non-`Unit` expression. - * - * [returnTypeText] is the type to write into the signature, or null when there is nothing to write - * -- the declaration already spells its type out, or the block body infers `Unit` anyway. A block - * body with no declared type returns `Unit`, so `return ` without this would not compile. - */ - data class ConvertExpressionBody( - val assignStart: Int, - val bodyStart: Int, - val bodyEnd: Int, - val indent: String, - val innerIndent: String, - val needsReturn: Boolean, - val returnTypeText: String? = null, - ) : AnchorForm -``` - -In `ExtractVariableEdit.kt`, replace `convertExpressionBodyRewrite` and add the helper below it: - -```kotlin -/** Converts `= expr` into a block body holding the declaration and a `return` of the rewritten body. */ -private fun convertExpressionBodyRewrite( - fileText: String, - form: AnchorForm.ConvertExpressionBody, - targets: List, - declaration: String, - name: String, -): RewriteSpan { - val bodySpan = TextSpan(form.bodyStart, form.bodyEnd) - val newline = detectNewline(fileText) - val body = replaceOccurrences(fileText, bodySpan, targets, name) - val returned = if (form.needsReturn) "return $body" else body - - // Writing a type means rewriting from the end of the signature, not from the `=`: starting at the - // `=` would leave the space in front of it and emit `fun area(r: Int) : Int {`. - val spanStart = - if (form.returnTypeText == null) form.assignStart else startOfWhitespaceBefore(fileText, form.assignStart) - val header = form.returnTypeText?.let { ": $it " } ?: "" - - val newText = - buildString { - append(header).append('{').append(newline) - append(form.innerIndent).append(declaration).append(newline) - append(form.innerIndent).append(returned).append(newline) - append(form.indent).append('}') - } - return RewriteSpan(TextSpan(spanStart, form.bodyEnd), newText) -} - -/** The offset where the run of whitespace ending at [offset] begins. */ -private fun startOfWhitespaceBefore( - text: String, - offset: Int, -): Int { - var index = offset.coerceIn(0, text.length) - while (index > 0 && text[index - 1].isWhitespace()) index-- - return index -} -``` - -- [ ] **Step 8: Run the rewrite tests and watch them pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" -``` - -Expected: PASS, all tests in the class (the two pre-existing `ConvertExpressionBody` tests pass `returnTypeText` implicitly as null and must be unchanged). - -- [ ] **Step 9: Write the failing plan tests for the three signature shapes** - -Append to `ExtractVariablePlanEndToEndTest`: - -```kotlin - @Test - fun `converting an inferred-type expression body writes the type out`() { - val content = - """ - package p - fun area(r: Int) = r * r - """.trimIndent() - - val target = "r * r" - val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - val candidate = result.candidates.first() - val rewrite = - buildExtractVariableRewrite( - fileText = result.fileText, - candidateSpan = candidate.span, - scope = candidate.scopes.first(), - name = "squared", - replaceAll = false, - )!! - - assertEquals( - "package p\n" + - "fun area(r: Int): Int {\n" + - "\tval squared = r * r\n" + - "\treturn squared\n" + - "}", - apply(content, rewrite), - ) - } - - @Test - fun `a declared return type is not written twice`() { - val content = - """ - package p - fun area(r: Int): Int = r * r - """.trimIndent() - - val target = "r * r" - val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - val candidate = result.candidates.first() - val rewrite = - buildExtractVariableRewrite( - fileText = result.fileText, - candidateSpan = candidate.span, - scope = candidate.scopes.first(), - name = "squared", - replaceAll = false, - )!! - - assertEquals( - "package p\n" + - "fun area(r: Int): Int {\n" + - "\tval squared = r * r\n" + - "\treturn squared\n" + - "}", - apply(content, rewrite), - ) - } - - @Test - fun `a Unit-returning expression body gets neither a type nor a return`() { - val content = - """ - package p - fun report(value: Int) { - println(value) - } - fun show(text: String) = report(text.length + 1) - """.trimIndent() - - val target = "text.length + 1" - val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - val candidate = result.candidates.first() - val rewrite = - buildExtractVariableRewrite( - fileText = result.fileText, - candidateSpan = candidate.span, - scope = candidate.scopes.first(), - name = "length", - replaceAll = false, - )!! - - assertEquals( - "package p\n" + - "fun report(value: Int) {\n" + - "\tprintln(value)\n" + - "}\n" + - "fun show(text: String) {\n" + - "\tval length = text.length + 1\n" + - "\treport(length)\n" + - "}", - apply(content, rewrite), - ) - } -``` - -- [ ] **Step 10: Run them and watch the first one fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariablePlanEndToEndTest" -``` - -Expected: `converting an inferred-type expression body writes the type out` FAILS (actual has `fun area(r: Int) {`). The other two PASS. - -- [ ] **Step 11: Compute the type in the planner, and decline when it cannot be written** - -In `ExtractVariablePlanner.kt`, add these imports: - -```kotlin -import org.jetbrains.kotlin.psi.KtCallableDeclaration -import org.jetbrains.kotlin.psi.KtPropertyAccessor -``` - -Replace `candidateFor`'s scope-building lines so a rung can be declined: - -```kotlin - val span = TextSpan(expression.textRange.startOffset, expression.textRange.endOffset) - val file = expression.containingKtFile - val scopes = frames.mapNotNull { scopeOptionFor(expression, span, it, file) } - if (scopes.isEmpty()) return null - val takenNames = visibleNamesAt(expression) -``` - -Replace `scopeOptionFor` with: - -```kotlin -/** - * Builds one scope option, resolving its occurrence set and fixing up expression-body details. - * - * Returns null when the rung cannot be honoured: converting an expression body whose return type is - * neither declared nor renderable would emit a block body that does not compile, and declining is - * always safe (ADR 0013). - */ -private fun KaSession.scopeOptionFor( - expression: KtExpression, - span: TextSpan, - frame: ScopeFrame, - file: KtFile, -): ScopeOption? { - val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) - val writes = writeOffsetsFor(expression, frame.scopeElement) - val occurrences = excludeUnsoundOccurrences(matches, span, writes) - - val anchorForm = - when (val form = frame.anchorForm) { - is AnchorForm.ConvertExpressionBody -> { - val declaration = frame.scopeElement.parent as? KtDeclarationWithBody - val needsReturn = expressionBodyNeedsReturn(frame.scopeElement) - val returnTypeText = - if (needsReturn && declaration != null && !declaration.declaresReturnType()) { - returnTypeTextOf(declaration, file) ?: return null - } else { - null - } - form.copy(needsReturn = needsReturn, returnTypeText = returnTypeText) - } - - else -> form - } - - return ScopeOption(label = frame.label, anchorForm = anchorForm, occurrences = occurrences) -} - -/** Whether the declaration spells its return type out, in which case nothing needs writing. */ -private fun KtDeclarationWithBody.declaresReturnType(): Boolean = - when (this) { - is KtPropertyAccessor -> returnTypeReference != null - is KtCallableDeclaration -> typeReference != null - else -> false - } - -/** The declaration's return type as source text, shortened where the file can resolve it. */ -private fun KaSession.returnTypeTextOf( - declaration: KtDeclarationWithBody, - file: KtFile, -): String? { - val type = runCatching { ((declaration as? KtDeclaration)?.symbol as? KaCallableSymbol)?.returnType }.getOrNull() ?: return null - val rendered = renderedTypeTextOrNull(type) ?: return null - return shortenTypeText(rendered, importedNamesOf(file), starImportedPackagesOf(file)) -} -``` - -- [ ] **Step 12: Run the whole module's tests and watch them pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest -``` - -Expected: PASS for the whole module, including all four refactor test classes. - -- [ ] **Step 13: Update the feature doc** - -In the R5 anchor-form table, replace the `ConvertExpressionBody` row's "Emitted as" cell: - -```markdown -| `ConvertExpressionBody` | an expression-bodied function or accessor, `fun area(r: Int) = r * r` | `=` and the body become a block body; `return` is added unless the declaration returns `Unit`; the return type is written into the signature when the declaration does not spell one out, because a block body with no declared type returns `Unit` | -``` - -Immediately after the table, add: - -```markdown -A written-out return type is rendered fully qualified and then shortened to its simple name only where -that name already resolves in the file -- an exact import, a star import of its package, or a -default-imported package such as `kotlin.collections`. Everything else stays qualified: verbose, but it -compiles, and this refactoring adds no imports. When the type cannot be written as source at all -(anonymous, intersection, an unresolved type, or a platform type the renderer cannot reduce) the rung -is declined rather than emitting a block body that does not compile. -``` - -Replace acceptance criteria 10 and 11: - -```markdown -10. Extracting from `fun area(r: Int): Int = r * r` converts it to a block body with `return`, leaving the declared type alone; extracting from `fun area(r: Int) = r * r` converts it *and* writes `: Int` into the signature. -11. Extracting from a `Unit`-returning expression-bodied function converts it without adding `return` and without writing a type. -``` - -- [ ] **Step 14: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ - docs/features/kotlin-extract-variable.md -git commit -m "ADFA-4826: Write the return type when converting an expression body" -``` - ---- - -### Task 4: Anchor the declaration in the scope the user picked - -**Files:** -- Modify: `.../utils/refactor/ExtractionPlan.kt:21-31` (`AnchorForm.ExistingBlock`) -- Modify: `.../utils/refactor/ScopeChain.kt:108-116` (`frameFor`'s block branch), plus a new `contentSpanOf` -- Modify: `.../utils/refactor/ExtractVariableEdit.kt:46-78` (`buildExtractVariableRewrite`, `existingBlockRewrite`) -- Modify: `docs/features/kotlin-extract-variable.md` (R5 anchor-point paragraph, acceptance criteria) -- Test: `.../utils/refactor/ExtractVariableEditTest.kt`, `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt`, `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt` - -**Interfaces:** -- Consumes: `AnchorForm` from Task 3 (unchanged by this task); rung labels from Task 2. -- Produces: `AnchorForm.ExistingBlock(contentSpan: TextSpan, statementSpans: List)` - `contentSpan` is the region *inside* the block's braces, `statementSpans` are the block's direct child statements, ascending. Task 5 consumes `contentSpan`. - -**Why:** `existingBlockRewrite` inserts before the line of the first *occurrence*, so every block rung produces byte-identical output and the sheet's `Declare in` choice does nothing. R5 defines the anchor point as the first statement *within the anchor scope* that contains a replaced occurrence, which needs that scope's statement list in the plan. - -- [ ] **Step 1: Write the failing rewrite tests for both rungs** - -Append to `ExtractVariableEditTest`: - -```kotlin - @Test - fun `the inner rung declares inside the if block`() { - val text = - "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + - "\tif (flag) {\n" + - "\t\treturn a + b * 2\n" + - "\t}\n" + - "\treturn 0\n" + - "}" - val candidate = spanOf(text, "a + b * 2") - val form = - AnchorForm.ExistingBlock( - contentSpan = spanOf(text, "\n\t\treturn a + b * 2\n\t"), - statementSpans = listOf(spanOf(text, "return a + b * 2")), - ) - - val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! - - assertEquals( - "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + - "\tif (flag) {\n" + - "\t\tval total = a + b * 2\n" + - "\t\treturn total\n" + - "\t}\n" + - "\treturn 0\n" + - "}", - apply(text, result), - ) - } - - @Test - fun `the outer rung declares above the enclosing statement`() { - val text = - "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + - "\tif (flag) {\n" + - "\t\treturn a + b * 2\n" + - "\t}\n" + - "\treturn 0\n" + - "}" - val candidate = spanOf(text, "a + b * 2") - // The function block's rung: its statements are the whole `if` and the trailing `return 0`. - val form = - AnchorForm.ExistingBlock( - contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), - statementSpans = - listOf( - spanOf(text, "if (flag) {\n\t\treturn a + b * 2\n\t}"), - spanOf(text, "return 0"), - ), - ) - - val result = rewrite(text, candidate, form, listOf(candidate), "total", replaceAll = false)!! - - assertEquals( - "fun f(flag: Boolean, a: Int, b: Int): Int {\n" + - "\tval total = a + b * 2\n" + - "\tif (flag) {\n" + - "\t\treturn total\n" + - "\t}\n" + - "\treturn 0\n" + - "}", - apply(text, result), - ) - } -``` - -- [ ] **Step 2: Run them and watch them fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" -``` - -Expected: compilation failure - `ExistingBlock` is an object and takes no arguments. - -- [ ] **Step 3: Give `ExistingBlock` its data** - -In `ExtractionPlan.kt`, replace the `ExistingBlock` declaration and its KDoc: - -```kotlin - /** - * The scope already has a `{ ... }` body (function body, `if` block, lambda body, ...), so the - * declaration is a new statement line inside it. - * - * [statementSpans] are the block's direct child statements, ascending. The anchor point is the - * first of them containing the first served occurrence -- which is what makes an outer rung differ - * from an inner one. Anchoring on the occurrence's own line instead would make every rung of a - * chain produce the same edit. - * - * [contentSpan] is the region *inside* the braces. It tells a block written on one line - * (`items.map { it.length + 1 }`) from a multi-line one, where inserting at the statement's line - * start would put the declaration outside the braces. - */ - data class ExistingBlock( - val contentSpan: TextSpan, - val statementSpans: List, - ) : AnchorForm -``` - -- [ ] **Step 4: Fill the fields in the scope chain** - -In `ScopeChain.kt`, replace the `parent is KtBlockExpression` branch of `frameFor`: - -```kotlin - if (parent is KtBlockExpression) { - val lineStart = lineStartOffset(text, inner.textRange.startOffset) - return ScopeFrame( - label = blockLabel(parent), - scopeElement = parent, - searchRange = parent.textRange.let { TextSpan(it.startOffset, it.endOffset) }, - statementSpan = TextSpan(lineStart, inner.textRange.endOffset), - anchorForm = - AnchorForm.ExistingBlock( - contentSpan = contentSpanOf(parent), - statementSpans = - parent.statements.map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) }, - ), - ) - } -``` - -and add, next to `lineStartOffset`: - -```kotlin -/** - * The region inside a block's braces. - * - * A function, `if` or loop body owns its braces, so they are trimmed off. A lambda body block does not - * -- the braces and any `param ->` header belong to the enclosing function literal -- so its own range - * already *is* the content, which is what keeps the header on the brace line when the block is - * expanded. Deriving this from the block's text rather than from brace PSI keeps one code path for - * both shapes. - */ -internal fun contentSpanOf(block: KtBlockExpression): TextSpan { - val range = block.textRange - val text = block.text - return if (text.length >= 2 && text.startsWith("{") && text.endsWith("}")) { - TextSpan(range.startOffset + 1, range.endOffset - 1) - } else { - TextSpan(range.startOffset, range.endOffset) - } -} -``` - -- [ ] **Step 5: Anchor the rewrite on the chosen scope's statement** - -In `ExtractVariableEdit.kt`, change the `ExistingBlock` dispatch line in `buildExtractVariableRewrite`: - -```kotlin - is AnchorForm.ExistingBlock -> existingBlockRewrite(fileText, form, targets, declaration, name) -``` - -and replace `existingBlockRewrite`: - -```kotlin -/** - * Inserts the declaration as its own line before the anchor statement, and rewrites everything from - * there through the last occurrence. - * - * The anchor is the statement *of this scope* that holds the first served occurrence, so picking an - * outer rung hoists the declaration above the enclosing statement rather than leaving it where the - * inner rung would have put it. The rewritten span starts at that statement's line start so the - * declaration lands on a line of its own at the right indentation, and ends at the last occurrence so - * untouched trailing code is left alone. - * - * Null when no statement of the scope contains the occurrence, which would mean the plan and the text - * disagree; the caller reports that rather than guessing. - */ -private fun existingBlockRewrite( - fileText: String, - form: AnchorForm.ExistingBlock, - targets: List, - declaration: String, - name: String, -): RewriteSpan? { - val first = targets.first() - val last = targets.last() - val anchor = form.statementSpans.firstOrNull { it.start <= first.start && first.end <= it.end } ?: return null - val lineStart = lineStartOffset(fileText, anchor.start) - val indent = leadingIndentAt(fileText, anchor.start) - val newline = detectNewline(fileText) - - val span = TextSpan(lineStart, last.end) - val body = replaceOccurrences(fileText, span, targets, name) - return RewriteSpan(span = span, newText = indent + declaration + newline + body) -} -``` - -- [ ] **Step 6: Update the existing `ExistingBlock` fixtures** - -In `ExtractVariableEditTest`, add this helper directly below `allSpansOf`: - -```kotlin - /** - * The block rung of a single-block fixture: content is everything between the first `{` and the - * last `}`, and [statements] are the block's direct child statements in source order. - */ - private fun existingBlock( - text: String, - vararg statements: String, - ) = AnchorForm.ExistingBlock( - contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), - statementSpans = statements.map { spanOf(text, it) }, - ) -``` - -Then replace each `AnchorForm.ExistingBlock` usage: - -- `inserts the declaration above the statement and replaces the selected occurrence`: - -```kotlin - val result = - rewrite( - text, - candidate, - existingBlock(text, "println(items.size * 2)"), - listOf(candidate), - "size", - replaceAll = false, - )!! -``` - -- `replace-all rewrites every occurrence and anchors above the first`: - -```kotlin - val result = - rewrite( - text, - candidate, - existingBlock(text, "println(items.size * 2)", "log(items.size * 2)", "use(items.size * 2)"), - occurrences, - "size", - replaceAll = true, - )!! -``` - -- `replace-all off leaves the other occurrences alone`: - -```kotlin - val result = - rewrite( - text, - occurrences[0], - existingBlock(text, "println(items.size * 2)", "log(items.size * 2)"), - occurrences, - "size", - replaceAll = false, - )!! -``` - -- `matches the file's space indentation rather than assuming tabs`, `keeps CRLF line endings when the file uses them` and `deeper indentation is preserved` (each has one statement): - -```kotlin - val result = - rewrite( - text, - candidate, - existingBlock(text, "println(items.size * 2)"), - listOf(candidate), - "size", - replaceAll = false, - )!! -``` - -- `null when there is nothing to replace` and `null when an occurrence lies outside the file` (text is `"fun f() {}"`, so the block is empty): - -```kotlin - scope = ScopeOption("scope", AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), emptyList()), -``` - -```kotlin - scope = - ScopeOption( - "scope", - AnchorForm.ExistingBlock(TextSpan(9, 9), emptyList()), - listOf(TextSpan(0, text.length + 5)), - ), -``` - -In `ExtractVariableViewModelTest`, replace the `anchorForm` line of the `scope` helper: - -```kotlin - anchorForm = AnchorForm.ExistingBlock(contentSpan = TextSpan(0, 100), statementSpans = emptyList()), -``` - -- [ ] **Step 7: Run the rewrite and view-model tests and watch them pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" \ - --tests "com.itsaky.androidide.lsp.kotlin.refactor.ui.ExtractVariableViewModelTest" -``` - -Expected: PASS in both classes. - -- [ ] **Step 8: Write the failing end-to-end test for the outer rung** - -Append to `ExtractVariablePlanEndToEndTest`: - -```kotlin - @Test - fun `picking the outer rung hoists the declaration above the enclosing statement`() { - val content = - """ - package p - fun demo(flag: Boolean, a: Int, b: Int): Int { - if (flag) { - return a + b * 2 - } - return 0 - } - """.trimIndent() - - val target = "a + b * 2" - val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - val candidate = result.candidates.first() - assertEquals(listOf("if block", "fun demo"), candidate.scopes.map { it.label }) - - val rewrite = - buildExtractVariableRewrite( - fileText = result.fileText, - candidateSpan = candidate.span, - scope = candidate.scopes[1], - name = "total", - replaceAll = false, - )!! - - assertEquals( - "package p\n" + - "fun demo(flag: Boolean, a: Int, b: Int): Int {\n" + - "\tval total = a + b * 2\n" + - "\tif (flag) {\n" + - "\t\treturn total\n" + - "\t}\n" + - "\treturn 0\n" + - "}", - apply(content, rewrite), - ) - } -``` - -- [ ] **Step 9: Run the whole module's tests and watch them pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest -``` - -Expected: PASS for the whole module. If `picking the outer rung ...` fails on the *inner* rung's output instead, the plan is handing both rungs the same statement list - check `contentSpanOf` and `parent.statements` in `frameFor`. - -- [ ] **Step 10: Update the feature doc** - -In R5, replace the anchor-point sentence in the Language section's `Anchor point` entry with: - -```markdown -The exact insertion offset - the start of the line holding the first statement *within the anchor -scope* that contains a replaced occurrence. Recorded per rung in the plan (`ExistingBlock`'s -`statementSpans`), because it is the only thing that makes an outer rung differ from an inner one. -``` - -In R9, after the first paragraph, add: - -```markdown -The span is anchored on the chosen rung's statement, not on the occurrence: for an outer rung the -declaration goes above the whole enclosing statement, at that statement's indentation. -``` - -Add an acceptance criterion after 9: - -```markdown -9a. With a candidate inside a braced `if` inside a function, picking `fun name` in `Declare in` puts the declaration above the `if`, and picking `if block` puts it inside the branch. -``` - -- [ ] **Step 11: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScopeChain.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt \ - docs/features/kotlin-extract-variable.md -git commit -m "ADFA-4826: Anchor the declaration in the scope the user picked" -``` - ---- - -### Task 5: Expand a block written on one line - -**Files:** -- Modify: `.../utils/refactor/ExtractVariableEdit.kt` (`existingBlockRewrite`, plus a new `oneLineBlockRewrite`) -- Modify: `docs/features/kotlin-extract-variable.md` (R9, acceptance criteria) -- Test: `.../utils/refactor/ExtractVariableEditTest.kt`, `.../utils/refactor/ExtractVariablePlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: `AnchorForm.ExistingBlock.contentSpan` from Task 4. -- Produces: nothing new; `buildExtractVariableRewrite` keeps its signature. - -**Why:** when the anchor statement shares its line with the block's `{`, inserting at the line start puts the declaration *outside* the block: `return items.map { it.length + 1 }` becomes a `val` above the `return` with an unresolved `it`, and `fun f(n: Int): Int { return n * 2 }` puts the `val` above the function signature. Both are uncompilable. - -- [ ] **Step 1: Write the failing rewrite tests** - -Append to `ExtractVariableEditTest`: - -```kotlin - @Test - fun `expands a one-line lambda so the declaration lands inside the braces`() { - val text = "fun f(items: List): List {\n\treturn items.map { it.length + 1 }\n}" - val candidate = spanOf(text, "it.length + 1") - val form = - AnchorForm.ExistingBlock( - contentSpan = spanOf(text, " it.length + 1 "), - statementSpans = listOf(candidate), - ) - - val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! - - assertEquals( - "fun f(items: List): List {\n" + - "\treturn items.map {\n" + - "\t\tval length = it.length + 1\n" + - "\t\tlength\n" + - "\t}\n" + - "}", - apply(text, result), - ) - } - - @Test - fun `expanding a one-line lambda keeps its parameter header on the brace line`() { - val text = "fun f(items: List): List {\n\treturn items.map { item -> item.length + 1 }\n}" - val candidate = spanOf(text, "item.length + 1") - // A lambda body block excludes the `item ->` header, so the header is outside the content span. - val form = - AnchorForm.ExistingBlock( - contentSpan = spanOf(text, " item.length + 1 "), - statementSpans = listOf(candidate), - ) - - val result = rewrite(text, candidate, form, listOf(candidate), "length", replaceAll = false)!! - - assertEquals( - "fun f(items: List): List {\n" + - "\treturn items.map { item ->\n" + - "\t\tval length = item.length + 1\n" + - "\t\tlength\n" + - "\t}\n" + - "}", - apply(text, result), - ) - } - - @Test - fun `expands a one-line function body`() { - val text = "fun f(n: Int): Int { return n * 2 }" - val candidate = spanOf(text, "n * 2") - val form = - AnchorForm.ExistingBlock( - contentSpan = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')), - statementSpans = listOf(spanOf(text, "return n * 2")), - ) - - val result = rewrite(text, candidate, form, listOf(candidate), "doubled", replaceAll = false)!! - - assertEquals( - "fun f(n: Int): Int {\n" + - "\tval doubled = n * 2\n" + - "\treturn doubled\n" + - "}", - apply(text, result), - ) - } -``` - -- [ ] **Step 2: Run them and watch them fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" -``` - -Expected: all three FAIL, each with the declaration emitted on its own line *before* `return`. - -- [ ] **Step 3: Branch to an expansion when the statement shares its line with the brace** - -In `ExtractVariableEdit.kt`, insert these four lines in `existingBlockRewrite` directly **after** its existing `val lineStart = lineStartOffset(fileText, anchor.start)` line and before `val indent = ...`: - -```kotlin - // The statement shares its line with the block's opening brace (a one-line lambda or body). The - // line start is then *outside* the block, so the declaration has to go inside the braces instead. - if (lineStart < form.contentSpan.start) { - return oneLineBlockRewrite(fileText, form, targets, declaration, name) - } -``` - -Add below the function: - -```kotlin -/** - * Puts the declaration inside a block written on one line, moving the block's content and its closing - * brace onto their own lines. - * - * Only the content between the braces is rewritten: the braces, and a lambda's `param ->` header, - * stay exactly where they are, so the expansion cannot disturb the call around it. - */ -private fun oneLineBlockRewrite( - fileText: String, - form: AnchorForm.ExistingBlock, - targets: List, - declaration: String, - name: String, -): RewriteSpan { - val content = form.contentSpan - val newline = detectNewline(fileText) - val indent = leadingIndentAt(fileText, content.start) - val innerIndent = indent + detectIndentUnit(fileText) - val body = replaceOccurrences(fileText, content, targets, name).trim() - - val newText = - buildString { - append(newline) - append(innerIndent).append(declaration).append(newline) - append(innerIndent).append(body).append(newline) - append(indent) - } - return RewriteSpan(span = content, newText = newText) -} -``` - -- [ ] **Step 4: Run the rewrite tests and watch them pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractVariableEditTest" -``` - -Expected: PASS, all tests in the class - the six multi-line `ExistingBlock` tests included, since their statement lines start after the brace. - -- [ ] **Step 5: Write the failing end-to-end test** - -Append to `ExtractVariablePlanEndToEndTest`: - -```kotlin - @Test - fun `extracting from a one-line lambda stays inside the lambda`() { - val content = - """ - package p - fun demo(items: List): List { - return items.map { it.length + 1 } - } - """.trimIndent() - - val target = "it.length + 1" - val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - val candidate = result.candidates.first() - // `it` is lambda-scoped, so the lambda is the ceiling: there is no outer rung to choose. - assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) - - val rewrite = - buildExtractVariableRewrite( - fileText = result.fileText, - candidateSpan = candidate.span, - scope = candidate.scopes.first(), - name = "length", - replaceAll = false, - )!! - - assertEquals( - "package p\n" + - "fun demo(items: List): List {\n" + - "\treturn items.map {\n" + - "\t\tval length = it.length + 1\n" + - "\t\tlength\n" + - "\t}\n" + - "}", - apply(content, rewrite), - ) - } -``` - -- [ ] **Step 6: Run the whole module's tests and watch them pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest -``` - -Expected: PASS for the whole module. - -- [ ] **Step 7: Update the feature doc** - -In R9, after the paragraph added in Task 4, add: - -```markdown -A block written on one line -- `items.map { it.length + 1 }`, `fun f(n: Int): Int { return n * 2 }`, -a one-line `if` body -- is expanded instead: the content between the braces moves onto its own line -with the declaration above it and the closing brace below. Anchoring on the statement's line start -there would place the declaration *before* the `{`, outside the scope the value belongs to, which -leaves a lambda's `it` unresolved. The braces themselves and a lambda's `param ->` header are left -where they are. -``` - -Add an acceptance criterion after 9a: - -```markdown -9b. Extracting from `return items.map { it.length + 1 }` puts the declaration inside the lambda and expands the block over three lines; the same holds for a one-line function body. -``` - -- [ ] **Step 8: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt \ - docs/features/kotlin-extract-variable.md -git commit -m "ADFA-4826: Expand a block written on one line" -``` - ---- - -### Task 6: Restack ADFA-5080 onto the fixes - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:404-470` (on branch `feat/ADFA-5080-extract-method`) - -**Interfaces:** -- Consumes: `renderedTypeTextOrNull`, `isUnrenderableTypeText` from Task 3's `TypeText.kt`. -- Produces: a rebased, pushed stack; no API change. - -**Why:** the fixes are three commits below #1655 in the stack, and `MethodSignature.kt` now carries private copies of the renderer that `TypeText.kt` owns. - -- [ ] **Step 1: Verify the branch is green and push it** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest :lsp:kotlin:compileV8DebugKotlin -git log --oneline stage..HEAD | head -8 -git push --force-with-lease origin feat/ADFA-4826-extract-variable -``` - -Expected: tests pass, five new `ADFA-4826:` commits on top of `617ed6f39`, push accepted. - -- [ ] **Step 2: Rebase the stack** - -```bash -gh stack rebase -gh stack view -``` - -Expected: `feat/ADFA-5080-extract-method` replays onto the new tip with no conflicts (its commits touch different files). If a conflict does appear in `ExtractionPlan.kt`, keep both changes: `ExistingBlock`'s new fields *and* whatever 5080 added. - -- [ ] **Step 3: Point `MethodSignature` at the shared renderer** - -```bash -git checkout feat/ADFA-5080-extract-method -``` - -In `MethodSignature.kt`, delete `isUnrenderable`, `SIGNATURE_TYPE_RENDERER` and `renderTypeText` together with their KDoc (they are the block from the `/** A type that cannot be written out as source ... */` comment through the `renderTypeText` body), and replace the four helpers that used them with: - -```kotlin -private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = renderedTypeTextOrNull(symbol.returnType) - -private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = - runCatching { expression.expressionType }.getOrNull()?.let { renderedTypeTextOrNull(it) } - -private fun KaSession.renderedDeclarationType(property: KtProperty): String? = - runCatching { (property.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } - -private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = - runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } -``` - -`renderedTypeTextOrNull` already wraps its own rendering in `runCatching` and applies the unrenderable filter, so the `?.takeUnless(::isUnrenderable)` suffixes go away with it. - -Then find the remaining `renderTypeText(` call in `usedTypeOf` (around line 404): - -```bash -grep -n "renderTypeText\|isUnrenderable\|SIGNATURE_TYPE_RENDERER" \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt -``` - -and replace each surviving `renderTypeText(x)` with `renderedTypeTextOrNull(x)`, keeping the `?: return UsedType.Absent` guard exactly as it is. Re-run that grep until it prints nothing. - -Finally drop the imports that are now unused - `KaTypeRendererForSource`, and `KaFlexibleType` / `renderName` if nothing else in the file references them (check with `grep -n "KaFlexibleType\|renderName" `). ktlint fails the build on an unused import, so this is not optional. - -- [ ] **Step 4: Verify and commit on the 5080 branch** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest :lsp:kotlin:compileV8DebugKotlin -flox activate -d flox/local -- ./gradlew spotlessApply -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt -git commit -m "ADFA-5080: Use the shared type-text helpers" -``` - -Expected: tests pass on the rebased 5080 branch. - -- [ ] **Step 5: Push the stack** - -```bash -gh stack push -gh stack view -``` - -Expected: #1654 and #1655 both updated, bases unchanged (`#1653` <- `#1654` <- `#1655`). - ---- - -### Task 7: Probe extract method for the same class of defect - -**Files:** -- Create (temporarily): `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScratchOneLineAnchorTest.kt` - deleted again in the last step, never committed. - -**Interfaces:** -- Consumes: `buildExtractMethodRewrites`, `ExtractMethodCandidate` from the 5080 branch. -- Produces: a Jira comment on ADFA-5080. No code change. - -**Why:** `MethodSignature` sets `insertOffset = anchor.textRange.startOffset` for a local-function target, which has the same line-sharing exposure Task 5 fixed for extract variable: if the anchor declaration shares its line with other code, a multi-line function is inserted mid-line. - -- [ ] **Step 1: Write the probe** - -On `feat/ADFA-5080-extract-method`, create `ScratchOneLineAnchorTest.kt`: - -```kotlin -package com.itsaky.androidide.lsp.kotlin.utils.refactor - -import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest -import org.junit.Test - -/** Scratch probe: what does extract method emit when the anchor shares its line with other code? */ -class ScratchOneLineAnchorTest : KtLspTest() { - @Test - fun probe() { - val cases = - listOf( - "package p\nfun outer(n: Int): Int { return n * 2 }", - "package p\nfun outer(n: Int): Int {\n\tfun inner(): Int { return n * 2 }\n\treturn inner()\n}", - "package p\nfun outer(items: List) { items.forEach { println(it.length + 1) } }", - ) - cases.forEachIndexed { index, content -> - createSourceFile("Main.kt", content) - val path = env.sourceRoots.first().resolve("Main.kt") - val target = "n * 2".takeIf { content.contains("n * 2") } ?: "it.length + 1" - val start = content.indexOf(target) - println("### case $index") - println( - buildExtractMethodPlan( - env = env, - nioPath = path, - selectionStart = start, - selectionEnd = start + target.length, - documentVersion = 1, - cancelChecker = noopCancelChecker(), - ), - ) - } - } -} -``` - -Before running, check the real entry point and its parameter names: - -```bash -grep -n "^internal fun buildExtractMethodPlan\|^fun buildExtractMethodPlan" -A 10 \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt -``` - -Adjust the call to match exactly what that signature says. - -- [ ] **Step 2: Run the probe and read the output** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ScratchOneLineAnchorTest" -i 2>&1 \ - | grep -vE "DEBUG|Took org" | sed -n '/### case 0/,$p' | head -60 -``` - -Expected: for each case either a refusal (fine - extract method declines) or a candidate whose `insertOffset` sits mid-line. Apply the rewrites by hand in the output if needed to judge whether the emitted text would compile. - -- [ ] **Step 3: Delete the probe** - -```bash -rm lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ScratchOneLineAnchorTest.kt -git status --short -``` - -Expected: no changes to tracked files. - -- [ ] **Step 4: Report the finding on ADFA-5080** - -Only if the probe showed a real defect. Write the comment body to the scratchpad first, then: - -```bash -jira issue comment add ADFA-5080 --template /tmp/claude-1000/adfa-5080-probe.md -``` - -Comment body, with the bracketed parts filled in from the probe output: - -```markdown -Probe while fixing the extract-variable defects on ADFA-4826 (PR #1654): extract method's insertion -anchor has the same line-sharing exposure. - -`MethodSignature` sets `insertOffset = anchor.textRange.startOffset` for a local-function target, and -`buildExtractMethodRewrites` emits the declaration at that offset. When the anchor declaration shares -its line with other code, the multi-line function lands mid-line. - -Case: `[the source that failed]` -Emitted: `[the text the rewrite produces]` -Compiles: no. - -Not fixed here - ADFA-4826's fix is confined to the extract-variable rewrite (`ExtractVariableEdit`), -which now expands a one-line block instead of anchoring outside it. Same shape of fix would apply. -``` - -If the probe found nothing, skip the comment and record "extract method declines / anchors soundly in all three shapes" in the execution notes instead. - ---- - -### Task 8: Hand ADFA-4826 to QA - -**Files:** -- Modify: `/tmp/claude-1000/-var-mnt-data-dev-work-adfa-cogo-code-on-the-go--claude-worktrees-ADFA-4826/93ae2f82-68a1-4eca-8a27-23fbfdc5f395/scratchpad/ADFA-4826-steps-to-qa.md` (two expectation edits) - -**Interfaces:** -- Consumes: the shipped behaviour from Tasks 1-5. -- Produces: ADFA-4826's `customfield_10250`, a Jira comment, and a status transition. - -**Why:** the QA draft was written against correct behaviour, so two of its cases describe what only Tasks 3 and 5 make true, and QA should not receive steps before the build can pass them. - -- [ ] **Step 1: Comment the findings on the ticket and move it out of QA** - -Write this to `/tmp/claude-1000/adfa-4826-findings.md`: - -```markdown -Five defects found while drafting the Steps to QA, each reproduced through the module's own test -fixture. Moving back to In Progress; all five are fixed on PR #1654 with tests. - -1. `Declare in` was ignored for block anchors. The rewrite anchored on the first *occurrence's* line, - so every rung of the scope chain produced a byte-identical edit. Now anchored on the statement of - the chosen scope, per R5. -2. A block written on one line put the declaration outside the braces. `return items.map { it.length - + 1 }` produced a `val` above the `return` with an unresolved `it`; `fun f(n: Int): Int { return n - * 2 }` put it above the signature. The block is now expanded over three lines instead. -3. An expression body with an inferred return type converted without writing the type, so - `fun area(r: Int) = r * r` became a Unit-returning function with `return squared` - uncompilable. - The type is now written into the signature, shortened only where the short name already resolves. -4. The `{ ... }` lambda expression was offered as a candidate (only the literal inside it was - excluded), which contradicts R2 and yields `val v = { it.length + 1 }`. Now excluded. -5. A braced `if` branch's rung was labelled `block` rather than `if block`, because the label lookup - saw the control-structure container node. Fixed. - -Steps to QA follows once the on-device pass over the three affected cases is done. -``` - -Then: - -```bash -jira issue comment add ADFA-4826 --template /tmp/claude-1000/adfa-4826-findings.md -jira issue move ADFA-4826 "In Progress" -jira issue view ADFA-4826 --plain | head -3 -``` - -Expected: status reads `In Progress`. If `jira issue move` rejects the target, list what is reachable with `jira issue move ADFA-4826` (no argument prints the available transitions) and pick the In Progress one - a subtask's workflow does not always allow every hop directly. - -- [ ] **Step 2: Build and install the debug APK** - -```bash -adb devices -l | grep -v offline -flox activate -d flox/local -- ./gradlew :app:assembleV8Debug --parallel --max-workers=6 -``` - -Expected: `BUILD SUCCESSFUL` and at least one arm device or arm-translation emulator listed. The app is arm-only, so an x86_64 emulator cannot run it - if only that is available, stop here and report that the device pass needs hardware. - -- [ ] **Step 3: Run the three device cases** - -Install the APK, open a Kotlin file containing the setup from the QA draft, and run: - -- **QA-12** - `fun squaredInferred(r: Int) = r * r`: select `r * r`, name it `squared`, extract. Expected `fun squaredInferred(r: Int): Int {` with `return squared` inside, and no new error underline. -- **QA-14** - `fun nested`: select `a + b * 2`, confirm `Declare in` lists `if block` then `fun nested`, extract once with each rung, and confirm the declaration lands inside the branch and above the `if` respectively. -- **QA-15** - `fun oneLineLambda`: select `it.length + 1` in `return items.map { it.length + 1 }`, extract, and confirm the block expands with the declaration inside the braces and no error underline. - -Record what each one actually produced. - -- [ ] **Step 4: Correct the two QA expectations** - -In `ADFA-4826-steps-to-qa.md`: - -- QA-12 step 3's Expected becomes: `the return type is added - fun squaredInferred(r: Int): Int { - and the file compiles.` and its "Fails if" becomes: `the signature is left as fun squaredInferred(r: Int) { with return squared inside, which does not compile.` -- QA-14's Expected for `Declare in` becomes: `Declare in lists two rungs, innermost first: if block, then fun nested.` (the label is no longer a generic `block`). - -- [ ] **Step 5: Post the Steps to QA field** - -The source is the corrected `ADFA-4826-steps-to-qa.md` from Step 4. Convert it to ADF - `heading` nodes for the section titles, `orderedList`/`bulletList` for the steps, `codeBlock` with `language: "kotlin"` for the fixture and expected-output snippets, `paragraph` elsewhere - and set the field with: - -``` -mcp__claude_ai_Atlassian_Rovo__editJiraIssue - cloudId: bb66613e-967d-4549-a8d6-d9166759f2d2 - issueIdOrKey: ADFA-4826 - fields: { "customfield_10250": { "type": "doc", "version": 1, "content": [ ... ] } } -``` - -A plain string is rejected for this field; it must be an ADF `doc` object. Load the tool schema first with `ToolSearch("select:mcp__claude_ai_Atlassian_Rovo__editJiraIssue")`. - -- [ ] **Step 6: Confirm what landed** - -```bash -jira issue view ADFA-4826 --raw | python3 -c "import json,sys; print(bool(json.load(sys.stdin)['fields']['customfield_10250']))" -``` - -Expected: `True`. Then report the device-pass results and the five commits to the user; whether the ticket moves on to Code review is theirs to call. - ---- - -## Notes for whoever executes this - -- **Do not** run `:app:assembleV8Debug` between tasks; it is multi-minute. `:lsp:kotlin:testV7DebugUnitTest` is the loop, and the assemble happens once, in Task 8. -- Tasks 4 and 5 both touch `existingBlockRewrite`. Task 4 deliberately leaves the one-line block broken (its line start is unchanged), so do not "fix" it early - Task 5's tests are what pin the expansion down. -- If a test in `ExtractVariablePlanEndToEndTest` reports a type or `needsReturn` that makes no sense, check that the test wrote `Main.kt` and not a uniquely named file: duplicate top-level declarations across files in package `p` break resolution silently. diff --git a/docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md b/docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md deleted file mode 100644 index 249ec1d1e3..0000000000 --- a/docs/superpowers/plans/2026-08-18-extract-method-review-fixes.md +++ /dev/null @@ -1,1008 +0,0 @@ -# Extract-Method Review Fixes 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:** Fix the four confirmed correctness defects John Andrés Trujillo found in PR #1655 (Kotlin extract method), each with a regression test and the matching feature-doc update. - -**Architecture:** Four independent, small changes inside `lsp/kotlin`. Tasks 1-3 each rewrite one private function in `MethodSignature.kt` (the analysis layer that derives a candidate). Task 4 adds one field to `ExtractMethodCandidate`, populates it in `MethodSignature.kt`, and reshapes `reindent` in `ExtractMethodEdit.kt` (the pure-text emission layer) to honour it. Tasks are ordered smallest-blast-radius first so a compile or test failure localises to the task that caused it. - -**Tech Stack:** Kotlin, Kotlin K2 Analysis API (2.3.20) + Kotlin PSI, JUnit 4 + Robolectric, Gradle with `v7`/`v8` ABI flavors, Spotless/ktlint. - -**Spec:** -- John's review on PR #1655: (review id `4928006446`, inline comments `3776099257`, `3776099272`, `3776099279`, `3776099288`) -- `docs/features/kotlin-extract-method.md` - the feature spec these fixes must keep true (R4, R8, R10, R15) -- `docs/adr/0013-refactorings-decline-rather-than-rewrite.md` - "an interactive refactoring moves the user's code; it does not edit the interior of what it moved" - -## Global Constraints - -- **Indentation: tabs. Line endings: LF.** Enforced by Spotless; ktlint formats Kotlin. The `ratchetFrom = origin/stage` ratchet is file-level, so any touched file is reformatted in full. -- **ASCII only** in code and code comments. No em dashes, no curly quotes, no arrow glyphs. -- **Comments:** no `//` line comments outside function bodies - use `/* ... */` or KDoc. No separator or banner comments. Comment the non-obvious *why* only. No references to this plan, task numbers, or phases in any comment or commit message. -- **Commit subject only**, format `ADFA-5080: `. No `Co-Authored-By` trailer, no AI-attribution trailers. Never `git add .` - stage the exact paths listed in each task. -- **Never commit this plan file** or any planning/status document. `docs/features/kotlin-extract-method.md` is a checked-in artifact and *is* committed. -- **Branch:** `feat/ADFA-5080-extract-method`, already checked out in the worktree at `/var/mnt/data/dev/work/adfa/cogo/code-on-the-go/.claude/worktrees/ADFA-4826`. Do not branch, rebase or push unless asked. -- **Test task:** `flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`. There is no flavorless `testDebugUnitTest`. A `local.properties` with `sdk.dir` may be required (git-ignored, safe to create, never commit). Run test tasks in the foreground; they take minutes. -- **No new dependencies.** `@Composable` is exercised by declaring `package androidx.compose.runtime; annotation class Composable` in a test source file, as the existing test already does. - ---- - -## File Structure - -**Modified - production:** - -- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt` (882 lines) - derives one `ExtractMethodCandidate` from a region inside an analysis session. Tasks 1, 2, 3 and part of 4 each change exactly one private function here. -- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt` (95 lines) - turns a candidate into the two `RewriteSpan`s. Task 4 only. -- `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt` (187 lines) - the PSI-free data carried from the analysis layer to the sheet and the edit builder. Task 4 adds one field to `ExtractMethodCandidate`. - -**Modified - tests:** - -- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` - analysis-backed, real PSI and resolution. Tasks 1, 2, 3, 4. -- `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt` - pure text, candidates built by hand. Task 4. - -**Modified - docs:** - -- `docs/features/kotlin-extract-method.md` - R4 target table (Task 1), R10 `@Composable` bullet + acceptance criterion 15 (Task 2), R8 declined list (Task 3), R15 emission paragraph (Task 4), Verification section (Task 6). - -No new files. No module, DI, Compose or string-resource changes, so no architecture surface is touched. - ---- - -### Task 1: An anonymous function is never an insertion anchor - -**Why:** `enclosingDeclaration` matches `is KtNamedFunction`, and Kotlin PSI represents an anonymous function expression (`fun(v: Int) { }` used as a value) as a `KtNamedFunction` whose `name` is null (see `KtNamedFunction.isAnonymous = name == null && isLocal`). `enclosingExecutableBody` already accepts it, so nothing upstream declines. The anonymous function then becomes both `enclosing` and `anchor`; its parent is a `KtValueArgument` or a `KtProperty`, so `isLocalTarget` is false, `private` is added, and `insertOffset` lands at the anonymous function's own end - inside an argument list or a property initializer. The emitted file does not parse. `ExtractMethodEdit.kt:32` only rejects an `insertOffset` strictly *inside* the region, so it does not catch this. - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:223-243` (`enclosingDeclaration` and its KDoc) -- Modify: `docs/features/kotlin-extract-method.md:70-78` (R4 target table) -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: nothing from earlier tasks. -- Produces: no signature change. `enclosingDeclaration(element: PsiElement): KtDeclaration?` keeps its name, parameters and return type; only which node it returns changes. - -- [ ] **Step 1: Write the two failing tests** - -Append to `ExtractMethodPlanEndToEndTest.kt`, inside the class: - -```kotlin - @Test - fun `a region inside an anonymous function argument anchors on the enclosing member`() { - val content = - """ - package p - class C { - fun demo() { - register(fun(v: Int) { - work(v) - }) - } - fun register(h: (Int) -> Unit) {} - fun work(n: Int) {} - } - """.trimIndent() - - val candidate = plan(content, content.indexOf("work(v)") + 1).candidates.first { it.label == "work(v)" } - - // The anonymous function is a value, not a declaration a sibling can follow: an insertion at its - // own end lands before the closing `)` of `register(...)` and the file stops parsing. - val callEnd = content.indexOf("})") + "})".length - assertTrue( - "insertOffset ${candidate.insertOffset} must be past the enclosing call at $callEnd", - candidate.insertOffset >= callEnd, - ) - assertEquals("\t", candidate.insertIndent) - assertEquals(listOf("private"), candidate.modifiers) - assertEquals(listOf("v" to "kotlin.Int"), candidate.parameters.map { it.name to it.typeText }) - } - - @Test - fun `a region inside an anonymous function initializer anchors on the enclosing function`() { - val content = - """ - package p - fun demo() { - val f = fun(): Int { - return compute() - } - f() - } - fun compute(): Int = 1 - """.trimIndent() - - val candidate = plan(content, content.indexOf("compute()") + 1).candidates.first { it.label == "compute()" } - - assertEquals("", candidate.insertIndent) - assertTrue( - "insertOffset ${candidate.insertOffset} must be past the property initializer", - candidate.insertOffset >= content.indexOf("\tf()"), - ) - assertEquals(listOf("private"), candidate.modifiers) - } -``` - -`assertTrue`, `assertEquals` and `Test` are already imported in this file. - -- [ ] **Step 2: Run the tests to verify they fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: both new tests FAIL. The first on `insertOffset ... must be past the enclosing call` (the offset sits one character before the closing `)`); the second on the `insertIndent` assertion (`expected:<> but was:<\t>`). - -- [ ] **Step 3: Skip a nameless `KtNamedFunction` and keep walking** - -Replace `enclosingDeclaration` and its KDoc in `MethodSignature.kt` with: - -```kotlin -/** - * The named function, accessor, `init` block or constructor whose body holds [element]. Lambdas and - * anonymous functions are skipped: the new function is a sibling of the enclosing *named* declaration - * (R4), and their captures become parameters. - */ -private fun enclosingDeclaration(element: PsiElement): KtDeclaration? { - var current: PsiElement? = element.parent - while (current != null) { - when (current) { - is KtNamedFunction -> { - // PSI gives an anonymous `fun(...) { }` the same node type as a named function, with a null - // name. It is a value, not a declaration a sibling can follow: anchoring on it inserts the - // new function into an argument list or a property initializer, and the file stops parsing. - if (current.name != null) return current - } - - is KtPropertyAccessor, is KtAnonymousInitializer, is KtSecondaryConstructor -> { - return current - } - - is KtClassOrObject -> { - return null - } - } - current = current.parent - } - return null -} -``` - -`current.name` smart-casts because `is KtNamedFunction` is now its own branch. Nothing else needs an import. - -- [ ] **Step 4: Run the tests to verify they pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: PASS, the whole class. If a pre-existing test now fails, stop and report it rather than editing the assertion. - -- [ ] **Step 5: Add the R4 target-table row** - -In `docs/features/kotlin-extract-method.md`, add this row to the R4 table immediately after the existing lambda row (`| a lambda inside either of the above | ... |`): - -```markdown -| an anonymous `fun(...) { }` used as a value | still a sibling of the enclosing *named* declaration, exactly as for a lambda; PSI gives it the same node type as a named function, but it is a value and nothing can be inserted after it | -``` - -- [ ] **Step 6: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ - docs/features/kotlin-extract-method.md -git commit -m "ADFA-5080: Stop anchoring an extraction on an anonymous function" -``` - ---- - -### Task 2: `@Composable` property getters count as Composable use - -**Why:** `usesComposable` walks only `KtCallExpression` descendants and resolves them with `successfulFunctionCallOrNull`. `MaterialTheme.colorScheme`, `MaterialTheme.typography` and `LocalDensity.current` are `@Composable @ReadOnlyComposable` *property getters* reached through a `KtDotQualifiedExpression`, not calls. Extracting such a region emits a function with no `@Composable`, which is exactly the compile failure R10 exists to prevent, on an everyday Compose shape. - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:825-839` (`usesComposable` and its KDoc), plus two imports -- Modify: `docs/features/kotlin-extract-method.md:122` (R10 `@Composable` bullet) and `:210` (acceptance criterion 15) -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: nothing from Task 1. -- Produces: `private fun KaAnnotatedSymbol.hasComposableAnnotation(): Boolean` - file-private, used only by `usesComposable`. `usesComposable(elements: List): Boolean` keeps its signature. - -- [ ] **Step 1: Write the failing test and its negative guard** - -Append to `ExtractMethodPlanEndToEndTest.kt`, inside the class: - -```kotlin - @Test - fun `reading a Composable property getter adds the Composable annotation`() { - createSourceFile( - "Composable.kt", - """ - package androidx.compose.runtime - annotation class Composable - """.trimIndent(), - ) - val content = - """ - package p - import androidx.compose.runtime.Composable - object Palette { - val accent: Int - @Composable get() = 1 - } - fun use(n: Int) {} - @Composable fun Demo() { - use(Palette.accent) - } - """.trimIndent() - - val candidate = plan(content, content.indexOf("Palette.accent") + 1).candidates.first { it.label == "Palette.accent" } - - assertEquals(listOf("@Composable"), candidate.annotations) - } - - @Test - fun `reading a plain property getter adds no annotation`() { - createSourceFile( - "Composable.kt", - """ - package androidx.compose.runtime - annotation class Composable - """.trimIndent(), - ) - val content = - """ - package p - import androidx.compose.runtime.Composable - object Palette { - val accent: Int - get() = 1 - } - fun use(n: Int) {} - @Composable fun Demo() { - use(Palette.accent) - } - """.trimIndent() - - val candidate = plan(content, content.indexOf("Palette.accent") + 1).candidates.first { it.label == "Palette.accent" } - - assertEquals(emptyList(), candidate.annotations) - } -``` - -The second test is not redundant: it rules out the naive fix of treating any `@Composable` anywhere in the file, or on the enclosing function, as a reason to annotate. - -- [ ] **Step 2: Run the tests to verify the first fails and the second passes** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: `reading a Composable property getter adds the Composable annotation` FAILS with `expected:<[@Composable]> but was:<[]>`. `reading a plain property getter adds no annotation` PASSES already. - -- [ ] **Step 3: Resolve simple names to property symbols too** - -Add these two imports to `MethodSignature.kt`, each in its existing alphabetical run: - -```kotlin -import org.jetbrains.kotlin.analysis.api.symbols.KaPropertySymbol -import org.jetbrains.kotlin.analysis.api.symbols.markers.KaAnnotatedSymbol -``` - -Replace `usesComposable` and its KDoc with: - -```kotlin -/** - * `@Composable` is added when the region uses one. Not polish: CoGo users write Compose apps on the - * device, and an extracted composable without the annotation does not compile (R10). - * - * Property *getters* count, not only calls. `MaterialTheme.colorScheme` and `LocalDensity.current` are - * annotated getters reached through a name reference, and they are as common in Compose code as any - * composable call. - */ -private fun KaSession.usesComposable(elements: List): Boolean = - descendantsOf(elements, KtCallExpression::class.java).any { call -> - runCatching { - call - .resolveToCall() - ?.successfulFunctionCallOrNull() - ?.symbol - ?.hasComposableAnnotation() - }.getOrNull() == true - } || - simpleNamesIn(elements).any { reference -> - runCatching { - val property = reference.mainReference?.resolveToSymbols()?.firstOrNull() as? KaPropertySymbol - property?.hasComposableAnnotation() == true || property?.getter?.hasComposableAnnotation() == true - }.getOrNull() == true - } - -/** Whether [this] carries `@Composable`. */ -private fun KaAnnotatedSymbol.hasComposableAnnotation(): Boolean = - annotations.any { it.classId?.asFqNameString() == COMPOSABLE_FQ_NAME } -``` - -The property symbol itself is checked as well as its getter because a use-site-free `@Composable` on a `val` targets whichever of the two the annotation declares. - -- [ ] **Step 4: Run the tests to verify both pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: PASS, the whole class - including the pre-existing `a Composable call adds the Composable annotation`, which must keep passing through the call branch. - -- [ ] **Step 5: Update R10 and acceptance criterion 15** - -In `docs/features/kotlin-extract-method.md`, replace the `@Composable` bullet under R10: - -```markdown -- **`@Composable`** - added when the region uses one: any call resolving to a `@Composable`-annotated function, **or any name reference resolving to a property whose getter is annotated**. The second half is not an edge case - `MaterialTheme.colorScheme` and `LocalDensity.current` are annotated getters, not calls. This is not polish: CoGo users write Compose apps on the device, and an extracted composable without the annotation does not compile. -``` - -And replace acceptance criterion 15: - -```markdown -15. A region calling a `@Composable` function, or reading a `@Composable` property such as `MaterialTheme.colorScheme`, produces a `@Composable` function that compiles. -``` - -- [ ] **Step 6: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ - docs/features/kotlin-extract-method.md -git commit -m "ADFA-5080: Detect Composable property getters when annotating" -``` - ---- - -### Task 3: A return belonging to a declaration inside the region is not an exit - -**Why:** `hasExit` and `isTailReturn` both use `descendantsOf(elements, KtReturnExpression::class.java)`, which collects every `return` in the subtree - including ones inside a local `fun`, an anonymous `fun`, or an anonymous-object override *declared within the region*. Those returns have no label, so `hasExit` returns true and the region is refused as `ExitsRegion` ("The selection jumps out of itself with return, break or continue") even though the jump never leaves the region. `isTailReturn` is skewed the same way through `returns.size != 1`. The `object : Listener { override fun onX() { ... return ... } }` shape makes this common in Android code, and the message describes something the user did not write. - -The nested-owner search must skip `KtFunctionLiteral`: a lambda is transparent to an unlabelled `return`, which targets the enclosing function declaration, so a non-local return out of a lambda inside the region is still a genuine exit and must stay refused. An anonymous `fun` is *not* transparent, and is not a literal, so the same walk handles it correctly. `hasLoopExit` already checks `inRegion(loop, span)` and needs no change. - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:511-538` (`isTailReturn`, `hasExit`, plus one new private helper), plus one import -- Modify: `docs/features/kotlin-extract-method.md:110` (R8 declined list) -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` - -**Interfaces:** -- Consumes: nothing from Tasks 1-2. -- Produces: `private fun returnTargetInRegion(returnExpression: KtReturnExpression, span: TextSpan): Boolean` - file-private, used by both `isTailReturn` and `hasExit`. Neither of those changes signature. - -- [ ] **Step 1: Write the four failing/guard tests** - -Append to `ExtractMethodPlanEndToEndTest.kt`, inside the class: - -```kotlin - @Test - fun `a return inside a local function declared in the region is not an exit`() { - val content = - """ - package p - fun demo(a: Int): Int { - fun helper(): Int { - return a * 2 - } - val x = helper() - return x - } - """.trimIndent() - val (start, end) = selection(content, "fun helper", "val x = helper()") - - val result = plan(content, start, end) - - assertNull(result.refusal) - val candidate = result.candidates.single() - assertEquals(CallSiteForm.AssignOutput("x"), candidate.callSite) - assertEquals(listOf("a"), candidate.parameters.map { it.name }) - } - - @Test - fun `a return inside an anonymous object override in the region is not an exit`() { - val content = - """ - package p - interface Runner { fun run() } - fun work() {} - fun use(r: Runner) {} - fun demo(flag: Boolean) { - val r = object : Runner { - override fun run() { - if (flag) return - work() - } - } - use(r) - } - """.trimIndent() - val (start, end) = selection(content, "val r = object", "use(r)") - - val result = plan(content, start, end) - - assertNull(result.refusal) - val candidate = result.candidates.single() - assertEquals(listOf("flag"), candidate.parameters.map { it.name }) - assertEquals(CallSiteForm.Call, candidate.callSite) - } - - @Test - fun `a tail return is recognised when a nested function in the region also returns`() { - val content = - """ - package p - fun demo(a: Int): Int { - fun helper(): Int { - return a * 2 - } - return helper() - } - """.trimIndent() - val (start, end) = selection(content, "fun helper", "return helper()") - - val candidate = plan(content, start, end).candidates.single() - - assertEquals(CallSiteForm.Return, candidate.callSite) - assertEquals("kotlin.Int", candidate.returnTypeText) - } - - @Test - fun `a non-local return from a lambda in the region is still an exit`() { - // A lambda is transparent to an unlabelled `return`, so this one really does leave the region. - val content = - """ - package p - fun demo(items: List): Int { - items.forEach { item -> - if (item > 0) return item - } - return 0 - } - """.trimIndent() - val (start, end) = selection(content, "items.forEach", "\t}") - - assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) - } -``` - -`assertNull` and the `selection` helper are already available in this file. - -- [ ] **Step 2: Run the tests to verify the first three fail and the fourth passes** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: the first two FAIL on `assertNull(result.refusal)` (the refusal is `ExitsRegion`), the third FAILS with `NoSuchElementException` / empty candidate list, and `a non-local return from a lambda in the region is still an exit` PASSES already. - -- [ ] **Step 3: Filter out returns owned by a declaration inside the region** - -Add this import to `MethodSignature.kt`, in its existing alphabetical run: - -```kotlin -import org.jetbrains.kotlin.psi.KtDeclarationWithBody -``` - -Insert this helper immediately above `isTailReturn`: - -```kotlin -/** - * Whether [returnExpression] returns from a function declared *inside* the region, so its jump never - * crosses the region boundary and it is not an exit (R8). - * - * A `KtFunctionLiteral` is skipped rather than accepted: a lambda is transparent to an unlabelled - * `return`, which targets the enclosing function declaration, so a non-local return out of a lambda in - * the region really does leave it. An anonymous `fun` is not transparent and is not a literal, so the - * same walk stops on it correctly. - */ -private fun returnTargetInRegion( - returnExpression: KtReturnExpression, - span: TextSpan, -): Boolean { - var owner = PsiTreeUtil.getParentOfType(returnExpression, KtDeclarationWithBody::class.java, true) - while (owner is KtFunctionLiteral) { - owner = PsiTreeUtil.getParentOfType(owner, KtDeclarationWithBody::class.java, true) - } - return owner != null && inRegion(owner, span) -} -``` - -Replace the body of `isTailReturn` so the nested returns are filtered before it counts: - -```kotlin -private fun isTailReturn( - elements: List, - span: TextSpan, -): Boolean { - if (elements.last() !is KtReturnExpression) return false - val returns = - descendantsOf(elements, KtReturnExpression::class.java) - .filterNot { returnTargetInRegion(it, span) } - if (returns.size != 1 || returns.single() !== elements.last()) return false - return !hasLoopExit(elements, span) -} -``` - -And add the same skip as the first statement of `hasExit`'s loop: - -```kotlin - for (returnExpression in descendantsOf(elements, KtReturnExpression::class.java)) { - if (returnTargetInRegion(returnExpression, span)) continue - // An unlabelled `return` always targets the enclosing named declaration, which is outside the - // region by construction. A labelled one targets the lambda carrying that label, which is not - // necessarily the nearest one -- `return@outer` from a nested lambda still leaves the region. - val label = returnExpression.getLabelName() ?: return true - val target = labelledLambdaFor(returnExpression, label) ?: return true - if (!inRegion(target, span)) return true - } -``` - -- [ ] **Step 4: Run the tests to verify all four pass** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: PASS, the whole class. The pre-existing mid-region-`return` and `break`-out-of-region refusal tests must still pass; if one does not, the filter is too broad - check whether its `return` sits in a lambda rather than a nested declaration. - -- [ ] **Step 5: Update the R8 declined list** - -In `docs/features/kotlin-extract-method.md`, replace the "Declined:" paragraph under R8 with: - -```markdown -Declined: a `return` anywhere but the tail position, a `break`/`continue` whose target loop is outside the region, a labelled `return@` whose target is outside it, and a non-local return from an inlined lambda. Each would silently change meaning, since a `return` in the extracted body returns from *it*. - -Not an exit: a `return` belonging to a function **declared inside** the region - a local `fun`, an anonymous `fun`, or an anonymous-object override. It moves with its own declaration and its jump never crosses the region boundary, so counting it would refuse a perfectly good extraction with a message describing something the user did not write. -``` - -- [ ] **Step 6: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ - docs/features/kotlin-extract-method.md -git commit -m "ADFA-5080: Stop counting a nested declaration's return as a region exit" -``` - ---- - -### Task 4: Re-indentation leaves multi-line string literals verbatim - -**Why:** `reindent` strips `baseIndent` from every line of the region and `buildExtractMethodRewrites` then prefixes `bodyIndent` to every line, with no awareness of string literals. Two ways that changes a raw string's value: - -1. `bodyIndent != baseIndent` - the normal case for a region nested inside an `if` or a lambda - shifts every continuation line by the difference. -2. `bodyIndent == baseIndent` still breaks a literal whose lines are indented *less* than the base: `removePrefix(baseIndent)` is a no-op on `line one`, but `bodyIndent` is prefixed anyway, so the literal gains an indent level on the ordinary member-function path. - -The result compiles but the runtime string differs, contradicting ADR 0013's "it does not edit the interior of what it moved". A literal followed by `.trimIndent()` is unaffected by case 1; nothing is safe from case 2. - -The fix carries the literal spans from the analysis layer (which has PSI) to the edit layer (which does not), and folds the `bodyIndent` prefixing into `reindent` so a protected line can be emitted untouched. The closing delimiter line is protected too - its position sets `trimIndent`'s margin, so moving it changes the value just as much. - -**Files:** -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt:53-67` (add `rawStringSpans` to `ExtractMethodCandidate`) -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt:195-217` (populate it) and add one private helper, plus one import -- Modify: `lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt:40-57` and `:83-95` (`reindent` becomes `indentedBodyLines`) -- Test: `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt`, `lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt` -- Modify: `docs/features/kotlin-extract-method.md:172` (R15 emission paragraph) - -**Interfaces:** -- Consumes: nothing from Tasks 1-3. -- Produces: - - `ExtractMethodCandidate.rawStringSpans: List`, defaulted to `emptyList()` so the four existing construction sites keep compiling unchanged. - - `private fun indentedBodyLines(regionText: String, regionStart: Int, baseIndent: String, bodyIndent: String, newline: String, protectedSpans: List): List` in `ExtractMethodEdit.kt`, replacing `reindent`. It returns **fully indented** lines, so the declaration builder no longer prefixes anything. - - `private fun multiLineStringSpans(elements: List): List` in `MethodSignature.kt`. - -- [ ] **Step 1: Write the failing text-level tests** - -Append to `ExtractMethodEditTest.kt`, inside the class: - -```kotlin - @Test - fun `a raw string keeps its interior lines when the body indent differs from the base`() { - val quotes = "\"\"\"" - val nested = - "package p\n" + - "class C {\n" + - "\tfun demo() {\n" + - "\t\tif (true) {\n" + - "\t\t\tsend($quotes\n" + - "line one\n" + - "\t\t\t\tline two\n" + - "$quotes)\n" + - "\t\t}\n" + - "\t}\n" + - "}\n" - val span = TextSpan(nested.indexOf("send("), nested.indexOf("$quotes)") + "$quotes)".length) - val rewrites = - buildExtractMethodRewrites( - nested, - candidate( - span, - ExtractedBody.StatementBody(trailingReturn = null), - CallSiteForm.Call, - ).copy( - insertOffset = nested.indexOf("\t}\n}") + 2, - insertIndent = "\t", - rawStringSpans = listOf(TextSpan(nested.indexOf(quotes), nested.indexOf("$quotes)") + quotes.length)), - ), - "emit", - ) - - val text = apply(nested, rewrites!!) - - assertTrue("the first line takes the body indent", text.contains("\n\t\tsend($quotes\n")) - assertTrue("an unindented literal line stays unindented", text.contains("\nline one\n")) - assertTrue("an indented literal line keeps its own indent", text.contains("\n\t\t\t\tline two\n")) - assertTrue("the closing delimiter line is untouched", text.contains("\n$quotes)\n")) - } - - @Test - fun `a raw string is left alone when the body and base indents match`() { - // The base indent is not a prefix of an unindented literal line, so stripping it is a no-op while - // the body indent is still prefixed. Equal indents are not a safe case. - val quotes = "\"\"\"" - val flat = - "package p\n" + - "class C {\n" + - "\tfun demo() {\n" + - "\t\tsend($quotes\n" + - "line one\n" + - "$quotes)\n" + - "\t}\n" + - "}\n" - val span = TextSpan(flat.indexOf("send("), flat.indexOf("$quotes)") + "$quotes)".length) - val rewrites = - buildExtractMethodRewrites( - flat, - candidate( - span, - ExtractedBody.StatementBody(trailingReturn = null), - CallSiteForm.Call, - ).copy( - insertOffset = flat.indexOf("\t}\n}") + 2, - insertIndent = "\t", - rawStringSpans = listOf(TextSpan(flat.indexOf(quotes), flat.indexOf("$quotes)") + quotes.length)), - ), - "emit", - ) - - val text = apply(flat, rewrites!!) - - assertTrue("an unindented literal line stays unindented", text.contains("\nline one\n")) - assertTrue("the closing delimiter line is untouched", text.contains("\n$quotes)\n")) - } -``` - -Then append to `ExtractMethodPlanEndToEndTest.kt`, inside the class, so the spans are proven to be *populated*, not just honoured: - -```kotlin - @Test - fun `a multi-line string in the region is recorded and emitted verbatim`() { - val quotes = "\"\"\"" - val content = - "package p\n" + - "fun send(s: String) {}\n" + - "fun demo() {\n" + - "\tif (true) {\n" + - "\t\tsend($quotes\n" + - "line one\n" + - "$quotes)\n" + - "\t}\n" + - "}\n" - val (start, end) = selection(content, "send($quotes", "$quotes)") - - val candidate = plan(content, start, end).candidates.single() - - assertEquals(1, candidate.rawStringSpans.size) - assertEquals(content.indexOf(quotes), candidate.rawStringSpans.single().start) - assertEquals(content.indexOf("$quotes)") + quotes.length, candidate.rawStringSpans.single().end) - - val text = apply(content, buildExtractMethodRewrites(content, candidate, "emit")!!) - assertTrue("the literal must not gain an indent level", text.contains("\nline one\n")) - } -``` - -- [ ] **Step 2: Run both test classes to verify the new tests fail** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest" \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: the two `ExtractMethodEditTest` cases fail to **compile** first (`rawStringSpans` is not a parameter of `ExtractMethodCandidate` yet). That counts as red. Add the field in Step 3, then re-run this command before Step 4 and expect assertion failures instead: `line one` comes out as `\tline one` / `\t\tline one`. - -- [ ] **Step 3: Add the field to the candidate** - -In `ExtractMethodPlan.kt`, add the last parameter of `ExtractMethodCandidate` and extend its KDoc with one paragraph: - -```kotlin - * [rawStringSpans] are the multi-line string literals inside the region, in file offsets. Their - * interior is whitespace-sensitive, so re-indentation must leave those lines byte-for-byte (ADR 0013). - */ -data class ExtractMethodCandidate( - val label: String, - val span: TextSpan, - val suggestedName: String, - val takenNames: Set, - val annotations: List, - val modifiers: List, - val receiverTypeText: String?, - val parameters: List, - val returnTypeText: String?, - val body: ExtractedBody, - val callSite: CallSiteForm, - val insertOffset: Int, - val insertIndent: String, - val rawStringSpans: List = emptyList(), -) -``` - -- [ ] **Step 4: Populate it in the analysis layer** - -Add this import to `MethodSignature.kt`, in its existing alphabetical run: - -```kotlin -import org.jetbrains.kotlin.psi.KtStringTemplateExpression -``` - -Add `rawStringSpans = multiLineStringSpans(elements),` to the `ExtractMethodCandidate(...)` construction in `buildCandidate`, immediately after `insertIndent = ...`. - -Add this helper next to the other `descendantsOf` users, above `localTypeNameIn`: - -```kotlin -/** - * The multi-line string literals inside [elements], in file offsets. A single-line literal needs no - * protection: `\n` inside it is an escape, not a line break the re-indentation can reach. - */ -private fun multiLineStringSpans(elements: List): List = - descendantsOf(elements, KtStringTemplateExpression::class.java) - .filter { it.text.contains('\n') } - .map { TextSpan(it.textRange.startOffset, it.textRange.endOffset) } -``` - -- [ ] **Step 5: Make the emission honour the spans** - -In `ExtractMethodEdit.kt`, replace the `bodyLines` block and the declaration builder (lines 40-57) with: - -```kotlin - val bodyLines = - when (val body = candidate.body) { - is ExtractedBody.ExpressionBody -> { - val lines = - indentedBodyLines(regionText, span.start, baseIndent, bodyIndent, newline, candidate.rawStringSpans) - // The first line is never inside a literal's interior -- the region starts at the code - // itself -- so it always carries bodyIndent and `return ` goes straight after it. - if (body.needsReturn) { - listOf(bodyIndent + "return " + lines.first().substring(bodyIndent.length)) + lines.drop(1) - } else { - lines - } - } - - is ExtractedBody.StatementBody -> { - indentedBodyLines(regionText, span.start, baseIndent, bodyIndent, newline, candidate.rawStringSpans) + - listOfNotNull(body.trailingReturn?.let { bodyIndent + it }) - } - } - - val declaration = - buildString { - append(indent).append(candidate.signatureText(name)).append(" {").append(newline) - bodyLines.forEach { append(it).append(newline) } - append(indent).append('}') - } -``` - -And replace `reindent` and its KDoc (lines 83-95) with: - -```kotlin -/** - * The region's lines at the new function's body indentation: the original base indentation removed and - * [bodyIndent] put in its place. Lines nested deeper than the base keep the extra depth; the first line - * only gains the indent, since the span starts at the code itself. - * - * A line inside one of [protectedSpans] is emitted byte-for-byte. Those are multi-line string literals, - * whose interior whitespace is part of their value, and whose closing delimiter sets `trimIndent`'s - * margin -- moving either edits the interior of the moved code (ADR 0013). - */ -private fun indentedBodyLines( - regionText: String, - regionStart: Int, - baseIndent: String, - bodyIndent: String, - newline: String, - protectedSpans: List, -): List { - var offset = regionStart - return regionText.split(newline).mapIndexed { index, line -> - val lineStart = offset - offset += line.length + newline.length - when { - index == 0 -> bodyIndent + line - protectedSpans.any { lineStart > it.start && lineStart < it.end } -> line - else -> bodyIndent + line.removePrefix(baseIndent) - } - } -} -``` - -- [ ] **Step 6: Run both test classes to verify everything passes** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodEditTest" \ - --tests "com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractMethodPlanEndToEndTest" -``` - -Expected: PASS, both classes. Every pre-existing `ExtractMethodEditTest` case - indentation, blank-line separation, CRLF preservation, the three call-site forms - must still pass unchanged; they all leave `rawStringSpans` at its default, and the refactored `indentedBodyLines` must produce byte-identical output for them. - -- [ ] **Step 7: Update the R15 emission paragraph** - -In `docs/features/kotlin-extract-method.md`, replace the paragraph beginning "The new function is emitted **fully indented**" with: - -```markdown -The new function is emitted **fully indented** at the enclosing declaration's own indentation, separated by one blank line, reusing `detectIndentUnit`, `detectNewline`, `leadingIndentAt` and `positionAt`. Code-action edits bypass the editor's auto-indent and `CMD_FORMAT_CODE` is a no-op for Kotlin. - -One exception to re-indenting every line: the interior and closing delimiter of a **multi-line string literal** are emitted byte-for-byte. Their whitespace is part of the literal's value, and the closing delimiter's column sets `trimIndent`'s margin, so shifting either would edit the interior of the moved code (ADR 0013). The candidate carries those literals' spans so the text layer can skip them without needing PSI. -``` - -- [ ] **Step 8: Commit** - -```bash -git add lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt \ - lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt \ - lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt \ - docs/features/kotlin-extract-method.md -git commit -m "ADFA-5080: Keep multi-line string literals verbatim when re-indenting" -``` - ---- - -### Task 5: File the type-text shortening follow-up - -**Why:** John's fifth point - extract-method signatures print `kotlin.Int` while `ExtractVariablePlanner.kt:162` runs the same rendering through `shortenTypeText` - is real but is a *documented* decision, not an oversight: R5 and R11 of `docs/features/kotlin-extract-method.md` both specify fully-qualified rendering, and `ExtractMethodPlanEndToEndTest` asserts it in several places. Changing it means a doc change, a code change and a test sweep, which does not belong in a review-fix commit. It gets a ticket instead. - -**Files:** none. No repository change; this task creates a Jira issue and leaves the working tree clean. - -**Interfaces:** -- Consumes: nothing. -- Produces: a ticket id, quoted in Task 6's PR reply. - -- [ ] **Step 1: Confirm no equivalent ticket exists** - -```bash -jira issue list -q 'project = ADFA AND text ~ "shorten type text" AND statusCategory != Done' --plain -``` - -If a matching ticket already exists, note its id and skip Step 2. - -- [ ] **Step 2: Create the ticket** - -Write the body to a tempfile first - the description contains backticks, which break inline heredocs: - -```bash -cat > /tmp/adfa-shorten-body.md <<'BODY' -Extract method renders every signature type fully qualified (`private fun total(a: kotlin.Int): kotlin.Int`). -Extract variable renders the same types through `shortenTypeText`, which drops the qualifier whenever the -file already resolves the simple name. Two refactorings in the same family read differently for no reason -the user can see. - -Raised by John Andrés Trujillo in review on PR #1655 as a follow-up, not a correctness issue: fully -qualified text always compiles. - -Scope: -- Run the derived parameter, return and receiver type text through `shortenTypeText` in the extract-method - path, using `importedNamesOf` / `starImportedPackagesOf` on the enclosing `KtFile`. -- Update R5 and R11 of `docs/features/kotlin-extract-method.md`, which currently specify fully-qualified - rendering as deliberate. -- Update the `ExtractMethodPlanEndToEndTest` assertions that expect `kotlin.Int`, and the signature-preview - assertions in `ExtractMethodViewModelTest`. -BODY - -jira issue create --type Task --project ADFA \ - --summary "Shorten extract-method signature types to match extract variable" \ - --template /tmp/adfa-shorten-body.md \ - --no-input -``` - -- [ ] **Step 3: Link it from the feature doc's Related list** - -In `docs/features/kotlin-extract-method.md`, add one bullet to the `## Related` list, after the ADFA-5082 line, substituting the real ticket id: - -```markdown -- ADFA- - shorten signature type text to match extract variable (revisits R5's fully-qualified rendering) -``` - -- [ ] **Step 4: Commit** - -```bash -git add docs/features/kotlin-extract-method.md -git commit -m "ADFA-5080: Link the type-text shortening follow-up" -``` - ---- - -### Task 6: Verify the whole module, format, and answer the review - -**Why:** Each earlier task ran only the classes it touched. This task proves the four fixes hold together, that nothing else in `:lsp:kotlin` regressed, and that the branch is formatted for push. Then it closes the loop with the reviewer and the ticket, which the project asks for explicitly. - -**Files:** -- Modify: `docs/features/kotlin-extract-method.md` (Verification section - the new coverage) -- No source changes expected; Spotless may reformat touched files. - -**Interfaces:** -- Consumes: the four commits from Tasks 1-4 and the ticket id from Task 5. -- Produces: nothing consumed by a later task. - -- [ ] **Step 1: Run the full module test suite** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest -``` - -Expected: PASS. Report the actual failure output if anything fails; do not weaken an assertion to make it green. - -- [ ] **Step 2: Compile the app flavor the fixes ship in** - -```bash -flox activate -d flox/local -- ./gradlew :lsp:kotlin:compileV8DebugKotlin -``` - -Expected: `BUILD SUCCESSFUL`. This catches anything that compiles under `v7` but not `v8`. - -- [ ] **Step 3: Update the Verification section's coverage list** - -In `docs/features/kotlin-extract-method.md`, replace the `ExtractMethodPlanEndToEndTest` and `ExtractMethodEditTest` bullets under `## Verification` with: - -```markdown -- **`ExtractMethodPlanEndToEndTest`** - analysis-backed, one case per rule: the parameter set, order and types (R5), the single output and the `Unit` case (R6, R7), the tail return and the nested-declaration `return` that is not an exit (R8), the extension receiver (R9), `suspend`, a `@Composable` call and a `@Composable` property getter (R10), the anonymous-function anchor (R4), the recorded multi-line-string spans (R15), and **one case per refusal reason** (R14). -- **`ExtractMethodEditTest`** - pure text: the two edits and their descending order, the three call-site forms, indentation, multi-line string literals left verbatim, the blank-line separation, and CRLF preservation (R15). -``` - -- [ ] **Step 4: Format and commit** - -```bash -flox activate -d flox/local -- ./gradlew spotlessApply -git status --short -``` - -Review what Spotless changed. The file-level ratchet reformats any file differing from `origin/stage` in full, so an unrelated whole-file reindent may appear - if it does, keep it in its own commit: - -```bash -git add docs/features/kotlin-extract-method.md -git commit -m "ADFA-5080: Record the new extract-method test coverage" -``` - -- [ ] **Step 5: Reply in each review thread** - -Reply in the thread, not as a top-level PR comment. One reply per comment id, each naming the commit and what changed: - -```bash -gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099257/replies -f body="Fixed. \`enclosingDeclaration\` now skips a nameless \`KtNamedFunction\` and keeps walking, so the anchor is the enclosing named declaration and the anonymous function's parameters become captures. Two end-to-end tests cover the argument and property-initializer shapes, and R4's target table has a row for it." -gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099272/replies -f body="Fixed. \`usesComposable\` now also resolves simple names to \`KaPropertySymbol\` and checks the property and its getter for the annotation. Tests cover an annotated getter and a plain one, so the negative case is pinned too." -gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099279/replies -f body="Fixed. Both \`hasExit\` and \`isTailReturn\` now skip a \`return\` whose nearest enclosing \`KtDeclarationWithBody\` is inside the region. The walk skips \`KtFunctionLiteral\` so a non-local return out of a lambda is still refused, and there is a test holding that line. Worth noting the same fault hit \`object : Runner { override fun run() { ... return ... } }\`, which is the more common shape - that case is tested." -gh api repos/appdevforall/CodeOnTheGo/pulls/1655/comments/3776099288/replies -f body="Fixed. The candidate carries the multi-line string spans and the text layer emits those lines byte-for-byte, closing delimiter included. One correction to the scope: it also fired when \`bodyIndent == baseIndent\`, because \`removePrefix(baseIndent)\` is a no-op on a literal line indented less than the base while \`bodyIndent\` was still prefixed - so an unindented literal gained an indent level on the ordinary member-function path. Both cases are tested." -``` - -- [ ] **Step 6: Comment on the ticket** - -```bash -jira issue comment add ADFA-5080 "Addressed John's review on PR #1655: anonymous-function insertion anchor, @Composable property getters, nested-declaration returns wrongly counted as region exits, and raw-string interiors being re-indented. Each has a regression test and the feature doc is updated. The fully-qualified-vs-shortened type text point is tracked separately as ADFA-." -``` - -Substitute the ticket id from Task 5. - ---- - -## Self-Review - -**Spec coverage.** All five items in the review analysis have a task: HIGH anonymous anchor (Task 1), MEDIUM `@Composable` getters (Task 2), LOW nested returns (Task 3), LOW raw-string re-indentation (Task 4), the qualified-type-text follow-up (Task 5). The "missing tests" row of the analysis is folded into Tasks 1-4 rather than deferred, and the doc-consistency requirement from `CLAUDE.md` is satisfied per task rather than in a sweep at the end. - -**Placeholder scan.** No TBDs. Every code step carries the actual replacement text; every doc step carries the actual markdown; every command is runnable as written. The only substitution is the Jira ticket id created in Task 5 and quoted in Task 6, which cannot be known ahead of time and is flagged at both use sites. - -**Type consistency.** `returnTargetInRegion` (Task 3) is used by both `isTailReturn` and `hasExit` with the same `(KtReturnExpression, TextSpan)` signature. `rawStringSpans: List` is named identically in `ExtractMethodPlan.kt`, `MethodSignature.kt`, `ExtractMethodEdit.kt` and both test files. `indentedBodyLines` replaces `reindent` at all three call sites in Task 4 Step 5, and its parameter order matches both invocations. `hasComposableAnnotation` is declared on `KaAnnotatedSymbol`, which both `KaDeclarationSymbol` (the function-call branch) and `KaPropertyGetterSymbol` (the property branch) satisfy. - -**Known risk.** Task 4 Step 2 goes red by failing to compile rather than by failing an assertion, because the test needs a field the fix introduces. The step says so and requires a second red run after the field exists, so the assertions themselves are still proven to fail before the behaviour changes. From dd298b40a202560f899c979f6648f9c1ddf53793 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 14:32:12 +0000 Subject: [PATCH 18/25] ADFA-4826: Refuse an unhostable block rung at plan time --- docs/features/kotlin-extract-variable.md | 8 +- .../utils/refactor/ExtractVariableEdit.kt | 97 +++++++++++++------ .../utils/refactor/ExtractVariablePlanner.kt | 10 ++ .../utils/refactor/ExtractVariableEditTest.kt | 64 ++++++++++++ .../ExtractVariablePlanEndToEndTest.kt | 24 ++--- 5 files changed, 155 insertions(+), 48 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index 44e27107ef..9d7d0c997a 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -89,6 +89,10 @@ The plan records the document version it was computed against. On confirm, the v **R4 - Value filter.** A candidate whose type is `Unit` or `Nothing` is dropped: `val u = println(x)` compiles but is pointless. A candidate whose legal scope chain is empty is dropped too - a candidate with no legal anchor is not a candidate. +A rung whose anchor geometry the rewrite cannot honour (see R9) is dropped during the plan pass, not on +confirm - so a candidate left with no rung is dropped, and a plan left with no candidate reports +"nothing to extract" instead of opening a sheet whose confirm is bound to fail. + **R5 - Scope chain.** Anchors are enumerated outward from the candidate's own statement, each one of three anchor forms: | Anchor form | When | Emitted as | @@ -168,7 +172,9 @@ A block that fails *both* conditions -- something besides indentation precedes t line, but the block's own content spans more than one line, as in `items.forEach { log(x)\n\tlog(y) }` -- is **declined** rather than hoisted. Hoisting would anchor before the block's own opening delimiter, outside the scope the user picked, which is unsound whenever anything inside that scope (a lambda's -`it`, say) is not visible there. +`it`, say) is not visible there. The placement decision - expand, line above, or refuse - is one function +shared by the planner and the rewriter, so the refusal reaches the user as "nothing to extract" before +the sheet opens rather than as a failed confirm. The emitted text is **fully indented**: code-action edits bypass the editor's auto-indent (raw `Content.replace`), and `CMD_FORMAT_CODE` is a no-op for Kotlin. The indent unit is inferred from the file's own lines (a tab if any line is tab-indented, else the smallest positive run of leading spaces, defaulting to a tab), mirroring `ImplementMembersAction`; CRLF is used only when the file already contains it, so the edit never mixes line endings. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index 2eed7a334d..8f4e3d6118 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -53,6 +53,65 @@ fun buildExtractVariableRewrite( } } +/** + * What a block rung can do with the anchor statement holding a given target. + * + * Shared by the planner and the rewriter so a rung is never *offered* that the rewrite would then + * refuse: the sheet would open, the user would fill it in, and the confirm would fail with the generic + * quick-fix error instead of the action reporting up front that there is nothing to extract. + */ +internal sealed interface BlockPlacement { + /** The declaration becomes a new line above [anchor], at [anchor]'s indentation. */ + data class LineAbove( + val anchor: TextSpan, + ) : BlockPlacement + + /** The block is written on one line and is expanded, with the declaration inside its braces. */ + data object ExpandOneLine : BlockPlacement + + /** Neither is sound here, so the rung is declined. */ + data object Refused : BlockPlacement +} + +/** + * Decides the placement for the anchor statement of [form] that contains [firstTarget]. + * + * [Refused] covers two shapes. Nothing in the block contains the target, which means the plan and the + * text disagree. Or something other than indentation precedes the anchor statement on its line while + * the block's own content spans several lines, as in `items.forEach { log(x)\n\tlog(y) }` -- anchoring + * at that line start would put the declaration before the block's own opening delimiter, outside the + * scope the user picked, where a lambda's `it` does not exist. + * + * A lambda body's content starts right at its first token with no owned whitespace, so `lineStart` + * sits before `contentSpan.start` on plain indentation alone; that gap must not read as "outside the + * block", which is why the second check tests the gap for real code rather than for mere distance. + */ +internal fun blockPlacementFor( + fileText: String, + form: AnchorForm.ExistingBlock, + firstTarget: TextSpan, +): BlockPlacement { + val anchor = + form.statementSpans.firstOrNull { it.start <= firstTarget.start && firstTarget.end <= it.end } + ?: return BlockPlacement.Refused + val lineStart = lineStartOffset(fileText, anchor.start) + + /* + * Two conditions together are what actually mean "written on one line": something other than + * indentation already precedes the statement on its line (the brace, a header, or a prior + * semicolon-separated statement), and the block's content itself contains no newline, so + * re-emitting it as a single line loses nothing. + */ + val linePrefix = fileText.substring(lineStart, anchor.start) + val contentIsOneLine = !fileText.substring(form.contentSpan.start, form.contentSpan.end).contains('\n') + if (linePrefix.isNotBlank() && contentIsOneLine) return BlockPlacement.ExpandOneLine + + if (form.contentSpan.start > lineStart && fileText.substring(lineStart, form.contentSpan.start).isNotBlank()) { + return BlockPlacement.Refused + } + return BlockPlacement.LineAbove(anchor) +} + /** * Inserts the declaration as its own line before the anchor statement, and rewrites everything from * there through the last occurrence. @@ -63,8 +122,7 @@ fun buildExtractVariableRewrite( * declaration lands on a line of its own at the right indentation, and ends at the last occurrence so * untouched trailing code is left alone. * - * Null when no statement of the scope contains the occurrence, which would mean the plan and the text - * disagree; the caller reports that rather than guessing. + * Null when [blockPlacementFor] refuses the anchor; the caller reports that rather than guessing. */ private fun existingBlockRewrite( fileText: String, @@ -73,36 +131,15 @@ private fun existingBlockRewrite( declaration: String, name: String, ): RewriteSpan? { - val first = targets.first() val last = targets.last() - val anchor = form.statementSpans.firstOrNull { it.start <= first.start && first.end <= it.end } ?: return null - val lineStart = lineStartOffset(fileText, anchor.start) - - // A block written on one line needs the declaration expanded inside the braces instead of hoisted - // above the line. `contentSpan.start` is not a reliable signal by itself: a lambda body's block - // does not own its braces, so `contentSpan.start` sits at the body's first token even when that - // token starts its own line -- comparing it to `lineStart` alone would misfire on an ordinary - // multi-line lambda. Two conditions together are what actually mean "one line": something other - // than indentation already precedes the statement on its line (the brace, a header, or a prior - // semicolon-separated statement), *and* the block's content itself contains no newline (so - // re-emitting it as a single line loses nothing). - val linePrefix = fileText.substring(lineStart, anchor.start) - val contentIsOneLine = !fileText.substring(form.contentSpan.start, form.contentSpan.end).contains('\n') - if (linePrefix.isNotBlank() && contentIsOneLine) { - return oneLineBlockRewrite(fileText, form, targets, declaration, name) - } - - // A lambda body's content starts right at its first token with no owned whitespace, so `lineStart` - // sits before `contentSpan.start` on plain indentation alone -- that gap must not trigger a - // decline. What does mean "outside the block" is *real code* in that gap: the block's own opening - // delimiter (a call and its brace, a header) sharing the anchor's line, which only happens for the - // multi-line case the one-line check above did not catch. Anchoring there would put the - // declaration before that delimiter, outside the scope the user picked. Declining is safe; hoisting - // is not. - if (form.contentSpan.start > lineStart && fileText.substring(lineStart, form.contentSpan.start).isNotBlank()) { - return null - } + val anchor = + when (val placement = blockPlacementFor(fileText, form, targets.first())) { + is BlockPlacement.Refused -> return null + is BlockPlacement.ExpandOneLine -> return oneLineBlockRewrite(fileText, form, targets, declaration, name) + is BlockPlacement.LineAbove -> placement.anchor + } + val lineStart = lineStartOffset(fileText, anchor.start) val indent = leadingIndentAt(fileText, anchor.start) val newline = detectNewline(fileText) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index 5f25af3e5c..313efd6feb 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -117,6 +117,16 @@ private fun KaSession.scopeOptionFor( val anchorForm = when (val form = frame.anchorForm) { + is AnchorForm.ExistingBlock -> { + /* + * The rewrite refuses this geometry, so refusing it here too is what turns a sheet whose + * confirm must fail into an up-front "nothing to extract". The candidate's own span is + * what the rewrite anchors on when replace-all is off, so it is the span to test. + */ + if (blockPlacementFor(file.text, form, span) is BlockPlacement.Refused) return null + form + } + is AnchorForm.ConvertExpressionBody -> { val declaration = frame.scopeElement.parent as? KtDeclarationWithBody val needsReturn = expressionBodyNeedsReturn(frame.scopeElement) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt index a670badf17..3936da29d2 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEditTest.kt @@ -559,4 +559,68 @@ class ExtractVariableEditTest { apply(text, result), ) } + + @Test + fun `placement expands a block written on one line`() { + val text = "fun f(items: List) {\n\treturn items.map { it.length + 1 }\n}" + val content = spanOf(text, "it.length + 1") + val statement = spanOf(text, "it.length + 1") + + assertEquals( + BlockPlacement.ExpandOneLine, + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = content, statementSpans = listOf(statement)), + firstTarget = statement, + ), + ) + } + + @Test + fun `placement puts the declaration on the line above an ordinary multi-line block`() { + val text = "fun f(n: Int): Int {\n\tval a = n * 2\n\treturn a\n}" + val statement = spanOf(text, "val a = n * 2") + val content = TextSpan(text.indexOf('{') + 1, text.lastIndexOf('}')) + + assertEquals( + BlockPlacement.LineAbove(statement), + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = content, statementSpans = listOf(statement)), + firstTarget = statement, + ), + ) + } + + @Test + fun `placement refuses an anchor sharing the brace line of a multi-line block`() { + val text = "fun f(items: List) {\n\titems.forEach { log(it.length + 1)\n\t\tlog(it) }\n}" + val first = spanOf(text, "log(it.length + 1)") + val second = spanOf(text, "log(it)") + // A lambda body block does not own its braces, so its content span starts at the first token. + val content = TextSpan(first.start, second.end) + + assertEquals( + BlockPlacement.Refused, + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = content, statementSpans = listOf(first, second)), + firstTarget = first, + ), + ) + } + + @Test + fun `placement refuses a target no statement of the block contains`() { + val text = "fun f() {\n\tval a = 1\n}" + + assertEquals( + BlockPlacement.Refused, + blockPlacementFor( + fileText = text, + form = AnchorForm.ExistingBlock(contentSpan = TextSpan(8, text.length), statementSpans = emptyList()), + firstTarget = TextSpan(0, 3), + ), + ) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 08430fc2a7..99258ea4a1 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -9,7 +9,6 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull -import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -832,7 +831,7 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { } @Test - fun `declines a lambda whose first statement shares the brace line but the block spans several lines`() { + fun `offers nothing when the only rung's anchor shares the brace line of a multi-line block`() { val content = """ package p @@ -845,22 +844,13 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { val target = "it.length + 1" val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) - val candidate = result.candidates.first() - // `it` is lambda-scoped, so the lambda body is the only legal anchor. - assertEquals(listOf("lambda"), candidate.scopes.map { it.label }) - // The statement shares the opening-brace line, but the block itself spans two lines, so this is - // not the one-line expansion case. Anchoring at the line start would put the declaration before - // the lambda's `{`, where `it` is out of scope -- declining is the only safe outcome here. - val rewrite = - buildExtractVariableRewrite( - fileText = result.fileText, - candidateSpan = candidate.span, - scope = candidate.scopes.first(), - name = "length", - replaceAll = false, - ) - assertNull(rewrite) + // `it` is lambda-scoped, so the lambda body is the only legal rung -- and its anchor statement + // shares the `items.forEach {` line while the block's own content spans two lines. Anchoring at + // that line start would put the declaration before the `{`, where `it` does not exist. The rung + // is refused, which leaves the candidate with no rung, which empties the plan: the action then + // reports "no expression to extract here" instead of opening a sheet whose confirm must fail. + assertTrue(result.isEmpty) } @Test From 8238b8809c68cd2c5aa104ea28814217f18dcfdd Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 14:41:34 +0000 Subject: [PATCH 19/25] ADFA-4826: Keep replace-all off an unhostable anchor --- .../utils/refactor/ExtractVariableEdit.kt | 21 +++++++++++ .../utils/refactor/ExtractVariablePlanner.kt | 6 ++- .../ExtractVariablePlanEndToEndTest.kt | 37 +++++++++++++++++++ 3 files changed, 62 insertions(+), 2 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index 8f4e3d6118..8a51c9ef24 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -112,6 +112,27 @@ internal fun blockPlacementFor( return BlockPlacement.LineAbove(anchor) } +/** + * Narrows [occurrences] to the ones a replace-all can actually be anchored on. + * + * A replace-all anchors on the *first* served occurrence, so a leading occurrence whose own statement + * shares the block's opening-brace line would refuse the whole rewrite even though the site the user + * selected is perfectly placeable. Dropping such leading sites keeps "Replace all N occurrences" + * achievable, which is the same guarantee `excludeUnsoundOccurrences` makes about soundness. + * + * [candidateSpan] is never dropped: the site the user selected is always served. Only leading sites + * matter, because a later occurrence never becomes the anchor. + */ +internal fun servableOccurrences( + fileText: String, + form: AnchorForm, + occurrences: List, + candidateSpan: TextSpan, +): List { + if (form !is AnchorForm.ExistingBlock) return occurrences + return occurrences.dropWhile { it != candidateSpan && blockPlacementFor(fileText, form, it) is BlockPlacement.Refused } +} + /** * Inserts the declaration as its own line before the anchor statement, and rewrites everything from * there through the last occurrence. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index 313efd6feb..d321def436 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -113,7 +113,8 @@ private fun KaSession.scopeOptionFor( ): ScopeOption? { val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) val writes = writeOffsetsFor(expression, frame.scopeElement) - val occurrences = excludeUnsoundOccurrences(matches, span, writes) + val sound = excludeUnsoundOccurrences(matches, span, writes) + val occurrences = servableOccurrences(file.text, frame.anchorForm, sound, span) val anchorForm = when (val form = frame.anchorForm) { @@ -121,7 +122,8 @@ private fun KaSession.scopeOptionFor( /* * The rewrite refuses this geometry, so refusing it here too is what turns a sheet whose * confirm must fail into an up-front "nothing to extract". The candidate's own span is - * what the rewrite anchors on when replace-all is off, so it is the span to test. + * tested here; servableOccurrences is what makes the first served target placeable when + * replace-all is on. */ if (blockPlacementFor(file.text, form, span) is BlockPlacement.Refused) return null form diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 99258ea4a1..54c8fe5763 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -888,4 +888,41 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { apply(content, rewrite), ) } + + @Test + fun `an occurrence sharing the brace line is not offered for replace-all`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(items: List) { + items.forEach { log(it.length + 1) + log(it.length + 1) } + } + """.trimIndent() + + val target = "it.length + 1" + val second = content.indexOf(target, content.indexOf(target) + 1) + val result = plan(content, second, second + target.length) + val candidate = result.candidates.first() + + // The second site is on its own line and can host the declaration, so the rung stands. The first + // site shares the `items.forEach {` line, and anchoring on it would refuse the whole rewrite -- + // so it is not offered as an occurrence, and the count the sheet shows stays achievable. + assertEquals(1, candidate.scopes.first().occurrences.size) + assertEquals( + listOf(TextSpan(second, second + target.length)), + candidate.scopes.first().occurrences, + ) + + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = true, + ) + assertNotNull(rewrite) + } } From f6344213ef2e8f3d8db44a451989bea85f075571 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 14:53:21 +0000 Subject: [PATCH 20/25] ADFA-4826: Validate the variable name against names actually in scope --- docs/features/kotlin-extract-variable.md | 6 +- .../utils/refactor/ExtractVariablePlanner.kt | 2 +- .../kotlin/utils/refactor/ExtractionPlan.kt | 4 + .../lsp/kotlin/utils/refactor/Occurrences.kt | 83 ++++++++++++++++--- .../ExtractVariablePlanEndToEndTest.kt | 74 +++++++++++++++++ 5 files changed, 155 insertions(+), 14 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index 9d7d0c997a..d4a2add722 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -140,7 +140,7 @@ Occurrence sets are ascending by offset and always contain the candidate's own s Validation returns a `NameProblem` - `Blank`, `NotAnIdentifier`, `Keyword`, `AlreadyTaken` - rather than throwing, since the input is a text field. Only Kotlin's **hard** keywords are rejected; soft and modifier keywords (`by`, `data`, `it`) are legal names. Backtick-quoted names are rejected: legal Kotlin, but a poor generated local, and accepting them would mean validating the quoted form too. -Taken names are every declaration name in the file - deliberately conservative rather than scope-exact. Being over-broad costs a `size1` where `size` would have done; being under-broad generates code that shadows something. It is also purely syntactic, so it needs no analysis and is unit-testable. +Taken names are what a new declaration at the anchor would collide with or shadow: the parameters and local declarations of each enclosing block, lambda, function and accessor, the members of each enclosing class or object, and the file's top-level declarations. A lambda that declares no parameter contributes `it`. Enclosing members and top-level names are included even though a local may legally shadow them, because shadowing one changes what every other reference to that name in the block means. A local in a *sibling* function is not included - it is invisible at the anchor, and treating it as taken refuses a legal name, which is a defect QA found on this ticket. The walk is purely syntactic, so it needs no analysis session and is unit-testable. **R8 - Sheet.** One surface holding every choice, with no navigation between steps: expression chooser, name field, scope chooser, replace-all checkbox, Cancel/Extract. The four are interdependent - a different expression changes the scope list and the occurrence count - so they are shown together where that relationship is visible, rather than across sequential dialogs the user would have to back out of to explore. @@ -228,7 +228,7 @@ ExtractVariableAction.execAction (background) lsp/kotlin/actions per candidate: type filter [R4] enclosingScopeFrames + truncateAtCeiling ScopeChain.kt / Occurrences.kt [R5] findOccurrences + excludeUnsoundOccurrences Occurrences.kt [R6] - suggestVariableName + visibleNamesAt NameSuggestion.kt / Occurrences.kt [R7] + suggestVariableName + namesInScopeAt NameSuggestion.kt / Occurrences.kt [R7] } } <- ExtractVariablePlan (plain data, no PSI) @@ -248,7 +248,7 @@ Components: - **`utils/refactor/ExtractionPlan.kt`** - `TextSpan`, `AnchorForm`, `ScopeOption`, `CandidateExpression`, the plan, `collapseForLabel`. To be renamed to `ExtractVariablePlan` under a sealed `RefactoringPlan` carrying `fileText`, `documentVersion` and the shared version guard, so ADFA-5080 adds a subtype rather than renaming this one. Both refactorings share these *primitives*, not the aggregate: extract method has no scope chain, so `ScopeOption`/`AnchorForm`/`CandidateExpression` are not shared. - **`CandidateExpressions.kt`** - purely syntactic, no analysis session, hence unit-testable on its own (R2). - **`ScopeChain.kt`** - the syntactic chain and the three anchor forms (R5); indentation and newline detection shared with the edit builder. -- **`Occurrences.kt`** - symbol-aware structural equality, the occurrence search, the unsoundness filter, the referenced-declaration ceiling, and `visibleNamesAt` (R5, R6, R7). +- **`Occurrences.kt`** - symbol-aware structural equality, the occurrence search, the unsoundness filter, the referenced-declaration ceiling, and `namesInScopeAt` (R5, R6, R7). - **`NameSuggestion.kt`** - suggestion and validation, no analysis session (R7). - **`ExtractVariableEdit.kt`** - `RewriteSpan`, the three anchor-form rewrites, `toTextEdit` (R9). Pure text and offsets. - **`refactor/ui/`** - `ExtractVariableSheet` (a `BottomSheetDialogFragment` hosting a `ComposeView`), stateless `ExtractVariableSheetContent`, `ExtractVariableViewModel` + `ExtractVariableUiState` + sealed `ExtractVariableUiEvent`. `LabelledSection` and `OptionList` become shared with ADFA-5080. The ViewModel uses a plain `ViewModelProvider.Factory` rather than a Koin definition: it is sheet-scoped, injects nothing, and takes the plan as a runtime argument. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index d321def436..fecc656ff7 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -86,7 +86,7 @@ private fun KaSession.candidateFor(expression: KtExpression): CandidateExpressio val file = expression.containingKtFile val scopes = frames.mapNotNull { scopeOptionFor(expression, span, it, file) } if (scopes.isEmpty()) return null - val takenNames = visibleNamesAt(expression) + val takenNames = namesInScopeAt(expression) return CandidateExpression( label = collapseForLabel(expression.text), diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt index be948e179b..67067d3a3f 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -89,6 +89,10 @@ data class ScopeOption( * [label] is the expression's source text with runs of whitespace collapsed, so a multi-line * expression stays readable in a one-line list item. * + * [takenNames] is what a new declaration here would collide with or shadow -- enclosing parameters and + * locals, enclosing class members, top-level names -- and is used both to uniquify [suggestedName] and + * to reject a typed name. A local in an unrelated function is not in it. + * * [scopes] is the legal scope chain, innermost first, and is never empty -- a candidate with no * legal anchor is not a candidate. */ diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt index 61eea683ae..c528665e3e 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt @@ -12,12 +12,23 @@ import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtBlockExpression +import org.jetbrains.kotlin.psi.KtCallableDeclaration +import org.jetbrains.kotlin.psi.KtCatchClause +import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression import org.jetbrains.kotlin.psi.KtFunctionLiteral +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtUnaryExpression +import org.jetbrains.kotlin.psi.KtWhenExpression import org.jetbrains.kotlin.psi.psiUtil.parents +import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf /** * Whether [a] and [b] are the same expression for extraction purposes: structurally identical *and* @@ -259,15 +270,67 @@ private val ASSIGNMENT_TOKENS = private val INCREMENT_TOKENS = setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) /** - * Names a suggestion must avoid: every declaration name in the file. + * Names a new declaration at [candidate] would collide with or shadow. * - * Deliberately conservative rather than scope-exact. A real scope query would need resolution and - * would let `size` be suggested in one function because the collision is in another -- correct, but - * the cost of being over-broad is only a `size1` where `size` would have done, while the cost of - * being under-broad is generated code that shadows something. Cheap, needs no analysis, and being - * purely syntactic it is unit-testable. + * Walks outward from the candidate collecting only what is visible there: the parameters and local + * declarations of each enclosing block, lambda, function and accessor, the members of each enclosing + * class or object, and the file's top-level declarations. A local in a *sibling* function is + * deliberately absent -- it is invisible here, and treating it as taken refuses a legal name. + * + * Enclosing members and top-level names stay in the set even though a local may legally shadow them: + * shadowing one changes what every *other* reference to that name in the block means. + * + * Purely syntactic, so it needs no analysis session and is unit-testable on its own. */ -internal fun visibleNamesAt(candidate: KtExpression): Set = - PsiTreeUtil - .collectElementsOfType(candidate.containingFile, KtDeclaration::class.java) - .mapNotNullTo(mutableSetOf()) { it.name } +internal fun namesInScopeAt(candidate: KtExpression): Set { + val names = mutableSetOf() + candidate.containingKtFile.declarations.forEach { it.addNameTo(names) } + + for (ancestor in candidate.parentsWithSelf) { + when (ancestor) { + is KtFile -> break + + is KtClassOrObject -> { + ancestor.declarations.forEach { it.addNameTo(names) } + ancestor.primaryConstructorParameters.forEach { it.addNameTo(names) } + } + + is KtBlockExpression -> ancestor.statements.forEach { (it as? KtDeclaration)?.addNameTo(names) } + + is KtFunctionLiteral -> { + val parameters = ancestor.valueParameters + // A lambda with no declared parameter still binds `it`, which a local would shadow. + if (parameters.isEmpty()) names += StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() + parameters.forEach { it.addNameTo(names) } + } + + is KtPropertyAccessor -> ancestor.valueParameters.forEach { it.addNameTo(names) } + + is KtCallableDeclaration -> ancestor.valueParameters.forEach { it.addNameTo(names) } + + is KtForExpression -> ancestor.loopParameter?.addNameTo(names) + + is KtCatchClause -> ancestor.catchParameter?.addNameTo(names) + + is KtWhenExpression -> ancestor.subjectVariable?.addNameTo(names) + + else -> Unit + } + } + return names +} + +/** Adds this declaration's name, or each entry name when it destructures. */ +private fun KtDeclaration.addNameTo(names: MutableSet) { + val destructuring = + when (this) { + is KtDestructuringDeclaration -> this + is KtParameter -> destructuringDeclaration + else -> null + } + if (destructuring != null) { + destructuring.entries.forEach { entry -> entry.name?.let(names::add) } + return + } + name?.let(names::add) +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 54c8fe5763..8ec336930d 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -9,6 +9,7 @@ import org.jetbrains.kotlin.psi.KtNamedFunction import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNotNull +import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test @@ -925,4 +926,77 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { ) assertNotNull(rewrite) } + + @Test + fun `a local in a sibling function does not take the name`() { + val content = + """ + package p + class Extract { + fun lengths(items: List): List { + return items.map { + val length = it.length + 1 + length + } + } + + fun oneLineLambda(items: List): List { + return items.map { it.length + 1 } + } + } + """.trimIndent() + + val target = "it.length + 1" + val start = content.indexOf(target, content.indexOf("oneLineLambda")) + val result = plan(content, start, start + target.length) + + // `val length` lives in another function's lambda: invisible here, so naming this one `length` + // is legal and must not be refused. + assertNull(validateVariableName("length", result.candidates.first().takenNames)) + } + + @Test + fun `an enclosing parameter and an enclosing local take the name`() { + val content = + """ + package p + fun wrap(n: Int): Int = n + fun demo(items: List) { + val size = 0 + wrap(items.size * 2) + } + """.trimIndent() + + val taken = plan(content, content.indexOf("items.size") + 1).candidates.first().takenNames + + assertEquals(NameProblem.AlreadyTaken, validateVariableName("items", taken)) + assertEquals(NameProblem.AlreadyTaken, validateVariableName("size", taken)) + } + + @Test + fun `a member of the enclosing class takes the name`() { + val content = + """ + package p + class Extract { + private val total = 0 + + fun demo(n: Int): Int { + return n * 2 + } + } + """.trimIndent() + + val target = "n * 2" + val taken = + plan(content, content.indexOf(target), content.indexOf(target) + target.length) + .candidates + .first() + .takenNames + + // A local `val total` would shadow the member, changing what every other `total` in the block + // means, so it stays refused. + assertEquals(NameProblem.AlreadyTaken, validateVariableName("total", taken)) + assertEquals(NameProblem.AlreadyTaken, validateVariableName("demo", taken)) + } } From 70cf174e9a1071f5452c87795742fe81a075ed35 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 15:09:51 +0000 Subject: [PATCH 21/25] ADFA-4826: Decide Unit-ness from the type text that gets written --- docs/features/kotlin-extract-variable.md | 3 ++ .../utils/refactor/ExtractVariablePlanner.kt | 29 ++++++++---- .../lsp/kotlin/utils/refactor/TypeText.kt | 25 +++++++++++ .../ExtractVariablePlanEndToEndTest.kt | 44 +++++++++++++++++++ .../utils/refactor/RefactorPrimitivesTest.kt | 24 ++++++++++ 5 files changed, 117 insertions(+), 8 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index d4a2add722..d2b35f7fb4 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -107,6 +107,9 @@ default-imported package such as `kotlin.collections`. Everything else stays qua compiles, and this refactoring adds no imports. When the type cannot be written as source at all (anonymous, intersection, an unresolved type, or a platform type the renderer cannot reduce) the rung is declined rather than emitting a block body that does not compile. +`Unit`-ness is decided from the resolved type and, if that cannot be answered, from the rendered text: +a rendered `Unit` retracts both the `return` and the written type, because the rendered text is what +lands in the file and a `Unit` return needs neither. Each rung is labelled with the construct that owns it -- `fun name`, `getter`, `setter`, `init block`, `lambda`, `if block`, `else block`, `for loop`, `while loop`, `do-while loop`, `when branch` -- so the diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index fecc656ff7..69e0f59bab 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -131,13 +131,15 @@ private fun KaSession.scopeOptionFor( is AnchorForm.ConvertExpressionBody -> { val declaration = frame.scopeElement.parent as? KtDeclarationWithBody - val needsReturn = expressionBodyNeedsReturn(frame.scopeElement) - val returnTypeText = - if (needsReturn && declaration != null && !declaration.declaresReturnType()) { - returnTypeTextOf(declaration, file) ?: return null - } else { - null - } + val mustWriteType = declaration != null && !declaration.declaresReturnType() + val rendered = if (mustWriteType) returnTypeTextOf(declaration, file) else null + val (needsReturn, returnTypeText) = + normalizeExpressionBodyReturn(expressionBodyNeedsReturn(frame.scopeElement), rendered) + /* + * A block body with no declared type returns Unit, so a return that needs a type it + * cannot get declines the rung -- the decline-rather-than-rewrite principle of ADR 0013. + */ + if (needsReturn && mustWriteType && returnTypeText == null) return null form.copy(needsReturn = needsReturn, returnTypeText = returnTypeText) } @@ -187,8 +189,19 @@ private fun KaSession.returnTypeTextOf( private fun KaSession.expressionBodyNeedsReturn(bodyExpression: PsiElement): Boolean { val declaration = bodyExpression.parent as? KtDeclarationWithBody ?: return true val returnType = returnTypeOf(declaration) ?: return true - return !runCatching { returnType.isUnitType }.getOrDefault(false) + return !isUnitReturnType(returnType) } +/** + * Whether [type] is `Unit`, with the rendered text as the fallback answer. + * + * A throw from `isUnitType` must not read as "not `Unit`": that writes the very `Unit` it failed to + * recognise into the signature and wraps a `Unit` call in a pointless `return`. + */ +private fun KaSession.isUnitReturnType(type: KaType): Boolean = + runCatching { type.isUnitType }.getOrNull() + ?: renderedTypeTextOrNull(type)?.let(::isUnitTypeText) + ?: false + /** `Unit` and `Nothing` carry no value worth binding to a `val`. */ private fun KaSession.isValuelessType(type: KaType): Boolean = runCatching { type.isUnitType || type.isNothingType }.getOrDefault(false) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt index b2b3efbae1..c5da194597 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt @@ -110,3 +110,28 @@ internal fun starImportedPackagesOf(file: KtFile): Set = file.importDirectives .filter { it.isAllUnder } .mapNotNullTo(mutableSetOf()) { it.importedFqName?.asString() } + +/** + * Whether [text] is the `Unit` type written as source, qualified or not. + * + * Exact match only: `kotlin.Unit?` is a different type, and a user type named `MyUnit` is not this one. + */ +internal fun isUnitTypeText(text: String): Boolean = text == "Unit" || text == "kotlin.Unit" + +/** + * Reconciles the `return`/written-type pair for an expression-body conversion. + * + * A `Unit` return needs neither, so a rendered `Unit` means the resolved-type check disagreed with the + * text that is about to be written into the signature -- and the text is what lands in the file. It + * therefore wins, retracting both. Without this, a failure to answer "is this `Unit`?" produces + * `fun show(text: String): Unit { ... return report(length) }`: compilable, but not what was asked for. + */ +internal fun normalizeExpressionBodyReturn( + needsReturn: Boolean, + returnTypeText: String?, +): Pair = + if (returnTypeText != null && isUnitTypeText(returnTypeText)) { + false to null + } else { + needsReturn to returnTypeText + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 8ec336930d..2e24cb49c5 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -999,4 +999,48 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { assertEquals(NameProblem.AlreadyTaken, validateVariableName("total", taken)) assertEquals(NameProblem.AlreadyTaken, validateVariableName("demo", taken)) } + + @Test + fun `a Unit-returning member expression body gets neither a type nor a return`() { + val content = + """ + package p + class Extract { + fun show(text: String) = report(text.length + 1) + + private fun report(value: Int) { + println(value) + } + } + """.trimIndent() + + val target = "text.length + 1" + val result = plan(content, content.indexOf(target), content.indexOf(target) + target.length) + val candidate = result.candidates.first() + val rewrite = + buildExtractVariableRewrite( + fileText = result.fileText, + candidateSpan = candidate.span, + scope = candidate.scopes.first(), + name = "length", + replaceAll = false, + )!! + + // The QA fixture's shape: a member, with the callee declared after the caller. `show` returns + // `Unit`, so the block body needs neither a `return` nor a written-out type. + assertEquals( + "package p\n" + + "class Extract {\n" + + "\tfun show(text: String) {\n" + + "\t\tval length = text.length + 1\n" + + "\t\treport(length)\n" + + "\t}\n" + + "\n" + + "\tprivate fun report(value: Int) {\n" + + "\t\tprintln(value)\n" + + "\t}\n" + + "}", + apply(content, rewrite), + ) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt index 6303290b14..653f433d61 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt @@ -199,4 +199,28 @@ class RefactorPrimitivesTest { assertTrue(isUnrenderableTypeText("kotlin.Any & kotlin.Comparable<*>")) assertFalse(isUnrenderableTypeText("kotlin.Int")) } + + @Test + fun `Unit type text is recognised qualified and short`() { + assertTrue(isUnitTypeText("Unit")) + assertTrue(isUnitTypeText("kotlin.Unit")) + assertFalse(isUnitTypeText("Int")) + assertFalse(isUnitTypeText("kotlin.Unit?")) + assertFalse(isUnitTypeText("MyUnit")) + } + + @Test + fun `a rendered Unit retracts both the return and the written type`() { + // The only way to reach here is the resolved-type check disagreeing with the text about to be + // written; the text is what lands in the file, so it wins. + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = "Unit")) + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = "kotlin.Unit")) + } + + @Test + fun `a non-Unit type keeps the return and the written type`() { + assertEquals(true to "Int", normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = "Int")) + assertEquals(true to null, normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = null)) + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = false, returnTypeText = null)) + } } From f699e7c92dd7ae4f48c3397765e5df3d51227ff1 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 15:25:34 +0000 Subject: [PATCH 22/25] ADFA-4826: Resolve a whitespace-only selection like a caret --- docs/features/kotlin-extract-variable.md | 2 +- .../utils/refactor/CandidateExpressions.kt | 10 +++++++--- .../refactor/ExtractVariablePlanEndToEndTest.kt | 17 +++++++++++++++++ .../utils/refactor/RefactorPrimitivesTest.kt | 8 ++++++-- 4 files changed, 31 insertions(+), 6 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index d2b35f7fb4..f35ce38047 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -75,7 +75,7 @@ Positions where no `val` can precede the expression, all rejected up front by `i There is deliberately **no `prepare()` visibility gate**. Deciding whether anything is extractable needs a K2 analysis session, which is far too costly for `prepare()` (UI thread, per menu item). The action stays visible on any Kotlin file and reports "nothing to extract" instead, matching `OrganizeImportsAction` and `ImplementMembersAction`. `requiresUIThread = false`, so the selection is read on a background thread; a torn read while the user is mid-edit can only produce a plan the version guard (R3) then refuses. -**R2 - Region.** The selection is whitespace-trimmed first, because a touch-screen selection routinely carries a leading or trailing space; a whitespace-only selection yields nothing. For a cursor, the element is looked up at the offset and then at `offset - 1`, so a caret resting just past a token still resolves. +**R2 - Region.** The selection is whitespace-trimmed first, because a touch-screen selection routinely carries a leading or trailing space; a selection holding nothing but whitespace collapses to a cursor at its start, since a drag over the gap between two tokens carries the same intent as a tap in it. For a cursor, the element is looked up at the offset and then at `offset - 1`, so a caret resting just past a token still resolves. From the innermost element the parent chain is walked outwards, collecting legal targets and stopping at the enclosing declaration. Illegal nodes along the way are **skipped rather than terminating the walk**, so `if (c) a else b` is still offered from inside one of its branches. At most 3 candidates, innermost first, deduplicated by range. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt index 2f6b6fd3ef..ca5f421f0d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt @@ -100,8 +100,12 @@ fun candidateExpressionsAt( } /** - * Trims whitespace off both ends of `[start, end)`. Returns null when nothing but whitespace was - * selected. A cursor (start == end) is returned unchanged. + * Trims whitespace off both ends of `[start, end)`. + * + * A selection holding nothing but whitespace collapses to a cursor at [start] rather than yielding + * nothing: a drag over the gap between two tokens carries the same intent as a tap in it, and the + * cursor path already resolves a position resting just past a token. Returns null only when the range + * is not a valid range into [text]. A cursor (start == end) is returned unchanged. */ internal fun trimToCode( text: String, @@ -114,7 +118,7 @@ internal fun trimToCode( var e = end while (s < e && text[s].isWhitespace()) s++ while (e > s && text[e - 1].isWhitespace()) e-- - return if (s == e) null else s to e + return if (s == e) start to start else s to e } /** diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 2e24cb49c5..03c4b41d9e 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -1043,4 +1043,21 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { apply(content, rewrite), ) } + + @Test + fun `a whitespace-only selection resolves like a caret at its start`() { + val content = + """ + package p + fun demo(a: Int, b: Int, c: Int): Int { + return a + b * c + } + """.trimIndent() + + // The gap between `b` and `*`, as a touch drag over whitespace produces it rather than a caret. + val gap = content.indexOf("b * c") + 1 + val result = plan(content, gap, gap + 1) + + assertEquals(listOf("b", "b * c", "a + b * c"), result.candidates.map { it.label }) + } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt index 653f433d61..79cf8cbcec 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt @@ -91,9 +91,13 @@ class RefactorPrimitivesTest { } @Test - fun `trim leaves a cursor untouched and rejects a whitespace-only selection`() { + fun `trim leaves a cursor untouched and collapses a whitespace-only selection`() { assertEquals(3 to 3, trimToCode("a b", 3, 3)) - assertNull(trimToCode("a b", 1, 5)) + // A drag over whitespace is the same intent as a tap in it: resolve from where it started. + assertEquals(1 to 1, trimToCode("a b", 1, 5)) + assertNull(trimToCode("a", 0, 5)) + assertNull(trimToCode("a", -1, 1)) + assertNull(trimToCode("abc", 2, 1)) } @Test From d942f1c3015b941e2fd72397394c8e8310271def Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 15:35:00 +0000 Subject: [PATCH 23/25] ADFA-4826: Offer the expression chooser for an exact selection too --- docs/features/kotlin-extract-variable.md | 11 ++++++--- .../refactor/ui/ExtractVariableUiState.kt | 6 ++--- .../refactor/ui/ExtractVariableViewModel.kt | 2 +- .../utils/refactor/CandidateExpressions.kt | 16 +++---------- .../utils/refactor/ExtractVariablePlanner.kt | 11 +-------- .../kotlin/utils/refactor/ExtractionPlan.kt | 6 +---- .../ui/ExtractVariableViewModelTest.kt | 24 +++++++++---------- .../ExtractVariablePlanEndToEndTest.kt | 8 +++---- 8 files changed, 32 insertions(+), 52 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index f35ce38047..ecffd284f5 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -81,8 +81,6 @@ From the innermost element the parent chain is walked outwards, collecting legal An expression is not a legal target when it is: a block, a loop, `return`/`throw`/`break`/`continue`, an operation reference, `super`, a lambda (the `{ ... }` expression and the literal inside it -- outside its call site the parameter types are gone, so `val v = { it.length + 1 }` does not compile), the selector of a qualified expression (`b` in `a.b`), a call's callee (`foo` in `foo(x)`), the left side of an assignment, or a **bare literal**. Excluding bare literals removes the only case where omitting the type annotation could change meaning - an `Int` literal where a `Long` is expected, or a bare `null` inferring `Nothing?`. -When the trimmed selection exactly equals the innermost candidate's range, the user has already said which expression they mean and the chooser is not shown (`selectionMatchedCandidate`). - **R3 - Live offsets and the version guard.** Analysis runs against `ktSymbolIndex.getCurrentKtFile(path)`, PSI refreshed to the open document's current version - an offset resolved against stale text points at the wrong element. The `KtFile` is fetched *before* entering `project.read`: the refresh needs `project.write`, and awaiting it under the read lock deadlocks. The plan records the document version it was computed against. On confirm, the version is re-read and the edit is **refused** if it has moved on (`msg_extract_variable_file_changed`) - the editor stays reachable while the sheet is open, and applying spans computed against older text would corrupt the file. Refusing is always safe; the user can invoke the action again. @@ -147,7 +145,14 @@ Taken names are what a new declaration at the anchor would collide with or shado **R8 - Sheet.** One surface holding every choice, with no navigation between steps: expression chooser, name field, scope chooser, replace-all checkbox, Cancel/Extract. The four are interdependent - a different expression changes the scope list and the occurrence count - so they are shown together where that relationship is visible, rather than across sequential dialogs the user would have to back out of to explore. -Each chooser is hidden when it has nothing to ask: the expression chooser when there is one candidate or the selection already matched one, the scope chooser when the chain has one rung, the replace-all checkbox at an occurrence count of one. Changing the expression re-suggests the name, because the old one described the old expression. +Each chooser is hidden when it has nothing to ask: the expression chooser when there is one candidate, +the scope chooser when the chain has one rung, the replace-all checkbox at an occurrence count of one. +An exact selection does *not* hide the expression chooser, even though it says which expression the +user meant: long-press is the natural phone gesture and selects exactly one token, so hiding the list +there leaves no way to widen to an enclosing expression short of cancelling and dragging the selection +handles. The matched expression is the innermost one, which is preselected anyway, so the cost is one +extra row to look at. Changing the expression re-suggests the name, because the old one described the +old expression. **R9 - Edit.** Exactly **one** `TextEdit`, built as a `RewriteSpan` covering one contiguous span. `IDELanguageClientImpl.applyActionEdits` applies each edit in its own `runOnUiThread` with no `beginBatchEdit`, and every range is interpreted against the *current* text - so a list of N edits would be applied against positions already shifted by its predecessors and would cost N undo steps with a typing window between each. Occurrences are substituted right-to-left within the span so an earlier substitution cannot shift a later offset. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt index c5937f79dd..7a54322405 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableUiState.kt @@ -8,9 +8,9 @@ import com.itsaky.androidide.lsp.kotlin.utils.refactor.ScopeOption * Everything the extract-variable sheet renders, derived entirely from the * [com.itsaky.androidide.lsp.kotlin.utils.refactor.ExtractionPlan]. * - * [showCandidatePicker] is false when the plan holds a single candidate, or when the user's selection - * already matched an expression exactly -- in both cases asking which expression they meant would be - * asking a question they have already answered. + * [showCandidatePicker] is false only when the plan holds a single candidate. It stays visible for an + * exact selection: long-press is the natural gesture and selects exactly one token, so hiding the list + * there leaves no way to widen to an enclosing expression short of cancelling and re-selecting. * * [occurrenceCount] counts every site the selected scope would rewrite, **including** the one the user * selected, so "Replace all 3 occurrences" means three sites in total. [showReplaceAll] is false at a diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt index 6d9494593e..d646c21d5c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModel.kt @@ -98,7 +98,7 @@ class ExtractVariableViewModel( return ExtractVariableUiState( candidateLabels = plan.candidates.map { it.label }, selectedCandidate = candidateIndex.coerceIn(plan.candidates.indices), - showCandidatePicker = plan.candidates.size > 1 && !plan.selectionMatchedCandidate, + showCandidatePicker = plan.candidates.size > 1, name = resolvedName, nameProblem = validateVariableName(resolvedName, candidate.takenNames), scopeLabels = candidate.scopes.map { it.label }, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt index ca5f421f0d..8498e1d1a4 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/CandidateExpressions.kt @@ -36,17 +36,13 @@ const val MAX_CANDIDATES = 3 /** * The purely syntactic result of resolving a cursor or selection to extraction targets. * - * [expressions] is innermost-first and at most [MAX_CANDIDATES] long. [selectionMatchedInnermost] is - * true when the caller passed a non-empty selection whose trimmed range is exactly the innermost - * candidate's range -- the user has already said which expression they mean, so the UI can skip - * asking. + * [expressions] is innermost-first and at most [MAX_CANDIDATES] long. */ data class CandidateSyntax( val expressions: List, - val selectionMatchedInnermost: Boolean, ) { companion object { - val NONE = CandidateSyntax(emptyList(), selectionMatchedInnermost = false) + val NONE = CandidateSyntax(emptyList()) } } @@ -90,13 +86,7 @@ fun candidateExpressionsAt( } if (collected.isEmpty()) return CandidateSyntax.NONE - - val innermost = collected.first().textRange - val matched = - selectionStart != selectionEnd && - innermost.startOffset == start && - innermost.endOffset == end - return CandidateSyntax(collected, matched) + return CandidateSyntax(collected) } /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index 69e0f59bab..7b7cc19764 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -47,19 +47,10 @@ internal fun buildExtractionPlan( if (syntax.expressions.isEmpty()) return@read ExtractionPlan.empty(ktFile.text, documentVersion) analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { - val candidates = syntax.expressions.mapNotNull { candidateFor(it) } ExtractionPlan( fileText = ktFile.text, documentVersion = documentVersion, - candidates = candidates, - // Only meaningful while the innermost candidate survived filtering; otherwise the - // user's selection no longer corresponds to the first option shown. - selectionMatchedCandidate = - syntax.selectionMatchedInnermost && - candidates.firstOrNull()?.span?.start == - syntax.expressions - .first() - .textRange.startOffset, + candidates = syntax.expressions.mapNotNull { candidateFor(it) }, ) } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt index 67067d3a3f..b2848e44bf 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -138,15 +138,11 @@ data class CandidateExpression( * [fileText] is the text the offsets here refer to, carried so the UI can build the replacement text * without PSI; [documentVersion] is what makes that safe -- if the live document has moved on by the * time the user confirms, the plan is discarded rather than applied against shifted offsets. - * - * [selectionMatchedCandidate] is true when the user's selection exactly matched the innermost - * candidate, meaning they already expressed which expression they want and the UI should not ask. */ data class ExtractionPlan( val fileText: String, val documentVersion: Int, val candidates: List, - val selectionMatchedCandidate: Boolean, ) { val isEmpty: Boolean get() = candidates.isEmpty() @@ -154,7 +150,7 @@ data class ExtractionPlan( fun empty( fileText: String = "", documentVersion: Int = -1, - ) = ExtractionPlan(fileText, documentVersion, emptyList(), selectionMatchedCandidate = false) + ) = ExtractionPlan(fileText, documentVersion, emptyList()) } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt index 1b008a941b..6e6ef5c773 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt @@ -42,15 +42,12 @@ class ExtractVariableViewModelTest { scopes = scopes, ) - private fun plan( - candidates: List, - selectionMatched: Boolean = false, - ) = ExtractionPlan( - fileText = "unused", - documentVersion = 1, - candidates = candidates, - selectionMatchedCandidate = selectionMatched, - ) + private fun plan(candidates: List) = + ExtractionPlan( + fileText = "unused", + documentVersion = 1, + candidates = candidates, + ) private val threeCandidatePlan = plan( @@ -81,10 +78,11 @@ class ExtractVariableViewModelTest { } @Test - fun `an exact selection suppresses the candidate picker`() { - // The user already said which expression they meant by selecting it. - val matched = plan(threeCandidatePlan.candidates, selectionMatched = true) - assertFalse(ExtractVariableViewModel(matched).uiState.value.showCandidatePicker) + fun `the candidate picker is offered even when the selection matched an expression`() { + // Long-press is the natural gesture and it selects exactly one token, which used to hide the + // list -- leaving no way to widen to an enclosing expression without cancelling and re-selecting. + assertTrue(ExtractVariableViewModel(threeCandidatePlan).uiState.value.showCandidatePicker) + assertEquals(0, ExtractVariableViewModel(threeCandidatePlan).uiState.value.selectedCandidate) } @Test diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index 03c4b41d9e..b541a3cb1b 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -116,7 +116,7 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { } @Test - fun `a selection matching an expression exactly short-circuits the chooser`() { + fun `a selection matching an expression exactly resolves to that expression`() { val content = """ package p @@ -129,12 +129,13 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { val result = plan(content, start, start + "n * 2".length) - assertTrue(result.selectionMatchedCandidate) assertEquals("n * 2", result.candidates.first().label) + // The enclosing expression stays on offer: an exact selection no longer hides the chooser. + assertEquals(listOf("n * 2", "wrap(n * 2)"), result.candidates.map { it.label }) } @Test - fun `an off-boundary selection still resolves, without short-circuiting`() { + fun `an off-boundary selection still resolves`() { val content = """ package p @@ -148,7 +149,6 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { // Selection stops mid-expression, as a touch-screen drag routinely does. val result = plan(content, start, start + 3) - assertFalse(result.selectionMatchedCandidate) assertEquals("n * 2", result.candidates.first().label) } From 921dd25cfac3ba05a7ee241b2b9b2763674b6d9e Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 15:45:44 +0000 Subject: [PATCH 24/25] ADFA-4826: Apply Spotless formatting --- .../lsp/kotlin/utils/refactor/Occurrences.kt | 32 ++++++++++++++----- .../ExtractVariablePlanEndToEndTest.kt | 7 +++- 2 files changed, 30 insertions(+), 9 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt index c528665e3e..dfeb343235 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt @@ -288,14 +288,18 @@ internal fun namesInScopeAt(candidate: KtExpression): Set { for (ancestor in candidate.parentsWithSelf) { when (ancestor) { - is KtFile -> break + is KtFile -> { + break + } is KtClassOrObject -> { ancestor.declarations.forEach { it.addNameTo(names) } ancestor.primaryConstructorParameters.forEach { it.addNameTo(names) } } - is KtBlockExpression -> ancestor.statements.forEach { (it as? KtDeclaration)?.addNameTo(names) } + is KtBlockExpression -> { + ancestor.statements.forEach { (it as? KtDeclaration)?.addNameTo(names) } + } is KtFunctionLiteral -> { val parameters = ancestor.valueParameters @@ -304,17 +308,29 @@ internal fun namesInScopeAt(candidate: KtExpression): Set { parameters.forEach { it.addNameTo(names) } } - is KtPropertyAccessor -> ancestor.valueParameters.forEach { it.addNameTo(names) } + is KtPropertyAccessor -> { + ancestor.valueParameters.forEach { it.addNameTo(names) } + } - is KtCallableDeclaration -> ancestor.valueParameters.forEach { it.addNameTo(names) } + is KtCallableDeclaration -> { + ancestor.valueParameters.forEach { it.addNameTo(names) } + } - is KtForExpression -> ancestor.loopParameter?.addNameTo(names) + is KtForExpression -> { + ancestor.loopParameter?.addNameTo(names) + } - is KtCatchClause -> ancestor.catchParameter?.addNameTo(names) + is KtCatchClause -> { + ancestor.catchParameter?.addNameTo(names) + } - is KtWhenExpression -> ancestor.subjectVariable?.addNameTo(names) + is KtWhenExpression -> { + ancestor.subjectVariable?.addNameTo(names) + } - else -> Unit + else -> { + Unit + } } } return names diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt index b541a3cb1b..8024a6746c 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanEndToEndTest.kt @@ -910,7 +910,12 @@ class ExtractVariablePlanEndToEndTest : KtLspTest() { // The second site is on its own line and can host the declaration, so the rung stands. The first // site shares the `items.forEach {` line, and anchoring on it would refuse the whole rewrite -- // so it is not offered as an occurrence, and the count the sheet shows stays achievable. - assertEquals(1, candidate.scopes.first().occurrences.size) + assertEquals( + 1, + candidate.scopes + .first() + .occurrences.size, + ) assertEquals( listOf(TextSpan(second, second + target.length)), candidate.scopes.first().occurrences, From 7fc37ce9272b126f0edea93880aab493f7e56bf6 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 18 Aug 2026 16:06:50 +0000 Subject: [PATCH 25/25] ADFA-4826: Address whole-branch review findings --- docs/features/kotlin-extract-variable.md | 4 +- .../utils/refactor/ExtractVariableEdit.kt | 3 + .../utils/refactor/ExtractVariablePlanner.kt | 75 ++++++++++++------- .../kotlin/utils/refactor/ExtractionPlan.kt | 5 ++ .../lsp/kotlin/utils/refactor/Occurrences.kt | 20 ++++- .../lsp/kotlin/utils/refactor/TypeText.kt | 8 +- .../ui/ExtractVariableViewModelTest.kt | 8 -- .../utils/refactor/RefactorPrimitivesTest.kt | 7 ++ 8 files changed, 88 insertions(+), 42 deletions(-) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index ecffd284f5..b60f10323d 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -135,13 +135,13 @@ foo(limit + 1) // same expression, different value Unsound sites are excluded rather than warned about, so "Replace all N occurrences" can never produce wrong code and N is always achievable. The walk grows outward from the candidate - never dropping the site the user selected - and stops in each direction at the first write it would cross. Writes counted: plain assignment, the augmented forms, and `++`/`--`, against any `var` the candidate reads. -Occurrence sets are ascending by offset and always contain the candidate's own span, so `occurrences.size` is the count shown in "Replace all N occurrences". Narrowing to an inner scope can only shrink the set, never grow it. +Occurrence sets are ascending by offset and always contain the candidate's own span, so `occurrences.size` is the count shown in "Replace all N occurrences". Narrowing to an inner scope can only shrink the set, never grow it. A block rung's set is narrowed once more, dropping leading occurrences whose own anchor statement cannot host the declaration: a replace-all anchors on the first served occurrence, so keeping an unhostable one would refuse the whole rewrite. That lowers the N the user is offered - two identical expressions can become "Replace all 1 occurrence", which hides the checkbox - and it is what keeps N always achievable. **R7 - Name.** The suggestion is derived from the expression's shape first (`items.size` -> `size`, `getFoo()` -> `foo`, an interpolated string -> `text`), then its rendered type (`List` -> `list`), then `"value"`; shape beats type because `size`, `count` and `name` are far better names than `int` and `string`. It is then uniquified with a numeric suffix. Validation returns a `NameProblem` - `Blank`, `NotAnIdentifier`, `Keyword`, `AlreadyTaken` - rather than throwing, since the input is a text field. Only Kotlin's **hard** keywords are rejected; soft and modifier keywords (`by`, `data`, `it`) are legal names. Backtick-quoted names are rejected: legal Kotlin, but a poor generated local, and accepting them would mean validating the quoted form too. -Taken names are what a new declaration at the anchor would collide with or shadow: the parameters and local declarations of each enclosing block, lambda, function and accessor, the members of each enclosing class or object, and the file's top-level declarations. A lambda that declares no parameter contributes `it`. Enclosing members and top-level names are included even though a local may legally shadow them, because shadowing one changes what every other reference to that name in the block means. A local in a *sibling* function is not included - it is invisible at the anchor, and treating it as taken refuses a legal name, which is a defect QA found on this ticket. The walk is purely syntactic, so it needs no analysis session and is unit-testable. +Taken names are what a new declaration at the anchor would collide with or shadow: the parameters and local declarations of each enclosing block, lambda, function and accessor, the *declared* members of each enclosing class or object including its companion, and the file's top-level declarations. Members inherited from a supertype are not included - finding them needs resolution, which a syntactic walk cannot do, so a local may still shadow an inherited member unnoticed. A lambda that declares no parameter contributes `it`. Enclosing members and top-level names are included even though a local may legally shadow them, because shadowing one changes what every other reference to that name in the block means. A local in a *sibling* function is not included - it is invisible at the anchor, and treating it as taken refuses a legal name, which is a defect QA found on this ticket. The walk is purely syntactic, so it needs no analysis session and is unit-testable. **R8 - Sheet.** One surface holding every choice, with no navigation between steps: expression chooser, name field, scope chooser, replace-all checkbox, Cancel/Extract. The four are interdependent - a different expression changes the scope list and the occurrence count - so they are shown together where that relationship is visible, rather than across sequential dialogs the user would have to back out of to explore. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt index 8a51c9ef24..205997b921 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariableEdit.kt @@ -85,6 +85,9 @@ internal sealed interface BlockPlacement { * A lambda body's content starts right at its first token with no owned whitespace, so `lineStart` * sits before `contentSpan.start` on plain indentation alone; that gap must not read as "outside the * block", which is why the second check tests the gap for real code rather than for mere distance. + * + * [form]'s spans are substringed against [fileText] unchecked, so callers must pass the very text those + * spans were computed against -- the plan's own text, never the live document. */ internal fun blockPlacementFor( fileText: String, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt index 7b7cc19764..1270709429 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractVariablePlanner.kt @@ -46,11 +46,14 @@ internal fun buildExtractionPlan( val syntax = candidateExpressionsAt(ktFile, selectionStart, selectionEnd) if (syntax.expressions.isEmpty()) return@read ExtractionPlan.empty(ktFile.text, documentVersion) + /* PsiFileImpl.getText() allocates a fresh String each call, so the plan pass reads it once and + * threads it down to every candidate and rung. */ + val fileText = ktFile.text analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { ExtractionPlan( - fileText = ktFile.text, + fileText = fileText, documentVersion = documentVersion, - candidates = syntax.expressions.mapNotNull { candidateFor(it) }, + candidates = syntax.expressions.mapNotNull { candidateFor(it, fileText) }, ) } } @@ -66,7 +69,10 @@ internal fun buildExtractionPlan( * compiles but is pointless) or when nothing remains of its legal scope chain. */ @OptIn(KaExperimentalApi::class) -private fun KaSession.candidateFor(expression: KtExpression): CandidateExpression? { +private fun KaSession.candidateFor( + expression: KtExpression, + fileText: String, +): CandidateExpression? { val type = runCatching { expression.expressionType }.getOrNull() if (type == null || isValuelessType(type)) return null @@ -75,7 +81,7 @@ private fun KaSession.candidateFor(expression: KtExpression): CandidateExpressio val span = TextSpan(expression.textRange.startOffset, expression.textRange.endOffset) val file = expression.containingKtFile - val scopes = frames.mapNotNull { scopeOptionFor(expression, span, it, file) } + val scopes = frames.mapNotNull { scopeOptionFor(expression, span, it, file, fileText) } if (scopes.isEmpty()) return null val takenNames = namesInScopeAt(expression) @@ -89,24 +95,22 @@ private fun KaSession.candidateFor(expression: KtExpression): CandidateExpressio } /** - * Builds one scope option, resolving its occurrence set and fixing up expression-body details. + * Builds one scope option: settles the anchor form, then resolves the occurrence set it can serve. + * + * Returns null when the rung cannot be honoured at all, either because the block's geometry refuses + * the declaration or because an expression-body conversion cannot be reconciled. Both declines run + * before the occurrence search, so a refused rung costs nothing. * - * Returns null when the rung cannot be honoured: converting an expression body whose return type is - * neither declared nor renderable would emit a block body that does not compile, and declining is - * always safe -- the decline-rather-than-rewrite principle that ADR 0013 records, landing alongside - * extract method (ADFA-5080). + * [fileText] must be the text the plan's spans were computed against, since [blockPlacementFor] and + * [servableOccurrences] index into it unchecked. */ private fun KaSession.scopeOptionFor( expression: KtExpression, span: TextSpan, frame: ScopeFrame, file: KtFile, + fileText: String, ): ScopeOption? { - val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) - val writes = writeOffsetsFor(expression, frame.scopeElement) - val sound = excludeUnsoundOccurrences(matches, span, writes) - val occurrences = servableOccurrences(file.text, frame.anchorForm, sound, span) - val anchorForm = when (val form = frame.anchorForm) { is AnchorForm.ExistingBlock -> { @@ -116,32 +120,49 @@ private fun KaSession.scopeOptionFor( * tested here; servableOccurrences is what makes the first served target placeable when * replace-all is on. */ - if (blockPlacementFor(file.text, form, span) is BlockPlacement.Refused) return null + if (blockPlacementFor(fileText, form, span) is BlockPlacement.Refused) return null form } is AnchorForm.ConvertExpressionBody -> { - val declaration = frame.scopeElement.parent as? KtDeclarationWithBody - val mustWriteType = declaration != null && !declaration.declaresReturnType() - val rendered = if (mustWriteType) returnTypeTextOf(declaration, file) else null - val (needsReturn, returnTypeText) = - normalizeExpressionBodyReturn(expressionBodyNeedsReturn(frame.scopeElement), rendered) - /* - * A block body with no declared type returns Unit, so a return that needs a type it - * cannot get declines the rung -- the decline-rather-than-rewrite principle of ADR 0013. - */ - if (needsReturn && mustWriteType && returnTypeText == null) return null - form.copy(needsReturn = needsReturn, returnTypeText = returnTypeText) + convertExpressionBodyForm(form, frame.scopeElement, file) ?: return null } - else -> { + is AnchorForm.WrapInBraces -> { form } } + val matches = findOccurrences(expression, frame.scopeElement, frame.searchRange) + val writes = writeOffsetsFor(expression, frame.scopeElement) + val sound = excludeUnsoundOccurrences(matches, span, writes) + val occurrences = servableOccurrences(fileText, anchorForm, sound, span) + return ScopeOption(label = frame.label, anchorForm = anchorForm, occurrences = occurrences) } +/** + * Fills in the `return` and written-type details of an expression-body rung, or null to decline it. + * + * A block body with no declared type returns `Unit`, so a `return` that needs a type neither declared + * nor renderable would emit a body that does not compile. Declining is always safe -- the + * decline-rather-than-rewrite principle that ADR 0013 records, landing alongside extract method + * (ADFA-5080). + */ +private fun KaSession.convertExpressionBodyForm( + form: AnchorForm.ConvertExpressionBody, + bodyExpression: PsiElement, + file: KtFile, +): AnchorForm.ConvertExpressionBody? { + val declaration = bodyExpression.parent as? KtDeclarationWithBody + val mustWriteType = declaration != null && !declaration.declaresReturnType() + val rendered = if (mustWriteType) returnTypeTextOf(declaration, file) else null + val (needsReturn, returnTypeText) = + normalizeExpressionBodyReturn(expressionBodyNeedsReturn(bodyExpression), rendered) + if (needsReturn && mustWriteType && returnTypeText == null) return null + return form.copy(needsReturn = needsReturn, returnTypeText = returnTypeText) +} + /** Whether the declaration spells its return type out, in which case nothing needs writing. */ private fun KtDeclarationWithBody.declaresReturnType(): Boolean = when (this) { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt index b2848e44bf..85a3180a4d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionPlan.kt @@ -76,6 +76,11 @@ sealed interface AnchorForm { * [occurrences] is ascending by offset and always contains the candidate's own span, so * `occurrences.size` is the count shown as "Replace all N occurrences". Narrowing to an inner scope * can only shrink this set, never grow it. + * + * A block rung's set is narrowed once more, dropping leading occurrences whose own anchor statement + * cannot host the declaration -- a replace-all anchors on the first served one, so keeping an + * unhostable occurrence would refuse the whole rewrite. That lowers the count the user is shown, which + * is the point: N stays achievable. */ data class ScopeOption( val label: String, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt index dfeb343235..84ad6b7ff3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/Occurrences.kt @@ -15,6 +15,7 @@ import org.jetbrains.kotlin.psi.KtBinaryExpression import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallableDeclaration import org.jetbrains.kotlin.psi.KtCatchClause +import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtDestructuringDeclaration @@ -273,9 +274,13 @@ private val INCREMENT_TOKENS = setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) * Names a new declaration at [candidate] would collide with or shadow. * * Walks outward from the candidate collecting only what is visible there: the parameters and local - * declarations of each enclosing block, lambda, function and accessor, the members of each enclosing - * class or object, and the file's top-level declarations. A local in a *sibling* function is - * deliberately absent -- it is invisible here, and treating it as taken refuses a legal name. + * declarations of each enclosing block, lambda, function and accessor, the *declared* members of each + * enclosing class or object including its companion, and the file's top-level declarations. A local in + * a *sibling* function is deliberately absent -- it is invisible here, and treating it as taken refuses + * a legal name. + * + * Members inherited from a supertype are *not* in the set: finding them needs resolution, which a + * syntactic walk cannot do. A local may therefore still shadow an inherited member unnoticed. * * Enclosing members and top-level names stay in the set even though a local may legally shadow them: * shadowing one changes what every *other* reference to that name in the block means. @@ -294,7 +299,14 @@ internal fun namesInScopeAt(candidate: KtExpression): Set { is KtClassOrObject -> { ancestor.declarations.forEach { it.addNameTo(names) } - ancestor.primaryConstructorParameters.forEach { it.addNameTo(names) } + /* A companion's members are visible unqualified inside the class, but `declarations` holds + * only the companion itself, so its members need collecting separately. */ + (ancestor as? KtClass)?.companionObjects?.forEach { companion -> + companion.declarations.forEach { it.addNameTo(names) } + } + /* A plain constructor parameter is not a member: it is out of scope in a member function + * body, and treating it as taken there refuses a legal name. */ + ancestor.primaryConstructorParameters.filter { it.hasValOrVar() }.forEach { it.addNameTo(names) } } is KtBlockExpression -> { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt index c5da194597..799186d4ab 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/TypeText.kt @@ -125,12 +125,18 @@ internal fun isUnitTypeText(text: String): Boolean = text == "Unit" || text == " * text that is about to be written into the signature -- and the text is what lands in the file. It * therefore wins, retracting both. Without this, a failure to answer "is this `Unit`?" produces * `fun show(text: String): Unit { ... return report(length) }`: compilable, but not what was asked for. + * + * The two components of the returned pair are never inconsistent: no `return` implies a `Unit` return, + * which needs no written type either, so a retracted `return` retracts the type with it. The rewrite + * reads the two independently, and the other pairing would emit `fun f(): Int { val v = ...; expr }`. */ internal fun normalizeExpressionBodyReturn( needsReturn: Boolean, returnTypeText: String?, ): Pair = - if (returnTypeText != null && isUnitTypeText(returnTypeText)) { + if (!needsReturn) { + false to null + } else if (returnTypeText != null && isUnitTypeText(returnTypeText)) { false to null } else { needsReturn to returnTypeText diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt index 6e6ef5c773..2712342ca7 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractVariableViewModelTest.kt @@ -77,14 +77,6 @@ class ExtractVariableViewModelTest { assertFalse(ExtractVariableViewModel(single).uiState.value.showCandidatePicker) } - @Test - fun `the candidate picker is offered even when the selection matched an expression`() { - // Long-press is the natural gesture and it selects exactly one token, which used to hide the - // list -- leaving no way to widen to an enclosing expression without cancelling and re-selecting. - assertTrue(ExtractVariableViewModel(threeCandidatePlan).uiState.value.showCandidatePicker) - assertEquals(0, ExtractVariableViewModel(threeCandidatePlan).uiState.value.selectedCandidate) - } - @Test fun `changing the expression re-derives name, scopes and count`() { val viewModel = ExtractVariableViewModel(threeCandidatePlan) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt index 79cf8cbcec..5171c807bf 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactorPrimitivesTest.kt @@ -227,4 +227,11 @@ class RefactorPrimitivesTest { assertEquals(true to null, normalizeExpressionBodyReturn(needsReturn = true, returnTypeText = null)) assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = false, returnTypeText = null)) } + + @Test + fun `a retracted return retracts the written type with it`() { + // No return means a Unit return, which needs no written type either. The rewrite reads the two + // independently, so the other pairing would emit `fun f(): Int { val v = ...; expr }`. + assertEquals(false to null, normalizeExpressionBodyReturn(needsReturn = false, returnTypeText = "Int")) + } }