From 21a4dfd5a36f1c90899ed7e168483a5847ef0320 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:17:07 +0000 Subject: [PATCH 01/62] ADFA-4826: Add shared IDE Compose theming in common-compose New leaf module holding the Compose theme any module can opt into: IdeColorScheme derives a Material3 scheme from the IDE's own colour resources, IdeTheme applies it and seeds LocalContentColor so text on a themed surface inherits the right colour. Compose types are exposed as `api` because consumers write Compose against them. Modules that are not Compose depend on nothing new. --- ARCHITECTURE.md | 2 +- common-compose/build.gradle.kts | 29 +++++ .../common/compose/IdeColorScheme.kt | 86 ++++++++++++ .../androidide/common/compose/IdeTheme.kt | 48 +++++++ .../common/compose/IdeColorSchemeTest.kt | 123 ++++++++++++++++++ settings.gradle.kts | 1 + 6 files changed, 288 insertions(+), 1 deletion(-) create mode 100644 common-compose/build.gradle.kts create mode 100644 common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt create mode 100644 common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt create mode 100644 common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt diff --git a/ARCHITECTURE.md b/ARCHITECTURE.md index 3ecaccc691..8c1d35d005 100644 --- a/ARCHITECTURE.md +++ b/ARCHITECTURE.md @@ -64,7 +64,7 @@ Strategy: **layer-and-subsystem based**, not feature-by-feature. The Gradle buil | Shell | `termux:{termux-app,termux-shared,termux-view,termux-emulator}` | Embedded Termux shell and terminal. | | Plugin system | `plugin-api`, `plugin-api:plugin-builder`, `plugin-manager` | In-app plugin SDK + manager — `AndroidManifest.xml` `` contract, permissions, extensions. See [plugin-api.md](docs/plugin-api.md) for the API surface & compatibility policy. | | On-device AI | `llama-api`, `llama-impl` | llama.cpp integration, shipped as a per-flavor native AAR. | -| Cross-cutting | `eventbus`, `eventbus-android`, `eventbus-events`, `common`, `common-ui`, `logger`, `resources`, `preferences`, `shared` | Shared infra and the event bus. | +| Cross-cutting | `eventbus`, `eventbus-android`, `eventbus-events`, `common`, `common-ui`, `common-compose`, `logger`, `resources`, `preferences`, `shared` | Shared infra and the event bus. `common-compose` holds the Compose theming any module can opt into (see [ADR 0009](docs/adr/0009-jetpack-compose-for-new-ui.md)); it is a leaf so modules that aren't Compose pay nothing. | | Testing | `testing:{android,unit,lsp,tooling,common}` | Shared test harnesses, split by what's under test. | **Dependency rules (enforced):** diff --git a/common-compose/build.gradle.kts b/common-compose/build.gradle.kts new file mode 100644 index 0000000000..50b8c4c042 --- /dev/null +++ b/common-compose/build.gradle.kts @@ -0,0 +1,29 @@ +import com.itsaky.androidide.build.config.BuildConfig + +plugins { + id("com.android.library") + id("kotlin-android") + alias(libs.plugins.kotlin.compose) +} + +android { + namespace = "${BuildConfig.PACKAGE_NAME}.common.compose" + + buildFeatures { + compose = true + } +} + +dependencies { + // api, not implementation: consumers write Compose against these types (ColorScheme, Typography), + // so they must be on the consumer's compile classpath. + api(platform(libs.compose.bom)) + api(libs.compose.runtime) + api(libs.compose.material3) + api(libs.compose.ui) + + implementation(libs.compose.foundation) + implementation(libs.google.material) + + testImplementation(projects.testing.unit) +} diff --git a/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt new file mode 100644 index 0000000000..a777ae3fb4 --- /dev/null +++ b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeColorScheme.kt @@ -0,0 +1,86 @@ +package com.itsaky.androidide.common.compose + +import android.content.Context +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import com.google.android.material.color.MaterialColors +import com.google.android.material.R as MaterialR + +/** + * Resolves a theme colour attribute, or null when the attribute is not defined. + * + * Exists so [ideColorScheme] can be exercised without an Android [Context]: the mapping from Material + * attributes to Compose colour roles is the part worth testing, and it is pure once resolution is a + * parameter. + */ +typealias ColorAttrResolver = (attr: Int) -> Color? + +/** + * A Compose [ColorScheme] built from the IDE's XML theme, so Compose UI matches the surrounding + * View-based IDE exactly -- including the user's light/dark choice and any theme overlay in effect. + * + * Every role falls back to the stock Material baseline ([lightColorScheme]/[darkColorScheme]) when its + * attribute is undefined, so a partial XML theme degrades to sensible colours rather than to + * transparent or black. + * + * [dark] selects the baseline. It is the caller's business rather than something read from the context + * here, because the attribute values already come from whichever theme is applied; the baseline only + * matters for roles the theme does not define. + */ +fun ideColorScheme( + dark: Boolean, + resolve: ColorAttrResolver, +): ColorScheme { + val base = if (dark) darkColorScheme() else lightColorScheme() + + fun role( + attr: Int, + fallback: Color, + ): Color = resolve(attr) ?: fallback + + return base.copy( + primary = role(MaterialR.attr.colorPrimary, base.primary), + onPrimary = role(MaterialR.attr.colorOnPrimary, base.onPrimary), + primaryContainer = role(MaterialR.attr.colorPrimaryContainer, base.primaryContainer), + onPrimaryContainer = role(MaterialR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), + secondary = role(MaterialR.attr.colorSecondary, base.secondary), + onSecondary = role(MaterialR.attr.colorOnSecondary, base.onSecondary), + secondaryContainer = role(MaterialR.attr.colorSecondaryContainer, base.secondaryContainer), + onSecondaryContainer = role(MaterialR.attr.colorOnSecondaryContainer, base.onSecondaryContainer), + tertiary = role(MaterialR.attr.colorTertiary, base.tertiary), + onTertiary = role(MaterialR.attr.colorOnTertiary, base.onTertiary), + tertiaryContainer = role(MaterialR.attr.colorTertiaryContainer, base.tertiaryContainer), + onTertiaryContainer = role(MaterialR.attr.colorOnTertiaryContainer, base.onTertiaryContainer), + // colorBackground is a platform attribute, not a Material one. + background = role(android.R.attr.colorBackground, base.background), + onBackground = role(MaterialR.attr.colorOnBackground, base.onBackground), + surface = role(MaterialR.attr.colorSurface, base.surface), + onSurface = role(MaterialR.attr.colorOnSurface, base.onSurface), + surfaceVariant = role(MaterialR.attr.colorSurfaceVariant, base.surfaceVariant), + onSurfaceVariant = role(MaterialR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), + outline = role(MaterialR.attr.colorOutline, base.outline), + outlineVariant = role(MaterialR.attr.colorOutlineVariant, base.outlineVariant), + error = role(MaterialR.attr.colorError, base.error), + onError = role(MaterialR.attr.colorOnError, base.onError), + errorContainer = role(MaterialR.attr.colorErrorContainer, base.errorContainer), + onErrorContainer = role(MaterialR.attr.colorOnErrorContainer, base.onErrorContainer), + ) +} + +/** [ideColorScheme] reading the live attribute values off this context's theme. */ +fun Context.ideColorScheme(dark: Boolean): ColorScheme = ideColorScheme(dark, materialColorResolver()) + +/** + * Resolves through [MaterialColors], which handles both direct colour values and colour-resource + * references. A sentinel distinguishes "undefined" from a legitimately resolved colour -- returning 0 + * would be indistinguishable from transparent black. + */ +private fun Context.materialColorResolver(): ColorAttrResolver = + { attr -> + val resolved = MaterialColors.getColor(this, attr, UNRESOLVED) + if (resolved == UNRESOLVED) null else Color(resolved) + } + +private const val UNRESOLVED = Int.MIN_VALUE diff --git a/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt new file mode 100644 index 0000000000..9fecc28919 --- /dev/null +++ b/common-compose/src/main/java/com/itsaky/androidide/common/compose/IdeTheme.kt @@ -0,0 +1,48 @@ +package com.itsaky.androidide.common.compose + +import androidx.compose.foundation.isSystemInDarkTheme +import androidx.compose.material3.LocalContentColor +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Typography +import androidx.compose.runtime.Composable +import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.remember +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.platform.LocalContext + +/** + * Wraps Compose content in a [MaterialTheme] whose colours come from the IDE's XML theme, so a Compose + * surface is indistinguishable from the View-based UI around it. + * + * Use this instead of a bare `MaterialTheme { }`: the bare form falls back to Material's purple + * baseline, which looks nothing like the IDE and ignores the user's theme entirely. + * + * [typography] is a parameter because branding type is a separate concern from colour -- overlay + * windows brand theirs with the IDE's Atkinson Hyperlegible face, while most surfaces want the + * default. + * + * [contentColor] seeds [LocalContentColor], which is **not** something [MaterialTheme] sets. Its + * global default is [androidx.compose.ui.graphics.Color.Black], and normally only a `Surface` replaces + * it (via `contentColorFor`). Content hosted inside a View that already draws the background -- a + * `BottomSheetDialog`, an overlay window, a `ComposeView` in an XML layout -- has no `Surface`, so + * every `Text` would render black regardless of how dark the background is. Defaulting to `onSurface` + * makes that case correct; a `Surface` further down still overrides it, so screens that do use one are + * unaffected. + */ +@Composable +fun IdeTheme( + typography: Typography = MaterialTheme.typography, + contentColor: Color? = null, + content: @Composable () -> Unit, +) { + val context = LocalContext.current + val dark = isSystemInDarkTheme() + // Attribute resolution reads the theme, so it is keyed on both the context and the dark-mode flag. + val colorScheme = remember(context, dark) { context.ideColorScheme(dark) } + MaterialTheme(colorScheme = colorScheme, typography = typography) { + CompositionLocalProvider( + LocalContentColor provides (contentColor ?: colorScheme.onSurface), + content = content, + ) + } +} diff --git a/common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt b/common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt new file mode 100644 index 0000000000..0c8b63f5d9 --- /dev/null +++ b/common-compose/src/test/java/com/itsaky/androidide/common/compose/IdeColorSchemeTest.kt @@ -0,0 +1,123 @@ +package com.itsaky.androidide.common.compose + +import androidx.compose.material3.ColorScheme +import androidx.compose.material3.darkColorScheme +import androidx.compose.material3.lightColorScheme +import androidx.compose.ui.graphics.Color +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test +import com.google.android.material.R as MaterialR + +/** + * The attribute-to-role mapping, tested with a fake resolver rather than a real themed + * [android.content.Context]. + * + * The interesting behaviour is entirely in the mapping and the per-role fallback, so making resolution + * a parameter buys full coverage with no Robolectric and no theme fixtures. + */ +class IdeColorSchemeTest { + private val red = Color(0xFFFF0000) + private val green = Color(0xFF00FF00) + + /** + * Every role [ideColorScheme] claims to map, paired with its name for readable failures. + * + * [ColorScheme] has no structural `equals`, so whole-scheme comparison would compare identity and + * pass vacuously. Listing the roles also makes "did the mapping forget one?" a real assertion. + */ + private fun mappedRoles(scheme: ColorScheme): List> = + listOf( + "primary" to scheme.primary, + "onPrimary" to scheme.onPrimary, + "primaryContainer" to scheme.primaryContainer, + "onPrimaryContainer" to scheme.onPrimaryContainer, + "secondary" to scheme.secondary, + "onSecondary" to scheme.onSecondary, + "secondaryContainer" to scheme.secondaryContainer, + "onSecondaryContainer" to scheme.onSecondaryContainer, + "tertiary" to scheme.tertiary, + "onTertiary" to scheme.onTertiary, + "tertiaryContainer" to scheme.tertiaryContainer, + "onTertiaryContainer" to scheme.onTertiaryContainer, + "background" to scheme.background, + "onBackground" to scheme.onBackground, + "surface" to scheme.surface, + "onSurface" to scheme.onSurface, + "surfaceVariant" to scheme.surfaceVariant, + "onSurfaceVariant" to scheme.onSurfaceVariant, + "outline" to scheme.outline, + "outlineVariant" to scheme.outlineVariant, + "error" to scheme.error, + "onError" to scheme.onError, + "errorContainer" to scheme.errorContainer, + "onErrorContainer" to scheme.onErrorContainer, + ) + + @Test + fun `a resolved attribute wins over the baseline`() { + val scheme = ideColorScheme(dark = false) { attr -> red.takeIf { attr == MaterialR.attr.colorPrimary } } + + assertEquals(red, scheme.primary) + } + + @Test + fun `an undefined attribute falls back to the light baseline`() { + val scheme = ideColorScheme(dark = false) { null } + + assertEquals(mappedRoles(lightColorScheme()), mappedRoles(scheme)) + } + + @Test + fun `an undefined attribute falls back to the dark baseline`() { + val scheme = ideColorScheme(dark = true) { null } + + assertEquals(mappedRoles(darkColorScheme()), mappedRoles(scheme)) + } + + @Test + fun `roles fall back individually, so a partial theme still yields sensible colours`() { + // A theme defining only the surface pair, as a minimal overlay might. + val scheme = + ideColorScheme(dark = false) { attr -> + when (attr) { + MaterialR.attr.colorSurface -> red + MaterialR.attr.colorOnSurface -> green + else -> null + } + } + + assertEquals(red, scheme.surface) + assertEquals(green, scheme.onSurface) + // Everything else keeps the baseline rather than going transparent or black. + assertEquals(lightColorScheme().primary, scheme.primary) + assertEquals(lightColorScheme().error, scheme.error) + } + + @Test + fun `background reads the platform attribute, not a Material one`() { + // colorBackground has no Material equivalent; mapping it to one would silently lose the theme's + // window background. + val scheme = ideColorScheme(dark = false) { attr -> red.takeIf { attr == android.R.attr.colorBackground } } + + assertEquals(red, scheme.background) + } + + @Test + fun `every role the mapping claims to cover is actually resolved`() { + // Resolving everything to one colour proves no listed role was left out of the copy() call: an + // unmapped role would still hold its baseline value. + val scheme = ideColorScheme(dark = false) { red } + + val unmapped = mappedRoles(scheme).filter { (_, color) -> color != red } + assertTrue("roles not read from the theme: ${unmapped.map { it.first }}", unmapped.isEmpty()) + } + + @Test + fun `the dark baseline differs from the light one, so the flag is not ignored`() { + val light = ideColorScheme(dark = false) { null } + val dark = ideColorScheme(dark = true) { null } + + assertTrue(mappedRoles(light) != mappedRoles(dark)) + } +} diff --git a/settings.gradle.kts b/settings.gradle.kts index 29fb8afcd8..7ce1b50938 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -105,6 +105,7 @@ include( ":app", ":build-info", ":common", + ":common-compose", ":common-ui", ":editor", ":editor-api", From 46cf26143f59b0a2c03f2fa72bd12130e9889f49 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:17:19 +0000 Subject: [PATCH 02/62] ADFA-4826: Move profiler and floating-window onto the shared theming Both modules carried their own near-identical copy of the IDE colour derivation. They now delegate to common-compose, so there is one place where the IDE's Compose colours are defined. --- floating-window/build.gradle.kts | 1 + .../androidide/floating/ui/FloatingTheme.kt | 55 +++----------- profiler/build.gradle.kts | 1 + .../cotg/profiler/ui/theme/ProfilerTheme.kt | 73 +++---------------- 4 files changed, 20 insertions(+), 110 deletions(-) diff --git a/floating-window/build.gradle.kts b/floating-window/build.gradle.kts index cfbbbe8a0b..bb638bc857 100644 --- a/floating-window/build.gradle.kts +++ b/floating-window/build.gradle.kts @@ -34,6 +34,7 @@ dependencies { implementation(libs.common.kotlin.coroutines.android) implementation(libs.google.material) + implementation(projects.commonCompose) implementation(projects.editorApi) implementation(projects.common) implementation(projects.resources) diff --git a/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt index c3401c9da3..6061716a0b 100644 --- a/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt +++ b/floating-window/src/main/java/com/itsaky/androidide/floating/ui/FloatingTheme.kt @@ -2,28 +2,17 @@ package com.itsaky.androidide.floating.ui -import android.content.Context -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Typography -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.text.TextStyle import androidx.compose.ui.text.font.Font import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontStyle import androidx.compose.ui.text.font.FontWeight -import com.google.android.material.color.MaterialColors -import com.google.android.material.R as MatR +import com.itsaky.androidide.common.compose.IdeTheme import com.itsaky.androidide.resources.R as ResR -private const val UNRESOLVED_COLOR = Int.MIN_VALUE - private val AtkinsonHyperlegible: FontFamily = FontFamily( Font(ResR.font.atkinson_hyperlegible_regular, FontWeight.Normal), @@ -33,22 +22,22 @@ private val AtkinsonHyperlegible: FontFamily = ) /** - * Wraps floating-window content in a [MaterialTheme] whose colors are read live from the IDE's XML - * `Theme.AndroidIDE` (via the supplied window context) and whose type uses the IDE's Atkinson - * Hyperlegible face. This keeps overlay windows visually identical to the docked editor, including - * light/dark. + * Wraps floating-window content in the shared [IdeTheme] -- colors read live from the IDE's XML + * `Theme.AndroidIDE` via the window context -- with type overridden to the IDE's Atkinson Hyperlegible + * face. This keeps overlay windows visually identical to the docked editor, including light/dark. + * + * Only the typography is local to this module; the color mapping is shared so every Compose surface + * resolves theme attributes the same way. */ @Composable fun FloatingTheme(content: @Composable () -> Unit) { - val context = LocalContext.current - val dark = isSystemInDarkTheme() - val colorScheme = remember(context, dark) { context.toComposeColorScheme(dark) } val typography = remember { brandedTypography() } - MaterialTheme(colorScheme = colorScheme, typography = typography, content = content) + IdeTheme(typography = typography, content = content) } private fun brandedTypography(): Typography { val base = Typography() + fun TextStyle.branded(): TextStyle = copy(fontFamily = AtkinsonHyperlegible) return base.copy( titleMedium = base.titleMedium.branded(), @@ -59,29 +48,3 @@ private fun brandedTypography(): Typography { labelSmall = base.labelSmall.branded(), ) } - -private fun Context.toComposeColorScheme(dark: Boolean): ColorScheme { - val base = if (dark) darkColorScheme() else lightColorScheme() - - fun color(attr: Int, fallback: Color): Color { - val resolved = MaterialColors.getColor(this, attr, UNRESOLVED_COLOR) - return if (resolved == UNRESOLVED_COLOR) fallback else Color(resolved) - } - - return base.copy( - primary = color(MatR.attr.colorPrimary, base.primary), - onPrimary = color(MatR.attr.colorOnPrimary, base.onPrimary), - primaryContainer = color(MatR.attr.colorPrimaryContainer, base.primaryContainer), - onPrimaryContainer = color(MatR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), - secondary = color(MatR.attr.colorSecondary, base.secondary), - onSecondary = color(MatR.attr.colorOnSecondary, base.onSecondary), - surface = color(MatR.attr.colorSurface, base.surface), - onSurface = color(MatR.attr.colorOnSurface, base.onSurface), - surfaceVariant = color(MatR.attr.colorSurfaceVariant, base.surfaceVariant), - onSurfaceVariant = color(MatR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), - outline = color(MatR.attr.colorOutline, base.outline), - error = color(MatR.attr.colorError, base.error), - onError = color(MatR.attr.colorOnError, base.onError), - background = color(android.R.attr.colorBackground, base.background), - ) -} diff --git a/profiler/build.gradle.kts b/profiler/build.gradle.kts index 0e02590550..bb061392d0 100644 --- a/profiler/build.gradle.kts +++ b/profiler/build.gradle.kts @@ -32,6 +32,7 @@ protobuf { dependencies { api(projects.actions) + implementation(projects.commonCompose) implementation(projects.logger) implementation(projects.subprojects.privilegedServices) implementation(projects.subprojects.flamegraph) diff --git a/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt b/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt index 7760600caa..32c74150f4 100644 --- a/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt +++ b/profiler/src/main/java/org/appdevforall/cotg/profiler/ui/theme/ProfilerTheme.kt @@ -1,68 +1,13 @@ package org.appdevforall.cotg.profiler.ui.theme -import android.content.Context -import android.util.TypedValue -import androidx.compose.foundation.isSystemInDarkTheme -import androidx.compose.material3.ColorScheme -import androidx.compose.material3.MaterialTheme -import androidx.compose.material3.darkColorScheme -import androidx.compose.material3.lightColorScheme import androidx.compose.runtime.Composable -import androidx.compose.runtime.remember -import androidx.compose.ui.graphics.Color -import androidx.compose.ui.platform.LocalContext -import androidx.core.content.ContextCompat -import com.google.android.material.R as MaterialR - +import com.itsaky.androidide.common.compose.IdeTheme + +/** + * Profiler content themed from the IDE's XML theme. + * + * A thin alias for [IdeTheme]: the attribute-to-role mapping this used to carry is shared, so every + * Compose surface in the app resolves colours the same way. + */ @Composable -fun ProfilerTheme(content: @Composable () -> Unit) { - val context = LocalContext.current - val darkTheme = isSystemInDarkTheme() - val colorScheme = - remember(context, darkTheme) { - context.toMaterial3ColorScheme(darkTheme) - } - MaterialTheme(colorScheme = colorScheme, content = content) -} - -private fun Context.toMaterial3ColorScheme(darkTheme: Boolean): ColorScheme { - val base = if (darkTheme) darkColorScheme() else lightColorScheme() - return base.copy( - primary = resolveColor(MaterialR.attr.colorPrimary, base.primary), - onPrimary = resolveColor(MaterialR.attr.colorOnPrimary, base.onPrimary), - primaryContainer = resolveColor(MaterialR.attr.colorPrimaryContainer, base.primaryContainer), - onPrimaryContainer = resolveColor(MaterialR.attr.colorOnPrimaryContainer, base.onPrimaryContainer), - secondary = resolveColor(MaterialR.attr.colorSecondary, base.secondary), - onSecondary = resolveColor(MaterialR.attr.colorOnSecondary, base.onSecondary), - secondaryContainer = resolveColor(MaterialR.attr.colorSecondaryContainer, base.secondaryContainer), - onSecondaryContainer = resolveColor(MaterialR.attr.colorOnSecondaryContainer, base.onSecondaryContainer), - tertiary = resolveColor(MaterialR.attr.colorTertiary, base.tertiary), - onTertiary = resolveColor(MaterialR.attr.colorOnTertiary, base.onTertiary), - background = resolveColor(android.R.attr.colorBackground, base.background), - onBackground = resolveColor(MaterialR.attr.colorOnBackground, base.onBackground), - surface = resolveColor(MaterialR.attr.colorSurface, base.surface), - onSurface = resolveColor(MaterialR.attr.colorOnSurface, base.onSurface), - surfaceVariant = resolveColor(MaterialR.attr.colorSurfaceVariant, base.surfaceVariant), - onSurfaceVariant = resolveColor(MaterialR.attr.colorOnSurfaceVariant, base.onSurfaceVariant), - outline = resolveColor(MaterialR.attr.colorOutline, base.outline), - error = resolveColor(MaterialR.attr.colorError, base.error), - onError = resolveColor(MaterialR.attr.colorOnError, base.onError), - ) -} - -private fun Context.resolveColor( - attr: Int, - fallback: Color, -): Color { - val value = TypedValue() - if (!theme.resolveAttribute(attr, value, true)) return fallback - val colorInt = - if (value.type in TypedValue.TYPE_FIRST_COLOR_INT..TypedValue.TYPE_LAST_COLOR_INT) { - value.data - } else if (value.resourceId != 0) { - ContextCompat.getColor(this, value.resourceId) - } else { - return fallback - } - return Color(colorInt) -} +fun ProfilerTheme(content: @Composable () -> Unit) = IdeTheme(content = content) From 97470d39aa2e51032da8a3be63502c5452289a7a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:17:35 +0000 Subject: [PATCH 03/62] 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 9c4e15649b..af4cc0b7f0 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 8442302f0ba40ade07186e5b98a63207e1f78e2b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:18:00 +0000 Subject: [PATCH 04/62] 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 196e3e15dad4bb5120f10f09301dc7957973a242 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:18:22 +0000 Subject: [PATCH 05/62] 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 b8e7660fef..3ebed7e55b 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -521,6 +521,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 22c7b4c75f7d8b6a88f7fdf67a9ed4807d37ef42 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:18:34 +0000 Subject: [PATCH 06/62] 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 + .../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 + 7 files changed, 214 insertions(+), 1 deletion(-) create mode 100644 docs/adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractVariableAction.kt diff --git a/docs/adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md b/docs/adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md new file mode 100644 index 0000000000..92a2a0b16b --- /dev/null +++ b/docs/adr/0012-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 7139d240d5..554f429bee 100644 --- a/docs/adr/README.md +++ b/docs/adr/README.md @@ -25,3 +25,4 @@ Format is lightweight **MADR / Nygard**: Context → Decision → Consequences | [0009](0009-jetpack-compose-for-new-ui.md) | Build new UI in Jetpack Compose, not XML Views | Proposed | | [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-refactoring-ui-lives-in-the-owning-lsp-module.md) | Refactoring UI lives in the owning LSP module | Proposed | 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 02a0571d1f..ac8fd24d98 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -93,6 +93,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 1188a15022..311d0dadd6 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 @@ -6,6 +6,7 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.actions.IActionsMenuProvider import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.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 @@ -39,5 +40,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { SurroundWithTryCatchAction(), 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 352e81eaec..504b4e50de 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 @@ -5,6 +5,7 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction 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 @@ -41,6 +42,7 @@ class KotlinCodeActionTooltipTagTest { NullSafetyAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX, ImplementMembersAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS, SurroundWithTryCatchAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, + ExtractVariableAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE, ) assertEquals(expected, actualTags) } From e72f1388f2936b9e69819210b50bc0c4f9c12bb2 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 14:19:21 +0000 Subject: [PATCH 07/62] 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 6956963bc838ac121d7d553f63c86949ac6241b0 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 14:34:01 +0000 Subject: [PATCH 08/62] 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 756d2698a7019fad2e11aae8f90015d8cd489a9e Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 14:45:59 +0000 Subject: [PATCH 09/62] 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 3dad921b0f546bd191bb6cec62f2d5557ddfaee4 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 14:54:58 +0000 Subject: [PATCH 10/62] 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 b9501106b78f8ef8c56ec9653b0d635d744bcb3c Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 15:10:57 +0000 Subject: [PATCH 11/62] 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 3a5f798b39a2f57b2116efc6373728622552db7a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 15:27:34 +0000 Subject: [PATCH 12/62] 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 b8d0bcc5d9cbae6383401639e64eef0f581fd5f6 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 16:08:05 +0000 Subject: [PATCH 13/62] 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 657d3a078232fe2a7d888f02dee6250ecec935e0 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 16:20:30 +0000 Subject: [PATCH 14/62] 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 d565bc12ff9376c9f79dd3b797ac064daddd1df8 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 16:38:36 +0000 Subject: [PATCH 15/62] 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 2bbf3a352c77a53bbe4e9c6a988e9ba82ba39ff1 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 17:31:46 +0000 Subject: [PATCH 16/62] 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 856e0be145f31da0ad62d57a80636a6479bc1772 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 18:27:35 +0000 Subject: [PATCH 17/62] 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 458a2aea106a00904e0bb9a8d4817e11ed01605c Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 18:28:09 +0000 Subject: [PATCH 18/62] 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 bb09111e8dd5e43fd8c102816dc2017c52a70acb Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 11 Aug 2026 14:29:48 +0000 Subject: [PATCH 19/62] ADFA-5080: Add extract-method requirements and ADR 0013 Requirements only - no implementation yet. R1 to R16 plus non-goals, 21 acceptance criteria, the design and the test split; shared vocabulary and primitives come from kotlin-extract-variable.md rather than being restated. ADR 0013 records the principle most of those requirements are an application of: the refactoring moves code, never edits the interior of what it moved, and declines with a specific reason where it cannot transform faithfully. Two limitations it creates are tracked separately - ADFA-5081 (multi-edit undo) and ADFA-5082 (reassigned outer var as the single output). --- ...efactorings-decline-rather-than-rewrite.md | 63 ++++ docs/adr/README.md | 1 + docs/features/kotlin-extract-method.md | 274 ++++++++++++++++++ docs/features/kotlin-extract-variable.md | 6 +- 4 files changed, 341 insertions(+), 3 deletions(-) create mode 100644 docs/adr/0013-refactorings-decline-rather-than-rewrite.md create mode 100644 docs/features/kotlin-extract-method.md diff --git a/docs/adr/0013-refactorings-decline-rather-than-rewrite.md b/docs/adr/0013-refactorings-decline-rather-than-rewrite.md new file mode 100644 index 0000000000..d4b8898a04 --- /dev/null +++ b/docs/adr/0013-refactorings-decline-rather-than-rewrite.md @@ -0,0 +1,63 @@ +# 0013. Interactive refactorings decline rather than rewrite unselected code + +- **Status:** Proposed +- **Date:** 2026-08-10 +- **Deciders:** Code On The Go team + +## Context + +The K2 Kotlin LSP is growing a family of interactive refactorings: extract variable (ADFA-4826), extract method (ADFA-5080), inline variable (ADFA-4827), semantic rename (ADFA-4825). [ADR 0012](0012-refactoring-ui-lives-in-the-owning-lsp-module.md) settles where their UI lives and that analysis produces plain data. It says nothing about how capable they should be. + +That question turns out to dominate the requirements. Designing extract method surfaced a run of cases where the transformation the user asked for cannot be performed by *moving* their code - it also needs the moved code's interior edited, or a guess about intent: + +- A `var` declared outside the selection and reassigned inside it. Kotlin has no `out` parameters, so the faithful emission is a parameter plus `var x = x` at the top of the body - which compiles, with a name-shadowing warning. +- Two or more values flowing out of the selection. There is no tuple to return that the user would have written themselves. +- A `return` in the middle of the selection. Real IDEs encode the exit in a nullable or sentinel return and re-test it at the call site. +- Members of an enclosing `with`/`apply`/`run` receiver used unqualified. They can only survive as a parameter if every unqualified access inside the body is qualified. +- A type parameter declared on the enclosing function. It needs a filtered copy of the type-parameter list with its bounds. + +Desktop IDEs handle most of these, and their users accept the result because they can read a multi-file diff, undo granularly, and fix up whatever the refactoring got slightly wrong. Code On The Go's users are on a phone: a small screen, no side-by-side diff, imprecise touch selection, and - per ADFA-5081 - a code-action edit history that is not even reliably one undo step yet. Many are also students, for whom generated code carrying a fresh compiler warning is indistinguishable from a broken tool. + +## Decision + +**An interactive refactoring moves the user's code. It does not edit the interior of what it moved, and where it cannot transform faithfully it declines with a specific, actionable reason.** + +Concretely: + +- **Refusal is a designed outcome, not an error.** Each refactoring's plan carries a typed reason (extract method: `ExtractionRefusal`), and each reason has its own user-facing message naming the construct in the way - "the selection assigns to `total`, which is declared outside it", not "cannot extract". +- **Prefer excluding a case by construction over filtering it later.** Extract method accepts only sibling statements in one block; extract variable rejects bare literals and expression fragments up front. Both remove whole classes of hard case before any analysis runs. +- **Prefer a stricter rule to a cleverer one** when strictness costs capability and cleverness costs certainty. Extract method refuses a reassigned outer `var` even when the write is provably dead, because proving it needs liveness analysis. +- **Never emit code that does not compile, and avoid emitting code that warns.** The two modifiers extract method *does* add - `suspend` and `@Composable` - are required precisely because omitting them breaks compilation. +- **A refusal is a backlog item, not a dead end.** Where the refused case is common, file it: ADFA-5082 tracks the reassigned-`var` output. + +This applies to the whole refactoring family, not just extract method. Inline variable and rename inherit it. + +## Consequences + +**Positive** + +- Every applied refactoring produces code the user could have written, so the feature earns trust on a device where verifying the result is expensive. +- Refusal reasons are cheap to specify, cheap to test (one case each) and cheap to QA, where a clever transformation needs its own test matrix and its own failure modes. +- The rules are stateable in a sentence each, which is what makes the feature docs reviewable by someone who has not read the implementation. +- Excluding cases by construction keeps the analysis pass small, which matters when it runs on a phone. + +**Negative / costs** + +- The refactorings are visibly less capable than a desktop IDE's. Two of extract method's refusals - a reassigned outer `var` (the accumulator loop) and an enclosing `with`/`apply` receiver (pervasive in Android code) - will be hit routinely. +- The quality of the *messages* becomes load-bearing. A generic refusal reads as a broken feature, so this decision spends translated strings: roughly seven for extract method alone. +- Users arriving from IntelliJ will read some refusals as regressions rather than as design. +- The line is a judgement, not a formalism. "Editing the interior of the moved code" is clear in the cases above but will need re-application, case by case, in each future refactoring. + +## Alternatives considered + +- **Match desktop IDE capability.** Handle multiple outputs, mid-selection returns, receiver capture and type parameters, as IntelliJ does. Rejected: each requires rewriting the body's interior or inventing a signature the user did not ask for, and the cost of getting it subtly wrong is paid on a device where the user can least easily see it. +- **Transform, but warn.** Apply the refactoring and flash a caveat ("check the result"). Rejected: it puts the verification burden on the person least equipped to do it, and a warning shown once is gone before the user reads the code. +- **Transform behind a setting**, off by default. Rejected: it doubles the behaviour to test and support for a feature whose hard cases are exactly the ones a setting's users would hit first. Revisit only if specific refusals prove to be common complaints - which is what ADFA-5082 exists to measure. +- **One generic refusal message.** Cheapest, and consistent with extract variable's single "nothing to extract". Rejected as a direct consequence of this decision: if declining is the primary answer in hard cases, the decline has to teach. + +## Related + +- [ADR 0012](0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - where refactoring UI lives; this ADR answers *how capable it is* +- [ADR 0010](0010-navigation-resolves-via-analysis-api.md) - the K2 Analysis API as the Kotlin semantic source of truth +- [kotlin-extract-method.md](../features/kotlin-extract-method.md) - R7 to R10 and R14 are this decision applied case by case +- [kotlin-extract-variable.md](../features/kotlin-extract-variable.md) - the shared vocabulary and primitives diff --git a/docs/adr/README.md b/docs/adr/README.md index 554f429bee..26767bbd3f 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-refactoring-ui-lives-in-the-owning-lsp-module.md) | Refactoring UI lives in the owning LSP module | Proposed | +| [0013](0013-refactorings-decline-rather-than-rewrite.md) | Interactive refactorings decline rather than rewrite unselected code | Proposed | diff --git a/docs/features/kotlin-extract-method.md b/docs/features/kotlin-extract-method.md new file mode 100644 index 0000000000..1b8c065c5c --- /dev/null +++ b/docs/features/kotlin-extract-method.md @@ -0,0 +1,274 @@ +# Kotlin extract method (K2 LSP) + +- **Ticket:** ADFA-5080 (subtask of ADFA-3317; split out of ADFA-4826, which now covers extract variable only) +- **Status:** Requirements only - not implemented +- **Module:** `lsp/kotlin` +- **Vocabulary:** the term is **method**, matching the ticket and the already-fixed tooltip tag `editor.codeactions.kotlin.extractmethod`, even though the refactoring's output is a Kotlin `fun`. + +Move the expression at the cursor, or a selected range of statements, into a new function, and replace it with a call to that function. + +Ships as the top of a three-PR stack: `common-compose` theming, then extract variable (ADFA-4826), then this. It reuses that PR's primitives - offsets, naming, indentation, edit emission - and adds no new module, no new dependency and no new UI mechanism. + +The governing principle is [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md): this refactoring **moves** code, it never edits the interior of what it moved, and where it cannot do that faithfully it **declines with a specific reason** rather than guessing. Most of the requirements below are that principle applied to one case each. + +## Language + +Shared vocabulary - *selection*, *extraction region*, *expression candidate*, *text span*, *occurrence*, *refactoring plan*, *rewrite span* - is defined once in [kotlin-extract-variable.md](kotlin-extract-variable.md#language). This feature adds: + +**Statement range**: +One or more *sibling* statements inside a single `KtBlockExpression`, snapped outward from the selection to whole statement boundaries. The second kind of extraction region; the first is an expression candidate. +_Avoid_: statement list, block, selection. + +**Enclosing declaration**: +The named function, property accessor or `init` block whose body contains the extraction region. It is both the boundary that decides what becomes a parameter and the sibling anchor the new function is inserted after. +_Avoid_: parent function, host, owner. + +**Captured declaration**: +A declaration the region references whose PSI lies *inside* the enclosing declaration - a local, a function or lambda parameter, `it`, a destructuring entry, a loop variable. Each becomes a **parameter**. Anything else (class members, top-level declarations, imports) resolves unchanged from the new function body and needs no parameter. +_Avoid_: free variable, capture, dependency. + +**Output**: +The single value that flows out of the region and is still needed after it - a local declared inside the region and read after it. Zero outputs means the extracted function returns `Unit`; two or more is declined. +_Avoid_: result, return value (that's the extracted function's `return`, which an output is only one cause of). + +**Exit**: +A `return`, `break`, `continue` or non-local return inside the region whose target lies outside it. Declined, except the tail return (R8). +_Avoid_: jump, control flow, early return. + +**Refusal**: +A typed reason (`ExtractionRefusal`) the region could not be extracted, carried on the plan and rendered as a specific message. A refusal is a designed outcome, not an error. +_Avoid_: failure, error, invalid. + +## Scope + +### In scope + +An expression, or a range of sibling statements, inside any executable body - a function body, an accessor, an `init` block, a constructor, or a lambda - in a Kotlin file. + +### Out of scope + +The positions extract variable already rejects, for the same reasons and via the same `isExtractionPosition` check: annotation arguments, default parameter values, super-constructor delegation arguments, and anything outside an executable body (notably a class-body property initializer). + +## Requirements + +**R1 - Trigger.** An "Extract method" item (`action_extract_method`) in the editor code-actions menu for Kotlin files, id `ide.editor.lsp.kt.extractMethod`, tooltip tag `EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD = "editor.codeactions.kotlin.extractmethod"` - a new constant in `TooltipTag.kt`. The tag string is fixed: tooltip *content* lives in the out-of-repo tooltips database keyed by tag, so it cannot be renamed here. + +As with extract variable: **no `prepare()` visibility gate** (deciding extractability needs an analysis session, far too costly for the UI thread), and `requiresUIThread = false` so the selection is read on a background thread. + +**R2 - Region.** The selection resolves to exactly one extraction region, of one of two kinds. + +*Expression candidate* - reuses `candidateExpressionsAt` unchanged, including whitespace trimming, the `offset - 1` cursor retry, the innermost-first walk, `MAX_CANDIDATES = 3`, the legal-target rules and `selectionMatchedCandidate`. A bare cursor always takes this path. + +*Statement range* - a non-empty selection that spans statement boundaries snaps **outward** to whole statements: a touch selection will not land on a boundary. The result must be 1..N statements that are **siblings in one `KtBlockExpression`**. A selection spanning two different blocks, or partially covering a statement that cannot be snapped, is declined (`NotASingleRegion`). + +Restricting to siblings in one block excludes every hard case - a selection covering half an `if` and half its `else`, a range straddling a lambda boundary - by construction rather than by later filtering, exactly as `isLegalExtractionTarget` excludes expression fragments today. + +**R3 - Live offsets and the version guard.** Identical to extract variable: analysis runs against `getCurrentKtFile(path)` fetched *before* entering `project.read`, the plan records the document version, and the version is re-read on confirm with a mismatch refusing the edit. Shared via the `RefactoringPlan` supertype. + +**R4 - Target.** One uniform rule, no target picker: **the new function is inserted as a sibling of the enclosing declaration**, immediately after it. That one rule produces the conventional answer in every context: + +| The region sits in | The new function becomes | +|---|---| +| a member function, accessor or `init` of a class | a `private fun` member of that class | +| a top-level function or property | a `private` top-level `fun` | +| a lambda inside either of the above | still a sibling of the enclosing *named* declaration; the lambda's captures become parameters | +| a local `fun` or local class | a local `fun` in the enclosing block, since the sibling *is* a statement there | +| a companion object body | a member of the companion | + +Unlike extract variable there is no scope chain and no ceiling, because anything not visible at the insertion site becomes a parameter instead of constraining the anchor. + +**R5 - Parameters.** A referenced declaration needs a parameter exactly when it is a captured declaration - its PSI lies inside the enclosing declaration. Members of the enclosing class need nothing, because the new function is a member of that same class. + +- **Order** - first textual appearance in the region, so the signature reads in the order the body uses it. +- **Names** - the original identifier, unchanged. `it` becomes a parameter literally named `it`, which is legal Kotlin, and the call site passes `it`. +- **Types** - the resolved type rendered with the existing `renderName(KaType)`. A type that cannot be rendered - an anonymous or intersection type, or a resolution failure - **declines the extraction** (`UnrenderableType`) rather than emitting uncompilable text. +- **Not editable.** The derived signature is shown read-only (R11). Renaming, reordering or excluding parameters is a desktop-sized dialog; a wrong parameter *name* is fixable afterwards with rename (ADFA-4825), and a wrong parameter *set* is not something the user could correct by hand anyway. + +**R6 - Return type and call-site form.** Determined by the region kind and its output: + +| Case | Extracted body | Call site | +|---|---|---| +| expression candidate | `return ` | `extracted(args)` in the expression's place | +| statement range, no output | the statements; returns `Unit` | `extracted(args)` as a statement | +| statement range, one output `x` | the statements, then `return x` | `val x = extracted(args)` | +| statement range, tail return (R8) | the statements including the `return` | `return extracted(args)` | + +A region that always throws still declares `Unit`; the exception propagates and the call site behaves identically, so `throw` needs no rule of its own. + +**R7 - Outputs.** An output is a local declared inside the region and read after it. Exactly one is supported; **two or more declines** (`MultipleOutputs`, naming them). + +A `var` declared outside the region and **reassigned inside it declines** (`ReassignsOuterVar`, naming the variable), because Kotlin has no `out` parameters and the faithful emission - a parameter plus `var x = x` at the top of the body - carries a name-shadowing warning into generated code. This is deliberately stricter than dataflow requires: a reassignment whose result is never read afterwards is still refused, because proving that needs real liveness analysis. ADFA-5082 tracks supporting it. + +The refused case is the accumulator loop, which is a genuinely common extraction, so its message must name the variable and read as a limitation rather than a malfunction. + +**R8 - Exits.** Every exit declines (`ExitsRegion`), with one syntactic exception. + +**Tail return:** when the region's *last* statement is a `return`, the region contains no other `return`, `break` or `continue`, and there is no other output, the extracted function takes the enclosing function's return type, keeps the `return`, and the call site becomes `return extracted(args)`. "Extract the rest of this function into a helper" is one of the most common real extractions and the enabling check is purely syntactic - last-child kind plus a recursive absence check - so it costs a predicate and one call-site form, not an analysis. + +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*. + +**R9 - Receivers.** + +- **Class dispatch receiver** - nothing to do; the new function is a member of the same class. +- **The enclosing declaration's extension receiver** - the new function is generated as an extension on the **same receiver type**, copied syntactically from the enclosing declaration's receiver type reference. The call site needs no change at all: inside `fun Foo.original()`, `this` is a `Foo`, so `extracted(args)` resolves to `private fun Foo.extracted(args)`. +- **An implicit receiver introduced inside the enclosing declaration** - the `with(x) { ... }` / `apply` / `run` / `buildString` case - **declines** (`InnerImplicitReceiver`). Turning that receiver into a parameter would require qualifying every unqualified member access inside the extracted body, which is editing the interior of the moved code. Android code uses these scoping functions heavily, so this refusal will be common and its message must say which construct is in the way. + +**R10 - Modifiers.** Copy nothing from the enclosing declaration; add only what the body needs in order to compile in its new home. + +- **Visibility** - always `private`, whether a class member or top-level. Never `internal`, never `open`, no annotations copied, no KDoc generated. +- **`suspend`** - added when any call in the region resolves to a suspend function, or the region references `coroutineContext`. The call site is necessarily already a suspend context. +- **`@Composable`** - added when any call in the region resolves to a `@Composable`-annotated function. This is not polish: CoGo users write Compose apps on the device, and an extracted composable without the annotation does not compile. +- **Function-level type parameters** - a region referencing a type parameter declared on the *enclosing function* **declines** (`UsesTypeParameter`, naming it). Class-level type parameters need no rule; they stay in scope for a member. A filtered copy of the enclosing type-parameter list with its bounds would mean deciding "is `T` referenced" from rendered type text, which is fragile. + +`suspend` and `@Composable` are the two cases where omitting a modifier produces non-compiling code, which is why they are requirements while everything else is left off. + +**R11 - Sheet.** A sibling of the extract-variable sheet, not a generalisation of it: `ExtractMethodSheet` (a `BottomSheetDialogFragment` hosting a `ComposeView`), a stateless `ExtractMethodSheetContent`, `ExtractMethodViewModel` + `ExtractMethodUiState` + a sealed `ExtractMethodUiEvent`. `LabelledSection` and `OptionList` are promoted to a shared internal file in `refactor/ui/`. + +Contents, top to bottom: title -> expression chooser (only for an expression region with more than one candidate and no exact selection match) -> name field with its `NameProblem` message -> signature preview -> Cancel/Extract. There is **no scope chooser** (R4) and **no replace-all checkbox** (R13). + +The preview is **one monospace line: the signature exactly as it will be emitted** - modifiers, receiver, parameters, return type, e.g. `private suspend fun loadUser(id: String): User`. It wraps rather than truncating. No body preview: the body is the code the user selected and can see behind the sheet, so it moves verbatim and previewing it says nothing new, while the signature is the one derived artefact and the one place the derivation can surprise them. + +ADR 0012 defers the shared-UI question until the extract-method surface is known; a single generalised sheet would need a state class where half the fields are meaningless to either caller, so that question stays open rather than being settled from one data point. + +**R12 - Name.** Suggestion: for an expression region, the existing shape/type derivation unchanged; for a statement range, the constant `extracted`, since there is no expression to read a name from and inventing a verb from statement shapes is guesswork. Uniquified as today. + +Validation reuses `validateVariableName` and `NameProblem` unchanged - so no new error strings - with taken names being **every callable name visible in the insertion container, including inherited members** (the container's `memberScope`, not just its declared members) for a class target; every top-level declaration name in the file for a top-level target; enclosing-block declarations for a local target. + +Including inherited names is a correctness requirement, not a nicety: 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. + +**R13 - One call site.** The region is the only site rewritten. No duplicate detection, no replace-all toggle: exact-duplicate matching would almost never fire, and near-duplicate matching needs anti-unification plus a per-site parameter mapping - a feature in its own right. `Occurrences.kt` is expression-granular by construction. + +**R14 - Refusals.** The plan carries a typed `ExtractionRefusal` rather than merely being empty, and `postExec` maps it to a specific message: + +| Reason | Message intent | +|---|---| +| `NotASingleRegion` | select an expression, or whole statements inside one block | +| `MultipleOutputs` | the selection produces more than one value | +| `ReassignsOuterVar` | the selection assigns to ``, declared outside it | +| `ExitsRegion` | the selection jumps out of itself (`return`/`break`/`continue`) | +| `InnerImplicitReceiver` | the selection uses members of an enclosing `with`/`apply` receiver | +| `UsesTypeParameter` | the selection uses type parameter `` | +| `UnrenderableType` | a type in the selection cannot be written out | + +Five of the seven are actionable - they tell the user what to change - and two of them (`ReassignsOuterVar`, `InnerImplicitReceiver`) are common enough that a generic message would read as the feature being broken. Given how much of this design is "decline cleanly", the refusal text is a first-class part of the feature. New entries in `resources/.../values/strings.xml`, picked up by the next translation batch. + +The refusal lives on `ExtractMethodPlan` only; extract variable keeps its single "nothing to extract" behaviour unchanged. + +**R15 - Edit.** Two regions change - the region becomes a call, and the new function appears after the enclosing declaration - emitted as **two `TextEdit`s in one `DocumentChange`, ordered new-function-first (descending document order)**. + +The ordering is mandatory, not stylistic. `IDELanguageClientImpl.applyActionEdits` iterates the edit list in order and `editInEditor` applies each with **line/column** ranges against whatever the text is at that moment (the `index` in `Position` is ignored). Emitting the call site first would shift the insertion point and corrupt the file. + +**Known consequence:** nothing on that path calls `beginBatchEdit`, so this is **two undo entries**, and a single undo leaves a half-refactored, non-compiling file. This knowingly diverges from `RewriteSpan`'s single-replacement rule, which extract variable relies on. **ADFA-5081** fixes it properly by batching the edit loop in `applyActionEdits`, which benefits every multi-edit action; until it lands, the two-step undo is a stated limitation to be covered in QA. + +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. + +**R16 - Responsiveness and failure isolation.** As extract variable: one background pass at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine produces the whole plan; the sheet does pure string and offset arithmetic and re-enters no analysis on confirm. Anything thrown in the pipeline degrades to a refusal plus a log line, never an uncaught throw - the action framework catches only `IllegalArgumentException` and this runs on a scope with no exception handler. + +## Non-goals + +- **Duplicate or near-duplicate call sites** (R13). +- **An editable parameter list** - rename, reorder or exclude (R5). +- **Two or more outputs, and a reassigned outer `var`** (R7). The latter is ADFA-5082. +- **Mid-region `return`/`break`/`continue`** (R8). +- **Inner `with`/`apply`/`run` receivers** (R9). +- **Function-level type parameters** (R10). +- **Choosing a different target** - another class, another file, a local `fun` when a member is possible, or a property instead of a function (R4). Moving a declaration elsewhere is a move refactoring. +- **Extraction from a property initializer or annotation argument** - inherited from `isExtractionPosition`. +- **Generated KDoc** for the new function. +- **Post-extract inline rename** of the new name in the editor - ADFA-4825. +- **Atomic undo** of the two edits - ADFA-5081. +- **Formatting the result.** R15 emits indented text instead. +- **Java extract method** - ADFA-5048. + +## 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 the innermost-first candidates; extracting one replaces it with a call and adds a `private fun` returning that expression, directly 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, and a call passing them. +4. A selection with ragged boundaries snaps outward to whole statements before extracting. +5. A selection spanning two different blocks reports "select an expression, or whole statements inside one block". +6. A range declaring a local that is read afterwards produces `val x = extracted(...)` at the call site. +7. A range declaring two locals that are both read afterwards is declined as producing more than one value. +8. Selecting a loop that accumulates into an outer `var` is declined, and the message names that variable. +9. Selecting the tail of a function ending in `return x` produces `return extracted(...)` and a function with the enclosing return type. +10. Selecting a range containing a `return` in the middle is declined. +11. Selecting a range with a `break` targeting a loop outside it is declined. +12. Extracting from inside `fun Foo.bar()` when the region touches `Foo`'s members produces `private fun Foo.extracted(...)`, and the call site is unchanged. +13. Extracting from inside a `with(x) { ... }` block whose region uses `x`'s members is declined, and the message names the construct. +14. A region calling a suspend function produces a `suspend fun`. +15. A region calling a `@Composable` produces a `@Composable` function that compiles. +16. A region using a type parameter of the enclosing function is declined, naming the parameter. +17. A name matching an existing member - including an inherited one - is rejected with "That name is already used". +18. The signature preview matches the emitted declaration exactly, including modifiers and receiver. +19. Editing the file while the sheet is open, then confirming, reports the file-changed message and leaves the file untouched. +20. Undo restores the file; it currently takes **two** undo steps (R15), and the intermediate state is non-compiling. +21. A space-indented file receives space-indented output; a CRLF file keeps CRLF. + +## Design + +Same shape as extract variable, and the same data boundary from [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md): one background pass produces a plain-data plan, the sheet holds no PSI. + +``` +ExtractMethodAction.execAction (background) lsp/kotlin/actions + server.compilationEnvironmentFor(path) ?: refusal + -> buildExtractMethodPlan(...) utils/refactor/ExtractMethodPlanner.kt + ktFile = env.ktSymbolIndex.getCurrentKtFile(path).get() [R3: before project.read] + env.project.read { + resolveRegion(ktFile, start, end) utils/refactor/ExtractionRegion.kt [R2] + expression -> candidateExpressionsAt(...) (reused unchanged) + statements -> snap outward, sibling check + analyzeMaybeDangling(INTERACTIVE, cancelChecker) { [R16] + captured declarations -> parameters utils/refactor/MethodSignature.kt [R5] + outputs / exits / receivers / modifiers [R6-R10] + -> ExtractMethodPlan | ExtractionRefusal [R14] + } + } + <- ExtractMethodPlan (plain data, no PSI) + +ExtractMethodAction.postExec (UI thread) + refusal -> flashInfo(message for reason) [R14] + ExtractMethodSheet.show refactor/ui [R11] + on confirm -> version re-read; mismatch -> refuse [R3] + buildExtractMethodRewrite -> two RewriteSpans utils/refactor/ExtractMethodEdit.kt [R15] + client.performCodeAction(one DocumentChange, two TextEdits, descending) +``` + +New files, all in `lsp/kotlin`: + +- **`utils/refactor/ExtractionRegion.kt`** - the region model and its resolution (R2). Purely syntactic, so unit-testable with no analysis session, exactly as `CandidateExpressions.kt` is. +- **`utils/refactor/MethodSignature.kt`** - captured declarations to parameters, outputs, exits, receivers, modifiers, and the rendered signature string (R5-R10). The only analysis-dependent part. +- **`utils/refactor/ExtractMethodPlan.kt`** - `ExtractMethodPlan` (a `RefactoringPlan` subtype) and `ExtractionRefusal`. +- **`utils/refactor/ExtractMethodPlanner.kt`** - the single background pass (R3, R16). +- **`utils/refactor/ExtractMethodEdit.kt`** - the two rewrites and their ordering (R15). Pure text and offsets. +- **`refactor/ui/ExtractMethod*.kt`** - sheet, content, ViewModel, state, events (R11). +- **`actions/ExtractMethodAction.kt`** - registered in `KotlinCodeActionsMenu`; the only class touching the editor, the document version or the language client. +- **`TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD`** - one new constant (R1). + +Reused from extract variable unchanged: `TextSpan`, `collapseForLabel`, `candidateExpressionsAt` / `CandidateSyntax`, `isExtractionPosition`, `enclosingExecutableBody`, `NameProblem` + `validateVariableName`, `suggestVariableName`, `detectIndentUnit`, `detectNewline`, `leadingIndentAt`, `lineStartOffset`, `RewriteSpan` + `toTextEdit`, `positionAt`, `renderName`. + +Deliberately **not** reused: `ScopeOption`, `AnchorForm` and `CandidateExpression`. Each is shaped by the legal scope chain, which this refactoring does not have (R4) - so the two refactorings share primitives, not the aggregate. What they do share is hoisted into the sealed `RefactoringPlan` (`fileText`, `documentVersion`, the version guard), introduced in the extract-variable PR so this one is purely additive. + +Nothing outside `lsp/kotlin` changes except `TooltipTag.kt` and `values/strings.xml`. No new module, no new dependency. + +## Verification + +Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`), mirroring the extract-variable split so a failure localises to one layer: + +- **`ExtractMethodRegionTest`** - no analysis session, PSI only: outward snapping to whole statements, the sibling-in-one-block rule, cross-block rejection, and the expression path (R2). +- **`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 (R8), the extension receiver (R9), `suspend` and `@Composable` (R10), and **one case per refusal reason** (R14). +- **`ExtractMethodEditTest`** - pure text: the two edits and their descending order, the three call-site forms, indentation, the blank-line separation, and CRLF preservation (R15). +- **`ExtractMethodViewModelTest`** - state derivation: chooser visibility, name validation against inherited names, and the rendered signature preview (R11, R12). + +`lsp/kotlin` has **no `androidTest`** source set, and none is added: `@Composable` detection is tested by declaring `package androidx.compose.runtime; annotation class Composable` in a test source module, and `suspend` is a language modifier, so both need **no new dependency** (`KtLspTestEnvironment` supports `extraLibraryJars`, but not for this). + +The sheet, `prepare()`/`ActionData`, the two-step undo and the new tooltip row are not unit-testable; they are covered by on-device QA from the acceptance criteria above, recorded in ADFA-5080's "Steps to QA" field. + +## Related + +- [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md) - refactorings decline rather than rewrite unselected code; the principle behind R7-R10 +- [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - refactoring UI lives in the owning LSP module +- [kotlin-extract-variable.md](kotlin-extract-variable.md) - ADFA-4826; owns the shared Language section and every primitive reused here +- ADFA-5081 - code action edits should be a single undo step (fixes R15's consequence) +- ADFA-5082 - support a reassigned outer `var` as the single output (lifts R7's refusal) +- ADFA-5048 - Java extract method, the sibling in `lsp/java` +- [ARCHITECTURE.md](../../ARCHITECTURE.md) diff --git a/docs/features/kotlin-extract-variable.md b/docs/features/kotlin-extract-variable.md index 44e27107ef..f4592973f1 100644 --- a/docs/features/kotlin-extract-variable.md +++ b/docs/features/kotlin-extract-variable.md @@ -6,7 +6,7 @@ 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). +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 [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md). ## Language @@ -264,8 +264,8 @@ Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotl ## 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 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md) - refactorings decline rather than rewrite unselected code - [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 +- [kotlin-extract-method.md](kotlin-extract-method.md) - ADFA-5080, the sibling refactoring - [ARCHITECTURE.md](../../ARCHITECTURE.md) From bb9dab1ac1e1a624455be48f511eefb998cdd8d8 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 15:42:47 +0000 Subject: [PATCH 20/62] ADFA-5080: Hoist the shared refactoring plan supertype --- .../lsp/kotlin/utils/refactor/ExtractionPlan.kt | 6 +++--- .../lsp/kotlin/utils/refactor/RefactoringPlan.kt | 13 +++++++++++++ 2 files changed, 16 insertions(+), 3 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt 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..d90fb2bb2a 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 @@ -139,11 +139,11 @@ data class CandidateExpression( * candidate, meaning they already expressed which expression they want and the UI should not ask. */ data class ExtractionPlan( - val fileText: String, - val documentVersion: Int, + override val fileText: String, + override val documentVersion: Int, val candidates: List, val selectionMatchedCandidate: Boolean, -) { +) : RefactoringPlan { val isEmpty: Boolean get() = candidates.isEmpty() companion object { diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt new file mode 100644 index 0000000000..b58d6137a7 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/RefactoringPlan.kt @@ -0,0 +1,13 @@ +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 +} From 60220d2cca8b4b4b0df0cb22f532ef02fe3401ec Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 15:50:37 +0000 Subject: [PATCH 21/62] ADFA-5080: Resolve a selection to an extraction region --- .../kotlin/utils/refactor/ExtractionRegion.kt | 120 ++++++++++++++++ .../utils/refactor/ExtractMethodRegionTest.kt | 132 ++++++++++++++++++ 2 files changed, 252 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt new file mode 100644 index 0000000000..38a848233e --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt @@ -0,0 +1,120 @@ +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 +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt new file mode 100644 index 0000000000..54413341e0 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt @@ -0,0 +1,132 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import org.jetbrains.kotlin.psi.KtFile +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * 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`() { + // On the `+`, not `+ 1`: that lands between `a` and the space, which resolves to the `a` + // identifier itself (also a legal candidate) rather than the binary expression. + val region = region(twoStatements, twoStatements.indexOf("a + b") + 2) + + 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)) + } +} From 513b2d4d3080e53368b4200715eaccff620449d1 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 16:03:38 +0000 Subject: [PATCH 22/62] ADFA-5080: Fix KDoc and pin fallthrough for extraction region --- .../kotlin/utils/refactor/ExtractionRegion.kt | 29 ++++++++++++------- .../utils/refactor/ExtractMethodRegionTest.kt | 17 +++++++++++ 2 files changed, 36 insertions(+), 10 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt index 38a848233e..1783322d94 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractionRegion.kt @@ -45,9 +45,12 @@ sealed interface ExtractionRegion { * 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. + * statements -- a touch selection will not land on a boundary. When the snapped range is a single + * statement and the selection sits strictly inside it, the expression path is preferred instead: + * that is what the user's selection actually points at, not the enclosing statement. But if nothing + * there is a legal expression target, the snapped statement is used anyway -- a near-miss drag + * (e.g. selecting `sum = a + b` and missing the leading `val`) should still extract something, + * rather than being refused for landing a few characters short. */ fun resolveExtractionRegion( file: KtFile, @@ -57,15 +60,14 @@ fun resolveExtractionRegion( 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 range = snapToStatements(file, start, end) ?: return expressionRegion(file, selectionStart, selectionEnd) - val only = statements.singleOrNull() + val only = range.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) + return ExtractionRegion.Statements(range.statements, range.block) } private fun expressionRegion( @@ -78,6 +80,12 @@ private fun expressionRegion( return ExtractionRegion.Expressions(syntax.expressions, syntax.selectionMatchedInnermost) } +/** A run of sibling statements together with the [KtBlockExpression] that holds them. */ +private class StatementRange( + val statements: List, + val block: KtBlockExpression, +) + /** * The whole statements `[start, end)` touches, when they are siblings in one [KtBlockExpression]. * @@ -88,9 +96,10 @@ private fun snapToStatements( file: KtFile, start: Int, end: Int, -): List? { +): StatementRange? { + // end > start is guaranteed by the start == end early-return in resolveExtractionRegion. val first = statementContaining(file, start) ?: return null - val last = statementContaining(file, (end - 1).coerceAtLeast(start)) ?: return null + val last = statementContaining(file, end - 1) ?: return null val block = first.parent as? KtBlockExpression ?: return null if (last.parent !== block) return null @@ -100,7 +109,7 @@ private fun snapToStatements( 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() + return StatementRange(statements.subList(from, to + 1).toList(), block) } /** diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt index 54413341e0..60fdf04ead 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodRegionTest.kt @@ -80,6 +80,23 @@ class ExtractMethodRegionTest : KtLspTest() { assertTrue(region.selectionMatchedInnermost) } + @Test + fun `a partial selection with no expression candidate still snaps to the statement`() { + // Skips the leading `val`, as a touch drag that starts a little late routinely does. Both + // ends land inside the same KtProperty, which is a declaration, not a legal expression + // target, so the expression path has nothing to offer and the snapped statement wins. + val start = twoStatements.indexOf("sum") + val end = twoStatements.indexOf("a + b") + "a + b".length + + val region = region(twoStatements, start, end) + + assertTrue(region is ExtractionRegion.Statements) + assertEquals( + listOf("val sum = a + b"), + (region as ExtractionRegion.Statements).statements.map { it.text }, + ) + } + @Test fun `a selection spanning two different blocks resolves to nothing`() { val content = From c1ff95b0ac7fb65b606a54862a1cbfadc615bb4b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 16:11:07 +0000 Subject: [PATCH 23/62] ADFA-5080: Add the extract-method plan model and its two rewrites --- .../utils/refactor/ExtractMethodEdit.kt | 83 +++++ .../utils/refactor/ExtractMethodPlan.kt | 145 +++++++++ .../utils/refactor/ExtractMethodEditTest.kt | 294 ++++++++++++++++++ 3 files changed, 522 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt new file mode 100644 index 0000000000..11a441c5f2 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt @@ -0,0 +1,83 @@ +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) + } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt new file mode 100644 index 0000000000..47ba274d3f --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt @@ -0,0 +1,145 @@ +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 0013): + * 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 0013). [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) } + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt new file mode 100644 index 0000000000..7ada10d58d --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt @@ -0,0 +1,294 @@ +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")) + } +} From a8177c1c18cc94d4a6326058555f4fe293451625 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 16:19:34 +0000 Subject: [PATCH 24/62] ADFA-5080: Strengthen the CRLF test and cover the Unit-expression case --- .../utils/refactor/ExtractMethodEditTest.kt | 59 +++++++++++++++++-- 1 file changed, 53 insertions(+), 6 deletions(-) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt index 7ada10d58d..32b4baa504 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt @@ -225,19 +225,24 @@ class ExtractMethodEditTest { } @Test - fun `a CRLF file keeps CRLF`() { + fun `a multi-line CRLF region is reindented and keeps CRLF throughout`() { + // Mirrors "a multi-line statement range is reindented under the new function" with \r\n in + // place of every \n, so reindent's split(newline) path -- the CRLF-sensitive code -- actually + // runs, not just the declaration builder's own append(newline) calls. val text = "package p\r\n" + "fun demo(a: Int) {\r\n" + - "\tprintln(a)\r\n" + + "\tif (a > 0) {\r\n" + + "\t\tprintln(a)\r\n" + + "\t}\r\n" + "}\r\n" - val start = text.indexOf("println(a)") + val start = text.indexOf("if (a > 0)") val rewrites = buildExtractMethodRewrites( text, ExtractMethodCandidate( label = "region", - span = TextSpan(start, start + "println(a)".length), + span = TextSpan(start, text.indexOf("\t}\r\n}") + 2), suggestedName = "extracted", takenNames = emptySet(), annotations = emptyList(), @@ -253,8 +258,50 @@ class ExtractMethodEditTest { "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")) + assertEquals( + "package p\r\n" + + "fun demo(a: Int) {\r\n" + + "\treport(a)\r\n" + + "}\r\n" + + "\r\n" + + "private fun report(a: Int) {\r\n" + + "\tif (a > 0) {\r\n" + + "\t\tprintln(a)\r\n" + + "\t}\r\n" + + "}\r\n", + apply(text, rewrites), + ) + } + + @Test + fun `a Unit-valued expression omits the return type and the return keyword`() { + val span = TextSpan(file.indexOf("a + b"), file.indexOf("a + b") + "a + b".length) + val rewrites = + buildExtractMethodRewrites( + file, + candidate( + span, + ExtractedBody.ExpressionBody(needsReturn = false), + CallSiteForm.Call, + returnTypeText = null, + ), + "log", + )!! + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = log()\n" + + "\t\treturn sum\n" + + "\t}\n" + + "\n" + + "\tprivate fun log() {\n" + + "\t\ta + b\n" + + "\t}\n" + + "}\n", + apply(file, rewrites), + ) } @Test From 83eef142648f4a0a1bea041b029a383c7b5acd94 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 16:33:54 +0000 Subject: [PATCH 25/62] ADFA-5080: Derive the extracted signature, or a typed refusal --- .../utils/refactor/ExtractMethodPlanner.kt | 80 +++ .../kotlin/utils/refactor/MethodSignature.kt | 497 ++++++++++++++++++ .../kotlin/utils/refactor/NameSuggestion.kt | 4 +- .../lsp/kotlin/utils/refactor/Occurrences.kt | 2 +- .../refactor/ExtractMethodPlanEndToEndTest.kt | 368 +++++++++++++ 5 files changed, 948 insertions(+), 3 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt new file mode 100644 index 0000000000..55143ab0c1 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt @@ -0,0 +1,80 @@ +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) + } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt new file mode 100644 index 0000000000..8e0b2e4b9f --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -0,0 +1,497 @@ +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.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.psi.KtAnonymousInitializer +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 + +/** 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 + } + + 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(" & ") + +@OptIn(KaExperimentalApi::class) +private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = + runCatching { renderName(symbol.returnType) }.getOrNull()?.takeUnless(::isUnrenderable) + +@OptIn(KaExperimentalApi::class) +private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = + runCatching { expression.expressionType?.let { renderName(it) } }.getOrNull()?.takeUnless(::isUnrenderable) + +@OptIn(KaExperimentalApi::class) +private fun KaSession.renderedDeclarationType(property: KtProperty): String? = + runCatching { (property.symbol as? KaCallableSymbol)?.returnType?.let { renderName(it) } } + .getOrNull() + ?.takeUnless(::isUnrenderable) + +@OptIn(KaExperimentalApi::class) +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 0013). + */ +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 +} + +/** + * 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() +} 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 index 3427571a18..c6cc362228 100644 --- 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 @@ -103,7 +103,7 @@ fun suggestVariableName( ?: typeName?.let(::nameFromType) ?: FALLBACK_NAME val sanitised = base.takeIf { isIdentifier(it) && it !in HARD_KEYWORDS } ?: FALLBACK_NAME - return makeUnique(sanitised, takenNames) + return uniqueName(sanitised, takenNames) } private fun nameFromShape(expression: KtExpression): String? = @@ -144,7 +144,7 @@ private fun nameFromType(typeName: String): String? = 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( +internal fun uniqueName( base: String, takenNames: Set, ): 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 61eea683ae..1c365f0482 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 @@ -246,7 +246,7 @@ internal fun KaSession.writeOffsetsFor( } /** Whether this reference is being written to rather than read. */ -private fun KtSimpleNameExpression.isWriteTarget(): Boolean { +internal 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 diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt new file mode 100644 index 0000000000..6e31cd89f6 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt @@ -0,0 +1,368 @@ +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")!!), + ) + } +} From 21a19241844ea63394c5df4743afa71752c67ac7 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 17:08:11 +0000 Subject: [PATCH 26/62] ADFA-5080: Close the extract-method refusal gaps that emit broken Kotlin --- .../utils/refactor/ExtractMethodPlan.kt | 12 +- .../kotlin/utils/refactor/MethodSignature.kt | 294 +++++++++++++----- .../refactor/ExtractMethodPlanEndToEndTest.kt | 214 +++++++++++++ 3 files changed, 439 insertions(+), 81 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt index 47ba274d3f..ec8dc179a9 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt @@ -75,7 +75,11 @@ 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). */ + /** + * The region declares something the code after it still needs, and a single returned value cannot + * carry it (R7): two or more locals, a destructuring declaration, a local `fun` or class, or a + * local the following code reassigns. [names] is what is in the way, so the message can name it. + */ data class MultipleOutputs( val names: List, ) : ExtractionRefusal @@ -100,6 +104,12 @@ sealed interface ExtractionRefusal { /** A parameter or return type that cannot be written out as source (R5). */ data object UnrenderableType : ExtractionRefusal + + /** + * A property accessor's `field` (R4). The backing field is reachable only from inside the + * accessor, so the reference would move verbatim into the new function and stop resolving. + */ + data object UsesBackingField : ExtractionRefusal } /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt index 8e0b2e4b9f..2685c0db16 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -3,11 +3,17 @@ 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.resolution.KaCallableMemberCall +import org.jetbrains.kotlin.analysis.api.resolution.KaImplicitReceiverValue +import org.jetbrains.kotlin.analysis.api.resolution.KaReceiverValue +import org.jetbrains.kotlin.analysis.api.resolution.successfulCallOrNull import org.jetbrains.kotlin.analysis.api.resolution.successfulFunctionCallOrNull import org.jetbrains.kotlin.analysis.api.resolution.symbol +import org.jetbrains.kotlin.analysis.api.symbols.KaBackingFieldSymbol 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.KaReceiverParameterSymbol 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 @@ -23,10 +29,13 @@ 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.KtLabeledExpression +import org.jetbrains.kotlin.psi.KtLambdaArgument +import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtLoopExpression import org.jetbrains.kotlin.psi.KtNameReferenceExpression +import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtPropertyAccessor @@ -35,18 +44,18 @@ 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.KtValueArgument +import org.jetbrains.kotlin.psi.KtValueArgumentList /** 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") +/** What a receiver-binding lambda is called in the refusal when it is not a call argument. */ +private const val UNNAMED_SCOPING_CONSTRUCT = "lambda" + +private const val BACKING_FIELD_NAME = "field" /** Either a derived candidate or the reason there is not one. */ internal sealed interface SignatureResult { @@ -75,19 +84,28 @@ internal fun KaSession.buildCandidate( 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)) } + val typeParameterNames = typeParameterNamesOf(enclosing) + typeParameterIn(typeParameterNames, elements)?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } + if (usesBackingField(enclosing, elements)) return refuse(ExtractionRefusal.UsesBackingField) 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 })) + val outputs = if (isExpression) RegionOutputs.NONE else outputsOf(enclosing, elements, span) + // Only a single plain `val`/`var` can come back as the return value. Everything else the region + // declares and the following code still needs -- a second local, a destructuring entry, a local + // `fun`, or a local reassigned afterwards -- is refused rather than silently dropped (R7). + if (outputs.declarations.size > 1 || + outputs.declarations.any { it !is KtProperty } || + outputs.writtenAfter.isNotEmpty() + ) { + return refuse(ExtractionRefusal.MultipleOutputs(outputs.declarations.mapNotNull { it.name })) } + val output = outputs.declarations.singleOrNull() as? KtProperty // The tail-return exception holds only when nothing else flows out (R8). - if (tailReturn && outputs.isNotEmpty()) return refuse(ExtractionRefusal.ExitsRegion) + if (tailReturn && output != null) return refuse(ExtractionRefusal.ExitsRegion) val parameters = capturedParameters(enclosing, elements, span) ?: return refuse(ExtractionRefusal.UnrenderableType) @@ -101,8 +119,8 @@ internal fun KaSession.buildCandidate( enclosingReturnType(enclosing) ?: return refuse(ExtractionRefusal.UnrenderableType) } - outputs.size == 1 -> { - renderedDeclarationType(outputs.single()) ?: return refuse(ExtractionRefusal.UnrenderableType) + output != null -> { + renderedDeclarationType(output) ?: return refuse(ExtractionRefusal.UnrenderableType) } else -> { @@ -110,21 +128,30 @@ internal fun KaSession.buildCandidate( } }.takeUnless { it == "Unit" } + // The syntactic check above misses an inferred type argument, which names no type anywhere in the + // region. The rendered signature is the last place to catch it before it is emitted (R10). + renderedTypeParameterIn(typeParameterNames, parameters.map { it.typeText } + listOfNotNull(returnTypeText)) + ?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } + val body = when { isExpression -> ExtractedBody.ExpressionBody(needsReturn = returnTypeText != null) - outputs.size == 1 -> ExtractedBody.StatementBody(trailingReturn = "return ${outputs.single().name.orEmpty()}") + output != null -> ExtractedBody.StatementBody(trailingReturn = "return ${output.name.orEmpty()}") else -> ExtractedBody.StatementBody(trailingReturn = null) } val callSite = when { tailReturn -> CallSiteForm.Return - outputs.size == 1 -> CallSiteForm.AssignOutput(outputs.single().name.orEmpty()) + output != null -> CallSiteForm.AssignOutput(output.name.orEmpty()) else -> CallSiteForm.Call } val takenNames = takenNamesFor(enclosing) + // A getter is not a place a function can follow -- inserting there lands between the accessors of + // a `var` and does not parse -- so the new member goes after the whole property (R4). The accessor + // itself stays the capture boundary everywhere else. + val anchor = (enclosing as? KtPropertyAccessor)?.property ?: enclosing return SignatureResult.Success( ExtractMethodCandidate( @@ -144,8 +171,8 @@ internal fun KaSession.buildCandidate( returnTypeText = returnTypeText, body = body, callSite = callSite, - insertOffset = enclosing.textRange.endOffset, - insertIndent = leadingIndentAt(fileText, enclosing.textRange.startOffset), + insertOffset = anchor.textRange.endOffset, + insertIndent = leadingIndentAt(fileText, anchor.textRange.startOffset), ), ) } @@ -219,9 +246,15 @@ private fun KaSession.capturedParameters( } // `it` has no source PSI, so it would otherwise read as "not captured" and be dropped. + // Its binding lambda stands in for the missing declaration: captured only when that + // lambda is outside the region, and keyed on the lambda so that an `it` bound inside the + // region cannot evict a genuinely captured outer one. symbol is KaValueParameterSymbol && reference.getReferencedName() == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() -> { - "it" + val lambda = + PsiTreeUtil.getParentOfType(reference, KtFunctionLiteral::class.java, true) ?: continue + if (inRegion(lambda, span)) continue + lambda } else -> { @@ -264,35 +297,58 @@ private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = ?.takeUnless(::isUnrenderable) /** - * Locals declared inside the region and read after it (R7). Exactly one is supported. + * What the region declares that the code after it still uses (R7). * - * "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. + * Every named declaration counts, not just [KtProperty]: a destructuring entry, a local `fun` and a + * local class are all things the following code can reference, and none of them can be returned. + * They are collected so [buildCandidate] can refuse them -- omitting them is what produced a call + * site referring to names that no longer exist. + * + * [writtenAfter] is the subset the following code assigns to. The call site emits a `val`, so even a + * single such output cannot be honoured. + */ +private class RegionOutputs( + val declarations: List, + val writtenAfter: List, +) { + companion object { + val NONE = RegionOutputs(emptyList(), emptyList()) + } +} + +/** + * "Used after the region" 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() +): RegionOutputs { + val declared = descendantsOf(elements, KtNamedDeclaration::class.java) + if (declared.isEmpty()) return RegionOutputs.NONE - val laterReads = + val laterReferences = 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 } + val read = laterReferences.filterNot { it.isWriteTarget() }.mapNotNullTo(mutableSetOf()) { resolvedPsi(it) } + val written = laterReferences.filter { it.isWriteTarget() }.mapNotNullTo(mutableSetOf()) { resolvedPsi(it) } + + return RegionOutputs( + declarations = declared.filter { it in read || it in written }, + writtenAfter = declared.filter { it in written }, + ) } +private fun KaSession.resolvedPsi(reference: KtSimpleNameExpression): PsiElement? = + runCatching { + reference.mainReference + ?.resolveToSymbols() + ?.firstOrNull() + ?.psi + }.getOrNull() + /** * 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 0013). @@ -305,9 +361,9 @@ private fun KaSession.reassignedOuterVar( for (reference in simpleNamesIn(elements)) { if (!reference.isWriteTarget()) continue val symbol = - runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaVariableSymbol - ?: continue - if (symbol.isVal) continue + runCatching { + (reference.mainReference?.resolveToSymbols()?.firstOrNull() as? KaVariableSymbol)?.takeIf { !it.isVal } + }.getOrNull() ?: continue val declarationPsi = runCatching { symbol.psi }.getOrNull() ?: continue if (!PsiTreeUtil.isAncestor(enclosing, declarationPsi, true)) continue if (inRegion(declarationPsi, span)) continue @@ -337,14 +393,48 @@ private fun hasExit( ): 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 + // 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 } return hasLoopExit(elements, span) } +/** The lambda `return@[label]` targets: the innermost enclosing one carrying that label. */ +private fun labelledLambdaFor( + returnExpression: KtReturnExpression, + label: String, +): KtFunctionLiteral? { + var lambda = PsiTreeUtil.getParentOfType(returnExpression, KtFunctionLiteral::class.java, true) + while (lambda != null) { + if (lambdaLabel(lambda) == label) return lambda + lambda = PsiTreeUtil.getParentOfType(lambda, KtFunctionLiteral::class.java, true) + } + return null +} + +/** + * The label a `return@` can name this lambda by: its explicit `label@` if it has one, otherwise the + * name of the function it is an argument to. + */ +private fun lambdaLabel(lambda: KtFunctionLiteral): String? { + val lambdaExpression = lambda.parent as? KtLambdaExpression ?: return null + (lambdaExpression.parent as? KtLabeledExpression)?.getLabelName()?.let { return it } + return callOwning(lambdaExpression)?.calleeName() +} + +/** The call [lambdaExpression] is an argument of, trailing or parenthesised. */ +private fun callOwning(lambdaExpression: KtLambdaExpression): KtCallExpression? = + when (val argument = lambdaExpression.parent) { + is KtLambdaArgument -> argument.parent as? KtCallExpression + is KtValueArgument -> (argument.parent as? KtValueArgumentList)?.parent as? KtCallExpression + else -> null + } + +private fun KtCallExpression.calleeName(): String? = (calleeExpression as? KtNameReferenceExpression)?.getReferencedName() + private fun hasLoopExit( elements: List, span: TextSpan, @@ -358,16 +448,21 @@ private fun hasLoopExit( } } +private fun typeParameterNamesOf(enclosing: KtDeclaration): List = + (enclosing as? KtNamedFunction)?.typeParameters?.mapNotNull { it.name }.orEmpty() + /** - * 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). + * The name of the enclosing function's type parameter the region *writes out*, 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). + * + * This catches only a type the region names. A type argument the region gets by inference names + * nothing at all, and is caught by [renderedTypeParameterIn] once the signature exists. */ private fun typeParameterIn( - enclosing: KtDeclaration, + names: List, elements: List, ): String? { - val names = (enclosing as? KtNamedFunction)?.typeParameters?.mapNotNull { it.name }.orEmpty() if (names.isEmpty()) return null val typeTexts = @@ -376,62 +471,101 @@ private fun typeParameterIn( return names.firstOrNull { name -> typeTexts.any { it == name || it.containsWord(name) } } } +/** + * The type parameter that leaked into the derived signature, or null. + * + * `fun demo(a: T, b: T) { pick(a, b) }` names `T` nowhere in the region, but the parameters + * render as `T` -- and the new function has no type-parameter list to bind it. Checking the rendered + * strings is the only place that shows up before the text is emitted. + */ +private fun renderedTypeParameterIn( + names: List, + renderedTypes: List, +): String? { + if (names.isEmpty()) return null + return names.firstOrNull { name -> renderedTypes.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) +/** + * Whether the region reads or writes a property accessor's backing field (R4). + * + * `field` is in scope only inside the accessor, so it would move verbatim into the new function and + * stop resolving. Gated on the enclosing declaration being an accessor, which costs nothing + * everywhere else, and confirmed against the resolved symbol so a local that happens to be called + * `field` is not mistaken for it. + */ +private fun KaSession.usesBackingField( + enclosing: KtDeclaration, + elements: List, +): Boolean { + if (enclosing !is KtPropertyAccessor) return false + return simpleNamesIn(elements).any { reference -> + reference.getReferencedName() == BACKING_FIELD_NAME && + runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() is KaBackingFieldSymbol + } +} + /** * 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. + * + * The question is asked of the resolved call rather than of a list of known scoping-function names: + * a name list both over-refuses (an inherited member or an outer-class member reached with no + * qualifier is not the receiver's) and under-refuses (it cannot know about `coroutineScope`, + * `buildAnnotatedString`, or any Compose scope). A receiver that is implicit and belongs to a lambda + * between the region and the enclosing declaration is exactly what does not survive the move. */ 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)) { + // A qualified selector already has its receiver written out next to it. 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 + val lambda = implicitReceiverLambdaFor(reference) ?: continue + if (inRegion(lambda, span)) continue + if (!PsiTreeUtil.isAncestor(enclosing, lambda, true)) continue + return (lambda.parent as? KtLambdaExpression)?.let { callOwning(it)?.calleeName() } ?: UNNAMED_SCOPING_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 +/** + * The lambda supplying [reference]'s implicit receiver, or null when it has none or the receiver + * comes from somewhere that survives the move (a class, the enclosing function's own receiver). + */ +private fun KaSession.implicitReceiverLambdaFor(reference: KtSimpleNameExpression): KtFunctionLiteral? = + runCatching { + // A callee name does not resolve to a call on its own; its call expression does. + val callSource = + (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference + val applied = + callSource + .resolveToCall() + ?.successfulCallOrNull>() + ?.partiallyAppliedSymbol + receiverLambda(applied?.dispatchReceiver) ?: receiverLambda(applied?.extensionReceiver) + }.getOrNull() + +private fun receiverLambda(receiver: KaReceiverValue?): KtFunctionLiteral? { + val owner = (receiver as? KaImplicitReceiverValue)?.symbol ?: return null + // A lambda's receiver reports itself either as the anonymous function or as that function's + // receiver parameter, and only the former carries the PSI. + val psi = + runCatching { owner.psi }.getOrNull() + ?: runCatching { (owner as? KaReceiverParameterSymbol)?.owningCallableSymbol?.psi }.getOrNull() + ?: return null + return psi as? KtFunctionLiteral ?: (psi as? KtLambdaExpression)?.functionLiteral } /** `suspend` is added when the region calls one, or touches `coroutineContext` (R10). */ diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt index 6e31cd89f6..47e4b95769 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt @@ -365,4 +365,218 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { apply(content, buildExtractMethodRewrites(result.fileText, candidate, "total")!!), ) } + + @Test + fun `an it bound by a lambda inside the region is not turned into a parameter`() { + val content = + """ + package p + fun log(n: Int) {} + fun demo(names: List, extra: Int) { + names.forEach { log(it + extra) } + } + """.trimIndent() + val (start, end) = selection(content, "names.forEach", "names.forEach { log(it + extra) }") + + val candidate = plan(content, start, end).candidates.single() + + // `it` belongs to a lambda the region carries with it, so it is not captured from outside. + assertEquals(listOf("names", "extra"), candidate.parameters.map { it.name }) + } + + @Test + fun `a destructuring declaration read after the region is declined`() { + val content = + """ + package p + data class Point(val a: Int, val b: Int) + fun demo(p: Point): Int { + val (x, y) = p + return x + y + } + """.trimIndent() + val (start, end) = selection(content, "val (x, y)", "val (x, y) = p") + + val refusal = plan(content, start, end).refusal + + assertTrue(refusal is ExtractionRefusal.MultipleOutputs) + assertEquals(listOf("x", "y"), (refusal as ExtractionRefusal.MultipleOutputs).names) + } + + @Test + fun `an output reassigned after the region is declined`() { + val content = + """ + package p + fun compute(): Int = 1 + fun demo(flag: Boolean): Int { + var result = compute() + if (flag) result = 0 + return result + } + """.trimIndent() + val (start, end) = selection(content, "var result", "var result = compute()") + + val refusal = plan(content, start, end).refusal + + // A `val` at the call site cannot carry an output the following code assigns to. + assertTrue(refusal is ExtractionRefusal.MultipleOutputs) + assertEquals(listOf("result"), (refusal as ExtractionRefusal.MultipleOutputs).names) + } + + @Test + fun `an inferred type parameter is declined even though the region names no type`() { + val content = + """ + package p + fun pick(a: T, b: T): T = a + fun demo(a: T, b: T): T { + return pick(a, b) + } + """.trimIndent() + + assertEquals( + ExtractionRefusal.UsesTypeParameter("T"), + plan(content, content.indexOf("pick(a, b)") + 1).refusal, + ) + } + + @Test + fun `a labelled return targeting an outer lambda is declined`() { + val content = + """ + package p + fun demo(items: List) { + items.forEach outer@{ item -> + listOf(item).forEach { + if (it < 0) return@outer + println(it) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "listOf(item).forEach {", "\t\t}") + + // The nearest lambda is in the region, but `outer@` is not. + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `an inherited member used inside a with block is not mistaken for the receiver`() { + val content = + """ + package p + open class Base { fun helper(): Int = 1 } + class Child : Base() { + fun demo(n: Int): Int = + with(n) { + helper() + 1 + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("helper() + 1") + 1) + + // `helper()` comes from the supertype, not from `with`'s receiver. + assertNull(result.refusal) + assertEquals("Int", result.candidates.first { it.label == "helper() + 1" }.returnTypeText) + } + + @Test + fun `a scope receiver outside the stdlib scoping names is still declined`() { + val content = + """ + package p + class Scope { fun item(n: Int) {} } + fun column(body: Scope.() -> Unit) {} + fun demo() { + column { + item(1) + } + } + """.trimIndent() + val (start, end) = selection(content, "item(1)", "item(1)") + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("column"), plan(content, start, end).refusal) + } + + @Test + fun `extracting from a getter inserts the new function after the whole property`() { + val content = + """ + package p + class C { + var backing: Int = 0 + var total: Int + get() { + return backing + 1 + } + set(value) { + backing = value + } + } + """.trimIndent() + + val result = plan(content, content.indexOf("backing + 1") + 1) + val candidate = result.candidates.first { it.label == "backing + 1" } + + assertEquals( + """ + package p + class C { + var backing: Int = 0 + var total: Int + get() { + return next() + } + set(value) { + backing = value + } + + private fun next(): Int { + return backing + 1 + } + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "next")!!), + ) + } + + @Test + fun `a region using the backing field is declined`() { + val content = + """ + package p + class C { + var n: Int = 0 + get() { + return field + 1 + } + } + """.trimIndent() + + assertEquals( + ExtractionRefusal.UsesBackingField, + plan(content, content.indexOf("field + 1") + 1).refusal, + ) + } + + @Test + fun `a parameter whose type cannot be written out is declined`() { + val content = + """ + package p + fun demo(): Int { + val helper = object { + fun value(): Int = 1 + } + return helper.value() + } + """.trimIndent() + + assertEquals( + ExtractionRefusal.UnrenderableType, + plan(content, content.indexOf("helper.value()") + 1).refusal, + ) + } } From ceb11bd45a534f669f81d1080af936810adaf67a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 17:32:56 +0000 Subject: [PATCH 27/62] ADFA-5080: Refuse the receiver, label and smart-cast cases that emit broken Kotlin --- .../utils/refactor/ExtractMethodPlan.kt | 9 + .../kotlin/utils/refactor/MethodSignature.kt | 153 ++++++++++--- .../lsp/kotlin/utils/refactor/Occurrences.kt | 13 +- .../refactor/ExtractMethodPlanEndToEndTest.kt | 202 ++++++++++++++++++ 4 files changed, 347 insertions(+), 30 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt index ec8dc179a9..17b39e3da3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt @@ -110,6 +110,15 @@ sealed interface ExtractionRefusal { * accessor, so the reference would move verbatim into the new function and stop resolving. */ data object UsesBackingField : ExtractionRefusal + + /** + * A captured value the region uses through a smart cast (R5). Its declared type does not compile + * in the new body and its narrowed type does not compile at the call site, so neither emission is + * faithful (ADR 0013). + */ + data class SmartCastParameter( + val name: String, + ) : ExtractionRefusal } /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt index 2685c0db16..5c95af11f6 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -4,6 +4,8 @@ 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.resolution.KaCallableMemberCall +import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundArrayAccessCall +import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundVariableAccessCall import org.jetbrains.kotlin.analysis.api.resolution.KaImplicitReceiverValue import org.jetbrains.kotlin.analysis.api.resolution.KaReceiverValue import org.jetbrains.kotlin.analysis.api.resolution.successfulCallOrNull @@ -14,6 +16,7 @@ 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.KaReceiverParameterSymbol +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.analysis.api.symbols.markers.KaNamedSymbol @@ -29,6 +32,7 @@ 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.KtExpressionWithLabel import org.jetbrains.kotlin.psi.KtFunctionLiteral import org.jetbrains.kotlin.psi.KtLabeledExpression import org.jetbrains.kotlin.psi.KtLambdaArgument @@ -37,12 +41,14 @@ import org.jetbrains.kotlin.psi.KtLoopExpression import org.jetbrains.kotlin.psi.KtNameReferenceExpression import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtParameter 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.KtThisExpression import org.jetbrains.kotlin.psi.KtTypeReference import org.jetbrains.kotlin.psi.KtValueArgument import org.jetbrains.kotlin.psi.KtValueArgumentList @@ -107,7 +113,11 @@ internal fun KaSession.buildCandidate( // The tail-return exception holds only when nothing else flows out (R8). if (tailReturn && output != null) return refuse(ExtractionRefusal.ExitsRegion) - val parameters = capturedParameters(enclosing, elements, span) ?: return refuse(ExtractionRefusal.UnrenderableType) + val parameters = + when (val captured = capturedParameters(enclosing, elements, span)) { + is CaptureResult.Captured -> captured.parameters + is CaptureResult.Refused -> return refuse(captured.refusal) + } val returnTypeText = when { @@ -128,10 +138,15 @@ internal fun KaSession.buildCandidate( } }.takeUnless { it == "Unit" } + val receiverTypeText = receiverTypeTextOf(enclosing) + // The syntactic check above misses an inferred type argument, which names no type anywhere in the - // region. The rendered signature is the last place to catch it before it is emitted (R10). - renderedTypeParameterIn(typeParameterNames, parameters.map { it.typeText } + listOfNotNull(returnTypeText)) - ?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } + // region. The rendered signature is the last place to catch it before it is emitted (R10), and it + // has to cover every slot the signature prints -- the receiver included. + renderedTypeParameterIn( + typeParameterNames, + parameters.map { it.typeText } + listOfNotNull(returnTypeText, receiverTypeText), + )?.let { return refuse(ExtractionRefusal.UsesTypeParameter(it)) } val body = when { @@ -152,6 +167,12 @@ internal fun KaSession.buildCandidate( // a `var` and does not parse -- so the new member goes after the whole property (R4). The accessor // itself stays the capture boundary everywhere else. val anchor = (enclosing as? KtPropertyAccessor)?.property ?: enclosing + val modifiers = + buildList { + // A local function joins a block, and a visibility modifier on one does not compile. + if (anchor.parent !is KtBlockExpression) add("private") + if (usesSuspend(elements)) add("suspend") + } return SignatureResult.Success( ExtractMethodCandidate( @@ -165,8 +186,8 @@ internal fun KaSession.buildCandidate( }, 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, + modifiers = modifiers, + receiverTypeText = receiverTypeText, parameters = parameters, returnTypeText = returnTypeText, body = body, @@ -220,14 +241,14 @@ private fun descendantsOf( * 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. + * Declines rather than emitting text that will not compile: a type that cannot be written out as + * source, or a value the region only uses through a smart cast. */ private fun KaSession.capturedParameters( enclosing: KtDeclaration, elements: List, span: TextSpan, -): List? { +): CaptureResult { val parameters = mutableListOf() val seen = mutableSetOf() @@ -263,10 +284,31 @@ private fun KaSession.capturedParameters( } if (!seen.add(key)) continue - val typeText = renderedSymbolType(symbol) ?: return null + val typeText = + renderedSymbolType(symbol) ?: return CaptureResult.Refused(ExtractionRefusal.UnrenderableType) + // The signature must print the declared type, but the region may be leaning on a smart cast to + // something narrower: the declared type breaks the moved body, the narrowed one breaks the call + // site. Asked only of values, since a smart cast is the only thing that can make the two differ. + if (symbol is KaVariableSymbol) { + val usedTypeText = renderedTypeOrNull(reference) + if (usedTypeText != null && usedTypeText != typeText) { + return CaptureResult.Refused(ExtractionRefusal.SmartCastParameter(reference.getReferencedName())) + } + } parameters += MethodParameter(name = reference.getReferencedName(), typeText = typeText) } - return parameters + return CaptureResult.Captured(parameters) +} + +/** Either the derived parameter list or the reason there cannot be one. */ +private sealed interface CaptureResult { + data class Captured( + val parameters: List, + ) : CaptureResult + + data class Refused( + val refusal: ExtractionRefusal, + ) : CaptureResult } /** A type that cannot be written out as source -- anonymous, intersection, or a resolution error. */ @@ -325,7 +367,12 @@ private fun KaSession.outputsOf( elements: List, span: TextSpan, ): RegionOutputs { - val declared = descendantsOf(elements, KtNamedDeclaration::class.java) + // Lambdas and parameters are named declarations too, and neither can be referenced after the + // region. Dropping them keeps the short-circuit below meaningful for any region holding a lambda, + // and keeps a lambda's "" out of a refusal message. + val declared = + descendantsOf(elements, KtNamedDeclaration::class.java) + .filterNot { it is KtFunctionLiteral || it is KtParameter } if (declared.isEmpty()) return RegionOutputs.NONE val laterReferences = @@ -439,15 +486,31 @@ private fun hasLoopExit( elements: List, span: TextSpan, ): Boolean { - val jumps = + val jumps: List = descendantsOf(elements, KtBreakExpression::class.java) + descendantsOf(elements, KtContinueExpression::class.java) return jumps.any { jump -> - val loop = PsiTreeUtil.getParentOfType(jump, KtLoopExpression::class.java, true) + val loop = targetLoopFor(jump) loop == null || !inRegion(loop, span) } } +/** + * The loop a `break`/`continue` leaves: the innermost enclosing one, or the one its label names. + * + * Reading the label matters for the same reason it does for a labelled `return` -- `break@outer` from + * a nested loop inside the region leaves the region, however local the nearest loop looks. + */ +private fun targetLoopFor(jump: KtExpressionWithLabel): KtLoopExpression? { + var loop = PsiTreeUtil.getParentOfType(jump, KtLoopExpression::class.java, true) + val label = jump.getLabelName() ?: return loop + while (loop != null) { + if ((loop.parent as? KtLabeledExpression)?.getLabelName() == label) return loop + loop = PsiTreeUtil.getParentOfType(loop, KtLoopExpression::class.java, true) + } + return null +} + private fun typeParameterNamesOf(enclosing: KtDeclaration): List = (enclosing as? KtNamedFunction)?.typeParameters?.mapNotNull { it.name }.orEmpty() @@ -533,13 +596,35 @@ private fun KaSession.innerImplicitReceiver( if (parent is KtQualifiedExpression && parent.selectorExpression === reference) continue val lambda = implicitReceiverLambdaFor(reference) ?: continue - if (inRegion(lambda, span)) continue - if (!PsiTreeUtil.isAncestor(enclosing, lambda, true)) continue - return (lambda.parent as? KtLambdaExpression)?.let { callOwning(it)?.calleeName() } ?: UNNAMED_SCOPING_CONSTRUCT + if (isBoundOutsideRegion(enclosing, lambda, span)) return constructNameFor(lambda) + } + + // A bare `this` names the receiver without going through a call, so no resolved call reports it. + // Left undetected it does not fail to compile -- it silently becomes the enclosing class instance, + // which is worse. + for (thisExpression in descendantsOf(elements, KtThisExpression::class.java)) { + val symbol = + runCatching { + thisExpression.instanceReference.mainReference + ?.resolveToSymbols() + ?.firstOrNull() + }.getOrNull() + val lambda = lambdaOwning(symbol) ?: continue + if (isBoundOutsideRegion(enclosing, lambda, span)) return constructNameFor(lambda) } return null } +/** Whether [lambda] binds its receiver between the region and [enclosing], so the move loses it. */ +private fun isBoundOutsideRegion( + enclosing: KtDeclaration, + lambda: KtFunctionLiteral, + span: TextSpan, +): Boolean = !inRegion(lambda, span) && PsiTreeUtil.isAncestor(enclosing, lambda, true) + +private fun constructNameFor(lambda: KtFunctionLiteral): String = + (lambda.parent as? KtLambdaExpression)?.let { callOwning(it)?.calleeName() } ?: UNNAMED_SCOPING_CONSTRUCT + /** * The lambda supplying [reference]'s implicit receiver, or null when it has none or the receiver * comes from somewhere that survives the move (a class, the enclosing function's own receiver). @@ -549,25 +634,43 @@ private fun KaSession.implicitReceiverLambdaFor(reference: KtSimpleNameExpressio // A callee name does not resolve to a call on its own; its call expression does. val callSource = (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference + val call = callSource.resolveToCall() + // An assignment target resolves to the whole compound access, which is not a member call and + // would otherwise slip through carrying its receiver with it: `n += 1` inside `apply { }`. val applied = - callSource - .resolveToCall() - ?.successfulCallOrNull>() - ?.partiallyAppliedSymbol + call?.successfulCallOrNull>()?.partiallyAppliedSymbol + ?: call?.successfulCallOrNull()?.variableCall?.partiallyAppliedSymbol + ?: call?.successfulCallOrNull()?.getterCall?.partiallyAppliedSymbol receiverLambda(applied?.dispatchReceiver) ?: receiverLambda(applied?.extensionReceiver) }.getOrNull() -private fun receiverLambda(receiver: KaReceiverValue?): KtFunctionLiteral? { - val owner = (receiver as? KaImplicitReceiverValue)?.symbol ?: return null +private fun receiverLambda(receiver: KaReceiverValue?): KtFunctionLiteral? = lambdaOwning((receiver as? KaImplicitReceiverValue)?.symbol) + +/** The lambda [symbol] belongs to, when it is a lambda's receiver rather than a class's. */ +private fun lambdaOwning(symbol: KaSymbol?): KtFunctionLiteral? { + if (symbol == null) return null // A lambda's receiver reports itself either as the anonymous function or as that function's // receiver parameter, and only the former carries the PSI. val psi = - runCatching { owner.psi }.getOrNull() - ?: runCatching { (owner as? KaReceiverParameterSymbol)?.owningCallableSymbol?.psi }.getOrNull() + runCatching { symbol.psi }.getOrNull() + ?: runCatching { (symbol as? KaReceiverParameterSymbol)?.owningCallableSymbol?.psi }.getOrNull() ?: return null return psi as? KtFunctionLiteral ?: (psi as? KtLambdaExpression)?.functionLiteral } +/** + * The receiver the new function must repeat, or null (R4). + * + * An accessor's receiver is declared on its property (`val Foo.x get() = ...`), not on the accessor, + * so reading only the accessor drops it and the moved body's unqualified members stop resolving. + */ +private fun receiverTypeTextOf(enclosing: KtDeclaration): String? = + when (enclosing) { + is KtNamedFunction -> enclosing.receiverTypeReference?.text + is KtPropertyAccessor -> enclosing.property.receiverTypeReference?.text + else -> 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 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 1c365f0482..8e0da41493 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 @@ -135,11 +135,14 @@ internal fun KaSession.referencedDeclarationCeiling(candidate: KtExpression): Ps * 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. + * The implicit-lambda-parameter branch below is **defensive, and unreachable in this Kotlin + * version**: `it` resolves to a value-parameter symbol whose PSI is the enclosing + * [KtFunctionLiteral] (`KtFakeSourceElementKind.ItLambdaParameter` is an allowed fake element kind), + * so the ordinary psi-based lookup already constrains it to that lambda. It is kept because 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 -- and without it a future version that stops supplying the PSI would silently hoist + * `it.length` clean out of its lambda into code that does not compile. */ private fun constrainingBodyFor( reference: KtSimpleNameExpression, diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt index 47e4b95769..603067343d 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt @@ -561,6 +561,208 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { ) } + @Test + fun `a compound assignment through a receiver lambda is declined`() { + val content = + """ + package p + class Counter { var n = 0 } + fun demo(c: Counter) { + c.apply { + n += 1 + } + } + """.trimIndent() + val (start, end) = selection(content, "n += 1", "n += 1") + + // The assignment resolves to a compound access, not a member call, and used to slip through. + assertEquals(ExtractionRefusal.InnerImplicitReceiver("apply"), plan(content, start, end).refusal) + } + + @Test + fun `an increment through a receiver lambda is declined`() { + val content = + """ + package p + class Counter { var n = 0 } + fun demo(c: Counter) { + c.apply { + n++ + } + } + """.trimIndent() + val (start, end) = selection(content, "n++", "n++") + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("apply"), plan(content, start, end).refusal) + } + + @Test + fun `a bare this inside a receiver lambda is declined`() { + val content = + """ + package p + class Foo(val n: Int) + fun log(f: Foo) {} + fun demo(f: Foo) { + f.apply { + log(this) + } + } + """.trimIndent() + val (start, end) = selection(content, "log(this)", "log(this)") + + assertEquals(ExtractionRefusal.InnerImplicitReceiver("apply"), plan(content, start, end).refusal) + } + + @Test + fun `a this inside a lambda that does not rebind it is not declined`() { + val content = + """ + package p + class Foo { + fun log(f: Foo) {} + fun demo(items: List) { + items.forEach { + log(this) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "log(this)", "log(this)") + + // `forEach` binds `it`, not `this`, so `this` still means the Foo instance after the move. + assertNull(plan(content, start, end).refusal) + } + + @Test + fun `a type parameter reaching only the receiver is declined`() { + val content = + """ + package p + fun log(s: String) {} + fun List.summarize() { + log("size=" + size) + } + """.trimIndent() + val (start, end) = selection(content, "log(\"size=\" + size)", "log(\"size=\" + size)") + + // Nothing in the region names `T`; only the copied receiver does. + assertEquals(ExtractionRefusal.UsesTypeParameter("T"), plan(content, start, end).refusal) + } + + @Test + fun `a labelled break targeting an outer loop is declined`() { + val content = + """ + package p + fun demo(rows: List>) { + outer@ for (row in rows) { + for (cell in row) { + if (cell < 0) break@outer + println(cell) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "for (cell in row)", "\t\t}") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a labelled continue targeting an outer loop is declined`() { + val content = + """ + package p + fun demo(rows: List>) { + outer@ for (row in rows) { + for (cell in row) { + if (cell < 0) continue@outer + println(cell) + } + } + } + """.trimIndent() + val (start, end) = selection(content, "for (cell in row)", "\t\t}") + + assertEquals(ExtractionRefusal.ExitsRegion, plan(content, start, end).refusal) + } + + @Test + fun `a local function target gets no visibility modifier`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun inner(b: Int): Int { + return b * 2 + } + return inner(a) + } + """.trimIndent() + + val result = plan(content, content.indexOf("b * 2") + 1) + val candidate = result.candidates.first { it.label == "b * 2" } + + // `private fun` inside a block does not compile. + assertEquals(emptyList(), candidate.modifiers) + assertEquals( + """ + package p + fun demo(a: Int): Int { + fun inner(b: Int): Int { + return doubled(b) + } + + fun doubled(b: Int): Int { + return b * 2 + } + return inner(a) + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "doubled")!!), + ) + } + + @Test + fun `an extension property accessor keeps its receiver`() { + val content = + """ + package p + class Foo(val n: Int) + fun Foo.bar(): Int = n * 2 + val Foo.doubled: Int + get() { + return bar() + 1 + } + """.trimIndent() + + val result = plan(content, content.indexOf("bar() + 1") + 1) + val candidate = result.candidates.first { it.label == "bar() + 1" } + + assertEquals("Foo", candidate.receiverTypeText) + } + + @Test + fun `a smart-cast parameter is declined`() { + val content = + """ + package p + fun demo(value: Any): Int { + if (value is String) { + return value.length + 1 + } + return 0 + } + """.trimIndent() + + // `value: Any` breaks the moved body; `value: String` breaks the call site. + assertEquals( + ExtractionRefusal.SmartCastParameter("value"), + plan(content, content.indexOf("value.length + 1") + 1).refusal, + ) + } + @Test fun `a parameter whose type cannot be written out is declined`() { val content = From 290e3ad6a9e3b3cf3bee2ad80f6018ed73eaa2de Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 17:57:13 +0000 Subject: [PATCH 28/62] ADFA-5080: Declare a local extracted function before its call site --- .../utils/refactor/ExtractMethodEdit.kt | 32 ++++-- .../utils/refactor/ExtractMethodPlan.kt | 8 ++ .../kotlin/utils/refactor/MethodSignature.kt | 74 +++++++++++-- .../utils/refactor/ExtractMethodEditTest.kt | 77 ++++++++++++++ .../refactor/ExtractMethodPlanEndToEndTest.kt | 100 +++++++++++++++++- 5 files changed, 266 insertions(+), 25 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt index 11a441c5f2..4d8f9c4f1e 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEdit.kt @@ -1,13 +1,15 @@ package com.itsaky.androidide.lsp.kotlin.utils.refactor /** - * The two replacements an extraction performs: the new function, then the call that replaces the + * The two replacements an extraction performs: the new function, and 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. + * **Descending document 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, so an earlier edit must never shift a later one. The result is therefore sorted by + * descending start offset rather than assuming which comes first: a member or top-level target is + * inserted *after* its anchor and leads the list, but a **local function must be declared before it + * is called**, so that insertion precedes the region and the call site leads instead. * * 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 @@ -25,7 +27,9 @@ fun buildExtractMethodRewrites( ): List? { val span = candidate.span if (span.end > fileText.length) return null - if (candidate.insertOffset > fileText.length || candidate.insertOffset < span.end) return null + if (candidate.insertOffset > fileText.length) return null + // Either side of the region is fine; inside it is incoherent -- the two edits would overlap. + if (candidate.insertOffset > span.start && candidate.insertOffset < span.end) return null val newline = detectNewline(fileText) val indent = candidate.insertIndent @@ -47,13 +51,21 @@ fun buildExtractMethodRewrites( 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('}') } + // A blank line separates the new function from its neighbour either way. Inserting before the + // anchor starts at the anchor's own line, whose indentation is already in the file ahead of the + // insertion point -- so that first indent is dropped here and put back in front of the anchor. + val insertionText = + if (candidate.insertOffset <= span.start) { + declaration.removePrefix(indent) + newline + newline + indent + } else { + newline + newline + declaration + } + val call = "$name(${candidate.parameters.joinToString(", ") { it.name }})" val callText = when (val form = candidate.callSite) { @@ -63,9 +75,9 @@ fun buildExtractMethodRewrites( } return listOf( - RewriteSpan(TextSpan(candidate.insertOffset, candidate.insertOffset), declaration), + RewriteSpan(TextSpan(candidate.insertOffset, candidate.insertOffset), insertionText), RewriteSpan(span, callText), - ) + ).sortedByDescending { it.span.start } } /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt index 17b39e3da3..920f0b1ed0 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt @@ -119,6 +119,14 @@ sealed interface ExtractionRefusal { data class SmartCastParameter( val name: String, ) : ExtractionRefusal + + /** + * A local `fun`, class or object the region uses but does not contain (R5). It goes out of scope + * once the region moves, and only values can be handed over as parameters. + */ + data class CapturedLocalDeclaration( + val name: String, + ) : ExtractionRefusal } /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt index 5c95af11f6..2b2c64d540 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -167,10 +167,11 @@ internal fun KaSession.buildCandidate( // a `var` and does not parse -- so the new member goes after the whole property (R4). The accessor // itself stays the capture boundary everywhere else. val anchor = (enclosing as? KtPropertyAccessor)?.property ?: enclosing + val isLocalTarget = anchor.parent is KtBlockExpression val modifiers = buildList { // A local function joins a block, and a visibility modifier on one does not compile. - if (anchor.parent !is KtBlockExpression) add("private") + if (!isLocalTarget) add("private") if (usesSuspend(elements)) add("suspend") } @@ -192,7 +193,10 @@ internal fun KaSession.buildCandidate( returnTypeText = returnTypeText, body = body, callSite = callSite, - insertOffset = anchor.textRange.endOffset, + // A local function is only visible from its declaration onward, so it has to go *before* the + // anchor that calls it. Sound in general: everything the anchor's body can reach is already + // declared above the anchor. Every other target keeps the new member after its anchor (R4). + insertOffset = if (isLocalTarget) anchor.textRange.startOffset else anchor.textRange.endOffset, insertIndent = leadingIndentAt(fileText, anchor.textRange.startOffset), ), ) @@ -284,22 +288,64 @@ private fun KaSession.capturedParameters( } if (!seen.add(key)) continue + val name = reference.getReferencedName() + // Only a value can be passed. A local `fun`, class or object declared outside the region goes + // out of scope once the region moves, and handing it over as a parameter of its own return type + // is not the same program (R5). + if (symbol !is KaVariableSymbol) { + return CaptureResult.Refused(ExtractionRefusal.CapturedLocalDeclaration(name)) + } + val typeText = renderedSymbolType(symbol) ?: return CaptureResult.Refused(ExtractionRefusal.UnrenderableType) // The signature must print the declared type, but the region may be leaning on a smart cast to // something narrower: the declared type breaks the moved body, the narrowed one breaks the call - // site. Asked only of values, since a smart cast is the only thing that can make the two differ. - if (symbol is KaVariableSymbol) { - val usedTypeText = renderedTypeOrNull(reference) - if (usedTypeText != null && usedTypeText != typeText) { - return CaptureResult.Refused(ExtractionRefusal.SmartCastParameter(reference.getReferencedName())) + // site. + when (val used = usedTypeOf(reference)) { + // An intersection (`A & B`) cannot be printed at all, but the declared type just rendered + // fine, so the two differ and this is a smart cast however it would have been spelled. + UsedType.Unrenderable -> { + return CaptureResult.Refused(ExtractionRefusal.SmartCastParameter(name)) + } + + is UsedType.Rendered -> { + if (used.text != typeText) { + return CaptureResult.Refused(ExtractionRefusal.SmartCastParameter(name)) + } + } + + UsedType.Absent -> { + Unit } } - parameters += MethodParameter(name = reference.getReferencedName(), typeText = typeText) + parameters += MethodParameter(name = name, typeText = typeText) } return CaptureResult.Captured(parameters) } +/** + * The type of a reference as the region uses it. + * + * [Unrenderable] is kept apart from [Absent] on purpose: folding them together is what let a smart + * cast to an intersection type pass as "no information" and emit the declared type. + */ +private sealed interface UsedType { + data object Absent : UsedType + + data object Unrenderable : UsedType + + data class Rendered( + val text: String, + ) : UsedType +} + +@OptIn(KaExperimentalApi::class) +private fun KaSession.usedTypeOf(expression: KtExpression): UsedType { + val rendered = + runCatching { expression.expressionType?.let { renderName(it) } }.getOrNull() ?: return UsedType.Absent + return if (isUnrenderable(rendered)) UsedType.Unrenderable else UsedType.Rendered(rendered) +} + /** Either the derived parameter list or the reason there cannot be one. */ private sealed interface CaptureResult { data class Captured( @@ -511,8 +557,13 @@ private fun targetLoopFor(jump: KtExpressionWithLabel): KtLoopExpression? { return null } +/** An accessor's type parameters live on its property, the same place its receiver does. */ private fun typeParameterNamesOf(enclosing: KtDeclaration): List = - (enclosing as? KtNamedFunction)?.typeParameters?.mapNotNull { it.name }.orEmpty() + when (enclosing) { + is KtNamedFunction -> enclosing.typeParameters.mapNotNull { it.name } + is KtPropertyAccessor -> enclosing.property.typeParameters.mapNotNull { it.name } + else -> emptyList() + } /** * The name of the enclosing function's type parameter the region *writes out*, or null. A filtered @@ -635,8 +686,9 @@ private fun KaSession.implicitReceiverLambdaFor(reference: KtSimpleNameExpressio val callSource = (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference val call = callSource.resolveToCall() - // An assignment target resolves to the whole compound access, which is not a member call and - // would otherwise slip through carrying its receiver with it: `n += 1` inside `apply { }`. + // Defensive only. A compound assignment (`n += 1` inside `apply { }`) redirects to the whole + // compound access, but the resolver flags that redirect and still hands back a plain variable + // access, so the branch above already catches it in this version. val applied = call?.successfulCallOrNull>()?.partiallyAppliedSymbol ?: call?.successfulCallOrNull()?.variableCall?.partiallyAppliedSymbol diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt index 32b4baa504..d722edabcd 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodEditTest.kt @@ -81,6 +81,83 @@ class ExtractMethodEditTest { ) } + @Test + fun `an insertion before the region puts the call site first`() { + // A local-function target: the new function is declared ahead of the one that calls it, so the + // descending-order invariant now puts the call site at the head of the list. + 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", + ).copy(insertOffset = enclosingStart, modifiers = emptyList()), + "total", + ) + + assertNotNull(rewrites) + assertEquals(2, rewrites!!.size) + assertTrue( + "the call site must come first when the insertion precedes the region", + rewrites[0].span.start > rewrites[1].span.start, + ) + assertEquals(span, rewrites[0].span) + assertEquals(enclosingStart, rewrites[1].span.start) + } + + @Test + fun `an insertion before the region declares the function ahead of its anchor`() { + 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", + ).copy(insertOffset = enclosingStart, modifiers = emptyList()), + "total", + ) + + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun total(a: Int, b: Int): Int {\n" + + "\t\treturn a + b\n" + + "\t}\n" + + "\n" + + "\tfun demo(a: Int, b: Int): Int {\n" + + "\t\tval sum = total(a, b)\n" + + "\t\treturn sum\n" + + "\t}\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `an insertion inside the region is rejected`() { + 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, + ).copy(insertOffset = span.start + 1), + "total", + ) + + assertNull(rewrites) + } + @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) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt index 603067343d..cf39509536 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt @@ -704,23 +704,115 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { val result = plan(content, content.indexOf("b * 2") + 1) val candidate = result.candidates.first { it.label == "b * 2" } - // `private fun` inside a block does not compile. + // `private fun` inside a block does not compile, and a local function is only visible from its + // declaration onward -- so it must land *before* the function that calls it. assertEquals(emptyList(), candidate.modifiers) assertEquals( """ package p fun demo(a: Int): Int { + fun doubled(b: Int): Int { + return b * 2 + } + fun inner(b: Int): Int { return doubled(b) } + return inner(a) + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "doubled")!!), + ) + } - fun doubled(b: Int): Int { + @Test + fun `a local target inserts the new function before the call site`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun inner(b: Int): Int { return b * 2 } return inner(a) } - """.trimIndent(), - apply(content, buildExtractMethodRewrites(result.fileText, candidate, "doubled")!!), + """.trimIndent() + + val result = plan(content, content.indexOf("b * 2") + 1) + val candidate = result.candidates.first { it.label == "b * 2" } + + assertTrue(candidate.insertOffset < candidate.span.start) + } + + @Test + fun `a type parameter on an extension property is declined`() { + val content = + """ + package p + val List.doubled: Int + get() { + return size * 2 + } + """.trimIndent() + val (start, end) = selection(content, "return size * 2", "return size * 2") + + // The accessor's type parameters live on its property, the same place its receiver does. + assertEquals(ExtractionRefusal.UsesTypeParameter("T"), plan(content, start, end).refusal) + } + + @Test + fun `a smart cast to an intersection type is declined`() { + val content = + """ + package p + interface A { fun a(): Int } + interface B { fun b(): Int } + fun demo(x: Any): Int { + if (x is A && x is B) { + return x.a() + x.b() + } + return 0 + } + """.trimIndent() + val (start, end) = selection(content, "return x.a() + x.b()", "return x.a() + x.b()") + + // The narrowed type cannot be written out at all, which is not the same as not knowing it. + assertEquals(ExtractionRefusal.SmartCastParameter("x"), plan(content, start, end).refusal) + } + + @Test + fun `a captured local function is declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + fun helper(): Int = 1 + return helper() + a + } + """.trimIndent() + val (start, end) = selection(content, "return helper() + a", "return helper() + a") + + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("helper"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a captured local class is declined`() { + val content = + """ + package p + fun demo(a: Int): Int { + class Holder(val n: Int) + return Holder(a).n + } + """.trimIndent() + val (start, end) = selection(content, "return Holder(a).n", "return Holder(a).n") + + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("Holder"), + plan(content, start, end).refusal, ) } From 0db25d27f1dae25896337153310e85988ccd3df6 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 18:17:11 +0000 Subject: [PATCH 29/62] ADFA-5080: Add the extract-method sheet state and strings --- .../refactor/ui/ExtractMethodUiState.kt | 50 ++++++ .../refactor/ui/ExtractMethodViewModel.kt | 80 ++++++++++ .../ui/ExtractVariableSheetContent.kt | 69 -------- .../lsp/kotlin/refactor/ui/SheetComponents.kt | 85 ++++++++++ .../refactor/ui/ExtractMethodViewModelTest.kt | 147 ++++++++++++++++++ resources/src/main/res/values/strings.xml | 16 ++ 6 files changed, 378 insertions(+), 69 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt new file mode 100644 index 0000000000..82bf60186f --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodUiState.kt @@ -0,0 +1,50 @@ +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, +) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt new file mode 100644 index 0000000000..4148307531 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModel.kt @@ -0,0 +1,80 @@ +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 + } + } +} 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 index 25409974ee..5d193ac223 100644 --- 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 @@ -6,14 +6,11 @@ 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 @@ -22,9 +19,7 @@ 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 /** @@ -134,67 +129,3 @@ fun ExtractVariableSheetContent( } } } - -@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/SheetComponents.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt new file mode 100644 index 0000000000..6a746e4634 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/SheetComponents.kt @@ -0,0 +1,85 @@ +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 + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt new file mode 100644 index 0000000000..755dbf054c --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodViewModelTest.kt @@ -0,0 +1,147 @@ +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()) + } +} diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 3ebed7e55b..94823eed10 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -539,6 +539,22 @@ That name is already used No expression to extract here The file changed. Try extracting again. + + + 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 + The selection uses field, which only exists inside this accessor + The selection uses %1$s under a smart cast that cannot be written outside it + The selection uses %1$s, which goes out of scope once the selection moves Select fields No fields selected No fields found From cf52d4560f01e5f44cfb5660f2e1b5c1eb6e735d Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 18:24:36 +0000 Subject: [PATCH 30/62] ADFA-5080: Reword two extract-method refusal messages --- resources/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 94823eed10..71e000dc4f 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -552,8 +552,8 @@ 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 - The selection uses field, which only exists inside this accessor - The selection uses %1$s under a smart cast that cannot be written outside it + The selection uses the property\'s backing field, which only exists inside this accessor + The selection uses %1$s under a smart cast that does not hold outside the selection The selection uses %1$s, which goes out of scope once the selection moves Select fields No fields selected From 626d97553615ec29e8ffe0b555926bed76770b47 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 18:30:08 +0000 Subject: [PATCH 31/62] ADFA-5080: Add the extract-method Compose sheet --- .../kotlin/refactor/ui/ExtractMethodSheet.kt | 96 +++++++++++++++++++ .../refactor/ui/ExtractMethodSheetContent.kt | 94 ++++++++++++++++++ 2 files changed, 190 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt new file mode 100644 index 0000000000..ef72fe6a78 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheet.kt @@ -0,0 +1,96 @@ +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 + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt new file mode 100644 index 0000000000..cf0e3cdf92 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/ExtractMethodSheetContent.kt @@ -0,0 +1,94 @@ +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 0012). + * + * 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)) + } + } + } +} From 13be859e92669eba99ddb45885cf40b6124181db Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Mon, 10 Aug 2026 18:44:05 +0000 Subject: [PATCH 32/62] ADFA-5080: Wire up the extract-method code action --- docs/features/kotlin-extract-method.md | 7 +- .../androidide/idetooltips/TooltipTag.kt | 1 + .../lsp/kotlin/KotlinCodeActionsMenu.kt | 2 + .../lsp/kotlin/actions/ExtractMethodAction.kt | 213 ++++++++++++++++++ .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 + 5 files changed, 223 insertions(+), 2 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt diff --git a/docs/features/kotlin-extract-method.md b/docs/features/kotlin-extract-method.md index 1b8c065c5c..45ddc42da0 100644 --- a/docs/features/kotlin-extract-method.md +++ b/docs/features/kotlin-extract-method.md @@ -1,7 +1,7 @@ # Kotlin extract method (K2 LSP) - **Ticket:** ADFA-5080 (subtask of ADFA-3317; split out of ADFA-4826, which now covers extract variable only) -- **Status:** Requirements only - not implemented +- **Status:** Implemented - **Module:** `lsp/kotlin` - **Vocabulary:** the term is **method**, matching the ticket and the already-fixed tooltip tag `editor.codeactions.kotlin.extractmethod`, even though the refactoring's output is a Kotlin `fun`. @@ -149,8 +149,11 @@ Including inherited names is a correctness requirement, not a nicety: a private | `InnerImplicitReceiver` | the selection uses members of an enclosing `with`/`apply` receiver | | `UsesTypeParameter` | the selection uses type parameter `` | | `UnrenderableType` | a type in the selection cannot be written out | +| `UsesBackingField` | the selection uses the property's backing field, only reachable inside this accessor | +| `SmartCastParameter` | the selection uses `` under a smart cast that does not hold outside it | +| `CapturedLocalDeclaration` | the selection uses ``, which goes out of scope once the selection moves | -Five of the seven are actionable - they tell the user what to change - and two of them (`ReassignsOuterVar`, `InnerImplicitReceiver`) are common enough that a generic message would read as the feature being broken. Given how much of this design is "decline cleanly", the refusal text is a first-class part of the feature. New entries in `resources/.../values/strings.xml`, picked up by the next translation batch. +Eight of the ten are actionable - they tell the user what to change - and several (`ReassignsOuterVar`, `InnerImplicitReceiver`, `UsesBackingField`) are common enough that a generic message would read as the feature being broken. Given how much of this design is "decline cleanly", the refusal text is a first-class part of the feature. New entries in `resources/.../values/strings.xml`, picked up by the next translation batch. The refusal lives on `ExtractMethodPlan` only; extract variable keeps its single "nothing to extract" behaviour unchanged. 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 ac8fd24d98..2295b925be 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -94,6 +94,7 @@ object TooltipTag { 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 EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD = "editor.codeactions.kotlin.extractmethod" const val EXIT_TO_MAIN = "exit.to.main" 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 311d0dadd6..e9be630f20 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 @@ -6,6 +6,7 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction import com.itsaky.androidide.lsp.actions.IActionsMenuProvider import com.itsaky.androidide.lsp.actions.UncommentLineAction import com.itsaky.androidide.lsp.kotlin.actions.AddImportAction +import com.itsaky.androidide.lsp.kotlin.actions.ExtractMethodAction import com.itsaky.androidide.lsp.kotlin.actions.ExtractVariableAction import com.itsaky.androidide.lsp.kotlin.actions.FindReferencesAction import com.itsaky.androidide.lsp.kotlin.actions.GoToDefinitionAction @@ -41,5 +42,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { NullSafetyAction(), ImplementMembersAction(), ExtractVariableAction(), + ExtractMethodAction(), ) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt new file mode 100644 index 0000000000..f1b7027b5b --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt @@ -0,0 +1,213 @@ +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 0013). + */ +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, + // Already in 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. + * + * Exhaustive with no `else`: a future variant added without a message here is a compile error + * rather than a silent gap. + */ + 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) + } + + ExtractionRefusal.UsesBackingField -> { + context.getString(R.string.msg_extract_method_uses_backing_field) + } + + is ExtractionRefusal.SmartCastParameter -> { + context.getString(R.string.msg_extract_method_smart_cast_parameter, refusal.name) + } + + is ExtractionRefusal.CapturedLocalDeclaration -> { + context.getString(R.string.msg_extract_method_captured_local_declaration, refusal.name) + } + } + + /** -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 504b4e50de..f93d1bc842 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 @@ -5,6 +5,7 @@ import com.itsaky.androidide.lsp.actions.CommentLineAction 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.ExtractMethodAction import com.itsaky.androidide.lsp.kotlin.actions.ExtractVariableAction import com.itsaky.androidide.lsp.kotlin.actions.FindReferencesAction import com.itsaky.androidide.lsp.kotlin.actions.GoToDefinitionAction @@ -43,6 +44,7 @@ class KotlinCodeActionTooltipTagTest { ImplementMembersAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS, SurroundWithTryCatchAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, ExtractVariableAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE, + ExtractMethodAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD, ) assertEquals(expected, actualTags) } From 6bc76f7de241e430e9c06a0d83da5ba3d7984f13 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 11 Aug 2026 13:26:19 +0000 Subject: [PATCH 33/62] ADFA-5080: Stop the analysis emitting Kotlin that does not compile Five ordinary shapes produced a broken file rather than a refusal, which ADR 0012 rules out: the refactoring moves code and declines where it cannot. - Signature types render fully qualified. A short name resolves only when the file already imports it, and a local's type usually comes from inference, so `val d = java.util.Date()` emitted an unresolved `Date`. `usedTypeOf` moves to the same renderer, or every capture would read as a smart cast. - A platform type is emitted as its flexible type's lower bound instead of `String!`, which does not parse. `!` anywhere in a rendered type now counts as unrenderable, catching the nested `List` the lower bound leaves. - `suspend` is no longer added for a call the region only makes inside a nested suspend-typed lambda. `launchIt { work() }` in a non-suspend function emitted a `suspend fun` its own call site could not call. Inline lambdas still propagate, and extracting from inside such a lambda still adds `suspend`. - The capture loop skips the selector of a qualified expression, refuses a value whose type is a class local to the enclosing declaration, and refuses rather than drops a local class or object used as a qualifier. `h.n` used to emit both a parameter named `n` that no call site had and a `Holder` type the new function could not see. - A tail return from a secondary constructor takes `Unit`, not the constructed class. `return extracted(...)` on a Unit call is legal in a constructor. Refusal quality and failure isolation, in the same pass: - `MultipleOutputs` split. It fired for three situations, two of which are one value, and rendered "produces more than one value: result". A single output the call site cannot receive back is now `OutputNotReturnable`. - `CouldNotAnalyse` added. A missing environment, an unreachable KtFile and a thrown error all reported "Select an expression, or whole statements inside one block" - the most confident message in the set, aimed at a selection nothing had looked at. Cancellation is re-thrown rather than swallowed. - `takenNamesFor` tests the local-`fun` target before the containing class, so a new local validates against its siblings instead of the class's members. - `applyChoice` runs from a Compose click handler outside every framework guard; its body is now wrapped. The feature doc's R4, R5, R7, R10, R14 and R15 are corrected to match, and its claim that the version guard lives on `RefactoringPlan` is dropped - the two actions each do their own comparison. --- docs/features/kotlin-extract-method.md | 22 +- .../lsp/kotlin/actions/ExtractMethodAction.kt | 28 +- .../utils/refactor/ExtractMethodPlan.kt | 21 +- .../utils/refactor/ExtractMethodPlanner.kt | 16 +- .../kotlin/utils/refactor/MethodSignature.kt | 231 ++++++++++--- .../refactor/ExtractMethodPlanEndToEndTest.kt | 315 +++++++++++++++++- resources/src/main/res/values/strings.xml | 2 + 7 files changed, 558 insertions(+), 77 deletions(-) diff --git a/docs/features/kotlin-extract-method.md b/docs/features/kotlin-extract-method.md index 45ddc42da0..f9965545a2 100644 --- a/docs/features/kotlin-extract-method.md +++ b/docs/features/kotlin-extract-method.md @@ -63,9 +63,9 @@ As with extract variable: **no `prepare()` visibility gate** (deciding extractab Restricting to siblings in one block excludes every hard case - a selection covering half an `if` and half its `else`, a range straddling a lambda boundary - by construction rather than by later filtering, exactly as `isLegalExtractionTarget` excludes expression fragments today. -**R3 - Live offsets and the version guard.** Identical to extract variable: analysis runs against `getCurrentKtFile(path)` fetched *before* entering `project.read`, the plan records the document version, and the version is re-read on confirm with a mismatch refusing the edit. Shared via the `RefactoringPlan` supertype. +**R3 - Live offsets and the version guard.** Identical to extract variable: analysis runs against `getCurrentKtFile(path)` fetched *before* entering `project.read`, the plan records the document version (on the `RefactoringPlan` supertype), and each action re-reads the version on confirm with a mismatch refusing the edit. -**R4 - Target.** One uniform rule, no target picker: **the new function is inserted as a sibling of the enclosing declaration**, immediately after it. That one rule produces the conventional answer in every context: +**R4 - Target.** One uniform rule, no target picker: **the new function is inserted as a sibling of the enclosing declaration** - immediately after it, except for a local `fun` target, where it goes immediately *before* it. A local function is only visible from its declaration onward, so it has to be declared above the code that calls it; every other target has no such constraint. That one rule produces the conventional answer in every context: | The region sits in | The new function becomes | |---|---| @@ -81,7 +81,7 @@ Unlike extract variable there is no scope chain and no ceiling, because anything - **Order** - first textual appearance in the region, so the signature reads in the order the body uses it. - **Names** - the original identifier, unchanged. `it` becomes a parameter literally named `it`, which is legal Kotlin, and the call site passes `it`. -- **Types** - the resolved type rendered with the existing `renderName(KaType)`. A type that cannot be rendered - an anonymous or intersection type, or a resolution failure - **declines the extraction** (`UnrenderableType`) rather than emitting uncompilable text. +- **Types** - the resolved type rendered **fully qualified** (`KaTypeRendererForSource.WITH_QUALIFIED_NAMES`), so `java.util.Date` rather than `Date`. Verbose, but a short name resolves only when the file already imports it, and a local's type usually comes from inference rather than a spelled-out type reference - this refactoring adds no imports. A **platform type** is emitted as its lower bound: the renderer prints `String!`, which does not parse, and the lower bound is both what IntelliJ writes and what the moved body already assumes. A type that cannot be rendered - anonymous, intersection, a resolution failure, or a `!` the lower bound did not remove (a platform type on a type *argument*) - **declines the extraction** (`UnrenderableType`) rather than emitting uncompilable text. A value whose type is a class declared inside the enclosing declaration declines too (`CapturedLocalDeclaration`): the value survives the move, its type name does not. - **Not editable.** The derived signature is shown read-only (R11). Renaming, reordering or excluding parameters is a desktop-sized dialog; a wrong parameter *name* is fixable afterwards with rename (ADFA-4825), and a wrong parameter *set* is not something the user could correct by hand anyway. **R6 - Return type and call-site form.** Determined by the region kind and its output: @@ -95,7 +95,7 @@ Unlike extract variable there is no scope chain and no ceiling, because anything A region that always throws still declares `Unit`; the exception propagates and the call site behaves identically, so `throw` needs no rule of its own. -**R7 - Outputs.** An output is a local declared inside the region and read after it. Exactly one is supported; **two or more declines** (`MultipleOutputs`, naming them). +**R7 - Outputs.** An output is a local declared inside the region and read after it. Exactly one plain `val`/`var` is supported; **two or more declines** (`MultipleOutputs`, naming them), and a single output the call site cannot receive back declines separately (`OutputNotReturnable`, naming it) - a destructuring entry or a local `fun`, which a `val` cannot stand in for, or a local the following code reassigns, which a `val` cannot be. The two are distinct refusals because "produces more than one value" is simply untrue of the second, and a refusal that misdescribes the situation teaches nothing. A `var` declared outside the region and **reassigned inside it declines** (`ReassignsOuterVar`, naming the variable), because Kotlin has no `out` parameters and the faithful emission - a parameter plus `var x = x` at the top of the body - carries a name-shadowing warning into generated code. This is deliberately stricter than dataflow requires: a reassignment whose result is never read afterwards is still refused, because proving that needs real liveness analysis. ADFA-5082 tracks supporting it. @@ -116,7 +116,7 @@ Declined: a `return` anywhere but the tail position, a `break`/`continue` whose **R10 - Modifiers.** Copy nothing from the enclosing declaration; add only what the body needs in order to compile in its new home. - **Visibility** - always `private`, whether a class member or top-level. Never `internal`, never `open`, no annotations copied, no KDoc generated. -- **`suspend`** - added when any call in the region resolves to a suspend function, or the region references `coroutineContext`. The call site is necessarily already a suspend context. +- **`suspend`** - added when any call in the region resolves to a suspend function, or the region references `coroutineContext`. The call site is necessarily already a suspend context. **Not** added for a suspension the region only performs inside a *nested* suspend-typed lambda - `scope.launch { }`, `runBlocking { }`, any `suspend () -> T` parameter: the region carries that lambda with it, so the new function needs no `suspend`, and adding it breaks a call site that is not itself a suspend context. An ordinary inline lambda (`forEach`, `let`, `run`) is not one of these and still propagates `suspend` outwards. - **`@Composable`** - added when any call in the region resolves to a `@Composable`-annotated function. This is not polish: CoGo users write Compose apps on the device, and an extracted composable without the annotation does not compile. - **Function-level type parameters** - a region referencing a type parameter declared on the *enclosing function* **declines** (`UsesTypeParameter`, naming it). Class-level type parameters need no rule; they stay in scope for a member. A filtered copy of the enclosing type-parameter list with its bounds would mean deciding "is `T` referenced" from rendered type text, which is fragile. @@ -143,7 +143,9 @@ Including inherited names is a correctness requirement, not a nicety: a private | Reason | Message intent | |---|---| | `NotASingleRegion` | select an expression, or whole statements inside one block | +| `CouldNotAnalyse` | the analysis could not run - deliberately neutral, since the selection may have been fine | | `MultipleOutputs` | the selection produces more than one value | +| `OutputNotReturnable` | the selection produces ``, which cannot be handed back as a return value | | `ReassignsOuterVar` | the selection assigns to ``, declared outside it | | `ExitsRegion` | the selection jumps out of itself (`return`/`break`/`continue`) | | `InnerImplicitReceiver` | the selection uses members of an enclosing `with`/`apply` receiver | @@ -153,13 +155,15 @@ Including inherited names is a correctness requirement, not a nicety: a private | `SmartCastParameter` | the selection uses `` under a smart cast that does not hold outside it | | `CapturedLocalDeclaration` | the selection uses ``, which goes out of scope once the selection moves | -Eight of the ten are actionable - they tell the user what to change - and several (`ReassignsOuterVar`, `InnerImplicitReceiver`, `UsesBackingField`) are common enough that a generic message would read as the feature being broken. Given how much of this design is "decline cleanly", the refusal text is a first-class part of the feature. New entries in `resources/.../values/strings.xml`, picked up by the next translation batch. +All but `CouldNotAnalyse` are actionable - they tell the user what to change - and several (`ReassignsOuterVar`, `InnerImplicitReceiver`, `UsesBackingField`) are common enough that a generic message would read as the feature being broken. `CouldNotAnalyse` exists precisely so the others stay truthful: a missing compilation environment, an unreachable `KtFile` or a thrown analysis error must not be reported as `NotASingleRegion`, which blames a selection nothing ever looked at. Given how much of this design is "decline cleanly", the refusal text is a first-class part of the feature. New entries in `resources/.../values/strings.xml`, picked up by the next translation batch. + +Cancellation is not a refusal at all: `buildExtractMethodPlan` re-throws `CancellationException` (which `AnalysisPreemptedException` is), so a cancelled action ends silently rather than flashing at a user who has moved on. The refusal lives on `ExtractMethodPlan` only; extract variable keeps its single "nothing to extract" behaviour unchanged. -**R15 - Edit.** Two regions change - the region becomes a call, and the new function appears after the enclosing declaration - emitted as **two `TextEdit`s in one `DocumentChange`, ordered new-function-first (descending document order)**. +**R15 - Edit.** Two regions change - the region becomes a call, and the new function appears next to the enclosing declaration - emitted as **two `TextEdit`s in one `DocumentChange`, sorted by descending start offset**. -The ordering is mandatory, not stylistic. `IDELanguageClientImpl.applyActionEdits` iterates the edit list in order and `editInEditor` applies each with **line/column** ranges against whatever the text is at that moment (the `index` in `Position` is ignored). Emitting the call site first would shift the insertion point and corrupt the file. +The ordering is mandatory, not stylistic. `IDELanguageClientImpl.applyActionEdits` iterates the edit list in order and `editInEditor` applies each with **line/column** ranges against whatever the text is at that moment (the `index` in `Position` is ignored), so an earlier edit must never shift a later one. Which edit leads follows from R4 rather than being fixed: a member or top-level target is inserted *after* its anchor, so the new function leads; a **local `fun` target is inserted before** its anchor, so the call site leads. **Known consequence:** nothing on that path calls `beginBatchEdit`, so this is **two undo entries**, and a single undo leaves a half-refactored, non-compiling file. This knowingly diverges from `RewriteSpan`'s single-replacement rule, which extract variable relies on. **ADFA-5081** fixes it properly by batching the edit loop in `applyActionEdits`, which benefits every multi-edit action; until it lands, the two-step undo is a stated limitation to be covered in QA. @@ -249,7 +253,7 @@ New files, all in `lsp/kotlin`: Reused from extract variable unchanged: `TextSpan`, `collapseForLabel`, `candidateExpressionsAt` / `CandidateSyntax`, `isExtractionPosition`, `enclosingExecutableBody`, `NameProblem` + `validateVariableName`, `suggestVariableName`, `detectIndentUnit`, `detectNewline`, `leadingIndentAt`, `lineStartOffset`, `RewriteSpan` + `toTextEdit`, `positionAt`, `renderName`. -Deliberately **not** reused: `ScopeOption`, `AnchorForm` and `CandidateExpression`. Each is shaped by the legal scope chain, which this refactoring does not have (R4) - so the two refactorings share primitives, not the aggregate. What they do share is hoisted into the sealed `RefactoringPlan` (`fileText`, `documentVersion`, the version guard), introduced in the extract-variable PR so this one is purely additive. +Deliberately **not** reused: `ScopeOption`, `AnchorForm` and `CandidateExpression`. Each is shaped by the legal scope chain, which this refactoring does not have (R4) - so the two refactorings share primitives, not the aggregate. What they do share is hoisted into the sealed `RefactoringPlan` (`fileText` and `documentVersion`), introduced in the extract-variable PR so this one is purely additive. The version *guard* itself - reading the live version and comparing - stays in each action rather than on the supertype, since it needs the `ActionData` and the action's own "file changed" string; hoisting it is a small cleanup, not a shared primitive today. Nothing outside `lsp/kotlin` changes except `TooltipTag.kt` and `values/strings.xml`. No new module, no new dependency. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt index f1b7027b5b..5dd2d95f65 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/ExtractMethodAction.kt @@ -59,11 +59,11 @@ class ExtractMethodAction : BaseKotlinCodeAction() { override suspend fun execAction(data: ActionData): ExtractMethodPlan { val server = data.get() - ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + ?: return ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) val nioPath = data.requireFile().toPath() val env = server.compilationEnvironmentFor(nioPath) - ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + ?: return ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) val cursor = data.requireEditor().cursor return buildExtractMethodPlan( @@ -86,7 +86,7 @@ class ExtractMethodAction : BaseKotlinCodeAction() { val context = data.requireContext() if (result.isEmpty) { - flashInfo(refusalMessage(context, result.refusal ?: ExtractionRefusal.NotASingleRegion)) + flashInfo(refusalMessage(context, result.refusal ?: ExtractionRefusal.CouldNotAnalyse)) return } @@ -111,11 +111,25 @@ class ExtractMethodAction : BaseKotlinCodeAction() { * 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. + * + * Runs from the sheet's click handler, outside `execAction` and so outside every guard the action + * framework provides -- nothing here may throw (R16), hence the [runCatching]. */ private fun applyChoice( data: ActionData, plan: ExtractMethodPlan, choice: ExtractMethodChoice, + ) { + runCatching { performChoice(data, plan, choice) }.onFailure { error -> + logger.error("Failed to apply the extract-method choice '{}'", choice.name, error) + flashError(R.string.msg_cannot_perform_fix) + } + } + + private fun performChoice( + data: ActionData, + plan: ExtractMethodPlan, + choice: ExtractMethodChoice, ) { val file = data.requireFile() val nioPath = file.toPath() @@ -171,10 +185,18 @@ class ExtractMethodAction : BaseKotlinCodeAction() { context.getString(R.string.msg_extract_method_not_single_region) } + ExtractionRefusal.CouldNotAnalyse -> { + context.getString(R.string.msg_extract_method_could_not_analyse) + } + is ExtractionRefusal.MultipleOutputs -> { context.getString(R.string.msg_extract_method_multiple_outputs, refusal.names.joinToString(", ")) } + is ExtractionRefusal.OutputNotReturnable -> { + context.getString(R.string.msg_extract_method_output_not_returnable, refusal.name) + } + is ExtractionRefusal.ReassignsOuterVar -> { context.getString(R.string.msg_extract_method_reassigns_outer_var, refusal.name) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt index 920f0b1ed0..afe51cfab3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlan.kt @@ -76,14 +76,29 @@ sealed interface ExtractionRefusal { data object NotASingleRegion : ExtractionRefusal /** - * The region declares something the code after it still needs, and a single returned value cannot - * carry it (R7): two or more locals, a destructuring declaration, a local `fun` or class, or a - * local the following code reassigns. [names] is what is in the way, so the message can name it. + * The analysis could not run at all -- no compilation environment, no `KtFile`, or something threw. + * Deliberately neutral: the selection may have been perfectly good, so it must not be blamed the way + * [NotASingleRegion] blames it. + */ + data object CouldNotAnalyse : ExtractionRefusal + + /** + * The region declares two or more values the code after it still needs, and one return cannot carry + * them (R7). [names] is what is in the way, so the message can name them. */ data class MultipleOutputs( val names: List, ) : ExtractionRefusal + /** + * The region declares exactly one thing the code after it still needs, but the call site cannot + * receive it back (R7): a destructuring entry or a local `fun`, which a `val` cannot stand in for, + * or a local the following code reassigns, which a `val` cannot be. + */ + data class OutputNotReturnable( + val name: String, + ) : ExtractionRefusal + /** A `var` declared outside the region is assigned inside it. ADFA-5082 lifts this (R7). */ data class ReassignsOuterVar( val name: String, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt index 55143ab0c1..ebc20d4eab 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanner.kt @@ -7,6 +7,7 @@ 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 +import kotlin.coroutines.cancellation.CancellationException private val logger = LoggerFactory.getLogger("ExtractMethodPlanner") @@ -18,7 +19,11 @@ private val logger = LoggerFactory.getLogger("ExtractMethodPlanner") * * 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). + * uncaught throw would crash the app (R16). Cancellation is the exception -- it is re-thrown, since a + * cancelled action has no result to report and the coroutine machinery already handles it. + * + * Everything that is not "your selection is not one region" refuses with [ExtractionRefusal.CouldNotAnalyse]: + * blaming a selection that may have been fine is worse than saying nothing useful. */ internal fun buildExtractMethodPlan( env: AbstractCompilationEnvironment, @@ -31,7 +36,7 @@ internal fun buildExtractMethodPlan( runCatching { val ktFile = env.ktSymbolIndex.getCurrentKtFile(nioPath).get() - ?: return ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + ?: return ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) env.project.read { val fileText = ktFile.text @@ -54,9 +59,11 @@ internal fun buildExtractMethodPlan( 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. + // A region with no reason at all cannot happen; if it does, saying nothing useful beats + // blaming the selection. val refusal = results.filterIsInstance().firstOrNull()?.refusal - ?: ExtractionRefusal.NotASingleRegion + ?: ExtractionRefusal.CouldNotAnalyse return@analyzeMaybeDangling ExtractMethodPlan.refused(refusal, fileText, documentVersion) } @@ -75,6 +82,7 @@ internal fun buildExtractMethodPlan( } } }.getOrElse { error -> + if (error is CancellationException) throw error logger.warn("Failed to build extract-method plan for {}", nioPath, error) - ExtractMethodPlan.refused(ExtractionRefusal.NotASingleRegion) + ExtractMethodPlan.refused(ExtractionRefusal.CouldNotAnalyse) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt index 2b2c64d540..03bfb1d7b5 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -3,6 +3,7 @@ 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.resolution.KaCallableMemberCall import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundArrayAccessCall import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundVariableAccessCall @@ -20,6 +21,10 @@ 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.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.types.KaClassType +import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaFunctionType +import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.builtins.StandardNames import org.jetbrains.kotlin.com.intellij.psi.PsiElement import org.jetbrains.kotlin.com.intellij.psi.util.PsiTreeUtil @@ -63,6 +68,11 @@ private const val UNNAMED_SCOPING_CONSTRUCT = "lambda" private const val BACKING_FIELD_NAME = "field" +private const val COROUTINE_CONTEXT_NAME = "coroutineContext" + +/** As [SIGNATURE_TYPE_RENDERER] prints it. A `Unit` return type is left off the signature entirely. */ +private const val UNIT_TYPE_TEXT = "kotlin.Unit" + /** Either a derived candidate or the reason there is not one. */ internal sealed interface SignatureResult { data class Success( @@ -101,15 +111,17 @@ internal fun KaSession.buildCandidate( val outputs = if (isExpression) RegionOutputs.NONE else outputsOf(enclosing, elements, span) // Only a single plain `val`/`var` can come back as the return value. Everything else the region - // declares and the following code still needs -- a second local, a destructuring entry, a local - // `fun`, or a local reassigned afterwards -- is refused rather than silently dropped (R7). - if (outputs.declarations.size > 1 || - outputs.declarations.any { it !is KtProperty } || - outputs.writtenAfter.isNotEmpty() - ) { + // declares and the following code still needs is refused rather than silently dropped (R7), split + // by which situation it is: two values genuinely cannot fit in one return, while a lone + // destructuring entry, local `fun` or reassigned local is one value the call site cannot receive. + if (outputs.declarations.size > 1) { return refuse(ExtractionRefusal.MultipleOutputs(outputs.declarations.mapNotNull { it.name })) } - val output = outputs.declarations.singleOrNull() as? KtProperty + val declared = outputs.declarations.singleOrNull() + if (declared != null && (declared !is KtProperty || outputs.writtenAfter.isNotEmpty())) { + return refuse(ExtractionRefusal.OutputNotReturnable(declared.name.orEmpty())) + } + val output = declared as? KtProperty // The tail-return exception holds only when nothing else flows out (R8). if (tailReturn && output != null) return refuse(ExtractionRefusal.ExitsRegion) @@ -126,7 +138,14 @@ internal fun KaSession.buildCandidate( } tailReturn -> { - enclosingReturnType(enclosing) ?: return refuse(ExtractionRefusal.UnrenderableType) + // A secondary constructor's symbol returns the constructed class, but its `return` + // carries no value -- so the extracted tail is `Unit`, and `return extracted(...)` on a + // `Unit` call is legal inside a constructor. (`init` needs no rule: `return` is illegal + // there, so no tail return can reach here.) + when (enclosing) { + is KtSecondaryConstructor -> UNIT_TYPE_TEXT + else -> enclosingReturnType(enclosing) ?: return refuse(ExtractionRefusal.UnrenderableType) + } } output != null -> { @@ -136,7 +155,7 @@ internal fun KaSession.buildCandidate( else -> { null } - }.takeUnless { it == "Unit" } + }.takeUnless { it == UNIT_TYPE_TEXT } val receiverTypeText = receiverTypeTextOf(enclosing) @@ -162,12 +181,12 @@ internal fun KaSession.buildCandidate( else -> CallSiteForm.Call } - val takenNames = takenNamesFor(enclosing) // A getter is not a place a function can follow -- inserting there lands between the accessors of // a `var` and does not parse -- so the new member goes after the whole property (R4). The accessor // itself stays the capture boundary everywhere else. val anchor = (enclosing as? KtPropertyAccessor)?.property ?: enclosing val isLocalTarget = anchor.parent is KtBlockExpression + val takenNames = takenNamesFor(enclosing, anchor, isLocalTarget) val modifiers = buildList { // A local function joins a block, and a visibility modifier on one does not compile. @@ -240,6 +259,35 @@ private fun descendantsOf( type: Class, ): List = elements.flatMap { PsiTreeUtil.collectElementsOfType(it, type) } +/** + * Whether [reference] is the selector of a qualified expression -- the `n` in `h.n`, the `f` in + * `h.f()`. A call wraps its callee, so the call expression is what the qualified expression holds. + */ +private fun isQualifiedSelector(reference: KtSimpleNameExpression): Boolean { + val selector = (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference + val parent = selector.parent + return parent is KtQualifiedExpression && parent.selectorExpression === selector +} + +/** + * The name of a class declared inside [enclosing] that [type] is written in terms of, or null. + * + * A value of such a type survives the move, but its type name does not resolve at the insertion + * point, so no parameter can be written for it. Type arguments are searched too: `List` is + * just as unwritable as `Holder`. + */ +private fun KaSession.localTypeNameIn( + type: KaType?, + enclosing: KtDeclaration, +): String? { + val classType = ((type as? KaFlexibleType)?.lowerBound ?: type) as? KaClassType ?: return null + val psi = runCatching { classType.symbol.psi }.getOrNull() + if (psi != null && PsiTreeUtil.isAncestor(enclosing, psi, true)) { + return (classType.symbol as? KaNamedSymbol)?.name?.asString() + } + return classType.typeArguments.firstNotNullOfOrNull { localTypeNameIn(it.type, enclosing) } +} + /** * 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 @@ -257,9 +305,27 @@ private fun KaSession.capturedParameters( val seen = mutableSetOf() for (reference in simpleNamesIn(elements).sortedBy { it.textRange.startOffset }) { - val symbol = - runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() as? KaCallableSymbol - ?: continue + // The selector of a qualified expression already has its receiver written out next to it and + // resolves through that receiver wherever the code lives, so it is never a capture. Without this + // the `n` in `h.n` became a parameter and the call site passed a name that does not exist. + // `innerImplicitReceiver` has the same guard for the same reason. + if (isQualifiedSelector(reference)) continue + + val resolved = + runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() ?: continue + val name = reference.getReferencedName() + + // A local class or object is not a callable, so it used to fail the cast below and be silently + // dropped -- emitting a body that names a type the new function cannot see. It is refused here + // for the same reason a local `fun` is: only values can be handed over as parameters (R5). + if (resolved is KaClassSymbol) { + val classPsi = runCatching { resolved.psi }.getOrNull() + if (classPsi != null && PsiTreeUtil.isAncestor(enclosing, classPsi, true) && !inRegion(classPsi, span)) { + return CaptureResult.Refused(ExtractionRefusal.CapturedLocalDeclaration(name)) + } + } + + val symbol = resolved as? KaCallableSymbol ?: continue val declarationPsi = runCatching { symbol.psi }.getOrNull() val key: Any = @@ -275,7 +341,7 @@ private fun KaSession.capturedParameters( // lambda is outside the region, and keyed on the lambda so that an `it` bound inside the // region cannot evict a genuinely captured outer one. symbol is KaValueParameterSymbol && - reference.getReferencedName() == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() -> { + name == StandardNames.IMPLICIT_LAMBDA_PARAMETER_NAME.asString() -> { val lambda = PsiTreeUtil.getParentOfType(reference, KtFunctionLiteral::class.java, true) ?: continue if (inRegion(lambda, span)) continue @@ -288,7 +354,6 @@ private fun KaSession.capturedParameters( } if (!seen.add(key)) continue - val name = reference.getReferencedName() // Only a value can be passed. A local `fun`, class or object declared outside the region goes // out of scope once the region moves, and handing it over as a parameter of its own return type // is not the same program (R5). @@ -296,6 +361,12 @@ private fun KaSession.capturedParameters( return CaptureResult.Refused(ExtractionRefusal.CapturedLocalDeclaration(name)) } + // The value survives the move but its type may not: a local class declared inside the enclosing + // declaration is out of scope at the insertion point, so the parameter could not be written. + localTypeNameIn(runCatching { symbol.returnType }.getOrNull(), enclosing)?.let { + return CaptureResult.Refused(ExtractionRefusal.CapturedLocalDeclaration(it)) + } + val typeText = renderedSymbolType(symbol) ?: return CaptureResult.Refused(ExtractionRefusal.UnrenderableType) // The signature must print the declared type, but the region may be leaning on a smart cast to @@ -339,10 +410,9 @@ private sealed interface UsedType { ) : UsedType } -@OptIn(KaExperimentalApi::class) private fun KaSession.usedTypeOf(expression: KtExpression): UsedType { val rendered = - runCatching { expression.expressionType?.let { renderName(it) } }.getOrNull() ?: return UsedType.Absent + runCatching { expression.expressionType?.let { renderTypeText(it) } }.getOrNull() ?: return UsedType.Absent return if (isUnrenderable(rendered)) UsedType.Unrenderable else UsedType.Rendered(rendered) } @@ -357,30 +427,54 @@ private sealed interface CaptureResult { ) : CaptureResult } -/** A type that cannot be written out as source -- anonymous, intersection, or a resolution error. */ +/** + * A type that cannot be written out as source -- anonymous, intersection, a resolution error, or a + * platform type [renderTypeText] could not reduce (`List`, where the `!` is on a type + * argument). `!` is not Kotlin syntax anywhere, so its presence alone settles it. + */ private fun isUnrenderable(text: String): Boolean = text.isBlank() || text.contains("anonymous") || text.contains("ERROR") || - text.contains(" & ") + text.contains(" & ") || + text.contains('!') +/** + * Types in the emitted signature are rendered **fully qualified**. + * + * A short name resolves only when the file already imports it, and a local's type usually comes from + * inference rather than a spelled-out type reference -- `val d = java.util.Date()` names `Date` + * nowhere -- so a short name is unresolved as often as not, and this refactoring adds no imports. + * Verbose, but it always resolves. [receiverTypeTextOf] deliberately stays on source text instead: + * that text is already in the file. + */ @OptIn(KaExperimentalApi::class) -private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = - runCatching { renderName(symbol.returnType) }.getOrNull()?.takeUnless(::isUnrenderable) +private val SIGNATURE_TYPE_RENDERER = KaTypeRendererForSource.WITH_QUALIFIED_NAMES +/** + * One type as the signature would print it, before the [isUnrenderable] check. + * + * A platform type is unwrapped to its lower bound: the renderer prints `String!`, which does not + * parse, and the lower bound is both what IntelliJ writes and what the moved body already assumes. + * Only the outermost bound is unwrapped, so a `!` on a type argument survives to [isUnrenderable]. + */ @OptIn(KaExperimentalApi::class) +private fun KaSession.renderTypeText(type: KaType): String = + renderName((type as? KaFlexibleType)?.lowerBound ?: type, SIGNATURE_TYPE_RENDERER) + +private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = + runCatching { renderTypeText(symbol.returnType) }.getOrNull()?.takeUnless(::isUnrenderable) + private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = - runCatching { expression.expressionType?.let { renderName(it) } }.getOrNull()?.takeUnless(::isUnrenderable) + runCatching { expression.expressionType?.let { renderTypeText(it) } }.getOrNull()?.takeUnless(::isUnrenderable) -@OptIn(KaExperimentalApi::class) private fun KaSession.renderedDeclarationType(property: KtProperty): String? = - runCatching { (property.symbol as? KaCallableSymbol)?.returnType?.let { renderName(it) } } + runCatching { (property.symbol as? KaCallableSymbol)?.returnType?.let { renderTypeText(it) } } .getOrNull() ?.takeUnless(::isUnrenderable) -@OptIn(KaExperimentalApi::class) private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = - runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType?.let { renderName(it) } } + runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType?.let { renderTypeText(it) } } .getOrNull() ?.takeUnless(::isUnrenderable) @@ -643,8 +737,7 @@ private fun KaSession.innerImplicitReceiver( ): String? { for (reference in simpleNamesIn(elements)) { // A qualified selector already has its receiver written out next to it. - val parent = reference.parent - if (parent is KtQualifiedExpression && parent.selectorExpression === reference) continue + if (isQualifiedSelector(reference)) continue val lambda = implicitReceiverLambdaFor(reference) ?: continue if (isBoundOutsideRegion(enclosing, lambda, span)) return constructNameFor(lambda) @@ -723,16 +816,59 @@ private fun receiverTypeTextOf(enclosing: KtDeclaration): String? = else -> 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 +/** + * `suspend` is added when the region calls one, or touches `coroutineContext` (R10). + * + * A suspension the region only performs inside a *nested* suspend-typed lambda does not count: the + * region carries that lambda with it, so the new function needs no `suspend`, and adding it breaks a + * call site that is not itself a suspend context. `scope.launch { }` and `runBlocking { }` are that + * shape, and "extract this whole launch block" is an everyday request. + */ +private fun KaSession.usesSuspend(elements: List): Boolean = + elements.any { root -> + PsiTreeUtil + .collectElementsOfType(root, KtSimpleNameExpression::class.java) + .any { it.getReferencedName() == COROUTINE_CONTEXT_NAME && !inNestedSuspendLambda(it, root) } || + PsiTreeUtil + .collectElementsOfType(root, KtCallExpression::class.java) + .any { isSuspendCall(it) && !inNestedSuspendLambda(it, root) } } + +private fun KaSession.isSuspendCall(call: KtCallExpression): Boolean = + runCatching { + (call.resolveToCall()?.successfulFunctionCallOrNull()?.symbol as? KaNamedFunctionSymbol)?.isSuspend + }.getOrNull() == true + +/** + * Whether [element] sits inside a suspend-typed lambda that is itself inside [root]. + * + * An ordinary inline lambda -- `forEach`, `let`, `run` -- is not one, so a suspension inside it still + * propagates `suspend` outwards, which is correct: those bodies run in the caller's context. + */ +private fun KaSession.inNestedSuspendLambda( + element: PsiElement, + root: PsiElement, +): Boolean { + // Strict ancestors of [element] that are strict descendants of [root]. A lambda *containing* the + // region is not one of these: the region moves out of it, so the suspension is the new function's. + var current: PsiElement? = element.takeIf { it !== root }?.parent + while (current != null && current !== root) { + if (current is KtFunctionLiteral && isSuspendLambda(current)) return true + current = current.parent + } + return false } +/** + * Read off the lambda expression's own functional type rather than its symbol: the anonymous-function + * symbol in this Analysis API build carries no `suspend`, while the type inferred from the parameter + * it is passed to does. + */ +private fun KaSession.isSuspendLambda(lambda: KtFunctionLiteral): Boolean = + runCatching { + ((lambda.parent as? KtLambdaExpression)?.expressionType as? KaFunctionType)?.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). @@ -752,12 +888,27 @@ private fun KaSession.usesComposable(elements: List): Boolean = /** * Names the new function must avoid (R12). * + * [isLocalTarget] is tested first, and must be: a local `fun` inside a class member competes with the + * enclosing block's declarations, not with the class's members, and validating against the class + * instead lets the new local collide with a sibling local -- a redeclaration error. + * * 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 { +private fun KaSession.takenNamesFor( + enclosing: KtDeclaration, + anchor: KtDeclaration, + isLocalTarget: Boolean, +): Set { + if (isLocalTarget) { + return PsiTreeUtil + .collectElementsOfType(anchor.parent, KtDeclaration::class.java) + .mapNotNull { it.name } + .toSet() + } + val containingClass = PsiTreeUtil.getParentOfType(enclosing, KtClassOrObject::class.java, true) if (containingClass != null) { val fromScope = @@ -772,14 +923,6 @@ private fun KaSession.takenNamesFor(enclosing: KtDeclaration): Set { 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() diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt index cf39509536..8ececb967a 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt @@ -1,10 +1,14 @@ package com.itsaky.androidide.lsp.kotlin.utils.refactor +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.progress.ICancelChecker import org.junit.Assert.assertEquals import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows import org.junit.Assert.assertTrue import org.junit.Test +import java.util.concurrent.CancellationException /** * The parts of the plan that need real resolution: the parameter set, the return type and call-site @@ -51,8 +55,9 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { 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) + // Types are emitted fully qualified so they resolve without an import the file may not have. + assertEquals(listOf("b" to "kotlin.Int", "a" to "kotlin.Int"), candidate.parameters.map { it.name to it.typeText }) + assertEquals("kotlin.Int", candidate.returnTypeText) assertEquals(listOf("private"), candidate.modifiers) } @@ -94,7 +99,7 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { val candidate = result.candidates.single() assertEquals(CallSiteForm.AssignOutput("doubled"), candidate.callSite) - assertEquals("Int", candidate.returnTypeText) + assertEquals("kotlin.Int", candidate.returnTypeText) } @Test @@ -152,7 +157,7 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { val candidate = result.candidates.single() assertEquals(CallSiteForm.Return, candidate.callSite) - assertEquals("Int", candidate.returnTypeText) + assertEquals("kotlin.Int", candidate.returnTypeText) assertEquals( """ package p @@ -161,7 +166,7 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { return finish(doubled) } - private fun finish(doubled: Int): Int { + private fun finish(doubled: kotlin.Int): kotlin.Int { return doubled + 1 } """.trimIndent(), @@ -357,7 +362,7 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { return total(a, b) } - private fun total(a: Int, b: Int): Int { + private fun total(a: kotlin.Int, b: kotlin.Int): kotlin.Int { return a + b } } @@ -417,11 +422,9 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { """.trimIndent() val (start, end) = selection(content, "var result", "var result = compute()") - val refusal = plan(content, start, end).refusal - - // A `val` at the call site cannot carry an output the following code assigns to. - assertTrue(refusal is ExtractionRefusal.MultipleOutputs) - assertEquals(listOf("result"), (refusal as ExtractionRefusal.MultipleOutputs).names) + // A `val` at the call site cannot carry an output the following code assigns to -- which is one + // value the call site cannot receive, not "more than one value". + assertEquals(ExtractionRefusal.OutputNotReturnable("result"), plan(content, start, end).refusal) } @Test @@ -479,7 +482,7 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { // `helper()` comes from the supertype, not from `with`'s receiver. assertNull(result.refusal) - assertEquals("Int", result.candidates.first { it.label == "helper() + 1" }.returnTypeText) + assertEquals("kotlin.Int", result.candidates.first { it.label == "helper() + 1" }.returnTypeText) } @Test @@ -533,7 +536,7 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { backing = value } - private fun next(): Int { + private fun next(): kotlin.Int { return backing + 1 } } @@ -711,7 +714,7 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { """ package p fun demo(a: Int): Int { - fun doubled(b: Int): Int { + fun doubled(b: kotlin.Int): kotlin.Int { return b * 2 } @@ -855,6 +858,290 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { ) } + @Test + fun `a file the analysis cannot reach is declined as not analysable, not as a bad selection`() { + createSourceFile("Main.kt", "package p\n") + val missing = env.sourceRoots.first().resolve("Absent.kt") + + // "Select an expression, or whole statements inside one block" would blame a selection that + // never got looked at. + assertEquals( + ExtractionRefusal.CouldNotAnalyse, + buildExtractMethodPlan(env, missing, 0, 0, documentVersion = 1, cancelChecker = noopCancelChecker()).refusal, + ) + } + + @Test + fun `cancellation propagates instead of being reported as a refusal`() { + val content = + """ + package p + fun demo(a: Int): Int { + return a * 2 + } + """.trimIndent() + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + val cancelled = ScheduledCancelChecker(ICancelChecker.CANCELLED) + + // A cancelled action has no result to report; swallowing this would flash a message at a user + // who already moved on. + assertThrows(CancellationException::class.java) { + buildExtractMethodPlan( + env, + path, + content.indexOf("a * 2"), + content.indexOf("a * 2") + 5, + documentVersion = 1, + cancelChecker = cancelled, + ) + } + } + + @Test + fun `a type the file does not import is emitted fully qualified`() { + val content = + """ + package p + fun demo() { + val d = java.util.Date() + println(d.time) + } + """.trimIndent() + val (start, end) = selection(content, "println(d.time)", "println(d.time)") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + // `Date` came from inference, so the file names it nowhere and a short name would not resolve. + assertEquals(listOf("d" to "java.util.Date"), candidate.parameters.map { it.name to it.typeText }) + assertEquals( + """ + package p + fun demo() { + val d = java.util.Date() + extracted(d) + } + + private fun extracted(d: java.util.Date) { + println(d.time) + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "extracted")!!), + ) + } + + @Test + fun `a platform type is emitted as its lower bound rather than as String bang`() { + val content = + """ + package p + fun demo() { + val v = System.getProperty("k") + println(v.length) + } + """.trimIndent() + val (start, end) = selection(content, "println(v.length)", "println(v.length)") + + val candidate = plan(content, start, end).candidates.single() + + // `String!` is not Kotlin syntax; the lower bound is what the moved body already assumes. + assertEquals(listOf("v" to "kotlin.String"), candidate.parameters.map { it.name to it.typeText }) + } + + @Test + fun `a suspend call inside a nested suspend lambda does not add the suspend modifier`() { + val content = + """ + package p + suspend fun work() {} + fun launchIt(block: suspend () -> Unit) {} + fun demo() { + launchIt { work() } + println("x") + } + """.trimIndent() + val (start, end) = selection(content, "launchIt { work() }", "launchIt { work() }") + + val candidate = plan(content, start, end).candidates.single() + + // `demo` is not a suspend context, so a `suspend fun` here would not compile at the call site. + assertEquals(listOf("private"), candidate.modifiers) + } + + @Test + fun `a suspend call inside an ordinary inline lambda still adds the suspend modifier`() { + val content = + """ + package p + suspend fun work(n: Int) {} + suspend fun demo(items: List) { + items.forEach { work(it) } + println("x") + } + """.trimIndent() + val (start, end) = selection(content, "items.forEach", "items.forEach { work(it) }") + + val candidate = plan(content, start, end).candidates.single() + + // `forEach`'s lambda runs in the caller's context, so the suspension is the new function's. + assertEquals(listOf("private", "suspend"), candidate.modifiers) + } + + @Test + fun `statements inside a suspend lambda still add the suspend modifier`() { + val content = + """ + package p + suspend fun work() {} + fun launchIt(block: suspend () -> Unit) {} + fun demo() { + launchIt { + work() + } + } + """.trimIndent() + val start = content.indexOf("work()", content.indexOf("launchIt {")) + + val candidate = plan(content, start, start + "work()".length).candidates.single() + + // The region is *inside* the suspend lambda, so its own call site is a suspend context. + assertEquals(listOf("private", "suspend"), candidate.modifiers) + } + + @Test + fun `a qualified selector is not turned into a parameter`() { + val content = + """ + package p + class Holder(val n: Int) + fun demo(h: Holder): Int { + return h.n + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return h.n + 1", "return h.n + 1") + + val candidate = plan(content, start, end).candidates.single() + + // `n` resolves through `h` wherever the code lives; passing it would name a nonexistent local. + assertEquals(listOf("h"), candidate.parameters.map { it.name }) + } + + @Test + fun `a value typed by a local class is declined rather than emitted`() { + val content = + """ + package p + fun demo(): Int { + class Holder(val n: Int) + val h = Holder(1) + return h.n + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return h.n + 1", "return h.n + 1") + + // `Holder` is out of scope at the insertion point, so no parameter for `h` can be written. + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("Holder"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a local object used as a qualifier is declined rather than dropped`() { + val content = + """ + package p + fun demo(): Int { + object Cfg { val n = 1 } + return Cfg.n + 1 + } + """.trimIndent() + val (start, end) = selection(content, "return Cfg.n + 1", "return Cfg.n + 1") + + // A class symbol is not callable, so it used to fail the capture cast and vanish silently. + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("Cfg"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a tail return in a secondary constructor extracts a Unit function`() { + val content = + """ + package p + class Foo { + constructor(x: Int) { + println(x) + return + } + } + """.trimIndent() + val (start, end) = selection(content, "println(x)", "return") + + val result = plan(content, start, end) + val candidate = result.candidates.single() + + // A constructor's symbol returns the constructed class, but its `return` carries no value. + assertNull(candidate.returnTypeText) + assertEquals( + """ + package p + class Foo { + constructor(x: Int) { + return tail(x) + } + + private fun tail(x: kotlin.Int) { + println(x) + return + } + } + """.trimIndent(), + apply(content, buildExtractMethodRewrites(result.fileText, candidate, "tail")!!), + ) + } + + @Test + fun `a single destructuring entry read after the region is not reported as more than one value`() { + val content = + """ + package p + data class Point(val a: Int, val b: Int) + fun demo(p: Point): Int { + val (x, y) = p + return x + 1 + } + """.trimIndent() + val (start, end) = selection(content, "val (x, y)", "val (x, y) = p") + + // One value, in a form the call site cannot receive -- not "more than one value". + assertEquals(ExtractionRefusal.OutputNotReturnable("x"), plan(content, start, end).refusal) + } + + @Test + fun `a local fun target validates its name against the enclosing block, not the class`() { + val content = + """ + package p + class C { + fun demo(a: Int): Int { + fun inner(b: Int): Int { + return b * 2 + } + return inner(a) + } + } + """.trimIndent() + + val candidate = plan(content, content.indexOf("b * 2") + 1).candidates.first { it.label == "b * 2" } + + // A sibling local named `inner` is what the new local `fun` would redeclare; `demo` is not. + assertTrue("inner" in candidate.takenNames) + assertTrue("demo" !in candidate.takenNames) + } + @Test fun `a parameter whose type cannot be written out is declined`() { val content = diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 71e000dc4f..22fa37ae8c 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -546,7 +546,9 @@ Signature The file changed. Try extracting again. Select an expression, or whole statements inside one block + Could not analyse the selection. Try again. The selection produces more than one value: %1$s + The selection produces %1$s, which cannot be handed back as a return value 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 From b95542afaa50263a81acda28a282a4d0182bdfd3 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Tue, 11 Aug 2026 13:57:46 +0000 Subject: [PATCH 34/62] ADFA-5080: Revert the qualified-selector capture guard The guard added in 0a20c0d76 dropped a selector that still needed capturing, and emitted a broken file in two shapes the previous behaviour refused: - a local extension `fun` called as `h.twice()` was skipped, so nothing refused and the moved body called a function out of scope there. - pointing `innerImplicitReceiver` at the same helper skipped a *call* selector, losing the `with`-receiver refusal for a member extension -- the pervasive Compose shape, `with(density) { size.toPx() }`. Both were reproduced before the revert and re-checked after it. The shape the guard was meant to fix, a member of a local class reached as `h.f()`, refuses identically without it: the capture loop is offset-ordered, so the receiver is refused for its local type before the selector is ever reached. `innerImplicitReceiver` gets its original guard back, with a comment on why it must stay shallow. The capture loop gets a comment on why it has none, so the guard does not come back. The refusals for a local class type and for a local class or object used as a qualifier are untouched. The test that defended the guard used a top-level class, whose members the pre-existing ancestor test already skips, so it passed either way. It is replaced by one test per broken shape, both of which fail against the guard. Three doc statements the previous commit should have moved and did not: - R8 said the extracted function takes the enclosing function's return type; the secondary-constructor exception lived only in a code comment. - R16 promised a refusal for anything thrown; cancellation is now re-thrown deliberately, and the reason it is safe belongs next to the promise. - R11's preview example predated fully-qualified rendering. --- docs/features/kotlin-extract-method.md | 8 +++-- .../kotlin/utils/refactor/MethodSignature.kt | 29 +++++++---------- .../refactor/ExtractMethodPlanEndToEndTest.kt | 31 +++++++++++++++---- 3 files changed, 42 insertions(+), 26 deletions(-) diff --git a/docs/features/kotlin-extract-method.md b/docs/features/kotlin-extract-method.md index f9965545a2..041c51c66c 100644 --- a/docs/features/kotlin-extract-method.md +++ b/docs/features/kotlin-extract-method.md @@ -105,6 +105,8 @@ The refused case is the accumulator loop, which is a genuinely common extraction **Tail return:** when the region's *last* statement is a `return`, the region contains no other `return`, `break` or `continue`, and there is no other output, the extracted function takes the enclosing function's return type, keeps the `return`, and the call site becomes `return extracted(args)`. "Extract the rest of this function into a helper" is one of the most common real extractions and the enabling check is purely syntactic - last-child kind plus a recursive absence check - so it costs a predicate and one call-site form, not an analysis. +One exception to "the enclosing function's return type": a **secondary constructor** is treated as `Unit`. Its symbol's return type is the constructed class, but its `return` carries no value, so taking that type would emit both a bare `return` in a value-returning function and a call site returning the wrong thing. `return extracted(args)` on a `Unit`-valued call is legal inside a constructor. An `init` block needs no rule - `return` is illegal there, so no tail return can arise. + 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*. **R9 - Receivers.** @@ -126,7 +128,7 @@ Declined: a `return` anywhere but the tail position, a `break`/`continue` whose Contents, top to bottom: title -> expression chooser (only for an expression region with more than one candidate and no exact selection match) -> name field with its `NameProblem` message -> signature preview -> Cancel/Extract. There is **no scope chooser** (R4) and **no replace-all checkbox** (R13). -The preview is **one monospace line: the signature exactly as it will be emitted** - modifiers, receiver, parameters, return type, e.g. `private suspend fun loadUser(id: String): User`. It wraps rather than truncating. No body preview: the body is the code the user selected and can see behind the sheet, so it moves verbatim and previewing it says nothing new, while the signature is the one derived artefact and the one place the derivation can surprise them. +The preview is **one monospace line: the signature exactly as it will be emitted** - modifiers, receiver, parameters, return type. Types render fully qualified (R5), so a real preview reads `private suspend fun loadUser(id: kotlin.String): com.example.User`. It wraps rather than truncating. No body preview: the body is the code the user selected and can see behind the sheet, so it moves verbatim and previewing it says nothing new, while the signature is the one derived artefact and the one place the derivation can surprise them. ADR 0012 defers the shared-UI question until the extract-method surface is known; a single generalised sheet would need a state class where half the fields are meaningless to either caller, so that question stays open rather than being settled from one data point. @@ -169,7 +171,9 @@ The ordering is mandatory, not stylistic. `IDELanguageClientImpl.applyActionEdit 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. -**R16 - Responsiveness and failure isolation.** As extract variable: one background pass at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine produces the whole plan; the sheet does pure string and offset arithmetic and re-enters no analysis on confirm. Anything thrown in the pipeline degrades to a refusal plus a log line, never an uncaught throw - the action framework catches only `IllegalArgumentException` and this runs on a scope with no exception handler. +**R16 - Responsiveness and failure isolation.** As extract variable: one background pass at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine produces the whole plan; the sheet does pure string and offset arithmetic and re-enters no analysis on confirm. Anything thrown in the pipeline degrades to a refusal (`CouldNotAnalyse`) plus a log line, never an uncaught throw - the action framework catches only `IllegalArgumentException` and this runs on a scope with no exception handler. + +**`CancellationException` is the one deliberate exception**, and it is re-thrown rather than swallowed - `AnalysisPreemptedException` is one. A cancelled action has no result worth reporting, and `DefaultActionsRegistry.executeAction` launches into a scope whose `invokeOnCompletion` already treats a `CancellationException` as an ordinary cancel, so re-throwing ends the action quietly instead of flashing a message at a user who has moved on. Swallowing it would also break structured concurrency for whatever cancelled the job. The sheet's confirm path is outside the framework's guards entirely, so `ExtractMethodAction.applyChoice` wraps its own body. ## Non-goals diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt index 03bfb1d7b5..dfc9da3b8d 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -259,16 +259,6 @@ private fun descendantsOf( type: Class, ): List = elements.flatMap { PsiTreeUtil.collectElementsOfType(it, type) } -/** - * Whether [reference] is the selector of a qualified expression -- the `n` in `h.n`, the `f` in - * `h.f()`. A call wraps its callee, so the call expression is what the qualified expression holds. - */ -private fun isQualifiedSelector(reference: KtSimpleNameExpression): Boolean { - val selector = (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference - val parent = selector.parent - return parent is KtQualifiedExpression && parent.selectorExpression === selector -} - /** * The name of a class declared inside [enclosing] that [type] is written in terms of, or null. * @@ -305,12 +295,11 @@ private fun KaSession.capturedParameters( val seen = mutableSetOf() for (reference in simpleNamesIn(elements).sortedBy { it.textRange.startOffset }) { - // The selector of a qualified expression already has its receiver written out next to it and - // resolves through that receiver wherever the code lives, so it is never a capture. Without this - // the `n` in `h.n` became a parameter and the call site passed a name that does not exist. - // `innerImplicitReceiver` has the same guard for the same reason. - if (isQualifiedSelector(reference)) continue - + // Deliberately no "skip a qualified selector" guard here. A selector can still resolve to a + // declaration inside the enclosing declaration -- a local extension `fun` called as `h.twice()` + // -- which goes out of scope once the region moves, and skipping it emits a body that no longer + // resolves. The ancestor test below already lets every selector resolving to a non-local member + // through, which is what a guard would have bought. val resolved = runCatching { reference.mainReference?.resolveToSymbols()?.firstOrNull() }.getOrNull() ?: continue val name = reference.getReferencedName() @@ -736,8 +725,12 @@ private fun KaSession.innerImplicitReceiver( span: TextSpan, ): String? { for (reference in simpleNamesIn(elements)) { - // A qualified selector already has its receiver written out next to it. - if (isQualifiedSelector(reference)) continue + // A qualified selector already has its receiver written out next to it. Deliberately syntactic + // and deliberately shallow: a *call* selector (`h.doubled()`) must NOT be skipped, because its + // dispatch receiver can still be an implicit one -- a member extension invoked on a `with` + // receiver is the pervasive Compose shape (`with(density) { size.toPx() }`). + val parent = reference.parent + if (parent is KtQualifiedExpression && parent.selectorExpression === reference) continue val lambda = implicitReceiverLambdaFor(reference) ?: continue if (isBoundOutsideRegion(enclosing, lambda, span)) return constructNameFor(lambda) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt index 8ececb967a..1209829719 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/ExtractMethodPlanEndToEndTest.kt @@ -1010,21 +1010,40 @@ class ExtractMethodPlanEndToEndTest : KtLspTest() { } @Test - fun `a qualified selector is not turned into a parameter`() { + fun `a local extension member reached through a qualified call is declined`() { val content = """ package p class Holder(val n: Int) fun demo(h: Holder): Int { - return h.n + 1 + fun Holder.twice(): Int = n * 2 + return h.twice() + 1 } """.trimIndent() - val (start, end) = selection(content, "return h.n + 1", "return h.n + 1") + val (start, end) = selection(content, "return h.twice() + 1", "return h.twice() + 1") - val candidate = plan(content, start, end).candidates.single() + // A qualified selector is NOT skipped: `twice` is local, so it goes out of scope with the move. + assertEquals( + ExtractionRefusal.CapturedLocalDeclaration("twice"), + plan(content, start, end).refusal, + ) + } + + @Test + fun `a member extension invoked on a with receiver is declined`() { + val content = + """ + package p + class Holder(val n: Int) + class Scope { fun Holder.doubled(): Int = n * 2 } + fun demo(h: Holder): Int = with(Scope()) { h.doubled() + 1 } + """.trimIndent() + val (start, end) = selection(content, "h.doubled() + 1", "h.doubled() + 1") - // `n` resolves through `h` wherever the code lives; passing it would name a nonexistent local. - assertEquals(listOf("h"), candidate.parameters.map { it.name }) + // `h.doubled()` reads as fully qualified but its *dispatch* receiver is `with`'s. This is the + // pervasive Compose shape (`with(density) { size.toPx() }`), so the selector guard in + // `innerImplicitReceiver` must stay shallow enough not to skip a call selector. + assertEquals(ExtractionRefusal.InnerImplicitReceiver("with"), plan(content, start, end).refusal) } @Test From 13df52dc831846558b715d404c3c7f8f5b26cd4d Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Wed, 12 Aug 2026 17:18:47 +0000 Subject: [PATCH 35/62] ADFA-5080: Use the shared type-text helpers --- .../kotlin/utils/refactor/MethodSignature.kt | 60 ++++--------------- 1 file changed, 10 insertions(+), 50 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt index dfc9da3b8d..987f98bbde 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/MethodSignature.kt @@ -1,9 +1,6 @@ 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.resolution.KaCallableMemberCall import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundArrayAccessCall import org.jetbrains.kotlin.analysis.api.resolution.KaCompoundVariableAccessCall @@ -70,7 +67,7 @@ private const val BACKING_FIELD_NAME = "field" private const val COROUTINE_CONTEXT_NAME = "coroutineContext" -/** As [SIGNATURE_TYPE_RENDERER] prints it. A `Unit` return type is left off the signature entirely. */ +/** As [renderedTypeTextOrNull] prints it. A `Unit` return type is left off the signature entirely. */ private const val UNIT_TYPE_TEXT = "kotlin.Unit" /** Either a derived candidate or the reason there is not one. */ @@ -400,9 +397,11 @@ private sealed interface UsedType { } private fun KaSession.usedTypeOf(expression: KtExpression): UsedType { - val rendered = - runCatching { expression.expressionType?.let { renderTypeText(it) } }.getOrNull() ?: return UsedType.Absent - return if (isUnrenderable(rendered)) UsedType.Unrenderable else UsedType.Rendered(rendered) + val type = runCatching { expression.expressionType }.getOrNull() ?: return UsedType.Absent + return runCatching { typeTextOrNull(type) }.fold( + onSuccess = { rendered -> rendered?.let { UsedType.Rendered(it) } ?: UsedType.Unrenderable }, + onFailure = { UsedType.Absent }, + ) } /** Either the derived parameter list or the reason there cannot be one. */ @@ -416,56 +415,17 @@ private sealed interface CaptureResult { ) : CaptureResult } -/** - * A type that cannot be written out as source -- anonymous, intersection, a resolution error, or a - * platform type [renderTypeText] could not reduce (`List`, where the `!` is on a type - * argument). `!` is not Kotlin syntax anywhere, so its presence alone settles it. - */ -private fun isUnrenderable(text: String): Boolean = - text.isBlank() || - text.contains("anonymous") || - text.contains("ERROR") || - text.contains(" & ") || - text.contains('!') - -/** - * Types in the emitted signature are rendered **fully qualified**. - * - * A short name resolves only when the file already imports it, and a local's type usually comes from - * inference rather than a spelled-out type reference -- `val d = java.util.Date()` names `Date` - * nowhere -- so a short name is unresolved as often as not, and this refactoring adds no imports. - * Verbose, but it always resolves. [receiverTypeTextOf] deliberately stays on source text instead: - * that text is already in the file. - */ -@OptIn(KaExperimentalApi::class) -private val SIGNATURE_TYPE_RENDERER = KaTypeRendererForSource.WITH_QUALIFIED_NAMES - -/** - * One type as the signature would print it, before the [isUnrenderable] check. - * - * A platform type is unwrapped to its lower bound: the renderer prints `String!`, which does not - * parse, and the lower bound is both what IntelliJ writes and what the moved body already assumes. - * Only the outermost bound is unwrapped, so a `!` on a type argument survives to [isUnrenderable]. - */ -@OptIn(KaExperimentalApi::class) -private fun KaSession.renderTypeText(type: KaType): String = - renderName((type as? KaFlexibleType)?.lowerBound ?: type, SIGNATURE_TYPE_RENDERER) - private fun KaSession.renderedSymbolType(symbol: KaCallableSymbol): String? = - runCatching { renderTypeText(symbol.returnType) }.getOrNull()?.takeUnless(::isUnrenderable) + runCatching { symbol.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } private fun KaSession.renderedTypeOrNull(expression: KtExpression): String? = - runCatching { expression.expressionType?.let { renderTypeText(it) } }.getOrNull()?.takeUnless(::isUnrenderable) + runCatching { expression.expressionType }.getOrNull()?.let { renderedTypeTextOrNull(it) } private fun KaSession.renderedDeclarationType(property: KtProperty): String? = - runCatching { (property.symbol as? KaCallableSymbol)?.returnType?.let { renderTypeText(it) } } - .getOrNull() - ?.takeUnless(::isUnrenderable) + runCatching { (property.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } private fun KaSession.enclosingReturnType(enclosing: KtDeclaration): String? = - runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType?.let { renderTypeText(it) } } - .getOrNull() - ?.takeUnless(::isUnrenderable) + runCatching { (enclosing.symbol as? KaCallableSymbol)?.returnType }.getOrNull()?.let { renderedTypeTextOrNull(it) } /** * What the region declares that the code after it still uses (R7). From e80d4c92923439689e9b12f8e45d75c497cf6801 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 13:30:24 +0000 Subject: [PATCH 36/62] ADFA-4827: Add inline-variable requirements and amend ADR 0013 Specifies inline variable as the inverse of extract variable and the third interactive Kotlin refactoring: a local val/var with an initializer, invoked from the declaration's name or from any reference, with the references rewritten and the declaration deleted once nothing needs it. Two parts of the design are worth flagging up front. It matches IntelliJ on partial inlining. A variable whose value is reassigned partway through is inlined at the references before the write and nowhere after it, rather than being refused outright, and the declaration survives whenever anything is left behind. That makes partial application a third designed outcome alongside applying and refusing, which ADR 0013 did not cover, so this amends it - together with a note that duplicated evaluation is outside what the ADR legislates, since inline deliberately does not check the initializer for side effects. It splits the unsound cases by blast radius rather than giving each its own refusal. Shadowing at a reference, a shifted implicit receiver, a smart cast and an invoked lambda initializer affect one site each, so they exclude that site; only an explicit type annotation on the declaration, which participates in inference at every reference, refuses the whole inline. --- ...efactorings-decline-rather-than-rewrite.md | 8 +- docs/features/kotlin-inline-variable.md | 276 ++++++++++++++++++ 2 files changed, 283 insertions(+), 1 deletion(-) create mode 100644 docs/features/kotlin-inline-variable.md diff --git a/docs/adr/0013-refactorings-decline-rather-than-rewrite.md b/docs/adr/0013-refactorings-decline-rather-than-rewrite.md index d4b8898a04..afe6609ce4 100644 --- a/docs/adr/0013-refactorings-decline-rather-than-rewrite.md +++ b/docs/adr/0013-refactorings-decline-rather-than-rewrite.md @@ -29,8 +29,11 @@ Concretely: - **Prefer a stricter rule to a cleverer one** when strictness costs capability and cleverness costs certainty. Extract method refuses a reassigned outer `var` even when the write is provably dead, because proving it needs liveness analysis. - **Never emit code that does not compile, and avoid emitting code that warns.** The two modifiers extract method *does* add - `suspend` and `@Composable` - are required precisely because omitting them breaks compilation. - **A refusal is a backlog item, not a dead end.** Where the refused case is common, file it: ADFA-5082 tracks the reassigned-`var` output. +- **Where part of the request is sound, apply that part and say so.** A refactoring has three outcomes, not two: apply, refuse, or **apply partially**. Inline variable (ADFA-4827) is the case that needs the third - a variable whose value is reassigned partway through can be inlined at the references before the write and nowhere after it, so refusing the whole thing would discard a sound transformation of the earlier half. A partial application must report both counts and what it left behind, and it must leave the file compiling on its own, exactly as a full application does. It is not a licence to apply the doubtful part and hope. -This applies to the whole refactoring family, not just extract method. Inline variable and rename inherit it. +This applies to the whole refactoring family, not just extract method. Inline variable and rename inherit it - inline variable adding the third outcome above, rename presumed to need only the first two until its design says otherwise. + +**Out of scope of this decision.** Whether a refactoring may duplicate an expression that is evaluated more than once. Inline variable does, without checking for side effects (see [kotlin-inline-variable.md](../features/kotlin-inline-variable.md)): the emitted code compiles, carries no warning, and is the user's own expression unedited, so nothing above forbids it. Kotlin offers no way to prove purity, so a check would be a heuristic rather than a stricter rule, and this ADR prefers strictness to cleverness in both directions. ## Consequences @@ -40,12 +43,14 @@ This applies to the whole refactoring family, not just extract method. Inline va - Refusal reasons are cheap to specify, cheap to test (one case each) and cheap to QA, where a clever transformation needs its own test matrix and its own failure modes. - The rules are stateable in a sentence each, which is what makes the feature docs reviewable by someone who has not read the implementation. - Excluding cases by construction keeps the analysis pass small, which matters when it runs on a phone. +- Partial application recovers capability that an all-or-nothing rule would throw away, without weakening the compile-and-do-not-warn guarantee: the sound part is applied and the doubtful part is simply not touched. **Negative / costs** - The refactorings are visibly less capable than a desktop IDE's. Two of extract method's refusals - a reassigned outer `var` (the accumulator loop) and an enclosing `with`/`apply` receiver (pervasive in Android code) - will be hit routinely. - The quality of the *messages* becomes load-bearing. A generic refusal reads as a broken feature, so this decision spends translated strings: roughly seven for extract method alone. - Users arriving from IntelliJ will read some refusals as regressions rather than as design. +- A partial application is harder to *report* than either other outcome, and harder to QA: the message has to convey two counts and a surviving declaration in one flash, and every "how many were left behind" case is its own test. A partial result the user misreads as a complete one is the failure mode to watch. - The line is a judgement, not a formalism. "Editing the interior of the moved code" is clear in the cases above but will need re-application, case by case, in each future refactoring. ## Alternatives considered @@ -61,3 +66,4 @@ This applies to the whole refactoring family, not just extract method. Inline va - [ADR 0010](0010-navigation-resolves-via-analysis-api.md) - the K2 Analysis API as the Kotlin semantic source of truth - [kotlin-extract-method.md](../features/kotlin-extract-method.md) - R7 to R10 and R14 are this decision applied case by case - [kotlin-extract-variable.md](../features/kotlin-extract-variable.md) - the shared vocabulary and primitives +- [kotlin-inline-variable.md](../features/kotlin-inline-variable.md) - R6 to R9 are this decision applied to a subtractive refactoring, and the origin of the third outcome diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md new file mode 100644 index 0000000000..fa5a937fad --- /dev/null +++ b/docs/features/kotlin-inline-variable.md @@ -0,0 +1,276 @@ +# Kotlin inline variable (K2 LSP) + +- **Ticket:** ADFA-4827 (subtask of ADFA-3317; split out of the closed ADFA-3324 "Refactoring") +- **Status:** Specified. Fourth link in the refactoring stack, based on extract method (ADFA-5080). +- **Module:** `lsp/kotlin` + +Replace the references to a local variable with its initializer, and delete the declaration once nothing needs it. + +The inverse of extract variable (ADFA-4826), and the third interactive Kotlin refactoring. It differs from both extracts in one way that shapes the whole design: it is **subtractive**. Extract adds a declaration next to code the user is looking at; inline rewrites references that are usually *off-screen* and then deletes the line under their finger. Where the UI lives is [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md); what it refuses to do, and what it does only partially, is [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md). + +## Language + +The family glossary lives in [kotlin-extract-variable.md](kotlin-extract-variable.md#language) - *selection*, *text span*, *refactoring plan*, *rewrite span* are defined there and used here unchanged. This feature adds: + +**Target declaration**: +The local variable being inlined - a `KtProperty` with `isLocal` and an initializer. Parameters, loop variables and destructuring entries are not `KtProperty` at all, so they are excluded by construction rather than by a check. +_Avoid_: variable (ambiguous with the references to it), local, symbol. + +**Reference**: +One read of the target declaration inside the enclosing declaration. Deliberately *not* called an occurrence: an **occurrence** in this codebase already means a site structurally equal to an expression (extract variable's `findOccurrences`), which is a different question resolved a different way. Inline matches by *symbol identity*, never by structure. +_Avoid_: occurrence, usage, use site. + +**Cutoff**: +The first offset after the target declaration where the inlined value stops being the value the declaration produced - either a write to the target itself, or a write to a mutable its initializer reads. References before the cutoff are sound; references at or after it are not. +_Avoid_: barrier, invalidation point, write boundary. + +**Inlinable reference**: +A reference that may be rewritten: before the cutoff, not shadowed, not receiver-shifted, not smart-cast, not a write target, and not an invocation of a lambda initializer. Every other reference is left exactly as it is. +_Avoid_: safe reference, valid usage, eligible reference. + +**Partial inline**: +Rewriting the inlinable references while leaving the rest, which necessarily keeps the declaration. The third designed outcome of a refactoring, alongside applying and refusing - see the [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md) amendment. +_Avoid_: partial success, best-effort, incomplete inline. + +**Substitution text**: +The initializer's source text as it lands at one reference - parenthesised per R10, or wrapped as `${...}` when the reference is a string-template entry. One initializer, but not necessarily one substitution text. +_Avoid_: replacement text, inlined value, expansion. + +## Scope + +### In scope + +A local `val` or `var` with an initializer, declared in any executable body - a function body, an accessor, an `init` block, a constructor, or a lambda - in a Kotlin file. Invoked with the cursor on the declaration's name or on any reference to it. + +### Out of scope + +Member properties, top-level properties and anything whose references can leave the file (R2). Inlining those is a multi-file refactoring: the plan model here is one file's text plus one document version, and a `public val`'s callers are not all reachable, let alone editable. + +## Requirements + +**R1 - Trigger.** An "Inline variable" item (`action_inline_variable`) in the editor code-actions menu for Kotlin files, id `ide.editor.lsp.kt.inlineVariable`, tooltip tag `EDITOR_CODE_ACTIONS_KT_INLINE_VARIABLE = "editor.codeactions.kotlin.inlinevariable"` - a new constant in `TooltipTag.kt`, and the tag string is fixed by the out-of-repo tooltips database rather than chosen here. + +As with both extracts: **no `prepare()` visibility gate** - deciding whether the cursor is on an inlinable local needs an analysis session, which `prepare()` runs on the UI thread and must not do - and `requiresUIThread = false` so the cursor is read on a background thread. + +**R2 - Target.** The cursor resolves to exactly one target declaration, from either of two positions: + +- **on the declaration's name** - anywhere in the `total` of `val total = a + b`; +- **on any reference** - the `total` of `println(total)`, resolved to its declaration with `mainReference.resolveToSymbols()`, then required to be a source `KtProperty` in the same file. + +The target must be a `KtProperty` with `isLocal` **and** an initializer. This excludes, without a single dedicated check, function parameters, lambda parameters, `it`, loop variables, `catch` parameters and destructuring entries - none of which is a `KtProperty`. It leaves three refusals to make explicitly, because each is a position a user can reasonably put the cursor in and deserves to be told about: a member or top-level property (`NotALocalVariable`), a local `val` with no initializer (`NoInitializer`, the `val x: Int` then `x = 1` shape, which Kotlin permits), and a destructuring declaration or one of its entries (`DestructuringDeclaration`). + +Both positions converge on the same target immediately, so there is one analysis pass and one plan shape. The *position* is still recorded on the plan, because R9's mode availability depends on it. + +**R3 - Live offsets and the version guard.** Identical to both extracts: analysis runs against `getCurrentKtFile(path)` fetched *before* entering `project.read`, the plan records the document version on the `RefactoringPlan` supertype, and the action re-reads the live version before emitting edits, refusing on a mismatch. + +**R4 - References.** Every read of the target within the **enclosing declaration** - the named function, accessor, `init` block or constructor whose body contains the declaration, via `enclosingExecutableBody`. A local's scope cannot leave that body, so this search root is complete rather than merely convenient: no index, no other file, no visibility reasoning. + +A reference matches by **symbol identity**, following `Occurrences.kt`'s existing rule - compare the resolved symbol's source PSI to the target `KtProperty` - never by name text, which would match a shadowing declaration's name in a nested scope. + +A reference that is a **write target** (`x = 5`, `x += 1`, `x++`, detected with the existing `isWriteTarget`) is never a reference to inline. It is a *cause* of the cutoff (R5), not a candidate for substitution. + +**Zero references refuses** (`NeverUsed`, naming the variable). With R9's single mode the declaration would otherwise be deleted with nothing inlined, which is a delete-unused-variable action wearing inline's hat, on a line the user may have been about to use. + +**R5 - Cutoff.** The first offset after the declaration where the value changes, from either cause: + +- **a write to the target** - only possible for a `var`; +- **a write to a mutable the initializer reads** - the `val bound = limit + 1` case, where `limit` is reassigned between two references and the two sites no longer hold the same value even though the text is identical. + +Both come from the existing `writeOffsetsFor(candidate, searchRoot)`, called with the initializer as the candidate, plus the target's own write references, filtered to offsets after the declaration's end. + +This matches IntelliJ, whose documented behaviour is that *"the variable must be initialized at declaration; if the initial value is modified somewhere in the code, only the occurrences before modification will be inlined."* The alternative - refusing the whole inline - was rejected: it discards a sound refactoring of the references before the write, and a partial result is what a user coming from a desktop IDE already expects here. + +**R6 - Per-site exclusions.** Four ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. + +- **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries - against the set of names the initializer references. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. +- **Receiver shift.** The initializer accesses a member through an implicit receiver, and the reference sits inside a lambda that introduces a different one - the `with(other) { ... }` / `apply` / `run` / `buildString` case. Tested as the conjunction of two cheap questions: does the initializer contain an unqualified reference resolving through an implicit receiver, and is there a receiver-introducing lambda (a `KtFunctionLiteral` whose functional type has a receiver) between the declaration and the reference? Only both together are a problem. This is extract method's `InnerImplicitReceiver` as a per-site exclusion rather than a refusal. +- **Smart cast.** `val b = a.b; if (b != null) b.length` inlines to `a.b.length`, which does not compile - a smart cast needs a stable value, and a property read is not one. Detected with `smartCastInfo` on the reference (`KaDataFlowProvider`); non-null means excluded. +- **Invoking a lambda initializer.** When the initializer is a lambda or anonymous function and the reference is the callee of a call - `val f = { n: Int -> n * 2 }` used as `f(3)` - the substitution would be a lambda literal in call position, which needs `.invoke()` to compile. Passing the same `f` as an argument (`list.map(f)`) is unaffected and stays inlinable. + +**R7 - An explicit type refuses.** A target declaration carrying an explicit type reference - `val x: Long = 1`, `val s: Any = "text"` - **refuses the whole inline** (`DeclaredTypeIsLoadBearing`, naming the type). + +The declared type participates in the initializer's inference and in overload resolution at every reference: `foo(x)` becomes `foo(1)`, an `Int`, which does not compile, and `val s: Any = "text"` can silently select a different overload. This refuses on the *presence* of the annotation rather than on a comparison against the initializer's type, because the comparison does not work: in `val x: Long = 1` the expected type propagates, so the initializer's `expressionType` **is** `Long` and a naive equality test would call the case safe. Telling the genuinely safe annotations apart needs the initializer's type computed *without* its expected type, which the Analysis API does not offer. + +Deliberately stricter than necessary - `val x: Long = 1L` is refused too - and stated as such per ADR 0013's preference for a stricter rule over a cleverer one. Explicit types on locals are uncommon in idiomatic Kotlin, and the ones that do appear (pinning a supertype, a nullable, a platform type) are usually exactly the load-bearing cases. + +**R8 - Partial inline and the declaration.** The inlinable references (R4-R6) are rewritten; every other reference is left alone. **The declaration is deleted only when nothing is left behind**: every reference was inlined *and* the target has no writes anywhere. + +The second clause is not redundant. A `var` whose reads were all inlined can still have a later `x = 5` assigning to it, so the declaration is still needed even though no read survives. Removing that write would be dead-store elimination, which is not this refactoring. + +**Nothing inlinable refuses** (`NothingInlinable`, naming the variable): every reference excluded or past the cutoff means there is no edit to make, and reporting that is better than a no-op. + +**R9 - Modes.** Two, and which are offered depends on the cursor position (R2) and the inlinable count (R8): + +| Cursor on | Offered | +|---|---| +| the declaration's name | all references (no choice - there is no reference at the cursor to single out) | +| a reference, 2+ inlinable | **both** - this reference only, or all references | +| a reference, 1 inlinable | all references (no choice) | +| a reference that is not inlinable | refuses (`ReferenceNotInlinable`) | + +The single-inlinable-reference row collapses deliberately. "This reference only" there would produce the same substitution as "all references" plus a declaration nothing reads - a `variable is never used` warning in generated code, which ADR 0013 forbids emitting. So the case has one honest answer. + +The last row refuses rather than inlining *other* references: rewriting every site except the one under the user's finger reads as the action having done nothing. + +"This reference only" keeps the declaration unconditionally, by definition. + +**R10 - Substitution text.** The initializer's text, wrapped in two situations. + +**Parentheses** are decided by classifying the **initializer alone**, never the reference site: no parentheses when the initializer is a single atomic or postfix expression - a literal, a name, `this`, a qualified chain, a call, an already-parenthesised expression, a lambda - and parentheses for everything else: binary operators, `as`/`is`, `?:`, unary operators, and `if`/`when`/`try` used as an expression. + +Site-sensitive precedence comparison was rejected. It emits marginally cleaner text and has to be right about every parent context - a receiver (`x.length` where `x = a ?: b`), a unary minus over a subtraction, an infix call, a `when` subject - where being wrong emits code that does not compile, and R13 shows no preview on the common path. The cost of the stricter rule is a redundant `return (a + b)`; the cost of the cleverer one is a miscompile the user did not see coming. + +**String templates.** A reference inside a template appears as `$x`, and the short form only accepts a simple name, so `val x = a + b` must become `"total: ${a + b}"` - never `"total: $a + b"`, which silently changes the string. The rule: inside a template entry emit `${...}` unless the substitution text is itself a plain identifier, where `$y` stays short. This makes the template flag a per-reference property of the plan, not a property of the target. + +**R11 - Deleting the declaration.** Three line shapes, all pure span arithmetic: + +- **Alone on its line** - delete from the line start through the line terminator inclusive, leaving no blank line. +- **Sharing its line with real code** - `val x = 1; return g(x)`, or a one-line body `fun f() { val x = 1; g(x) }` - delete the declaration's own span plus a following `;` and one following space if present. Deleting "the line" here would take the `return` or the closing brace with it, which is the defect class extract variable already hit (`ADFA-4826: Decline a block whose statement shares the brace line`). +- **With a trailing comment** - `val total = a + b // running total` - **the comment is preserved** on its own line at the declaration's indentation. + +Preserving the comment is the asymmetry argument: a comment left describing nothing is *visible* and removed with one gesture, while a deleted comment is invisible, and nothing in a diff-less phone UI reports that prose went missing. Comments are the one thing here that cannot be regenerated. A comment on its own line above the declaration is untouched, since only the declaration's own line is ever deleted. + +**R12 - Edit.** One `DocumentChange` carrying **one `TextEdit` per inlined reference plus, when R8 deletes it, one for the declaration, sorted by descending start offset**. + +The ordering is mandatory rather than stylistic, for the reason extract method records: `IDELanguageClientImpl.applyActionEdits` iterates the list in order and applies each edit with **line/column** ranges against the text as it then stands, so an earlier edit must never shift a later one. The declaration precedes every reference, so its deletion always sorts last. Spans never overlap: references are distinct reads, and the declaration's span contains none of them. + +Substitutions are emitted as final text; code-action edits bypass the editor's auto-indent and `CMD_FORMAT_CODE` is a no-op for Kotlin. Nothing re-indents, but nothing needs to - a substitution is intra-line and the deletion removes whole lines. + +**Known consequence:** as with extract method, nothing on this path calls `beginBatchEdit`, so an inline over N references is **N+1 undo entries** and an intermediate undo state does not compile. **ADFA-5081** fixes this properly by batching `applyActionEdits` for every multi-edit action. Working around it here - collapsing everything into one spanning replacement of the whole enclosing declaration - was rejected: it would hide a real bug behind one feature's implementation, rewrite untouched lines, and leave the next multi-edit action to rediscover the problem. + +**R13 - Sheet.** Shown **only when R9 offers a choice**. Every other path applies immediately and reports with `flashInfo` - "Inlined 3 references to `total`", or the partial form naming what was kept. + +`InlineVariableSheet` (a `BottomSheetDialogFragment` hosting a `ComposeView`) and a stateless `InlineVariableSheetContent`, reusing `LabelledSection` / `OptionList` from `refactor/ui/SheetComponents.kt` and `IdeTheme`. **No ViewModel and no UiState**: both extracts need one to own an editable name and a selected scope, and inline has no mutable state at all - an immutable plan, two derived labels, and three events (either mode, or dismissed). A ViewModel holding nothing would be ceremony, and its test would assert that a constant is a constant. + +Contents: title -> the two mode buttons with their derived labels -> the substitution text as one monospace line -> Cancel. + +**The labels are derived by pure functions beside the plan, not composed in the composable**, and unit-tested there - the same discipline as extract method's shared `signatureText`. "Inline all 5 references and delete `total`" versus "Inline 3 of 5 references" is exactly the string that can drift from what the edit does, and R8's deletion rule makes the difference invisible to a reader of the composable. + +**R14 - Refusals and reports.** The plan carries a typed `InlineRefusal` and `postExec` maps it to a specific message. Ten reasons, each naming what is in the way, per ADR 0013: + +| Reason | Message intent | +|---|---| +| `NotAVariable` | place the cursor on a local variable or one of its uses | +| `NotALocalVariable` | only a local variable can be inlined | +| `NoInitializer` | `` has no value at its declaration | +| `DestructuringDeclaration` | a destructuring declaration cannot be inlined | +| `DeclaredTypeIsLoadBearing` | `` is declared ``, and its uses need that type (R7) | +| `NeverUsed` | `` is never used (R4) | +| `NothingInlinable` | no use of `` can be inlined safely (R8) | +| `ReferenceNotInlinable` | the value of `` changes before this use (R9) | +| `CouldNotAnalyse` | the analysis could not run - deliberately neutral, since the cursor may have been fine | +| `FileChanged` | the file changed while the sheet was open (R3) | + +Plus two success reports: the whole-inline form and the partial form, which must say both counts and that the declaration was kept, because a user who asked to inline everything and got three of five needs to know from the flash rather than by rereading the file. + +`CouldNotAnalyse` exists so the other nine stay truthful - a missing compilation environment or a thrown analysis error must not be reported as `NotAVariable`, which blames a cursor nothing ever looked at. New entries in `resources/.../values/strings.xml`, picked up by the next translation batch. + +Cancellation is not a refusal: the planner re-throws `CancellationException` (`AnalysisPreemptedException` is one), so a cancelled action ends silently. + +**R15 - Responsiveness and failure isolation.** As both extracts: one background pass at `AnalysisPriority.INTERACTIVE` under a cancel checker tied to the action's coroutine builds the whole plan; the sheet does pure string and offset arithmetic and re-enters no analysis on confirm. Anything thrown degrades to `CouldNotAnalyse` plus a log line rather than an uncaught throw, and the sheet's confirm path - outside every guard the action framework provides - wraps its own body in `runCatching`. + +## Non-goals + +- **Member and top-level properties** (R2). A cross-file refactoring with a different plan model. +- **Inline function, inline parameter, inline property accessor.** Separate refactorings; only a local variable is in scope. +- **A purity or side-effect check.** `val n = queue.removeFirst()` with three references inlines into three `removeFirst()` calls. This is deliberate, not an oversight: Kotlin offers nothing to prove purity with, so any check would be a heuristic, and IntelliJ does not check either. The user reads the expression they are inlining and decides. +- **Dead-store elimination** (R8). A `var`'s later write keeps the declaration alive; the write is not removed. +- **Site-sensitive parenthesisation** (R10). +- **Reformatting the result** (R12). Substitution text is emitted final. +- **Atomic undo** of the N+1 edits (R12) - ADFA-5081. +- **A preview on the no-choice paths** (R13). The sheet appears only where there is a decision to make. +- **Java inline variable.** The sibling in `lsp/java`, unticketed. + +## Acceptance criteria + +1. "Inline variable" appears in the code-actions menu of a Kotlin file and is absent in a non-Kotlin file. +2. Cursor on `val total = a + b` with three references inlines all three and removes the declaration line, in one action with no sheet. +3. Cursor on one of those three references offers a choice; picking "this reference only" rewrites exactly that reference and keeps the declaration. +4. Cursor on the single reference of a variable inlines it and deletes the declaration, with no choice offered. +5. `val sum = a + b` referenced in `sum * 2` produces `(a + b) * 2`. +6. `val name = user.name` referenced in `f(name)` produces `f(user.name)` with no parentheses. +7. `val sum = a + b` referenced in `"total: $sum"` produces `"total: ${a + b}"`. +8. `val other = name` referenced in `"hi $other"` produces `"hi $name"`, still in short form. +9. `var count = 1` read twice, then reassigned, then read again inlines the first two reads, keeps the declaration, and reports 2 of 3. +10. `val bound = limit + 1` with `limit` reassigned between two references inlines only the first and keeps the declaration. +11. A reference inside `run { val a = 99; f(x) }`, where the initializer reads an outer `a`, is left untouched and the declaration is kept. +12. A reference inside `with(other) { ... }`, where the initializer uses the enclosing receiver's members, is left untouched. +13. `val b = a.b` used as `if (b != null) b.length` leaves the smart-cast reference untouched. +14. `val x: Long = 1` is refused, and the message names the declared type. +15. A member property is refused with "only a local variable can be inlined". +16. `val x: Int` with a later `x = 1` is refused as having no value at its declaration. +17. A cursor on a destructuring entry is refused specifically, not as "not a variable". +18. An unused local is refused as never used, and the declaration is **not** deleted. +19. A cursor on a reference past the cutoff is refused, and no other reference is rewritten. +20. `val x = 1; return g(x)` on one line inlines to `return g(1)` with the rest of the line intact. +21. `val total = a + b // running total` leaves `// running total` on its own line, correctly indented. +22. Editing the file while the sheet is open, then confirming, reports the file-changed message and leaves the file untouched. +23. Undo restores the file; it currently takes **N+1** undo steps (R12) and intermediate states do not compile. +24. A space-indented file receives space-indented output; a CRLF file keeps CRLF. + +## Design + +Same shape as both extracts, and the same data boundary from [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md): one background pass produces a plain-data plan, the UI holds no PSI. + +``` +InlineVariableAction.execAction (background) lsp/kotlin/actions + server.compilationEnvironmentFor(path) ?: refusal + -> buildInlineVariablePlan(...) utils/refactor/InlineVariablePlanner.kt + ktFile = env.ktSymbolIndex.getCurrentKtFile(path).get() [R3: before project.read] + env.project.read { + analyzeMaybeDangling(INTERACTIVE, cancelChecker) { [R15] + resolveTarget(ktFile, offset) [R2, R7] + references(target, enclosingExecutableBody) [R4] + cutoffAfter(target) [R5] + exclude per site: shadow / receiver / smartcast / invoke [R6] + -> InlineVariablePlan | InlineRefusal [R8, R14] + } + } + <- InlineVariablePlan (plain data, no PSI) + +InlineVariableAction.postExec (UI thread) + refusal -> flashInfo(message for reason) [R14] + no choice -> apply immediately + flashInfo(report) [R9, R13] + choice -> InlineVariableSheet.show refactor/ui [R13] + on apply -> version re-read; mismatch -> refuse [R3] + buildInlineVariableRewrites -> N+1 RewriteSpans utils/refactor/InlineVariableEdit.kt + client.performCodeAction(one DocumentChange, descending) [R12] +``` + +New files, all in `lsp/kotlin`: + +- **`utils/refactor/InlineVariablePlan.kt`** - `InlineVariablePlan` (a `RefactoringPlan` subtype), `InlineReference` (span, template flag, inlinable-or-why), `InlineMode`, `InlineRefusal`, and the derived label/report functions (R13). +- **`utils/refactor/InlineVariablePlanner.kt`** - the single background pass: target resolution, references, cutoff, per-site exclusions (R2-R8, R15). The only analysis-dependent part. +- **`utils/refactor/InlineVariableEdit.kt`** - substitution text, parenthesisation, template wrapping, the three deletion shapes, and the descending edit list (R10-R12). Pure text and offsets. +- **`refactor/ui/InlineVariableSheet.kt`**, **`InlineVariableSheetContent.kt`** - sheet and content only, no ViewModel (R13). +- **`actions/InlineVariableAction.kt`** - registered in `KotlinCodeActionsMenu`; the only class touching the editor, the document version or the language client. +- **`TooltipTag.EDITOR_CODE_ACTIONS_KT_INLINE_VARIABLE`** - one new constant (R1). + +Reused unchanged: `TextSpan`, `RewriteSpan` + `toTextEdit`, `positionAt`, `lineStartOffset`, `leadingIndentAt`, `detectNewline`, `enclosingExecutableBody`, `isWriteTarget`, `writeOffsetsFor`, `collapseForLabel`, `SheetComponents.kt`, `IdeTheme`, and `Occurrences.kt`'s symbol-identity comparison. + +Deliberately **not** reused: `findOccurrences`, `excludeUnsoundOccurrences`, `CandidateExpression`, `ScopeOption` and `AnchorForm`. Inline has no candidate list, no scope chain and no insertion anchor, and its references are found by symbol identity rather than structural equality - the opposite direction from `findOccurrences`. The two families share primitives, not aggregates. `excludeUnsoundOccurrences` is close in spirit to R5 and still not reusable: it grows a contiguous run *outward* from a candidate in both directions, where the cutoff runs *forward* from a fixed declaration. + +Nothing outside `lsp/kotlin` changes except `TooltipTag.kt` and `values/strings.xml`. No new module, no new dependency. + +## Verification + +Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`), split so a failure localises to one layer: + +- **`InlineVariablePlanEndToEndTest`** - analysis-backed, one case per rule: both cursor positions (R2), the reference set (R4), the cutoff from each cause (R5), one case per per-site exclusion (R6), the explicit-type refusal (R7), the deletion rule including the `var`-with-a-later-write case (R8), mode availability per row of R9's table, and **one case per refusal reason** (R14). +- **`InlineVariableEditTest`** - pure text: parenthesisation per initializer class, both template forms, the three deletion line shapes, comment preservation, descending edit order, and CRLF preservation (R10-R12). +- **`RefactorPrimitivesTest`** - extended for whatever R6's scope walk factors out syntactically. +- **`KotlinCodeActionTooltipTagTest`** - one new row (R1). + +There is no `ViewModelTest`, because there is no ViewModel (R13); the label and report derivations are tested with the plan instead. + +The sheet, `prepare()`/`ActionData`, the N+1 undo and the new tooltip row are not unit-testable; they are covered by on-device QA from the acceptance criteria above, recorded in ADFA-4827's "Steps to QA" field. + +## Related + +- [ADR 0013](../adr/0013-refactorings-decline-rather-than-rewrite.md) - refactorings decline rather than rewrite; amended by this feature to add partial application as a third outcome (R8) +- [ADR 0012](../adr/0012-refactoring-ui-lives-in-the-owning-lsp-module.md) - refactoring UI lives in the owning LSP module +- [kotlin-extract-variable.md](kotlin-extract-variable.md) - ADFA-4826; owns the family Language section and most primitives reused here +- [kotlin-extract-method.md](kotlin-extract-method.md) - ADFA-5080; the branch this one is based on, and the precedent for R12's edit ordering +- ADFA-5081 - code action edits should be a single undo step (fixes R12's consequence) +- ADFA-4825 - semantic rename, the remaining member of the family +- [ARCHITECTURE.md](../../ARCHITECTURE.md) From 0f5997c9b0f213f9bf8cb54b95c2908fed452f0a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 14:17:19 +0000 Subject: [PATCH 37/62] ADFA-4827: Add the inline-variable plan model and its derived labels --- .../utils/refactor/InlineVariablePlan.kt | 258 ++++++++++++++++++ .../utils/refactor/InlineVariablePlanTest.kt | 133 +++++++++ 2 files changed, 391 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt new file mode 100644 index 0000000000..d4aca29236 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt @@ -0,0 +1,258 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** + * Where the cursor was when the action ran. Recorded because mode availability depends on it: only a + * cursor already sitting on a reference can single that reference out. + */ +enum class InlineCursorPosition { + Declaration, + Reference, +} + +/** + * Why one reference cannot be rewritten. Every one of these excludes *that reference* and leaves it + * untouched; none refuses the whole inline, because each is a property of one site. + */ +enum class InlineExclusion { + /** At or after the cutoff: the value the declaration produced no longer holds. */ + PastCutoff, + + /** A name the initializer reads means something else here. */ + Shadowed, + + /** The initializer reads through an implicit receiver a lambda in between replaces. */ + ReceiverShift, + + /** The reference is used under a smart cast, which an expression cannot carry. */ + SmartCast, + + /** A lambda initializer in call position, which would need `.invoke()` to compile. */ + InvokesLambdaInitializer, +} + +/** + * One read of the target declaration inside the enclosing declaration. + * + * [span] is what the substitution replaces. For a short-form string-template entry (`$x`) it covers + * the whole entry including the `$`, so the substitution can emit either `$name` or `${...}`. + */ +data class InlineReference( + val span: TextSpan, + val isShortTemplateEntry: Boolean, + val exclusion: InlineExclusion?, +) { + val isInlinable: Boolean get() = exclusion == null +} + +/** The two things the user can ask for. */ +enum class InlineMode { + ThisReferenceOnly, + AllReferences, +} + +/** + * Why nothing can be inlined. A refusal is a designed outcome, not an error: each reason names what + * is in the way, because a generic message reads as the feature being broken. + */ +sealed interface InlineRefusal { + /** The cursor is not on a variable or one of its uses at all. */ + data object NotAVariable : InlineRefusal + + /** A member or top-level property, whose references can leave the file. */ + data object NotALocalVariable : InlineRefusal + + /** A local declared without a value -- the `val x: Int` then `x = 1` shape, which Kotlin permits. */ + data class NoInitializer( + val name: String, + ) : InlineRefusal + + /** A destructuring declaration or one of its entries. */ + data object DestructuringDeclaration : InlineRefusal + + /** + * An explicit type on the declaration. [typeText] is the annotation as written, so the message + * can name it. + */ + data class DeclaredTypeIsLoadBearing( + val name: String, + val typeText: String, + ) : InlineRefusal + + /** No reference at all: inlining would be a delete-unused-variable action in disguise. */ + data class NeverUsed( + val name: String, + ) : InlineRefusal + + /** Every reference is excluded or past the cutoff, so there is no edit to make. */ + data class NothingInlinable( + val name: String, + ) : InlineRefusal + + /** + * The cursor is on a reference that cannot be rewritten. Rewriting the *other* references + * instead reads as the action having done nothing. + */ + data class ReferenceNotInlinable( + val name: String, + ) : InlineRefusal + + /** + * The analysis could not run -- no compilation environment, no `KtFile`, or something threw. + * Deliberately neutral: the cursor may have been perfectly fine, so it must not be blamed. + */ + data object CouldNotAnalyse : InlineRefusal + + /** The file changed between building the plan and applying it. Raised by the action. */ + data object FileChanged : InlineRefusal +} + +/** + * The complete result of the background pass: plain data, no PSI, so the UI can hold it. + * + * [initializerNeedsParentheses] is decided from the initializer alone during analysis and consumed by + * the edit builder, which stays pure. [cursorReferenceIndex] is -1 when the cursor was on the + * declaration. [canDeleteDeclaration] is the conjunction of two conditions -- every reference + * inlinable *and* the target never written -- and is honoured only by [InlineMode.AllReferences]. + */ +data class InlineVariablePlan( + override val fileText: String, + override val documentVersion: Int, + val variableName: String, + val declarationSpan: TextSpan, + val initializerText: String, + val initializerNeedsParentheses: Boolean, + val references: List, + val cursorPosition: InlineCursorPosition, + val cursorReferenceIndex: Int, + val canDeleteDeclaration: Boolean, + val modes: List, + val refusal: InlineRefusal?, +) : RefactoringPlan { + val inlinableReferences: List get() = references.filter { it.isInlinable } + + val isRefused: Boolean get() = refusal != null + + /** Whether the mode table leaves the user a decision, and so whether the sheet is shown at all. */ + val offersChoice: Boolean get() = modes.size > 1 + + companion object { + fun refused( + refusal: InlineRefusal, + fileText: String = "", + documentVersion: Int = -1, + ) = InlineVariablePlan( + fileText = fileText, + documentVersion = documentVersion, + variableName = "", + declarationSpan = TextSpan(0, 0), + initializerText = "", + initializerNeedsParentheses = false, + references = emptyList(), + cursorPosition = InlineCursorPosition.Declaration, + cursorReferenceIndex = -1, + canDeleteDeclaration = false, + modes = emptyList(), + refusal = refusal, + ) + } +} + +/** + * The mode table. The single-inlinable-reference row collapses deliberately: "this reference only" there + * produces the same substitution as "all references" plus a declaration nothing reads. + * + * A cursor on a reference that is not itself inlinable never reaches here -- the planner refuses with + * [InlineRefusal.ReferenceNotInlinable]. + */ +fun modesFor( + cursorPosition: InlineCursorPosition, + inlinableCount: Int, +): List = + if (cursorPosition == InlineCursorPosition.Reference && inlinableCount >= 2) { + listOf(InlineMode.ThisReferenceOnly, InlineMode.AllReferences) + } else { + listOf(InlineMode.AllReferences) + } + +/** + * What one of the mode buttons says, as data rather than as a string: the plan layer stays free of + * Android resources and the derivation stays unit-testable, while the sheet maps each case to a + * localised string. + */ +sealed interface InlineLabel { + data object ThisReferenceOnly : InlineLabel + + data class AllAndDelete( + val count: Int, + val name: String, + ) : InlineLabel + + data class AllKeepingDeclaration( + val count: Int, + val name: String, + ) : InlineLabel + + data class PartialKeepingDeclaration( + val count: Int, + val total: Int, + val name: String, + ) : InlineLabel +} + +/** What the flash says afterwards. Same reasoning as [InlineLabel], in the past tense. */ +sealed interface InlineReport { + data class InlinedAndRemoved( + val count: Int, + val name: String, + ) : InlineReport + + data class InlinedKeepingDeclaration( + val count: Int, + val name: String, + ) : InlineReport + + data class InlinedPartially( + val count: Int, + val total: Int, + val name: String, + ) : InlineReport +} + +/** + * The label for [mode], derived here rather than composed in the composable. + * + * "Inline all 5 references and remove `total`" versus "Inline 3 of 5 references" is exactly the string + * that can drift from what the edit does, and the deletion rule makes the difference invisible to a + * reader of the composable. + */ +fun InlineVariablePlan.labelFor(mode: InlineMode): InlineLabel = + when (mode) { + InlineMode.ThisReferenceOnly -> InlineLabel.ThisReferenceOnly + + InlineMode.AllReferences -> { + val count = inlinableReferences.size + when { + count < references.size -> InlineLabel.PartialKeepingDeclaration(count, references.size, variableName) + canDeleteDeclaration -> InlineLabel.AllAndDelete(count, variableName) + else -> InlineLabel.AllKeepingDeclaration(count, variableName) + } + } + } + +/** + * What to report once [mode] has been applied. "This reference only" always keeps the declaration and + * always leaves other references behind, so it is a partial result by definition. + */ +fun InlineVariablePlan.reportFor(mode: InlineMode): InlineReport = + when (mode) { + InlineMode.ThisReferenceOnly -> InlineReport.InlinedPartially(1, references.size, variableName) + + InlineMode.AllReferences -> { + val count = inlinableReferences.size + when { + count < references.size -> InlineReport.InlinedPartially(count, references.size, variableName) + canDeleteDeclaration -> InlineReport.InlinedAndRemoved(count, variableName) + else -> InlineReport.InlinedKeepingDeclaration(count, variableName) + } + } + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanTest.kt new file mode 100644 index 0000000000..33a69a0b63 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanTest.kt @@ -0,0 +1,133 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import org.junit.Assert.assertEquals +import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * The plan's pure derivations: the mode table and the derived labels and reports. Analysis-free, so + * the strings the user reads are pinned without a compilation environment. + */ +class InlineVariablePlanTest { + private fun plan( + references: List, + canDeleteDeclaration: Boolean = true, + cursorPosition: InlineCursorPosition = InlineCursorPosition.Declaration, + cursorReferenceIndex: Int = -1, + ) = InlineVariablePlan( + fileText = "", + documentVersion = 1, + variableName = "total", + declarationSpan = TextSpan(0, 0), + initializerText = "a + b", + initializerNeedsParentheses = true, + references = references, + cursorPosition = cursorPosition, + cursorReferenceIndex = cursorReferenceIndex, + canDeleteDeclaration = canDeleteDeclaration, + modes = modesFor(cursorPosition, references.count { it.isInlinable }), + refusal = null, + ) + + private fun reference(exclusion: InlineExclusion? = null) = + InlineReference(span = TextSpan(0, 0), isShortTemplateEntry = false, exclusion = exclusion) + + @Test + fun `a cursor on the declaration offers only all references`() { + assertEquals(listOf(InlineMode.AllReferences), modesFor(InlineCursorPosition.Declaration, 3)) + } + + @Test + fun `a cursor on a reference with two or more inlinable offers both modes`() { + assertEquals( + listOf(InlineMode.ThisReferenceOnly, InlineMode.AllReferences), + modesFor(InlineCursorPosition.Reference, 2), + ) + } + + @Test + fun `a cursor on a reference with one inlinable collapses to all references`() { + // "This reference only" would leave a declaration nothing reads -- a `never used` warning in + // generated code that this refactoring must decline rather than emit. + assertEquals(listOf(InlineMode.AllReferences), modesFor(InlineCursorPosition.Reference, 1)) + } + + @Test + fun `the all-references label says the declaration goes when nothing is left behind`() { + val result = plan(listOf(reference(), reference(), reference())) + + assertTrue(result.offersChoice.not()) + assertEquals(InlineLabel.AllAndDelete(3, "total"), result.labelFor(InlineMode.AllReferences)) + } + + @Test + fun `the all-references label keeps the declaration when a write survives`() { + val result = plan(listOf(reference(), reference()), canDeleteDeclaration = false) + + assertEquals(InlineLabel.AllKeepingDeclaration(2, "total"), result.labelFor(InlineMode.AllReferences)) + } + + @Test + fun `the all-references label states both counts for a partial inline`() { + val result = plan(listOf(reference(), reference(InlineExclusion.PastCutoff)), canDeleteDeclaration = false) + + assertEquals( + InlineLabel.PartialKeepingDeclaration(1, 2, "total"), + result.labelFor(InlineMode.AllReferences), + ) + } + + @Test + fun `this-reference-only has a fixed label and always keeps the declaration`() { + val result = + plan( + listOf(reference(), reference()), + cursorPosition = InlineCursorPosition.Reference, + cursorReferenceIndex = 0, + ) + + assertEquals(InlineLabel.ThisReferenceOnly, result.labelFor(InlineMode.ThisReferenceOnly)) + assertEquals( + InlineReport.InlinedPartially(1, 2, "total"), + result.reportFor(InlineMode.ThisReferenceOnly), + ) + } + + @Test + fun `the whole-inline report names the count and the removed declaration`() { + val result = plan(listOf(reference(), reference(), reference())) + + assertEquals(InlineReport.InlinedAndRemoved(3, "total"), result.reportFor(InlineMode.AllReferences)) + } + + @Test + fun `the partial report says both counts`() { + val result = + plan( + listOf(reference(), reference(), reference(InlineExclusion.SmartCast)), + canDeleteDeclaration = false, + ) + + assertEquals(InlineReport.InlinedPartially(2, 3, "total"), result.reportFor(InlineMode.AllReferences)) + } + + @Test + fun `a report distinguishes all-inlined-but-kept from partial`() { + val result = plan(listOf(reference(), reference()), canDeleteDeclaration = false) + + assertEquals( + InlineReport.InlinedKeepingDeclaration(2, "total"), + result.reportFor(InlineMode.AllReferences), + ) + } + + @Test + fun `a refused plan carries no references and no modes`() { + val refused = InlineVariablePlan.refused(InlineRefusal.CouldNotAnalyse) + + assertTrue(refused.isRefused) + assertFalse(refused.offersChoice) + assertEquals(emptyList(), refused.references) + } +} From 30723aa43b45c2de208642617483fc74530877c5 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 14:32:24 +0000 Subject: [PATCH 38/62] ADFA-4827: Document the inline-variable plan model's public members --- .../kotlin/utils/refactor/InlineVariablePlan.kt | 17 +++++++++++++++++ 1 file changed, 17 insertions(+) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt index d4aca29236..231eda0e0a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt @@ -5,7 +5,10 @@ package com.itsaky.androidide.lsp.kotlin.utils.refactor * cursor already sitting on a reference can single that reference out. */ enum class InlineCursorPosition { + /** The cursor was on the declaration's own name. */ Declaration, + + /** The cursor was on one of the target's references. */ Reference, } @@ -41,12 +44,16 @@ data class InlineReference( val isShortTemplateEntry: Boolean, val exclusion: InlineExclusion?, ) { + /** Whether this reference can be rewritten, meaning nothing excluded it. */ val isInlinable: Boolean get() = exclusion == null } /** The two things the user can ask for. */ enum class InlineMode { + /** Rewrites only the reference under the cursor, always keeping the declaration. */ ThisReferenceOnly, + + /** Rewrites every reference that can be rewritten, removing the declaration when nothing is left behind. */ AllReferences, } @@ -128,14 +135,17 @@ data class InlineVariablePlan( val modes: List, val refusal: InlineRefusal?, ) : RefactoringPlan { + /** The references that can be rewritten. */ val inlinableReferences: List get() = references.filter { it.isInlinable } + /** Whether the plan carries a refusal instead of something to apply. */ val isRefused: Boolean get() = refusal != null /** Whether the mode table leaves the user a decision, and so whether the sheet is shown at all. */ val offersChoice: Boolean get() = modes.size > 1 companion object { + /** Builds a plan carrying [refusal] and nothing to apply. */ fun refused( refusal: InlineRefusal, fileText: String = "", @@ -180,18 +190,22 @@ fun modesFor( * localised string. */ sealed interface InlineLabel { + /** Offers rewriting only the reference under the cursor. */ data object ThisReferenceOnly : InlineLabel + /** Offers rewriting every reference and removing the declaration. */ data class AllAndDelete( val count: Int, val name: String, ) : InlineLabel + /** Offers rewriting every reference, keeping the declaration. */ data class AllKeepingDeclaration( val count: Int, val name: String, ) : InlineLabel + /** Offers rewriting [count] of [total] references, keeping the declaration. */ data class PartialKeepingDeclaration( val count: Int, val total: Int, @@ -201,16 +215,19 @@ sealed interface InlineLabel { /** What the flash says afterwards. Same reasoning as [InlineLabel], in the past tense. */ sealed interface InlineReport { + /** Every reference was rewritten and the declaration was removed. */ data class InlinedAndRemoved( val count: Int, val name: String, ) : InlineReport + /** Every reference was rewritten but the declaration was kept. */ data class InlinedKeepingDeclaration( val count: Int, val name: String, ) : InlineReport + /** [count] of [total] references were rewritten and the declaration was kept. */ data class InlinedPartially( val count: Int, val total: Int, From 0bc39351fe5e1e5a6d7a7867224716cdd404d177 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 14:39:22 +0000 Subject: [PATCH 39/62] ADFA-4827: Emit the inline-variable substitutions and declaration deletion --- .../utils/refactor/InlineVariableEdit.kt | 121 ++++++ .../utils/refactor/InlineVariableEditTest.kt | 410 ++++++++++++++++++ 2 files changed, 531 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt new file mode 100644 index 0000000000..a9311a74df --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt @@ -0,0 +1,121 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +/** + * The edits one inline performs: one replacement per inlined reference plus, when the declaration is + * deleted, one for the declaration. + * + * **Descending document 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, so an earlier edit must never shift a later one. The declaration precedes every + * reference, so its deletion always sorts last. Spans never overlap: references are distinct reads and + * the declaration's span contains none of them. + * + * Nothing on that path calls `beginBatchEdit`, so an inline over N references costs the user **N+1** + * undo steps and the intermediate states do not compile. A follow-up change will batch these edits + * into one undo step; collapsing everything into one spanning replacement here was rejected in favor + * of the per-reference edit list described above. + * + * Returns null when there is nothing to rewrite or the offsets cannot be honoured, which the caller + * reports rather than applying. + */ +fun buildInlineVariableRewrites( + plan: InlineVariablePlan, + mode: InlineMode, +): List? { + val text = plan.fileText + val targets = + when (mode) { + InlineMode.ThisReferenceOnly -> + listOfNotNull(plan.references.getOrNull(plan.cursorReferenceIndex)?.takeIf { it.isInlinable }) + + InlineMode.AllReferences -> plan.inlinableReferences + } + if (targets.isEmpty()) return null + if (targets.any { it.span.end > text.length || it.span.start < 0 }) return null + if (plan.declarationSpan.end > text.length) return null + + val substitutions = targets.map { RewriteSpan(it.span, substitutionTextFor(plan, it)) } + val deletion = + if (mode == InlineMode.AllReferences && plan.canDeleteDeclaration) declarationDeletion(plan) else null + + return (substitutions + listOfNotNull(deletion)).sortedByDescending { it.span.start } +} + +/** + * The initializer's text as it lands at one reference. + * + * Inside a short-form template entry the braces do the delimiting, so the parenthesisation is not + * applied on top: `"total: ${a + b}"`, never `"total: ${(a + b)}"`. The short form survives only for a + * plain identifier, because `$user.name` means `user.toString() + ".name"`. + */ +internal fun substitutionTextFor( + plan: InlineVariablePlan, + reference: InlineReference, +): String { + val value = plan.initializerText + if (reference.isShortTemplateEntry) { + return if (isPlainIdentifier(value)) "\$" + value else "\${" + value + "}" + } + return if (plan.initializerNeedsParentheses) "($value)" else value +} + +/** Whether [text] is a bare Kotlin identifier, and so legal after a `$` in a template. */ +internal fun isPlainIdentifier(text: String): Boolean { + if (text.isEmpty()) return false + if (!(text[0].isLetter() || text[0] == '_')) return false + return text.all { it.isLetterOrDigit() || it == '_' } +} + +/** + * The deletion of the declaration, in one of three line shapes -- all pure span arithmetic. + * + * Real code on the line means only the declaration's own span goes (plus a following `;` and one + * space): deleting "the line" would take the `return` or the closing brace with it. A trailing comment + * is preserved on its own line at the declaration's indentation, because a comment left describing + * nothing is visible and removed with one gesture, while a deleted comment is invisible. + */ +private fun declarationDeletion(plan: InlineVariablePlan): RewriteSpan { + val text = plan.fileText + val span = plan.declarationSpan + val lineStart = lineStartOffset(text, span.start) + val lineEnd = endOfLineContent(text, span.end) + val prefix = text.substring(lineStart, span.start) + val suffix = text.substring(span.end, lineEnd).trim() + + if (prefix.isNotBlank() || !(suffix.isEmpty() || isWholeLineComment(suffix))) { + var end = span.end + if (text.startsWith(";", end)) end++ + if (text.startsWith(" ", end)) end++ + return RewriteSpan(TextSpan(span.start, end), "") + } + + val newline = detectNewline(text) + val replacement = if (suffix.isEmpty()) "" else leadingIndentAt(text, span.start) + suffix + newline + return RewriteSpan(TextSpan(lineStart, endOfLineWithTerminator(text, lineEnd)), replacement) +} + +/** Whether what follows the declaration on its line is only a comment. */ +private fun isWholeLineComment(suffix: String): Boolean = + suffix.startsWith("//") || (suffix.startsWith("/*") && suffix.endsWith("*/")) + +/** The offset of the line terminator at or after [offset], or the end of the text. */ +private fun endOfLineContent( + text: String, + offset: Int, +): Int { + var index = offset.coerceIn(0, text.length) + while (index < text.length && text[index] != '\n') index++ + // A CRLF file must not leave its lone `\r` behind as line content. + return if (index > offset && text[index - 1] == '\r') index - 1 else index +} + +/** [offset] advanced past the line terminator, so the deletion leaves no blank line. */ +private fun endOfLineWithTerminator( + text: String, + offset: Int, +): Int { + var index = offset.coerceIn(0, text.length) + if (index < text.length && text[index] == '\r') index++ + if (index < text.length && text[index] == '\n') index++ + return index +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt new file mode 100644 index 0000000000..b2b07ac392 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt @@ -0,0 +1,410 @@ +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 plan 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 InlineVariableEditTest { + /** 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) + } + + /** The span of [fragment], skipping [after] earlier occurrences of it. */ + private fun spanOf( + text: String, + fragment: String, + after: Int = 0, + ): TextSpan { + var start = -1 + repeat(after + 1) { start = text.indexOf(fragment, start + 1) } + return TextSpan(start, start + fragment.length) + } + + private fun plan( + fileText: String, + declaration: String, + initializerText: String, + references: List, + initializerNeedsParentheses: Boolean = false, + canDeleteDeclaration: Boolean = true, + cursorReferenceIndex: Int = 0, + name: String = "x", + ) = InlineVariablePlan( + fileText = fileText, + documentVersion = 1, + variableName = name, + declarationSpan = spanOf(fileText, declaration), + initializerText = initializerText, + initializerNeedsParentheses = initializerNeedsParentheses, + references = references, + cursorPosition = InlineCursorPosition.Reference, + cursorReferenceIndex = cursorReferenceIndex, + canDeleteDeclaration = canDeleteDeclaration, + modes = modesFor(InlineCursorPosition.Reference, references.count { it.isInlinable }), + refusal = null, + ) + + private fun reference( + fileText: String, + fragment: String, + after: Int = 0, + isShortTemplateEntry: Boolean = false, + exclusion: InlineExclusion? = null, + ) = InlineReference(spanOf(fileText, fragment, after), isShortTemplateEntry, exclusion) + + @Test + fun `a binary initializer is parenthesised and the declaration line goes`() { + val file = + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + "\tval sum = a + b\n" + + "\treturn sum * 2\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val sum = a + b", + initializerText = "a + b", + references = listOf(reference(file, "sum", after = 1)), + initializerNeedsParentheses = true, + name = "sum", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + assertNotNull(rewrites) + assertEquals( + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + "\treturn (a + b) * 2\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `an atomic initializer needs no parentheses`() { + val file = + "package p\n" + + "fun demo(user: User): String {\n" + + "\tval name = user.name\n" + + "\treturn f(name)\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val name = user.name", + initializerText = "user.name", + references = listOf(reference(file, "name", after = 2)), + name = "name", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + assertEquals( + "package p\n" + + "fun demo(user: User): String {\n" + + "\treturn f(user.name)\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `a template entry is wrapped in braces when the value is not a plain name`() { + val file = + "package p\n" + + "fun demo(a: Int, b: Int): String {\n" + + "\tval sum = a + b\n" + + "\treturn \"total: \$sum\"\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val sum = a + b", + initializerText = "a + b", + references = listOf(reference(file, "\$sum", isShortTemplateEntry = true)), + initializerNeedsParentheses = true, + name = "sum", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + // The braces already delimit the expression, so the parenthesisation is not applied on top. + assertEquals( + "package p\n" + + "fun demo(a: Int, b: Int): String {\n" + + "\treturn \"total: \${a + b}\"\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `a template entry stays in short form for a plain name`() { + val file = + "package p\n" + + "fun demo(name: String): String {\n" + + "\tval other = name\n" + + "\treturn \"hi \$other\"\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val other = name", + initializerText = "name", + references = listOf(reference(file, "\$other", isShortTemplateEntry = true)), + name = "other", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + assertEquals( + "package p\n" + + "fun demo(name: String): String {\n" + + "\treturn \"hi \$name\"\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `a declaration sharing its line keeps the rest of the line`() { + val file = + "package p\n" + + "fun demo(): Int {\n" + + "\tval x = 1; return g(x)\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val x = 1", + initializerText = "1", + references = listOf(reference(file, "x", after = 1)), + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + // Deleting "the line" here would take the `return` with it -- the same defect class the sibling + // extract-variable refactoring already hit. + assertEquals( + "package p\n" + + "fun demo(): Int {\n" + + "\treturn g(1)\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `a trailing comment is preserved on its own line at the declaration's indentation`() { + val file = + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + "\tval total = a + b // running total\n" + + "\treturn total\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val total = a + b", + initializerText = "a + b", + // after = 2: the comment text contains "total" too, so it is the third occurrence. + references = listOf(reference(file, "total", after = 2)), + initializerNeedsParentheses = true, + name = "total", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + assertEquals( + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + "\t// running total\n" + + "\treturn (a + b)\n" + + "}\n", + apply(file, rewrites!!), + ) + } + + @Test + fun `edits are sorted descending with the declaration deletion last`() { + val file = + "package p\n" + + "fun demo(a: Int): Int {\n" + + "\tval x = a\n" + + "\treturn x + x\n" + + "}\n" + val first = file.indexOf("x + x") + val references = + listOf( + InlineReference(TextSpan(first, first + 1), false, null), + InlineReference(TextSpan(first + 4, first + 5), false, null), + ) + val result = plan(fileText = file, declaration = "val x = a", initializerText = "a", references = references) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences)!! + + assertEquals(3, rewrites.size) + assertEquals(rewrites.map { it.span.start }.sortedDescending(), rewrites.map { it.span.start }) + assertEquals("", rewrites.last().newText) + assertEquals( + "package p\n" + + "fun demo(a: Int): Int {\n" + + "\treturn a + a\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `this-reference-only rewrites one site and keeps the declaration`() { + val file = + "package p\n" + + "fun demo(a: Int): Int {\n" + + "\tval x = a\n" + + "\treturn x + x\n" + + "}\n" + val first = file.indexOf("x + x") + val references = + listOf( + InlineReference(TextSpan(first, first + 1), false, null), + InlineReference(TextSpan(first + 4, first + 5), false, null), + ) + val result = + plan( + fileText = file, + declaration = "val x = a", + initializerText = "a", + references = references, + cursorReferenceIndex = 1, + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.ThisReferenceOnly)!! + + assertEquals(1, rewrites.size) + assertEquals( + "package p\n" + + "fun demo(a: Int): Int {\n" + + "\tval x = a\n" + + "\treturn x + a\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `an excluded reference is left untouched and the declaration stays`() { + val file = + "package p\n" + + "fun demo(a: Int): Int {\n" + + "\tval x = a\n" + + "\treturn x + x\n" + + "}\n" + val first = file.indexOf("x + x") + val references = + listOf( + InlineReference(TextSpan(first, first + 1), false, null), + InlineReference(TextSpan(first + 4, first + 5), false, InlineExclusion.PastCutoff), + ) + val result = + plan( + fileText = file, + declaration = "val x = a", + initializerText = "a", + references = references, + canDeleteDeclaration = false, + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences)!! + + assertEquals(1, rewrites.size) + assertEquals( + "package p\n" + + "fun demo(a: Int): Int {\n" + + "\tval x = a\n" + + "\treturn a + x\n" + + "}\n", + apply(file, rewrites), + ) + } + + @Test + fun `a CRLF file keeps CRLF when the comment line is re-emitted`() { + val file = + "package p\r\n" + + "fun demo(a: Int): Int {\r\n" + + "\tval x = a // keep me\r\n" + + "\treturn x\r\n" + + "}\r\n" + val at = file.indexOf("return x") + "return ".length + val result = + plan( + fileText = file, + declaration = "val x = a", + initializerText = "a", + references = listOf(InlineReference(TextSpan(at, at + 1), false, null)), + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences)!! + + assertEquals( + "package p\r\n" + + "fun demo(a: Int): Int {\r\n" + + "\t// keep me\r\n" + + "\treturn a\r\n" + + "}\r\n", + apply(file, rewrites), + ) + } + + @Test + fun `nothing to rewrite returns null rather than an empty edit list`() { + val file = "package p\nfun demo(a: Int) {\n\tval x = a\n}\n" + val result = + plan( + fileText = file, + declaration = "val x = a", + initializerText = "a", + references = emptyList(), + canDeleteDeclaration = false, + ) + + assertNull(buildInlineVariableRewrites(result, InlineMode.AllReferences)) + } + + @Test + fun `a span past the end of the text is refused rather than applied`() { + val file = "package p\nfun demo(a: Int) {\n\tval x = a\n\tg(x)\n}\n" + val result = + plan( + fileText = file, + declaration = "val x = a", + initializerText = "a", + references = listOf(InlineReference(TextSpan(file.length - 1, file.length + 5), false, null)), + ) + + assertNull(buildInlineVariableRewrites(result, InlineMode.AllReferences)) + } + + @Test + fun `a plain identifier is recognised, an expression is not`() { + assertTrue(isPlainIdentifier("name")) + assertTrue(isPlainIdentifier("_x2")) + assertTrue(!isPlainIdentifier("user.name")) + assertTrue(!isPlainIdentifier("a + b")) + assertTrue(!isPlainIdentifier("2fast")) + assertTrue(!isPlainIdentifier("")) + } +} From 5abf21ec2a918564fb588c264f523471fa9c888f Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 14:55:44 +0000 Subject: [PATCH 40/62] ADFA-4827: Resolve the inline-variable target, references and cutoff --- .../utils/refactor/InlineVariablePlanner.kt | 290 ++++++++++++ .../InlineVariablePlanEndToEndTest.kt | 426 ++++++++++++++++++ 2 files changed, 716 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt create mode 100644 lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt new file mode 100644 index 0000000000..0f2e3b70fd --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -0,0 +1,290 @@ +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.jetbrains.kotlin.analysis.api.KaSession +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.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtCallableReferenceExpression +import org.jetbrains.kotlin.psi.KtClassLiteralExpression +import org.jetbrains.kotlin.psi.KtCollectionLiteralExpression +import org.jetbrains.kotlin.psi.KtConstantExpression +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtObjectLiteralExpression +import org.jetbrains.kotlin.psi.KtParenthesizedExpression +import org.jetbrains.kotlin.psi.KtPostfixExpression +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtQualifiedExpression +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtSimpleNameStringTemplateEntry +import org.jetbrains.kotlin.psi.KtStringTemplateExpression +import org.jetbrains.kotlin.psi.KtSuperExpression +import org.jetbrains.kotlin.psi.KtThisExpression +import org.slf4j.LoggerFactory +import java.nio.file.Path +import kotlin.coroutines.cancellation.CancellationException + +private val logger = LoggerFactory.getLogger("InlineVariablePlanner") + +/** + * Computes the whole [InlineVariablePlan] 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 [InlineRefusal.CouldNotAnalyse] 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. Cancellation is the exception -- it is re-thrown, + * since a cancelled action has no result to report. + */ +internal fun buildInlineVariablePlan( + env: AbstractCompilationEnvironment, + nioPath: Path, + offset: Int, + documentVersion: Int, + cancelChecker: ScheduledCancelChecker, +): InlineVariablePlan = + runCatching { + val ktFile = + env.ktSymbolIndex.getCurrentKtFile(nioPath).get() + ?: return InlineVariablePlan.refused(InlineRefusal.CouldNotAnalyse) + + env.project.read { + val fileText = ktFile.text + analyzeMaybeDangling(ktFile, AnalysisPriority.INTERACTIVE, cancelChecker) { + planFor(ktFile, fileText, offset, documentVersion) + } + } + }.getOrElse { error -> + if (error is CancellationException) throw error + logger.warn("Failed to build inline-variable plan for {}", nioPath, error) + InlineVariablePlan.refused(InlineRefusal.CouldNotAnalyse) + } + +/** The target declaration and how the cursor found it, or the reason there is none. */ +private sealed interface TargetResolution { + data class Resolved( + val target: KtProperty, + val cursorPosition: InlineCursorPosition, + ) : TargetResolution + + data class Refused( + val refusal: InlineRefusal, + ) : TargetResolution +} + +private fun KaSession.planFor( + ktFile: KtFile, + fileText: String, + offset: Int, + documentVersion: Int, +): InlineVariablePlan { + val resolution = resolveTarget(ktFile, offset) + if (resolution is TargetResolution.Refused) { + return InlineVariablePlan.refused(resolution.refusal, fileText, documentVersion) + } + val resolved = resolution as TargetResolution.Resolved + val target = resolved.target + val name = target.name ?: return InlineVariablePlan.refused(InlineRefusal.CouldNotAnalyse, fileText, documentVersion) + + // Order matters: a `val x: Int` with a later `x = 1` carries both an absent initializer and an + // explicit type, and "has no value at its declaration" is the truthful reason. + val initializer = + target.initializer + ?: return InlineVariablePlan.refused(InlineRefusal.NoInitializer(name), fileText, documentVersion) + target.typeReference?.let { typeReference -> + return InlineVariablePlan.refused( + InlineRefusal.DeclaredTypeIsLoadBearing(name, typeReference.text), + fileText, + documentVersion, + ) + } + + val searchRoot = + enclosingExecutableBody(target) + ?: return InlineVariablePlan.refused(InlineRefusal.CouldNotAnalyse, fileText, documentVersion) + + val reads = mutableListOf() + val targetWriteOffsets = mutableListOf() + for (candidate in PsiTreeUtil.collectElementsOfType(searchRoot, KtSimpleNameExpression::class.java)) { + if (!resolvesToTarget(candidate, target)) continue + // A write is a *cause* of the cutoff, never a candidate for substitution. + if (candidate.isWriteTarget()) targetWriteOffsets += candidate.textRange.startOffset else reads += candidate + } + + if (reads.isEmpty()) { + return InlineVariablePlan.refused(InlineRefusal.NeverUsed(name), fileText, documentVersion) + } + + val declarationEnd = target.textRange.endOffset + val cutoff = cutoffAfter(initializer, searchRoot, targetWriteOffsets, declarationEnd) + + val references = + reads.map { read -> + val entry = read.parent as? KtSimpleNameStringTemplateEntry + val span = + if (entry != null) { + TextSpan(entry.textRange.startOffset, entry.textRange.endOffset) + } else { + TextSpan(read.textRange.startOffset, read.textRange.endOffset) + } + InlineReference( + span = span, + isShortTemplateEntry = entry != null, + exclusion = if (span.start >= cutoff) InlineExclusion.PastCutoff else null, + ) + } + + val cursorReferenceIndex = + if (resolved.cursorPosition == InlineCursorPosition.Reference) { + references.indexOfFirst { offset >= it.span.start && offset <= it.span.end } + } else { + -1 + } + if (resolved.cursorPosition == InlineCursorPosition.Reference) { + val cursorReference = references.getOrNull(cursorReferenceIndex) + // Rewriting every site except the one under the user's finger reads as having done nothing. + if (cursorReference == null || !cursorReference.isInlinable) { + return InlineVariablePlan.refused(InlineRefusal.ReferenceNotInlinable(name), fileText, documentVersion) + } + } + + val inlinable = references.count { it.isInlinable } + if (inlinable == 0) { + return InlineVariablePlan.refused(InlineRefusal.NothingInlinable(name), fileText, documentVersion) + } + + return InlineVariablePlan( + fileText = fileText, + documentVersion = documentVersion, + variableName = name, + declarationSpan = TextSpan(target.textRange.startOffset, declarationEnd), + initializerText = initializer.text, + initializerNeedsParentheses = needsParentheses(initializer), + references = references, + cursorPosition = resolved.cursorPosition, + cursorReferenceIndex = cursorReferenceIndex, + // The deletion rule's second clause is not redundant: a `var` whose reads were all inlined can still have a + // later `x = 5` assigning to it, so the declaration is still needed. + canDeleteDeclaration = inlinable == references.size && targetWriteOffsets.isEmpty(), + modes = modesFor(resolved.cursorPosition, inlinable), + refusal = null, + ) +} + +/** + * The target the cursor points at, from either of the two cursor positions. + * + * Function parameters, lambda parameters, `it`, loop variables, `catch` parameters and destructuring + * entries are not [KtProperty] at all, so they are excluded by construction. The three refusals made + * explicitly here are the positions a user can reasonably put the cursor in and deserves to be told + * about. + */ +private fun KaSession.resolveTarget( + ktFile: KtFile, + offset: Int, +): TargetResolution { + val leaf = + ktFile.findElementAt(offset) + ?: ktFile.findElementAt(offset - 1) + ?: return TargetResolution.Refused(InlineRefusal.NotAVariable) + + PsiTreeUtil.getParentOfType(leaf, KtDestructuringDeclaration::class.java, false)?.let { + return TargetResolution.Refused(InlineRefusal.DestructuringDeclaration) + } + + val declaration = PsiTreeUtil.getParentOfType(leaf, KtProperty::class.java, false) + val nameRange = declaration?.nameIdentifier?.textRange + if (declaration != null && nameRange != null && offset >= nameRange.startOffset && offset <= nameRange.endOffset) { + if (!declaration.isLocal) return TargetResolution.Refused(InlineRefusal.NotALocalVariable) + return TargetResolution.Resolved(declaration, InlineCursorPosition.Declaration) + } + + val reference = + PsiTreeUtil.getParentOfType(leaf, KtSimpleNameExpression::class.java, false) + ?: return TargetResolution.Refused(InlineRefusal.NotAVariable) + val referenced = + runCatching { + reference.mainReference + ?.resolveToSymbols() + ?.firstNotNullOfOrNull { symbol -> runCatching { symbol.psi }.getOrNull() } + }.getOrNull() ?: return TargetResolution.Refused(InlineRefusal.NotAVariable) + + if (referenced is KtProperty) { + // A reference can resolve into another file (or a library); this plan model is one file's text + // plus one document version, so anything else is out of scope. + if (referenced.containingFile != ktFile) return TargetResolution.Refused(InlineRefusal.NotALocalVariable) + if (!referenced.isLocal) return TargetResolution.Refused(InlineRefusal.NotALocalVariable) + return TargetResolution.Resolved(referenced, InlineCursorPosition.Reference) + } + return TargetResolution.Refused(InlineRefusal.NotAVariable) +} + +/** Whether [reference] resolves to [target], compared by source PSI identity as `Occurrences.kt` does. */ +private fun KaSession.resolvesToTarget( + reference: KtSimpleNameExpression, + target: KtProperty, +): Boolean = + runCatching { + reference.mainReference?.resolveToSymbols()?.any { symbol -> + runCatching { symbol.psi }.getOrNull() === target + } == true + }.getOrDefault(false) + +/** + * The first offset after the declaration where the inlined value stops being the value the + * declaration produced: either a write to the target itself -- only possible for a `var` -- or a + * write to a mutable the initializer reads. + * + * [Int.MAX_VALUE] when nothing writes, so every reference compares as before the cutoff. + */ +private fun KaSession.cutoffAfter( + initializer: KtExpression, + searchRoot: PsiElement, + targetWriteOffsets: List, + declarationEnd: Int, +): Int = + (writeOffsetsFor(initializer, searchRoot) + targetWriteOffsets) + .filter { it >= declarationEnd } + .minOrNull() ?: Int.MAX_VALUE + +/** + * Parenthesisation, decided by classifying the initializer alone: no parentheses for a single + * atomic or postfix expression, parentheses for everything else. + * + * Site-sensitive precedence comparison was rejected: it has to be right about every parent context, + * and no preview is shown on the common path. The cost of the stricter rule is a redundant + * `return (a + b)`; the cost of the cleverer one is a miscompile the user did not see coming. + */ +private fun needsParentheses(initializer: KtExpression): Boolean = + when (initializer) { + is KtConstantExpression, + is KtStringTemplateExpression, + is KtSimpleNameExpression, + is KtThisExpression, + is KtSuperExpression, + is KtCallExpression, + is KtQualifiedExpression, + is KtArrayAccessExpression, + is KtParenthesizedExpression, + is KtLambdaExpression, + is KtNamedFunction, + is KtObjectLiteralExpression, + is KtCollectionLiteralExpression, + is KtCallableReferenceExpression, + is KtClassLiteralExpression, + is KtPostfixExpression, + -> false + + else -> true + } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt new file mode 100644 index 0000000000..2f97ba4ad6 --- /dev/null +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -0,0 +1,426 @@ +package com.itsaky.androidide.lsp.kotlin.utils.refactor + +import com.itsaky.androidide.lsp.kotlin.compiler.modules.ScheduledCancelChecker +import com.itsaky.androidide.lsp.kotlin.fixtures.KtLspTest +import com.itsaky.androidide.progress.ICancelChecker +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull +import org.junit.Assert.assertThrows +import org.junit.Assert.assertTrue +import org.junit.Test +import java.util.concurrent.CancellationException + +/** + * The parts of the plan that need real resolution: the target from either cursor position, the + * reference set by symbol identity, the cutoff, the deletion rule, the mode table 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 InlineVariablePlanEndToEndTest : KtLspTest() { + private fun plan( + content: String, + offset: Int, + ): InlineVariablePlan { + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + return buildInlineVariablePlan(env, path, offset, 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) + } + + /** The offset of [fragment]'s first character, skipping [after] occurrences of it. */ + private fun at( + content: String, + fragment: String, + after: Int = 0, + ): Int { + var index = -1 + repeat(after + 1) { index = content.indexOf(fragment, index + 1) } + return index + } + + @Test + fun `both cursor positions resolve the same target`() { + val content = + """ + package p + fun demo(a: Int, b: Int): Int { + val total = a + b + return total * 2 + } + """.trimIndent() + + val onName = plan(content, at(content, "total")) + val onReference = plan(content, at(content, "total", after = 1)) + + assertEquals("total", onName.variableName) + assertEquals(InlineCursorPosition.Declaration, onName.cursorPosition) + assertEquals(-1, onName.cursorReferenceIndex) + assertEquals("total", onReference.variableName) + assertEquals(InlineCursorPosition.Reference, onReference.cursorPosition) + assertEquals(0, onReference.cursorReferenceIndex) + assertEquals(1, onName.references.size) + assertEquals(listOf(InlineMode.AllReferences), onReference.modes) + } + + @Test + fun `a whole inline rewrites every reference and removes the declaration`() { + val content = + """ + package p + fun demo(a: Int, b: Int): Int { + val total = a + b + return total * 2 + } + """.trimIndent() + + val result = plan(content, at(content, "total")) + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences)!! + + assertTrue(result.canDeleteDeclaration) + assertEquals( + """ + package p + fun demo(a: Int, b: Int): Int { + return (a + b) * 2 + } + """.trimIndent(), + apply(content, rewrites), + ) + } + + @Test + fun `a reference with two or more inlinable offers both modes`() { + val content = + """ + package p + fun demo(a: Int): Int { + val x = a + return x + x + } + """.trimIndent() + + val result = plan(content, at(content, "x", after = 1)) + + assertEquals(2, result.references.size) + assertEquals(listOf(InlineMode.ThisReferenceOnly, InlineMode.AllReferences), result.modes) + assertTrue(result.offersChoice) + } + + @Test + fun `a shadowing declaration's name is not mistaken for a reference`() { + val content = + """ + package p + fun demo(a: Int): Int { + val x = a + val outer = x + return run { + val x = 99 + x + } + } + """.trimIndent() + + val result = plan(content, at(content, "x")) + + // Matching is by symbol identity, never by name text: the inner `val x` and its use belong to a + // different declaration. + assertEquals(1, result.references.size) + assertEquals(at(content, "x", after = 1), result.references.single().span.start) + } + + @Test + fun `a var read after its own reassignment is past the cutoff`() { + val content = + """ + package p + fun demo(): Int { + var count = 1 + val a = count + val b = count + count = 2 + return a + b + count + } + """.trimIndent() + + val result = plan(content, at(content, "count")) + + assertEquals(3, result.references.size) + assertEquals(2, result.inlinableReferences.size) + assertEquals(InlineExclusion.PastCutoff, result.references.last().exclusion) + assertEquals(false, result.canDeleteDeclaration) + assertEquals(InlineReport.InlinedPartially(2, 3, "count"), result.reportFor(InlineMode.AllReferences)) + } + + @Test + fun `a write to a mutable the initializer reads sets the cutoff`() { + val content = + """ + package p + fun demo(): Int { + var limit = 1 + val bound = limit + 1 + val first = bound + limit = 5 + val second = bound + return first + second + } + """.trimIndent() + + val result = plan(content, at(content, "bound")) + + assertEquals(2, result.references.size) + assertNull(result.references.first().exclusion) + assertEquals(InlineExclusion.PastCutoff, result.references.last().exclusion) + assertEquals(false, result.canDeleteDeclaration) + } + + @Test + fun `a var with a later write keeps its declaration even when every read is inlined`() { + val content = + """ + package p + fun demo(): Int { + var count = 1 + val a = count + count = 2 + return a + } + """.trimIndent() + + val result = plan(content, at(content, "count")) + + // Removing the write would be dead-store elimination, which is not this refactoring. + assertEquals(1, result.inlinableReferences.size) + assertEquals(false, result.canDeleteDeclaration) + assertEquals( + InlineReport.InlinedKeepingDeclaration(1, "count"), + result.reportFor(InlineMode.AllReferences), + ) + } + + @Test + fun `a reference inside a string template is recorded as a short-form entry`() { + val content = + """ + package p + fun demo(a: Int, b: Int): String { + val sum = a + b + return "total: ${'$'}sum" + } + """.trimIndent() + + val result = plan(content, at(content, "sum")) + val reference = result.references.single() + + assertTrue(reference.isShortTemplateEntry) + // The span covers the whole entry, `$sum`, so the substitution can emit `${a + b}`. + assertEquals(at(content, "${'$'}sum"), reference.span.start) + assertEquals( + """ + package p + fun demo(a: Int, b: Int): String { + return "total: ${'$'}{a + b}" + } + """.trimIndent(), + apply(content, buildInlineVariableRewrites(result, InlineMode.AllReferences)!!), + ) + } + + @Test + fun `an initializer needing no parentheses is classified as atomic`() { + val content = + """ + package p + class User(val name: String) + fun f(s: String) = s + fun demo(user: User): String { + val name = user.name + return f(name) + } + """.trimIndent() + + // Occurrence count: the class's own `val name` is 0, the local declaration's `name` is 1, its + // `user.name` initializer is 2, so the reference in `f(name)` is 3. + val result = plan(content, at(content, "name", after = 3)) + + assertEquals(false, result.initializerNeedsParentheses) + assertEquals("user.name", result.initializerText) + } + + @Test + fun `an unused local is refused as never used`() { + val content = + """ + package p + fun demo(a: Int) { + val unused = a + } + """.trimIndent() + + assertEquals(InlineRefusal.NeverUsed("unused"), plan(content, at(content, "unused")).refusal) + } + + @Test + fun `a member property is refused as not local`() { + val content = + """ + package p + class C { + val size = 1 + fun demo(): Int = size + } + """.trimIndent() + + assertEquals(InlineRefusal.NotALocalVariable, plan(content, at(content, "size")).refusal) + } + + @Test + fun `a local with no initializer is refused before its declared type is considered`() { + val content = + """ + package p + fun demo(): Int { + val x: Int + x = 1 + return x + } + """.trimIndent() + + // This shape carries an explicit type too, and "has no value at its declaration" + // is the truthful reason. + assertEquals(InlineRefusal.NoInitializer("x"), plan(content, at(content, "x")).refusal) + } + + @Test + fun `an explicit type refuses and names the type`() { + val content = + """ + package p + fun demo(): Long { + val x: Long = 1 + return x + } + """.trimIndent() + + assertEquals( + InlineRefusal.DeclaredTypeIsLoadBearing("x", "Long"), + plan(content, at(content, "x")).refusal, + ) + } + + @Test + fun `a destructuring entry is refused specifically`() { + val content = + """ + package p + fun demo(pair: Pair): Int { + val (first, second) = pair + return first + second + } + """.trimIndent() + + assertEquals( + InlineRefusal.DestructuringDeclaration, + plan(content, at(content, "first")).refusal, + ) + } + + @Test + fun `a cursor on nothing inlinable is refused as not a variable`() { + val content = + """ + package p + fun demo(a: Int): Int { + return a + } + """.trimIndent() + + // A parameter is not a KtProperty, so it is excluded by construction rather than by a check. + assertEquals(InlineRefusal.NotAVariable, plan(content, at(content, "a", after = 1)).refusal) + } + + @Test + fun `a cursor on a reference past the cutoff refuses rather than rewriting the others`() { + val content = + """ + package p + fun demo(): Int { + var count = 1 + val a = count + count = 2 + return count + } + """.trimIndent() + + assertEquals( + InlineRefusal.ReferenceNotInlinable("count"), + plan(content, at(content, "return count") + "return ".length).refusal, + ) + } + + @Test + fun `a file the analysis cannot reach is refused as not analysable`() { + createSourceFile("Main.kt", "package p\n") + val missing = env.sourceRoots.first().resolve("Absent.kt") + + // "Place the cursor on a local variable" would blame a cursor nothing ever looked at. + assertEquals( + InlineRefusal.CouldNotAnalyse, + buildInlineVariablePlan(env, missing, 0, documentVersion = 1, cancelChecker = noopCancelChecker()).refusal, + ) + } + + @Test + fun `cancellation propagates instead of being reported as a refusal`() { + val content = + """ + package p + fun demo(a: Int): Int { + val x = a + return x + } + """.trimIndent() + createSourceFile("Main.kt", content) + val path = env.sourceRoots.first().resolve("Main.kt") + val cancelled = ScheduledCancelChecker(ICancelChecker.CANCELLED) + + assertThrows(CancellationException::class.java) { + buildInlineVariablePlan(env, path, at(content, "x"), documentVersion = 1, cancelChecker = cancelled) + } + } + + @Test + fun `a one-line declaration inlines without taking the rest of the line`() { + val content = + """ + package p + fun g(n: Int) = n + fun demo(): Int { + val x = 1; return g(x) + } + """.trimIndent() + + val result = plan(content, at(content, "x")) + + assertEquals( + """ + package p + fun g(n: Int) = n + fun demo(): Int { + return g(1) + } + """.trimIndent(), + apply(content, buildInlineVariableRewrites(result, InlineMode.AllReferences)!!), + ) + } +} From 89444d74d650769ae899e66c1f0fc8c63c3dbb3b Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 15:20:40 +0000 Subject: [PATCH 41/62] ADFA-4827: Exclude the references an inline would break --- .../utils/refactor/InlineVariablePlanner.kt | 180 +++++++++++++++++- .../InlineVariablePlanEndToEndTest.kt | 134 +++++++++++++ 2 files changed, 312 insertions(+), 2 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index 0f2e3b70fd..0c5d9c46b1 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -6,24 +6,35 @@ 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.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.resolution.KaCallableMemberCall +import org.jetbrains.kotlin.analysis.api.resolution.KaImplicitReceiverValue +import org.jetbrains.kotlin.analysis.api.resolution.successfulCallOrNull +import org.jetbrains.kotlin.analysis.api.symbols.KaAnonymousFunctionSymbol +import org.jetbrains.kotlin.analysis.api.types.KaFunctionType 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.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtCallableReferenceExpression +import org.jetbrains.kotlin.psi.KtCatchClause import org.jetbrains.kotlin.psi.KtClassLiteralExpression import org.jetbrains.kotlin.psi.KtCollectionLiteralExpression import org.jetbrains.kotlin.psi.KtConstantExpression 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.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtObjectLiteralExpression import org.jetbrains.kotlin.psi.KtParenthesizedExpression import org.jetbrains.kotlin.psi.KtPostfixExpression import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtQualifiedExpression import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtSimpleNameStringTemplateEntry @@ -129,6 +140,10 @@ private fun KaSession.planFor( val declarationEnd = target.textRange.endOffset val cutoff = cutoffAfter(initializer, searchRoot, targetWriteOffsets, declarationEnd) + val initializerNames = namesReadBy(initializer) + val initializerUsesImplicitReceiver = readsThroughImplicitReceiver(initializer) + val initializerIsFunctionLiteral = initializer is KtLambdaExpression || initializer is KtNamedFunction + val references = reads.map { read -> val entry = read.parent as? KtSimpleNameStringTemplateEntry @@ -141,7 +156,16 @@ private fun KaSession.planFor( InlineReference( span = span, isShortTemplateEntry = entry != null, - exclusion = if (span.start >= cutoff) InlineExclusion.PastCutoff else null, + exclusion = + when { + span.start >= cutoff -> InlineExclusion.PastCutoff + isShadowedAt(read, target, initializerNames) -> InlineExclusion.Shadowed + initializerUsesImplicitReceiver && + hasReceiverLambdaBetween(target, read) -> InlineExclusion.ReceiverShift + isSmartCast(read) -> InlineExclusion.SmartCast + initializerIsFunctionLiteral && isCallee(read) -> InlineExclusion.InvokesLambdaInitializer + else -> null + }, ) } @@ -161,7 +185,23 @@ private fun KaSession.planFor( val inlinable = references.count { it.isInlinable } if (inlinable == 0) { - return InlineVariablePlan.refused(InlineRefusal.NothingInlinable(name), fileText, documentVersion) + // Unlike the other refusals above, the references are already known here, each carrying the + // exclusion that ruled it out -- worth keeping on the plan rather than discarding it the way + // InlineVariablePlan.refused()'s empty-references default would. + return InlineVariablePlan( + fileText = fileText, + documentVersion = documentVersion, + variableName = name, + declarationSpan = TextSpan(target.textRange.startOffset, declarationEnd), + initializerText = initializer.text, + initializerNeedsParentheses = needsParentheses(initializer), + references = references, + cursorPosition = resolved.cursorPosition, + cursorReferenceIndex = cursorReferenceIndex, + canDeleteDeclaration = false, + modes = emptyList(), + refusal = InlineRefusal.NothingInlinable(name), + ) } return InlineVariablePlan( @@ -288,3 +328,139 @@ private fun needsParentheses(initializer: KtExpression): Boolean = else -> true } + +/** The names the initializer reads unqualified, which a nested scope could shadow. */ +private fun namesReadBy(initializer: KtExpression): Set = + PsiTreeUtil + .collectElementsOfType(initializer, KtSimpleNameExpression::class.java) + .filterNot { (it.parent as? KtQualifiedExpression)?.selectorExpression === it } + .mapTo(mutableSetOf()) { it.getReferencedName() } + +/** + * Whether a scope between [reference] and the target's own block redeclares a name the initializer + * reads. + * + * The converse case cannot arise: a local's scope runs to the end of its block, so everything the + * initializer reads is still in scope at every reference. Only shadowing bites. + */ +private fun isShadowedAt( + reference: KtSimpleNameExpression, + target: KtProperty, + initializerNames: Set, +): Boolean { + if (initializerNames.isEmpty()) return false + val ceiling = target.parent ?: return false + var child: PsiElement = reference + var scope: PsiElement? = reference.parent + while (scope != null && scope !== ceiling) { + if (declaredNamesIn(scope, child).any { it in initializerNames }) return true + child = scope + scope = scope.parent + } + return false +} + +/** The names [scope] declares that are already in effect at [site]. */ +private fun declaredNamesIn( + scope: PsiElement, + site: PsiElement, +): Set = + when (scope) { + is KtBlockExpression -> + scope.statements + .filter { it.textRange.endOffset <= site.textRange.startOffset } + .flatMapTo(mutableSetOf()) { declaredNamesOf(it) } + + is KtFunctionLiteral -> lambdaParameterNames(scope) + + is KtNamedFunction -> scope.valueParameters.mapNotNullTo(mutableSetOf()) { it.name } + + is KtPropertyAccessor -> scope.valueParameters.mapNotNullTo(mutableSetOf()) { it.name } + + is KtCatchClause -> setOfNotNull(scope.catchParameter?.name) + + is KtForExpression -> { + val parameter = scope.loopParameter + val entries = parameter?.destructuringDeclaration?.entries?.mapNotNull { it.name } ?: emptyList() + (entries + listOfNotNull(parameter?.name)).toSet() + } + + else -> emptySet() + } + +private fun declaredNamesOf(statement: KtExpression): List = + when (statement) { + is KtDestructuringDeclaration -> statement.entries.mapNotNull { it.name } + is KtNamedDeclaration -> listOfNotNull(statement.name) + else -> emptyList() + } + +/** A lambda with no declared parameters still declares `it`. */ +private fun lambdaParameterNames(lambda: KtFunctionLiteral): Set { + val declared = lambda.valueParameters + if (declared.isEmpty()) return setOf("it") + return declared.flatMapTo(mutableSetOf()) { parameter -> + parameter.destructuringDeclaration?.entries?.mapNotNull { it.name } ?: listOfNotNull(parameter.name) + } +} + +/** + * Whether the initializer reaches a member through an implicit receiver. Half of the receiver-shift + * test; on its own it is perfectly fine. + */ +private fun KaSession.readsThroughImplicitReceiver(initializer: KtExpression): Boolean = + PsiTreeUtil.collectElementsOfType(initializer, KtSimpleNameExpression::class.java).any { reference -> + // A qualified selector already has its receiver written out next to it. + if ((reference.parent as? KtQualifiedExpression)?.selectorExpression === reference) { + false + } else { + runCatching { + val callSource = + (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference + val applied = + callSource + .resolveToCall() + ?.successfulCallOrNull>() + ?.partiallyAppliedSymbol + applied?.dispatchReceiver is KaImplicitReceiverValue || + applied?.extensionReceiver is KaImplicitReceiverValue + }.getOrDefault(false) + } || + // A bare `this` names the receiver without going through a call. + PsiTreeUtil.collectElementsOfType(initializer, KtThisExpression::class.java).isNotEmpty() + } + +/** + * Whether a receiver-introducing lambda -- `with`, `apply`, `run`, `buildString`, a Compose scope -- + * sits between the declaration and [reference]. The other half of the receiver-shift test. + */ +private fun KaSession.hasReceiverLambdaBetween( + target: KtProperty, + reference: KtSimpleNameExpression, +): Boolean { + var current: PsiElement? = reference.parent + while (current != null && !PsiTreeUtil.isAncestor(current, target, false)) { + if (current is KtFunctionLiteral && introducesReceiver(current)) return true + current = current.parent + } + return false +} + +/** + * Whether [lambda]'s functional type has a receiver. Asked of the anonymous function's symbol first, + * which does not depend on the expected type having propagated to the lambda expression. + */ +private fun KaSession.introducesReceiver(lambda: KtFunctionLiteral): Boolean = + runCatching { + val symbol = lambda.symbol as? KaAnonymousFunctionSymbol + if (symbol?.receiverParameter != null) return true + ((lambda.parent as? KtLambdaExpression)?.expressionType as? KaFunctionType)?.hasReceiver == true + }.getOrDefault(false) + +/** Whether the reference is used under a smart cast, which a property read cannot carry. */ +private fun KaSession.isSmartCast(reference: KtSimpleNameExpression): Boolean = + runCatching { reference.smartCastInfo != null }.getOrDefault(false) + +/** Whether the reference is the callee of a call, rather than a value being passed around. */ +private fun isCallee(reference: KtSimpleNameExpression): Boolean = + (reference.parent as? KtCallExpression)?.calleeExpression === reference diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index 2f97ba4ad6..5c40b847e3 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -423,4 +423,138 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { apply(content, buildInlineVariableRewrites(result, InlineMode.AllReferences)!!), ) } + + @Test + fun `a shadowed reference is left untouched and the declaration is kept`() { + val content = + """ + package p + fun f(n: Int) = n + fun demo(): Int { + val a = 1 + val x = a + 1 + return run { + val a = 99 + f(x) + } + } + """.trimIndent() + + val result = plan(content, at(content, "val x") + "val ".length) + + // Inlining would produce `f(a + 1)` reading the inner `a`. + assertEquals(1, result.references.size) + assertEquals(InlineExclusion.Shadowed, result.references.single().exclusion) + assertEquals(InlineRefusal.NothingInlinable("x"), result.refusal) + } + + @Test + fun `a reference under a different implicit receiver is left untouched`() { + val content = + """ + package p + class Other { + val label: String = "other" + } + class Holder { + val label: String = "holder" + + fun demo(other: Other): String { + val text = label + "!" + return with(other) { text } + } + } + """.trimIndent() + + val result = plan(content, at(content, "val text") + "val ".length) + + assertEquals(InlineExclusion.ReceiverShift, result.references.single().exclusion) + } + + @Test + fun `an implicit-receiver initializer is fine where no lambda changes the receiver`() { + val content = + """ + package p + class Holder { + val label: String = "holder" + + fun demo(): String { + val text = label + "!" + return text + } + } + """.trimIndent() + + val result = plan(content, at(content, "val text") + "val ".length) + + // Only the conjunction of both questions is a problem; either alone is not. + assertNull(result.references.single().exclusion) + } + + @Test + fun `a smart-cast reference is left untouched`() { + val content = + """ + package p + class Box(val value: String?) + fun demo(box: Box): Int { + val b = box.value + return if (b != null) b.length else 0 + } + """.trimIndent() + + val result = plan(content, at(content, "val b") + "val ".length) + + // `box.value.length` does not compile: a smart cast needs a stable value. + assertEquals(2, result.references.size) + assertNull(result.references.first().exclusion) + assertEquals(InlineExclusion.SmartCast, result.references.last().exclusion) + assertEquals(false, result.canDeleteDeclaration) + } + + @Test + fun `a lambda initializer in call position is left untouched`() { + val content = + """ + package p + fun demo(): Int { + val f = { n: Int -> n * 2 } + return f(3) + } + """.trimIndent() + + val result = plan(content, at(content, "val f") + "val ".length) + + // The substitution would be a lambda literal in call position, which needs `.invoke()`. + assertEquals(InlineExclusion.InvokesLambdaInitializer, result.references.single().exclusion) + assertEquals(InlineRefusal.NothingInlinable("f"), result.refusal) + } + + @Test + fun `a lambda initializer passed as an argument stays inlinable`() { + val content = + """ + package p + fun call(f: (Int) -> Int): Int = f(1) + fun demo(): Int { + val f = { n: Int -> n * 2 } + return call(f) + } + """.trimIndent() + + val result = plan(content, at(content, "val f") + "val ".length) + + assertNull(result.references.single().exclusion) + assertEquals( + """ + package p + fun call(f: (Int) -> Int): Int = f(1) + fun demo(): Int { + return call({ n: Int -> n * 2 }) + } + """.trimIndent(), + apply(content, buildInlineVariableRewrites(result, InlineMode.AllReferences)!!), + ) + } } From 002c0f057276f88ea9f5c439286db1034d385ade Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 15:36:27 +0000 Subject: [PATCH 42/62] ADFA-4827: Close two shadowing gaps in the inline-variable scope walk --- docs/features/kotlin-inline-variable.md | 2 +- .../utils/refactor/InlineVariablePlanner.kt | 19 +++++- .../InlineVariablePlanEndToEndTest.kt | 67 +++++++++++++++++++ 3 files changed, 84 insertions(+), 4 deletions(-) diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index fa5a937fad..9ccd4a257e 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -82,7 +82,7 @@ This matches IntelliJ, whose documented behaviour is that *"the variable must be **R6 - Per-site exclusions.** Four ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. -- **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries - against the set of names the initializer references. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. +- **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries, a `when` subject variable, a class or object body - against the set of names the initializer references. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. - **Receiver shift.** The initializer accesses a member through an implicit receiver, and the reference sits inside a lambda that introduces a different one - the `with(other) { ... }` / `apply` / `run` / `buildString` case. Tested as the conjunction of two cheap questions: does the initializer contain an unqualified reference resolving through an implicit receiver, and is there a receiver-introducing lambda (a `KtFunctionLiteral` whose functional type has a receiver) between the declaration and the reference? Only both together are a problem. This is extract method's `InnerImplicitReceiver` as a per-site exclusion rather than a refusal. - **Smart cast.** `val b = a.b; if (b != null) b.length` inlines to `a.b.length`, which does not compile - a smart cast needs a stable value, and a property read is not one. Detected with `smartCastInfo` on the reference (`KaDataFlowProvider`); non-null means excluded. - **Invoking a lambda initializer.** When the initializer is a lambda or anonymous function and the reference is the callee of a call - `val f = { n: Int -> n * 2 }` used as `f(3)` - the substitution would be a lambda literal in call position, which needs `.invoke()` to compile. Passing the same `f` as an argument (`list.map(f)`) is unaffected and stays inlinable. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index 0c5d9c46b1..db08f78544 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -19,6 +19,7 @@ import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtCallableReferenceExpression import org.jetbrains.kotlin.psi.KtCatchClause +import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassLiteralExpression import org.jetbrains.kotlin.psi.KtCollectionLiteralExpression import org.jetbrains.kotlin.psi.KtConstantExpression @@ -41,6 +42,7 @@ import org.jetbrains.kotlin.psi.KtSimpleNameStringTemplateEntry import org.jetbrains.kotlin.psi.KtStringTemplateExpression import org.jetbrains.kotlin.psi.KtSuperExpression import org.jetbrains.kotlin.psi.KtThisExpression +import org.jetbrains.kotlin.psi.KtWhenExpression import org.slf4j.LoggerFactory import java.nio.file.Path import kotlin.coroutines.cancellation.CancellationException @@ -185,9 +187,11 @@ private fun KaSession.planFor( val inlinable = references.count { it.isInlinable } if (inlinable == 0) { - // Unlike the other refusals above, the references are already known here, each carrying the - // exclusion that ruled it out -- worth keeping on the plan rather than discarding it the way - // InlineVariablePlan.refused()'s empty-references default would. + /* + * Unlike the other refusals above, the references are already known here, each carrying the + * exclusion that ruled it out -- worth keeping on the plan rather than discarding it the way + * InlineVariablePlan.refused()'s empty-references default would. + */ return InlineVariablePlan( fileText = fileText, documentVersion = documentVersion, @@ -385,6 +389,10 @@ private fun declaredNamesIn( (entries + listOfNotNull(parameter?.name)).toSet() } + is KtWhenExpression -> setOfNotNull(scope.subjectVariable?.name) + + is KtClassBody -> scope.declarations.mapNotNullTo(mutableSetOf()) { it.name } + else -> emptySet() } @@ -398,6 +406,11 @@ private fun declaredNamesOf(statement: KtExpression): List = /** A lambda with no declared parameters still declares `it`. */ private fun lambdaParameterNames(lambda: KtFunctionLiteral): Set { val declared = lambda.valueParameters + /* + * Over-approximates: a zero-argument or receiver lambda (`run`, `with`, `apply`, `buildString`) + * binds no `it`. That only ever leaves a reference alone that could have been rewritten, never + * a wrong rewrite, so this stays syntactic rather than asking the Analysis API for the arity. + */ if (declared.isEmpty()) return setOf("it") return declared.flatMapTo(mutableSetOf()) { parameter -> parameter.destructuringDeclaration?.entries?.mapNotNull { it.name } ?: listOfNotNull(parameter.name) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index 5c40b847e3..b0e8813c11 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -448,6 +448,52 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { assertEquals(InlineRefusal.NothingInlinable("x"), result.refusal) } + @Test + fun `a when subject variable shadowing the initializer's name is not inlined`() { + val content = + """ + package p + fun f(n: Int) = n + fun demo(): Int { + val a = 1 + val x = a + 1 + return when (val a = 99) { + else -> f(x) + } + } + """.trimIndent() + + val result = plan(content, at(content, "val x") + "val ".length) + + // Inlining would produce `f(a + 1)` reading the subject variable's `a`. + assertEquals(InlineExclusion.Shadowed, result.references.single().exclusion) + } + + @Test + fun `a name shadowed in an anonymous object's body is not inlined`() { + val content = + """ + package p + fun f(n: Int) = n + fun demo(): Int { + val a = 1 + val x = a + 1 + val holder = + object { + val a = 99 + + fun g(): Int = f(x) + } + return holder.g() + } + """.trimIndent() + + val result = plan(content, at(content, "val x") + "val ".length) + + // Inlining would produce `f(a + 1)` reading the object's own `a`. + assertEquals(InlineExclusion.Shadowed, result.references.single().exclusion) + } + @Test fun `a reference under a different implicit receiver is left untouched`() { val content = @@ -492,6 +538,27 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { assertNull(result.references.single().exclusion) } + @Test + fun `a receiver lambda is fine where the initializer uses no implicit receiver`() { + val content = + """ + package p + class Other { + val label: String = "other" + } + fun demo(other: Other, prefix: String): String { + val text = prefix + "!" + return with(other) { text } + } + """.trimIndent() + + val result = plan(content, at(content, "val text") + "val ".length) + + // The lambda does introduce a receiver, but the initializer reads only a parameter, so there is + // nothing for the receiver shift to break. + assertNull(result.references.single().exclusion) + } + @Test fun `a smart-cast reference is left untouched`() { val content = From 119ac9487e9a526f445c549c0ed0aef6762bdb43 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 15:45:30 +0000 Subject: [PATCH 43/62] ADFA-4827: Add the inline-variable sheet and its strings --- .../kotlin/refactor/ui/InlineVariableSheet.kt | 85 ++++++++++++++ .../refactor/ui/InlineVariableSheetContent.kt | 106 ++++++++++++++++++ resources/src/main/res/values/strings.xml | 28 +++++ 3 files changed, 219 insertions(+) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt new file mode 100644 index 0000000000..473c07d118 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt @@ -0,0 +1,85 @@ +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.ui.platform.ComposeView +import androidx.compose.ui.platform.ViewCompositionStrategy +import androidx.fragment.app.FragmentActivity +import com.google.android.material.bottomsheet.BottomSheetDialogFragment +import com.itsaky.androidide.common.compose.IdeTheme +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineMode +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineVariablePlan + +/** + * Hosts [InlineVariableSheetContent]. + * + * 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 InlineVariableSheet : BottomSheetDialogFragment() { + private var plan: InlineVariablePlan? = null + private var onMode: ((InlineMode) -> Unit)? = null + + override fun onCreateView( + inflater: LayoutInflater, + container: ViewGroup?, + savedInstanceState: Bundle?, + ): View? { + val plan = plan ?: run { + dismissAllowingStateLoss() + return null + } + + return ComposeView(requireContext()).apply { + setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) + setContent { + IdeTheme { + InlineVariableSheetContent( + plan = plan, + onEvent = ::handleEvent, + ) + } + } + } + } + + private fun handleEvent(event: InlineVariableUiEvent) { + when (event) { + is InlineVariableUiEvent.ModeChosen -> { + onMode?.invoke(event.mode) + dismiss() + } + + InlineVariableUiEvent.Dismissed -> { + dismiss() + } + } + } + + companion object { + private const val TAG = "inline_variable_sheet" + + /** + * Shows the sheet on [activity], calling [onMode] once if the user picks a mode. Returns false + * when it could not be shown, so the caller can report a failure rather than doing nothing. + */ + fun show( + activity: FragmentActivity, + plan: InlineVariablePlan, + onMode: (InlineMode) -> Unit, + ): Boolean { + val manager = activity.supportFragmentManager + if (manager.isStateSaved || manager.isDestroyed) return false + InlineVariableSheet() + .apply { + this.plan = plan + this.onMode = onMode + }.show(manager, TAG) + return true + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt new file mode 100644 index 0000000000..5165c91eeb --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt @@ -0,0 +1,106 @@ +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.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.lsp.kotlin.utils.refactor.InlineLabel +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineMode +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineVariablePlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.labelFor +import com.itsaky.androidide.lsp.kotlin.utils.refactor.substitutionTextFor +import com.itsaky.androidide.resources.R + +/** What the sheet reports back up; it never touches the document itself. */ +sealed interface InlineVariableUiEvent { + /** The user picked [mode]. */ + data class ModeChosen( + val mode: InlineMode, + ) : InlineVariableUiEvent + + /** The user dismissed the sheet without picking a mode. */ + data object Dismissed : InlineVariableUiEvent +} + +/** + * The inline-variable sheet: one button per available mode, and the substitution text. + * + * Stateless and ViewModel-free: there is no mutable state to own -- an immutable plan, two + * derived labels and three events. A ViewModel holding nothing would be ceremony, and its test would + * assert that a constant is a constant. + */ +@Composable +fun InlineVariableSheetContent( + plan: InlineVariablePlan, + onEvent: (InlineVariableUiEvent) -> 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_inline_variable), + style = MaterialTheme.typography.titleLarge, + ) + + plan.modes.forEach { mode -> + Button( + onClick = { onEvent(InlineVariableUiEvent.ModeChosen(mode)) }, + modifier = Modifier.fillMaxWidth(), + ) { + Text(plan.labelFor(mode).text()) + } + } + + LabelledSection(stringResource(R.string.label_inline_variable_value)) { + Text( + // The cursor's own reference: the sheet is shown only when the cursor is on an inlinable one. + text = substitutionTextFor(plan, plan.references[plan.cursorReferenceIndex]), + style = MaterialTheme.typography.bodyMedium.copy(fontFamily = FontFamily.Monospace), + modifier = Modifier.fillMaxWidth(), + ) + } + + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.End, + ) { + TextButton(onClick = { onEvent(InlineVariableUiEvent.Dismissed) }) { + Text(stringResource(android.R.string.cancel)) + } + } + } +} + +/** + * The localised form of a label derived beside the plan. The derivation lives with the plan so it + * cannot drift from what the edit does; only the wording lives here. + */ +@Composable +private fun InlineLabel.text(): String = + when (this) { + InlineLabel.ThisReferenceOnly -> stringResource(R.string.label_inline_variable_this_reference) + + is InlineLabel.AllAndDelete -> stringResource(R.string.label_inline_variable_all_and_delete, count, name) + + is InlineLabel.AllKeepingDeclaration -> stringResource(R.string.label_inline_variable_all_keeping, count, name) + + is InlineLabel.PartialKeepingDeclaration -> + stringResource(R.string.label_inline_variable_partial, count, total, name) + } diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index 22fa37ae8c..a96c90171e 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -557,6 +557,34 @@ The selection uses the property\'s backing field, which only exists inside this accessor The selection uses %1$s under a smart cast that does not hold outside the selection The selection uses %1$s, which goes out of scope once the selection moves + + + Inline variable + Inline variable + Value + Inline this reference only + Inline all %1$d references and remove %2$s + Inline all %1$d references, keeping %2$s + Inline %1$d of %2$d references, keeping %3$s + Place the cursor on a local variable or one of its uses + Only a local variable can be inlined + %1$s has no value at its declaration + A destructuring declaration cannot be inlined + %1$s is declared %2$s, and its uses need that type + %1$s is never used + No use of %1$s can be inlined safely + The value of %1$s changes before this use + Could not analyse the file. Try again. + The file changed. Try inlining again. + + Inlined %1$d reference to %2$s and removed the declaration + Inlined %1$d references to %2$s and removed the declaration + + + Inlined %1$d reference to %2$s, keeping the declaration + Inlined %1$d references to %2$s, keeping the declaration + + Inlined %1$d of %2$d references to %3$s, keeping the declaration Select fields No fields selected No fields found From 915f1d7aafa6e35e33cbd541cc64819a24c80caf Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 16:03:55 +0000 Subject: [PATCH 44/62] ADFA-4827: Wire up the inline-variable code action --- docs/features/kotlin-inline-variable.md | 5 +- .../androidide/idetooltips/TooltipTag.kt | 1 + .../lsp/kotlin/KotlinCodeActionsMenu.kt | 2 + .../kotlin/actions/InlineVariableAction.kt | 276 ++++++++++++++++++ .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 + 5 files changed, 284 insertions(+), 2 deletions(-) create mode 100644 lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index 9ccd4a257e..79b2628574 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -1,7 +1,7 @@ # Kotlin inline variable (K2 LSP) - **Ticket:** ADFA-4827 (subtask of ADFA-3317; split out of the closed ADFA-3324 "Refactoring") -- **Status:** Specified. Fourth link in the refactoring stack, based on extract method (ADFA-5080). +- **Status:** Implemented. Fourth link in the refactoring stack, based on extract method (ADFA-5080). - **Module:** `lsp/kotlin` Replace the references to a local variable with its initializer, and delete the declaration once nothing needs it. @@ -258,10 +258,11 @@ Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotl - **`InlineVariablePlanEndToEndTest`** - analysis-backed, one case per rule: both cursor positions (R2), the reference set (R4), the cutoff from each cause (R5), one case per per-site exclusion (R6), the explicit-type refusal (R7), the deletion rule including the `var`-with-a-later-write case (R8), mode availability per row of R9's table, and **one case per refusal reason** (R14). - **`InlineVariableEditTest`** - pure text: parenthesisation per initializer class, both template forms, the three deletion line shapes, comment preservation, descending edit order, and CRLF preservation (R10-R12). +- **`InlineVariablePlanTest`** - the pure derivations: R9's mode table and R13's labels and reports, against hand-built plans. - **`RefactorPrimitivesTest`** - extended for whatever R6's scope walk factors out syntactically. - **`KotlinCodeActionTooltipTagTest`** - one new row (R1). -There is no `ViewModelTest`, because there is no ViewModel (R13); the label and report derivations are tested with the plan instead. +There is no `ViewModelTest`, because there is no ViewModel (R13); the label and report derivations are tested in `InlineVariablePlanTest` against hand-built plans instead. The sheet, `prepare()`/`ActionData`, the N+1 undo and the new tooltip row are not unit-testable; they are covered by on-device QA from the acceptance criteria above, recorded in ADFA-4827's "Steps to QA" field. 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 2295b925be..63c165a622 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_FIND_REFS = "editor.codeactions.kotlin.findrefs" const val EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE = "editor.codeactions.kotlin.extractvariable" const val EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD = "editor.codeactions.kotlin.extractmethod" + const val EDITOR_CODE_ACTIONS_KT_INLINE_VARIABLE = "editor.codeactions.kotlin.inlinevariable" const val EXIT_TO_MAIN = "exit.to.main" 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 e9be630f20..96d5fa503a 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 @@ -11,6 +11,7 @@ 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 +import com.itsaky.androidide.lsp.kotlin.actions.InlineVariableAction import com.itsaky.androidide.lsp.kotlin.actions.NullSafetyAction import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction import com.itsaky.androidide.lsp.kotlin.actions.SurroundWithTryCatchAction @@ -43,5 +44,6 @@ object KotlinCodeActionsMenu : IActionsMenuProvider { ImplementMembersAction(), ExtractVariableAction(), ExtractMethodAction(), + InlineVariableAction(), ) } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt new file mode 100644 index 0000000000..a494fdf3a4 --- /dev/null +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt @@ -0,0 +1,276 @@ +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.InlineVariableSheet +import com.itsaky.androidide.lsp.kotlin.refactor.ui.findFragmentActivity +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineMode +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineRefusal +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineReport +import com.itsaky.androidide.lsp.kotlin.utils.refactor.InlineVariablePlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildInlineVariablePlan +import com.itsaky.androidide.lsp.kotlin.utils.refactor.buildInlineVariableRewrites +import com.itsaky.androidide.lsp.kotlin.utils.refactor.reportFor +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 + +/** + * Replaces the references to the local variable at the cursor with its initializer, and removes the + * declaration once nothing needs it. + * + * [execAction] runs one background analysis pass and returns a plain-data [InlineVariablePlan]; + * [postExec] either applies it immediately or, where the mode table leaves a decision, shows the + * sheet. Where a reference or the whole inline cannot be performed faithfully the plan carries a + * typed refusal, which postExec renders as a specific message rather than a generic failure. + */ +class InlineVariableAction : BaseKotlinCodeAction() { + companion object { + /** This action's registration id. */ + const val ID = "ide.editor.lsp.kt.inlineVariable" + } + + override var titleTextRes: Int = R.string.action_inline_variable + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_KT_INLINE_VARIABLE + + override val id: String = ID + override var label: String = "" + + /* Analysis must not run on the UI thread, so the cursor 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 the cursor is on an inlinable local + 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): InlineVariablePlan { + val server = + data.get() + ?: return InlineVariablePlan.refused(InlineRefusal.CouldNotAnalyse) + val nioPath = data.requireFile().toPath() + val env = + server.compilationEnvironmentFor(nioPath) + ?: return InlineVariablePlan.refused(InlineRefusal.CouldNotAnalyse) + + val cursor = data.requireEditor().cursor + return buildInlineVariablePlan( + env = env, + nioPath = nioPath, + // The selection start: a user who selected the whole name still points at its first character. + offset = minOf(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 InlineVariablePlan) return + + val context = data.requireContext() + val refusal = result.refusal ?: if (result.modes.isEmpty()) InlineRefusal.CouldNotAnalyse else null + if (refusal != null) { + flashInfo(refusalMessage(context, refusal)) + return + } + + // Only a two-mode choice is a decision; every other path applies immediately. + if (!result.offersChoice) { + applyMode(data, result, result.modes.single()) + 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 inline sheet.") + flashError(R.string.msg_cannot_perform_fix) + return + } + + val shown = InlineVariableSheet.show(activity, result) { mode -> applyMode(data, result, mode) } + if (!shown) { + logger.warn("Fragment manager unavailable. Cannot show the inline sheet.") + } + } + + /** + * Turns the chosen mode into edits and hands them to the language client. + * + * Runs from the sheet's click handler on the choice path, outside `execAction` and so outside every + * guard the action framework provides -- nothing here may throw, hence the [runCatching]. + */ + private fun applyMode( + data: ActionData, + plan: InlineVariablePlan, + mode: InlineMode, + ) { + runCatching { performMode(data, plan, mode) }.onFailure { error -> + logger.error("Failed to apply the inline-variable mode '{}'", mode.name, error) + flashError(R.string.msg_cannot_perform_fix) + } + } + + private fun performMode( + data: ActionData, + plan: InlineVariablePlan, + mode: InlineMode, + ) { + val context = data.requireContext() + val nioPath = data.requireFile().toPath() + // Re-read rather than trust the plan: the editor stays reachable while the sheet is open, and + // applying spans computed against older text would corrupt the file. + if (documentVersionOf(nioPath) != plan.documentVersion) { + flashInfo(refusalMessage(context, InlineRefusal.FileChanged)) + return + } + + val rewrites = + buildInlineVariableRewrites(plan, mode) ?: run { + logger.warn("Could not build an inline-variable rewrite for '{}'", plan.variableName) + flashError(R.string.msg_cannot_perform_fix) + return + } + + val client = + data.languageClient ?: run { + logger.warn("No language client set. Cannot inline variable.") + return + } + + client.performCodeAction( + CodeActionItem( + title = label, + changes = + listOf( + DocumentChange( + file = nioPath, + // Already in descending document order: applyActionEdits applies these in list order + // with line/column ranges, so an earlier edit must never shift a later one. + edits = rewrites.map { it.toTextEdit(plan.fileText) }, + ), + ), + kind = CodeActionKind.QuickFix, + // The substitutions are emitted final; CMD_FORMAT_CODE is a no-op for Kotlin anyway. + command = Command("", ""), + ), + ) + + flashInfo(reportMessage(context, plan.reportFor(mode))) + } + + /** + * Each refusal names what is in the way; a generic message reads as a broken feature. + * + * Exhaustive with no `else`: a future variant added without a message here is a compile error + * rather than a silent gap. + */ + private fun refusalMessage( + context: Context, + refusal: InlineRefusal, + ): String = + when (refusal) { + InlineRefusal.NotAVariable -> { + context.getString(R.string.msg_inline_variable_not_a_variable) + } + + InlineRefusal.NotALocalVariable -> { + context.getString(R.string.msg_inline_variable_not_local) + } + + is InlineRefusal.NoInitializer -> { + context.getString(R.string.msg_inline_variable_no_initializer, refusal.name) + } + + InlineRefusal.DestructuringDeclaration -> { + context.getString(R.string.msg_inline_variable_destructuring) + } + + is InlineRefusal.DeclaredTypeIsLoadBearing -> { + context.getString(R.string.msg_inline_variable_declared_type, refusal.name, refusal.typeText) + } + + is InlineRefusal.NeverUsed -> { + context.getString(R.string.msg_inline_variable_never_used, refusal.name) + } + + is InlineRefusal.NothingInlinable -> { + context.getString(R.string.msg_inline_variable_nothing_inlinable, refusal.name) + } + + is InlineRefusal.ReferenceNotInlinable -> { + context.getString(R.string.msg_inline_variable_reference_not_inlinable, refusal.name) + } + + InlineRefusal.CouldNotAnalyse -> { + context.getString(R.string.msg_inline_variable_could_not_analyse) + } + + InlineRefusal.FileChanged -> { + context.getString(R.string.msg_inline_variable_file_changed) + } + } + + /** + * The partial form must say both counts and that the declaration was kept: a user who asked to + * inline everything and got three of five needs to know from the flash rather than by rereading the + * file. + */ + private fun reportMessage( + context: Context, + report: InlineReport, + ): String = + when (report) { + is InlineReport.InlinedAndRemoved -> { + context.resources.getQuantityString( + R.plurals.msg_inline_variable_inlined_all, + report.count, + report.count, + report.name, + ) + } + + is InlineReport.InlinedKeepingDeclaration -> { + context.resources.getQuantityString( + R.plurals.msg_inline_variable_inlined_keeping, + report.count, + report.count, + report.name, + ) + } + + is InlineReport.InlinedPartially -> { + context.getString( + R.string.msg_inline_variable_inlined_partially, + report.count, + report.total, + report.name, + ) + } + } + + /** -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 f93d1bc842..9d5b5d3c3b 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 @@ -10,6 +10,7 @@ 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 +import com.itsaky.androidide.lsp.kotlin.actions.InlineVariableAction import com.itsaky.androidide.lsp.kotlin.actions.NullSafetyAction import com.itsaky.androidide.lsp.kotlin.actions.OrganizeImportsAction import com.itsaky.androidide.lsp.kotlin.actions.SurroundWithTryCatchAction @@ -45,6 +46,7 @@ class KotlinCodeActionTooltipTagTest { SurroundWithTryCatchAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_SURROUND_TRY_CATCH, ExtractVariableAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_VARIABLE, ExtractMethodAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_EXTRACT_METHOD, + InlineVariableAction.ID to TooltipTag.EDITOR_CODE_ACTIONS_KT_INLINE_VARIABLE, ) assertEquals(expected, actualTags) } From a9ef135e94f8ebbff6877f1d15d03d3c7da70fec Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 16:07:18 +0000 Subject: [PATCH 45/62] ADFA-4827: Apply spotless formatting to the inline-variable files --- .../kotlin/refactor/ui/InlineVariableSheet.kt | 9 +++-- .../refactor/ui/InlineVariableSheetContent.kt | 15 +++++-- .../utils/refactor/InlineVariableEdit.kt | 10 +++-- .../utils/refactor/InlineVariablePlan.kt | 8 +++- .../utils/refactor/InlineVariablePlanner.kt | 39 ++++++++++++++----- .../InlineVariablePlanEndToEndTest.kt | 7 +++- 6 files changed, 63 insertions(+), 25 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt index 473c07d118..de6797f797 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheet.kt @@ -29,10 +29,11 @@ class InlineVariableSheet : BottomSheetDialogFragment() { container: ViewGroup?, savedInstanceState: Bundle?, ): View? { - val plan = plan ?: run { - dismissAllowingStateLoss() - return null - } + val plan = + plan ?: run { + dismissAllowingStateLoss() + return null + } return ComposeView(requireContext()).apply { setViewCompositionStrategy(ViewCompositionStrategy.DisposeOnViewTreeLifecycleDestroyed) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt index 5165c91eeb..6a14341c74 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt @@ -95,12 +95,19 @@ fun InlineVariableSheetContent( @Composable private fun InlineLabel.text(): String = when (this) { - InlineLabel.ThisReferenceOnly -> stringResource(R.string.label_inline_variable_this_reference) + InlineLabel.ThisReferenceOnly -> { + stringResource(R.string.label_inline_variable_this_reference) + } - is InlineLabel.AllAndDelete -> stringResource(R.string.label_inline_variable_all_and_delete, count, name) + is InlineLabel.AllAndDelete -> { + stringResource(R.string.label_inline_variable_all_and_delete, count, name) + } - is InlineLabel.AllKeepingDeclaration -> stringResource(R.string.label_inline_variable_all_keeping, count, name) + is InlineLabel.AllKeepingDeclaration -> { + stringResource(R.string.label_inline_variable_all_keeping, count, name) + } - is InlineLabel.PartialKeepingDeclaration -> + is InlineLabel.PartialKeepingDeclaration -> { stringResource(R.string.label_inline_variable_partial, count, total, name) + } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt index a9311a74df..a18d1fd6c6 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt @@ -25,10 +25,13 @@ fun buildInlineVariableRewrites( val text = plan.fileText val targets = when (mode) { - InlineMode.ThisReferenceOnly -> + InlineMode.ThisReferenceOnly -> { listOfNotNull(plan.references.getOrNull(plan.cursorReferenceIndex)?.takeIf { it.isInlinable }) + } - InlineMode.AllReferences -> plan.inlinableReferences + InlineMode.AllReferences -> { + plan.inlinableReferences + } } if (targets.isEmpty()) return null if (targets.any { it.span.end > text.length || it.span.start < 0 }) return null @@ -95,8 +98,7 @@ private fun declarationDeletion(plan: InlineVariablePlan): RewriteSpan { } /** Whether what follows the declaration on its line is only a comment. */ -private fun isWholeLineComment(suffix: String): Boolean = - suffix.startsWith("//") || (suffix.startsWith("/*") && suffix.endsWith("*/")) +private fun isWholeLineComment(suffix: String): Boolean = suffix.startsWith("//") || (suffix.startsWith("/*") && suffix.endsWith("*/")) /** The offset of the line terminator at or after [offset], or the end of the text. */ private fun endOfLineContent( diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt index 231eda0e0a..34ce9000f7 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt @@ -244,7 +244,9 @@ sealed interface InlineReport { */ fun InlineVariablePlan.labelFor(mode: InlineMode): InlineLabel = when (mode) { - InlineMode.ThisReferenceOnly -> InlineLabel.ThisReferenceOnly + InlineMode.ThisReferenceOnly -> { + InlineLabel.ThisReferenceOnly + } InlineMode.AllReferences -> { val count = inlinableReferences.size @@ -262,7 +264,9 @@ fun InlineVariablePlan.labelFor(mode: InlineMode): InlineLabel = */ fun InlineVariablePlan.reportFor(mode: InlineMode): InlineReport = when (mode) { - InlineMode.ThisReferenceOnly -> InlineReport.InlinedPartially(1, references.size, variableName) + InlineMode.ThisReferenceOnly -> { + InlineReport.InlinedPartially(1, references.size, variableName) + } InlineMode.AllReferences -> { val count = inlinableReferences.size diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index db08f78544..778548f42c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -161,11 +161,16 @@ private fun KaSession.planFor( exclusion = when { span.start >= cutoff -> InlineExclusion.PastCutoff + isShadowedAt(read, target, initializerNames) -> InlineExclusion.Shadowed + initializerUsesImplicitReceiver && hasReceiverLambdaBetween(target, read) -> InlineExclusion.ReceiverShift + isSmartCast(read) -> InlineExclusion.SmartCast + initializerIsFunctionLiteral && isCallee(read) -> InlineExclusion.InvokesLambdaInitializer + else -> null }, ) @@ -370,18 +375,27 @@ private fun declaredNamesIn( site: PsiElement, ): Set = when (scope) { - is KtBlockExpression -> + is KtBlockExpression -> { scope.statements .filter { it.textRange.endOffset <= site.textRange.startOffset } .flatMapTo(mutableSetOf()) { declaredNamesOf(it) } + } - is KtFunctionLiteral -> lambdaParameterNames(scope) + is KtFunctionLiteral -> { + lambdaParameterNames(scope) + } - is KtNamedFunction -> scope.valueParameters.mapNotNullTo(mutableSetOf()) { it.name } + is KtNamedFunction -> { + scope.valueParameters.mapNotNullTo(mutableSetOf()) { it.name } + } - is KtPropertyAccessor -> scope.valueParameters.mapNotNullTo(mutableSetOf()) { it.name } + is KtPropertyAccessor -> { + scope.valueParameters.mapNotNullTo(mutableSetOf()) { it.name } + } - is KtCatchClause -> setOfNotNull(scope.catchParameter?.name) + is KtCatchClause -> { + setOfNotNull(scope.catchParameter?.name) + } is KtForExpression -> { val parameter = scope.loopParameter @@ -389,11 +403,17 @@ private fun declaredNamesIn( (entries + listOfNotNull(parameter?.name)).toSet() } - is KtWhenExpression -> setOfNotNull(scope.subjectVariable?.name) + is KtWhenExpression -> { + setOfNotNull(scope.subjectVariable?.name) + } - is KtClassBody -> scope.declarations.mapNotNullTo(mutableSetOf()) { it.name } + is KtClassBody -> { + scope.declarations.mapNotNullTo(mutableSetOf()) { it.name } + } - else -> emptySet() + else -> { + emptySet() + } } private fun declaredNamesOf(statement: KtExpression): List = @@ -475,5 +495,4 @@ private fun KaSession.isSmartCast(reference: KtSimpleNameExpression): Boolean = runCatching { reference.smartCastInfo != null }.getOrDefault(false) /** Whether the reference is the callee of a call, rather than a value being passed around. */ -private fun isCallee(reference: KtSimpleNameExpression): Boolean = - (reference.parent as? KtCallExpression)?.calleeExpression === reference +private fun isCallee(reference: KtSimpleNameExpression): Boolean = (reference.parent as? KtCallExpression)?.calleeExpression === reference diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index b0e8813c11..d601451aa3 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -135,7 +135,12 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { // Matching is by symbol identity, never by name text: the inner `val x` and its use belong to a // different declaration. assertEquals(1, result.references.size) - assertEquals(at(content, "x", after = 1), result.references.single().span.start) + assertEquals( + at(content, "x", after = 1), + result.references + .single() + .span.start, + ) } @Test From 96f85f415f90a9f712013725f176e9e7877e4013 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 16:45:27 +0000 Subject: [PATCH 46/62] ADFA-4827: Close the inline-variable soundness gaps found in review --- .../utils/refactor/InlineVariablePlan.kt | 14 ++- .../utils/refactor/InlineVariablePlanner.kt | 48 +++++++++- .../utils/refactor/InlineVariableEditTest.kt | 31 ++++++ .../InlineVariablePlanEndToEndTest.kt | 95 ++++++++++++++++++- 4 files changed, 180 insertions(+), 8 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt index 34ce9000f7..e95065c1e0 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt @@ -29,8 +29,18 @@ enum class InlineExclusion { /** The reference is used under a smart cast, which an expression cannot carry. */ SmartCast, - /** A lambda initializer in call position, which would need `.invoke()` to compile. */ - InvokesLambdaInitializer, + /** + * The reference is the callee of a call and the initializer is not a bare name -- a lambda or + * anonymous function would need `.invoke()`, a callable reference does not parse there, and a + * qualified access could silently resolve to a different member than `invoke`. + */ + UnsafeInCalleePosition, + + /** + * The reference is inside a body that may run after a write invalidates the cutoff -- a lambda, a + * local function, or an anonymous object -- so the cutoff's textual position cannot be trusted. + */ + DeferredExecution, } /** diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index 778548f42c..87d1dfc63a 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -31,6 +31,7 @@ import org.jetbrains.kotlin.psi.KtFunctionLiteral import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtObjectDeclaration import org.jetbrains.kotlin.psi.KtObjectLiteralExpression import org.jetbrains.kotlin.psi.KtParenthesizedExpression import org.jetbrains.kotlin.psi.KtPostfixExpression @@ -144,7 +145,6 @@ private fun KaSession.planFor( val initializerNames = namesReadBy(initializer) val initializerUsesImplicitReceiver = readsThroughImplicitReceiver(initializer) - val initializerIsFunctionLiteral = initializer is KtLambdaExpression || initializer is KtNamedFunction val references = reads.map { read -> @@ -162,6 +162,8 @@ private fun KaSession.planFor( when { span.start >= cutoff -> InlineExclusion.PastCutoff + cutoff != Int.MAX_VALUE && isDeferred(read, target) -> InlineExclusion.DeferredExecution + isShadowedAt(read, target, initializerNames) -> InlineExclusion.Shadowed initializerUsesImplicitReceiver && @@ -169,7 +171,7 @@ private fun KaSession.planFor( isSmartCast(read) -> InlineExclusion.SmartCast - initializerIsFunctionLiteral && isCallee(read) -> InlineExclusion.InvokesLambdaInitializer + initializer !is KtSimpleNameExpression && isCallee(read) -> InlineExclusion.UnsafeInCalleePosition else -> null }, @@ -223,9 +225,13 @@ private fun KaSession.planFor( references = references, cursorPosition = resolved.cursorPosition, cursorReferenceIndex = cursorReferenceIndex, - // The deletion rule's second clause is not redundant: a `var` whose reads were all inlined can still have a - // later `x = 5` assigning to it, so the declaration is still needed. - canDeleteDeclaration = inlinable == references.size && targetWriteOffsets.isEmpty(), + /* + * The deletion rule's second clause is not redundant: a `var` whose reads were all inlined can + * still have a later `x = 5` assigning to it, so the declaration is still needed. The third + * clause excludes a `when` subject variable and similar shapes: deleting `val a` there takes the + * enclosing `when (val a = ...)` syntax with it, which does not parse. + */ + canDeleteDeclaration = inlinable == references.size && targetWriteOffsets.isEmpty() && target.parent is KtBlockExpression, modes = modesFor(resolved.cursorPosition, inlinable), refusal = null, ) @@ -366,6 +372,21 @@ private fun isShadowedAt( child = scope scope = scope.parent } + /* + * The loop above never inspects the ceiling block's own statements. A name the target's own block + * redeclares after the target and before the reference shadows it too -- Kotlin permits this (it + * is a warning, not an error) -- so it needs the same check, bounded to that span only: a + * statement before the target is exactly what the initializer legitimately resolves to. + */ + if (scope === ceiling && ceiling is KtBlockExpression) { + val shadowing = + ceiling.statements + .filter { + it.textRange.startOffset > target.textRange.endOffset && + it.textRange.endOffset <= child.textRange.startOffset + }.flatMapTo(mutableSetOf()) { declaredNamesOf(it) } + if (shadowing.any { it in initializerNames }) return true + } return false } @@ -496,3 +517,20 @@ private fun KaSession.isSmartCast(reference: KtSimpleNameExpression): Boolean = /** Whether the reference is the callee of a call, rather than a value being passed around. */ private fun isCallee(reference: KtSimpleNameExpression): Boolean = (reference.parent as? KtCallExpression)?.calleeExpression === reference + +/** + * Whether [reference] sits inside a lambda, local function or anonymous object between it and + * [target]. A body that runs later does not read the value at the offset where its text sits, so once + * a write exists at all, the cutoff's textual position cannot judge whether such a reference is safe. + */ +private fun isDeferred( + reference: KtSimpleNameExpression, + target: KtProperty, +): Boolean { + var current: PsiElement? = reference.parent + while (current != null && !PsiTreeUtil.isAncestor(current, target, false)) { + if (current is KtFunctionLiteral || current is KtNamedFunction || current is KtObjectDeclaration) return true + current = current.parent + } + return false +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt index b2b07ac392..0be9ebad04 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt @@ -238,6 +238,37 @@ class InlineVariableEditTest { ) } + @Test + fun `a space-indented file keeps its own indentation on the preserved comment`() { + val file = + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + " val total = a + b // running total\n" + + " return total\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val total = a + b", + initializerText = "a + b", + // after = 2: the comment text contains "total" too, so it is the third occurrence. + references = listOf(reference(file, "total", after = 2)), + initializerNeedsParentheses = true, + name = "total", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + assertEquals( + "package p\n" + + "fun demo(a: Int, b: Int): Int {\n" + + " // running total\n" + + " return (a + b)\n" + + "}\n", + apply(file, rewrites!!), + ) + } + @Test fun `edits are sorted descending with the declaration deletion last`() { val file = diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index d601451aa3..a9ffd3b360 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -429,6 +429,99 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `a when subject variable is inlined but its declaration is never deleted`() { + val content = + """ + package p + fun g(n: Int) = n + fun compute(): Int = 1 + fun demo(): Int { + return when (val a = compute()) { + 1 -> g(a) + else -> 0 + } + } + """.trimIndent() + + val result = plan(content, at(content, "val a") + "val ".length) + + assertEquals(false, result.canDeleteDeclaration) + assertEquals( + """ + package p + fun g(n: Int) = n + fun compute(): Int = 1 + fun demo(): Int { + return when (val a = compute()) { + 1 -> g(compute()) + else -> 0 + } + } + """.trimIndent(), + apply(content, buildInlineVariableRewrites(result, InlineMode.AllReferences)!!), + ) + } + + @Test + fun `a name redeclared later in the target's own block is not inlined`() { + val content = + """ + package p + fun f(n: Int) = n + fun demo(): Int { + val a = 1 + val x = a + 1 + val a = 99 + return f(x) + } + """.trimIndent() + + val result = plan(content, at(content, "val x") + "val ".length) + + assertEquals(InlineExclusion.Shadowed, result.references.single().exclusion) + } + + @Test + fun `a reference inside a stored lambda is excluded once a write exists`() { + val content = + """ + package p + class Button { + fun setOnClickListener(listener: () -> Unit) {} + } + fun show(s: String) {} + fun demo(button: Button) { + var index = 0 + val label = "item ${'$'}index" + button.setOnClickListener { show(label) } + index = 1 + } + """.trimIndent() + + val result = plan(content, at(content, "val label") + "val ".length) + + assertEquals(InlineExclusion.DeferredExecution, result.references.single().exclusion) + assertEquals(false, result.canDeleteDeclaration) + } + + @Test + fun `a callable reference initializer in call position is left untouched`() { + val content = + """ + package p + fun g(n: Int) = n + fun demo(): Int { + val f = ::g + return f(3) + } + """.trimIndent() + + val result = plan(content, at(content, "val f") + "val ".length) + + assertEquals(InlineExclusion.UnsafeInCalleePosition, result.references.single().exclusion) + } + @Test fun `a shadowed reference is left untouched and the declaration is kept`() { val content = @@ -599,7 +692,7 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { val result = plan(content, at(content, "val f") + "val ".length) // The substitution would be a lambda literal in call position, which needs `.invoke()`. - assertEquals(InlineExclusion.InvokesLambdaInitializer, result.references.single().exclusion) + assertEquals(InlineExclusion.UnsafeInCalleePosition, result.references.single().exclusion) assertEquals(InlineRefusal.NothingInlinable("f"), result.refusal) } From 2d113fc68ccb3b56870c0992c3351ab332d138d3 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 16:45:40 +0000 Subject: [PATCH 47/62] ADFA-4827: Record the inline-variable cutoff limitations --- docs/features/kotlin-inline-variable.md | 8 +++++++- resources/src/main/res/values/strings.xml | 2 +- 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index 79b2628574..709d30180c 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -80,6 +80,10 @@ Both come from the existing `writeOffsetsFor(candidate, searchRoot)`, called wit This matches IntelliJ, whose documented behaviour is that *"the variable must be initialized at declaration; if the initial value is modified somewhere in the code, only the occurrences before modification will be inlined."* The alternative - refusing the whole inline - was rejected: it discards a sound refactoring of the references before the write, and a partial result is what a user coming from a desktop IDE already expects here. +The cutoff is a purely textual position, and cannot know that a reference inside a body that runs later - a lambda, a local function, or an anonymous object - does not read the value at the offset where its text happens to sit (`button.setOnClickListener { show(label) }` before a later `index = 1`, where `label` reads `index`). Once any write exists at all, such a reference is excluded outright (`DeferredExecution`, R6) rather than judged by where its text falls relative to the cutoff. + +**Known limitation:** the shared `writeOffsetsFor` primitive tests a simple name, so a write through a qualified access - `config.limit = 5` - is not detected as a write at all. A variable whose initializer reads a qualified mutable therefore gets no cutoff. Fixing the shared primitive is out of scope here; extract method also depends on its current behaviour. + **R6 - Per-site exclusions.** Four ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. - **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries, a `when` subject variable, a class or object body - against the set of names the initializer references. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. @@ -157,7 +161,7 @@ Contents: title -> the two mode buttons with their derived labels -> the substit | `DeclaredTypeIsLoadBearing` | `` is declared ``, and its uses need that type (R7) | | `NeverUsed` | `` is never used (R4) | | `NothingInlinable` | no use of `` can be inlined safely (R8) | -| `ReferenceNotInlinable` | the value of `` changes before this use (R9) | +| `ReferenceNotInlinable` | this use of `` cannot be inlined safely (R9) | | `CouldNotAnalyse` | the analysis could not run - deliberately neutral, since the cursor may have been fine | | `FileChanged` | the file changed while the sheet was open (R3) | @@ -262,6 +266,8 @@ Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotl - **`RefactorPrimitivesTest`** - extended for whatever R6's scope walk factors out syntactically. - **`KotlinCodeActionTooltipTagTest`** - one new row (R1). +Every new scope shape added to R6's shadowing walk needs its own end-to-end case in `InlineVariablePlanEndToEndTest` - that walk is where three separate defects have been found in review (a `when` subject variable, a class or object body, and a redeclaration in the target's own block). + There is no `ViewModelTest`, because there is no ViewModel (R13); the label and report derivations are tested in `InlineVariablePlanTest` against hand-built plans instead. The sheet, `prepare()`/`ActionData`, the N+1 undo and the new tooltip row are not unit-testable; they are covered by on-device QA from the acceptance criteria above, recorded in ADFA-4827's "Steps to QA" field. diff --git a/resources/src/main/res/values/strings.xml b/resources/src/main/res/values/strings.xml index a96c90171e..0b0b341d8d 100644 --- a/resources/src/main/res/values/strings.xml +++ b/resources/src/main/res/values/strings.xml @@ -573,7 +573,7 @@ %1$s is declared %2$s, and its uses need that type %1$s is never used No use of %1$s can be inlined safely - The value of %1$s changes before this use + This use of %1$s cannot be inlined safely Could not analyse the file. Try again. The file changed. Try inlining again. From 71da532557b689e79ed606b9a7fd4b43db19769c Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 16:51:04 +0000 Subject: [PATCH 48/62] ADFA-4827: Update the exclusion requirements for the widened rules --- docs/features/kotlin-inline-variable.md | 38 ++++++++++++++----------- 1 file changed, 21 insertions(+), 17 deletions(-) diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index 709d30180c..0eff21bd86 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -84,12 +84,13 @@ The cutoff is a purely textual position, and cannot know that a reference inside **Known limitation:** the shared `writeOffsetsFor` primitive tests a simple name, so a write through a qualified access - `config.limit = 5` - is not detected as a write at all. A variable whose initializer reads a qualified mutable therefore gets no cutoff. Fixing the shared primitive is out of scope here; extract method also depends on its current behaviour. -**R6 - Per-site exclusions.** Four ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. +**R6 - Per-site exclusions.** Five ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. +- **Deferred execution.** The reference sits inside a body that runs later than the offset where its text happens to sit - a lambda, a local function, or an anonymous object - so it does not read the value the cutoff (R5) would credit it with: `button.setOnClickListener { show(label) }` followed by a later `index = 1`, where `label`'s initializer read `index`. Once any write exists at all - the cutoff is no longer `Int.MAX_VALUE` - such a reference is excluded outright rather than judged by its textual position relative to the cutoff. Deliberately over-broad: a lambda invoked immediately, `run { show(label) }`, is excluded too even though it runs synchronously and would have been safe. Over-exclusion is this feature's safe direction. - **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries, a `when` subject variable, a class or object body - against the set of names the initializer references. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. - **Receiver shift.** The initializer accesses a member through an implicit receiver, and the reference sits inside a lambda that introduces a different one - the `with(other) { ... }` / `apply` / `run` / `buildString` case. Tested as the conjunction of two cheap questions: does the initializer contain an unqualified reference resolving through an implicit receiver, and is there a receiver-introducing lambda (a `KtFunctionLiteral` whose functional type has a receiver) between the declaration and the reference? Only both together are a problem. This is extract method's `InnerImplicitReceiver` as a per-site exclusion rather than a refusal. - **Smart cast.** `val b = a.b; if (b != null) b.length` inlines to `a.b.length`, which does not compile - a smart cast needs a stable value, and a property read is not one. Detected with `smartCastInfo` on the reference (`KaDataFlowProvider`); non-null means excluded. -- **Invoking a lambda initializer.** When the initializer is a lambda or anonymous function and the reference is the callee of a call - `val f = { n: Int -> n * 2 }` used as `f(3)` - the substitution would be a lambda literal in call position, which needs `.invoke()` to compile. Passing the same `f` as an argument (`list.map(f)`) is unaffected and stays inlinable. +- **Unsafe in callee position.** The reference is the callee of a call and the initializer is anything but a bare name. A lambda or anonymous function - `val f = { n: Int -> n * 2 }` used as `f(3)` - would need `.invoke()` to compile; the rule is not limited to those two shapes, because a callable reference - `val f = ::g` used as `f(3)` - does not parse there at all, and a qualified initializer - `val f = a.b` used as `f(3)` - would silently prefer a member function `b` over `invoke`. Passing the same `f` as an argument (`list.map(f)`) is unaffected and stays inlinable. **R7 - An explicit type refuses.** A target declaration carrying an explicit type reference - `val x: Long = 1`, `val s: Any = "text"` - **refuses the whole inline** (`DeclaredTypeIsLoadBearing`, naming the type). @@ -97,10 +98,12 @@ The declared type participates in the initializer's inference and in overload re Deliberately stricter than necessary - `val x: Long = 1L` is refused too - and stated as such per ADR 0013's preference for a stricter rule over a cleverer one. Explicit types on locals are uncommon in idiomatic Kotlin, and the ones that do appear (pinning a supertype, a nullable, a platform type) are usually exactly the load-bearing cases. -**R8 - Partial inline and the declaration.** The inlinable references (R4-R6) are rewritten; every other reference is left alone. **The declaration is deleted only when nothing is left behind**: every reference was inlined *and* the target has no writes anywhere. +**R8 - Partial inline and the declaration.** The inlinable references (R4-R6) are rewritten; every other reference is left alone. **The declaration is deleted only when nothing is left behind**: every reference was inlined, the target has no writes anywhere, *and* the declaration sits directly in a block. The second clause is not redundant. A `var` whose reads were all inlined can still have a later `x = 5` assigning to it, so the declaration is still needed even though no read survives. Removing that write would be dead-store elimination, which is not this refactoring. +The third clause exists because "every reference was inlined" no longer implies the declaration is safe to remove. A `when` subject variable - `when (val a = compute()) { 1 -> g(a); else -> 0 }` - is a `KtProperty` with `isLocal` true like any other target, so its one reference can be inlined same as any other; but its own text is not a statement, it is the `when`'s subject, so deleting it takes `when (val a = ...)` itself with it and leaves code that does not parse. The fix is a deletion guard, not a refusal: rewriting the reference and keeping the declaration is perfectly sound, so this stays a useful partial-shaped result rather than a decline. + **Nothing inlinable refuses** (`NothingInlinable`, naming the variable): every reference excluded or past the cutoff means there is no edit to make, and reporting that is better than a no-op. **R9 - Modes.** Two, and which are offered depends on the cursor position (R2) and the inlinable count (R8): @@ -197,20 +200,21 @@ Cancellation is not a refusal: the planner re-throws `CancellationException` (`A 8. `val other = name` referenced in `"hi $other"` produces `"hi $name"`, still in short form. 9. `var count = 1` read twice, then reassigned, then read again inlines the first two reads, keeps the declaration, and reports 2 of 3. 10. `val bound = limit + 1` with `limit` reassigned between two references inlines only the first and keeps the declaration. -11. A reference inside `run { val a = 99; f(x) }`, where the initializer reads an outer `a`, is left untouched and the declaration is kept. -12. A reference inside `with(other) { ... }`, where the initializer uses the enclosing receiver's members, is left untouched. -13. `val b = a.b` used as `if (b != null) b.length` leaves the smart-cast reference untouched. -14. `val x: Long = 1` is refused, and the message names the declared type. -15. A member property is refused with "only a local variable can be inlined". -16. `val x: Int` with a later `x = 1` is refused as having no value at its declaration. -17. A cursor on a destructuring entry is refused specifically, not as "not a variable". -18. An unused local is refused as never used, and the declaration is **not** deleted. -19. A cursor on a reference past the cutoff is refused, and no other reference is rewritten. -20. `val x = 1; return g(x)` on one line inlines to `return g(1)` with the rest of the line intact. -21. `val total = a + b // running total` leaves `// running total` on its own line, correctly indented. -22. Editing the file while the sheet is open, then confirming, reports the file-changed message and leaves the file untouched. -23. Undo restores the file; it currently takes **N+1** undo steps (R12) and intermediate states do not compile. -24. A space-indented file receives space-indented output; a CRLF file keeps CRLF. +11. A `when` subject variable's single reference inlines, but its declaration is never deleted - `when (val a = compute()) { 1 -> g(a); else -> 0 }` keeps `val a = compute()` intact inside the `when`. +12. A reference inside `run { val a = 99; f(x) }`, where the initializer reads an outer `a`, is left untouched and the declaration is kept. +13. A reference inside `with(other) { ... }`, where the initializer uses the enclosing receiver's members, is left untouched. +14. `val b = a.b` used as `if (b != null) b.length` leaves the smart-cast reference untouched. +15. `val x: Long = 1` is refused, and the message names the declared type. +16. A member property is refused with "only a local variable can be inlined". +17. `val x: Int` with a later `x = 1` is refused as having no value at its declaration. +18. A cursor on a destructuring entry is refused specifically, not as "not a variable". +19. An unused local is refused as never used, and the declaration is **not** deleted. +20. A cursor on a reference past the cutoff is refused, and no other reference is rewritten. +21. `val x = 1; return g(x)` on one line inlines to `return g(1)` with the rest of the line intact. +22. `val total = a + b // running total` leaves `// running total` on its own line, correctly indented. +23. Editing the file while the sheet is open, then confirming, reports the file-changed message and leaves the file untouched. +24. Undo restores the file; it currently takes **N+1** undo steps (R12) and intermediate states do not compile. +25. A space-indented file receives space-indented output; a CRLF file keeps CRLF. ## Design From a302b4bd85ffdc50cfda4e335a1b33cbdb5210b3 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 16:54:52 +0000 Subject: [PATCH 49/62] ADFA-4827: Bring the inline-variable spec fully in step with the code --- docs/features/kotlin-inline-variable.md | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index 0eff21bd86..4fbbe01870 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -21,11 +21,11 @@ One read of the target declaration inside the enclosing declaration. Deliberatel _Avoid_: occurrence, usage, use site. **Cutoff**: -The first offset after the target declaration where the inlined value stops being the value the declaration produced - either a write to the target itself, or a write to a mutable its initializer reads. References before the cutoff are sound; references at or after it are not. +The first offset after the target declaration where the inlined value stops being the value the declaration produced - either a write to the target itself, or a write to a mutable its initializer reads. References before the cutoff are sound; the one exception is a reference inside a body that runs later than its own text position, which is judged separately regardless of where it sits (R6). _Avoid_: barrier, invalidation point, write boundary. **Inlinable reference**: -A reference that may be rewritten: before the cutoff, not shadowed, not receiver-shifted, not smart-cast, not a write target, and not an invocation of a lambda initializer. Every other reference is left exactly as it is. +A reference that may be rewritten: before the cutoff, not deferred, not shadowed, not receiver-shifted, not smart-cast, not unsafe in callee position, and not a write target. Every other reference is left exactly as it is. _Avoid_: safe reference, valid usage, eligible reference. **Partial inline**: @@ -230,7 +230,7 @@ InlineVariableAction.execAction (background) lsp/kotlin/actions resolveTarget(ktFile, offset) [R2, R7] references(target, enclosingExecutableBody) [R4] cutoffAfter(target) [R5] - exclude per site: shadow / receiver / smartcast / invoke [R6] + exclude per site: deferred / shadow / receiver / smartcast / callee [R6] -> InlineVariablePlan | InlineRefusal [R8, R14] } } @@ -264,8 +264,8 @@ Nothing outside `lsp/kotlin` changes except `TooltipTag.kt` and `values/strings. Unit tests in `:lsp:kotlin` (`flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV7DebugUnitTest`), split so a failure localises to one layer: -- **`InlineVariablePlanEndToEndTest`** - analysis-backed, one case per rule: both cursor positions (R2), the reference set (R4), the cutoff from each cause (R5), one case per per-site exclusion (R6), the explicit-type refusal (R7), the deletion rule including the `var`-with-a-later-write case (R8), mode availability per row of R9's table, and **one case per refusal reason** (R14). -- **`InlineVariableEditTest`** - pure text: parenthesisation per initializer class, both template forms, the three deletion line shapes, comment preservation, descending edit order, and CRLF preservation (R10-R12). +- **`InlineVariablePlanEndToEndTest`** - analysis-backed, one case per rule: both cursor positions (R2), the reference set (R4), the cutoff from each cause (R5), one case per per-site exclusion (R6), the explicit-type refusal (R7), the deletion rule including the `var`-with-a-later-write case and the `when`-subject-variable case (R8), mode availability per row of R9's table, and **one case per refusal reason** (R14). +- **`InlineVariableEditTest`** - pure text: parenthesisation per initializer class, both template forms, the three deletion line shapes, comment preservation on both a tab- and a space-indented fixture, descending edit order, and CRLF preservation (R10-R12). - **`InlineVariablePlanTest`** - the pure derivations: R9's mode table and R13's labels and reports, against hand-built plans. - **`RefactorPrimitivesTest`** - extended for whatever R6's scope walk factors out syntactically. - **`KotlinCodeActionTooltipTagTest`** - one new row (R1). From f6359f003795d547245e68591b2f61a2bb0c4b62 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 14 Aug 2026 17:03:05 +0000 Subject: [PATCH 50/62] ADFA-4827: Correct the shadowing walk and write-detection notes --- docs/features/kotlin-inline-variable.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index 4fbbe01870..fe3f7abce5 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -82,12 +82,12 @@ This matches IntelliJ, whose documented behaviour is that *"the variable must be The cutoff is a purely textual position, and cannot know that a reference inside a body that runs later - a lambda, a local function, or an anonymous object - does not read the value at the offset where its text happens to sit (`button.setOnClickListener { show(label) }` before a later `index = 1`, where `label` reads `index`). Once any write exists at all, such a reference is excluded outright (`DeferredExecution`, R6) rather than judged by where its text falls relative to the cutoff. -**Known limitation:** the shared `writeOffsetsFor` primitive tests a simple name, so a write through a qualified access - `config.limit = 5` - is not detected as a write at all. A variable whose initializer reads a qualified mutable therefore gets no cutoff. Fixing the shared primitive is out of scope here; extract method also depends on its current behaviour. +**Known limitation:** the shared `writeOffsetsFor` primitive tests a simple name, so a write through a qualified access - `config.limit = 5` - is not detected as a write at all. A variable whose initializer reads a qualified mutable therefore gets no cutoff. Fixing the shared primitive is out of scope here; extract variable also depends on its current behaviour. **R6 - Per-site exclusions.** Five ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. - **Deferred execution.** The reference sits inside a body that runs later than the offset where its text happens to sit - a lambda, a local function, or an anonymous object - so it does not read the value the cutoff (R5) would credit it with: `button.setOnClickListener { show(label) }` followed by a later `index = 1`, where `label`'s initializer read `index`. Once any write exists at all - the cutoff is no longer `Int.MAX_VALUE` - such a reference is excluded outright rather than judged by its textual position relative to the cutoff. Deliberately over-broad: a lambda invoked immediately, `run { show(label) }`, is excluded too even though it runs synchronously and would have been safe. Over-exclusion is this feature's safe direction. -- **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries, a `when` subject variable, a class or object body - against the set of names the initializer references. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. +- **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries, a `when` subject variable, a class or object body - against the set of names the initializer references. The walk also checks the target's own block, restricted to declarations that come after the target's end and before the reference: a declaration before the target is exactly what the initializer legitimately resolves to, which is why the restriction exists. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. - **Receiver shift.** The initializer accesses a member through an implicit receiver, and the reference sits inside a lambda that introduces a different one - the `with(other) { ... }` / `apply` / `run` / `buildString` case. Tested as the conjunction of two cheap questions: does the initializer contain an unqualified reference resolving through an implicit receiver, and is there a receiver-introducing lambda (a `KtFunctionLiteral` whose functional type has a receiver) between the declaration and the reference? Only both together are a problem. This is extract method's `InnerImplicitReceiver` as a per-site exclusion rather than a refusal. - **Smart cast.** `val b = a.b; if (b != null) b.length` inlines to `a.b.length`, which does not compile - a smart cast needs a stable value, and a property read is not one. Detected with `smartCastInfo` on the reference (`KaDataFlowProvider`); non-null means excluded. - **Unsafe in callee position.** The reference is the callee of a call and the initializer is anything but a bare name. A lambda or anonymous function - `val f = { n: Int -> n * 2 }` used as `f(3)` - would need `.invoke()` to compile; the rule is not limited to those two shapes, because a callable reference - `val f = ::g` used as `f(3)` - does not parse there at all, and a qualified initializer - `val f = a.b` used as `f(3)` - would silently prefer a member function `b` over `invoke`. Passing the same `f` as an argument (`list.map(f)`) is unaffected and stays inlinable. From 7b01959985ae4d706df37b7b5a18c884784481f9 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 06:11:28 +0000 Subject: [PATCH 51/62] ADFA-4827: Resolve the inline target from two more caret positions A caret one character past a use is a routine editor position, but the leaf there is whitespace or a `)`, neither of which has a simple-name ancestor. Only the reference path was affected: the declaration branch already matched, because trailing whitespace is a child of the KtProperty. The destructuring guard fired for any leaf under the node, and the initializer is part of that node, so `val (p, q) = split(total)` refused a cursor on `total` with a reason that did not apply to it. --- .../utils/refactor/InlineVariablePlanner.kt | 35 ++++++++- .../InlineVariablePlanEndToEndTest.kt | 72 +++++++++++++++++++ 2 files changed, 104 insertions(+), 3 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index 87d1dfc63a..9900ddccd8 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -240,12 +240,33 @@ private fun KaSession.planFor( /** * The target the cursor points at, from either of the two cursor positions. * + * A caret resting immediately *after* a use -- `val y = x| + 1`, `foo(x|)` -- is a routine editor + * position, and there the leaf at the offset is the whitespace or the `)`, which resolves to nothing. + * Retrying one character back recovers it. The retry is confined to [InlineRefusal.NotAVariable]: every + * other refusal already names something real at the caret and must not be second-guessed. The + * declaration position needs no retry -- trailing whitespace is a child of the [KtProperty], so its + * name-range test still matches. + */ +private fun KaSession.resolveTarget( + ktFile: KtFile, + offset: Int, +): TargetResolution { + val resolution = resolveTargetAt(ktFile, offset) + if (resolution is TargetResolution.Refused && resolution.refusal == InlineRefusal.NotAVariable && offset > 0) { + return resolveTargetAt(ktFile, offset - 1) + } + return resolution +} + +/** + * One resolution attempt at exactly [offset]. + * * Function parameters, lambda parameters, `it`, loop variables, `catch` parameters and destructuring * entries are not [KtProperty] at all, so they are excluded by construction. The three refusals made * explicitly here are the positions a user can reasonably put the cursor in and deserves to be told * about. */ -private fun KaSession.resolveTarget( +private fun KaSession.resolveTargetAt( ktFile: KtFile, offset: Int, ): TargetResolution { @@ -254,8 +275,16 @@ private fun KaSession.resolveTarget( ?: ktFile.findElementAt(offset - 1) ?: return TargetResolution.Refused(InlineRefusal.NotAVariable) - PsiTreeUtil.getParentOfType(leaf, KtDestructuringDeclaration::class.java, false)?.let { - return TargetResolution.Refused(InlineRefusal.DestructuringDeclaration) + PsiTreeUtil.getParentOfType(leaf, KtDestructuringDeclaration::class.java, false)?.let { destructuring -> + /* + * The initializer is part of the destructuring node, so an unscoped ancestor test refuses a + * perfectly inlinable target: `val (p, q) = split(total)` with the caret on `total`. Only the + * entries and the syntax around them cannot be inlined. + */ + val initializer = destructuring.initializer + if (initializer == null || !PsiTreeUtil.isAncestor(initializer, leaf, false)) { + return TargetResolution.Refused(InlineRefusal.DestructuringDeclaration) + } } val declaration = PsiTreeUtil.getParentOfType(leaf, KtProperty::class.java, false) diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index a9ffd3b360..8abbbe689a 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -505,6 +505,78 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { assertEquals(false, result.canDeleteDeclaration) } + @Test + fun `a reference inside a destructuring initializer resolves its own target`() { + val content = + """ + package p + fun split(sep: String): Pair = sep to sep + fun demo(a: String, b: String): String { + val total = a + b + val (p, q) = split(total) + return p + q + } + """.trimIndent() + + val result = plan(content, at(content, "total", after = 1)) + + // The initializer is part of the destructuring node, but only the entries cannot be inlined. + assertNull(result.refusal) + assertEquals("total", result.variableName) + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + assertEquals( + """ + package p + fun split(sep: String): Pair = sep to sep + fun demo(a: String, b: String): String { + val (p, q) = split((a + b)) + return p + q + } + """.trimIndent(), + apply(content, rewrites!!), + ) + } + + @Test + fun `a caret immediately after a reference still resolves it`() { + val content = + """ + package p + fun demo(a: Int, b: Int): Int { + val total = a + b + val y = total + 1 + return y + } + """.trimIndent() + + // The leaf at this offset is the whitespace before the `+`, not the name. + val result = plan(content, at(content, "total", after = 1) + "total".length) + + assertNull(result.refusal) + assertEquals(InlineCursorPosition.Reference, result.cursorPosition) + assertEquals(0, result.cursorReferenceIndex) + } + + @Test + fun `a caret on the closing parenthesis after a reference still resolves it`() { + val content = + """ + package p + fun f(n: Int) = n + fun demo(a: Int, b: Int): Int { + val total = a + b + return f(total) + } + """.trimIndent() + + // The leaf at this offset is the `)`, which has no simple-name ancestor at all. + val result = plan(content, at(content, "total", after = 1) + "total".length) + + assertNull(result.refusal) + assertEquals(InlineCursorPosition.Reference, result.cursorPosition) + assertEquals(0, result.cursorReferenceIndex) + } + @Test fun `a callable reference initializer in call position is left untouched`() { val content = From ee6fdb7297a68746cc5c18acd918c9bb3a8f6b2a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 06:13:39 +0000 Subject: [PATCH 52/62] ADFA-4827: Exclude loop-body references from the inline cutoff The cutoff is a textual offset, so a reference inside a loop that precedes the write executes after it on every iteration but the first: `val step = i + 1` with `println(step)` above `i += 2` inlined and deleted the declaration, turning "1 1 1 1 1" into "1 3 5 7 9" while reporting a clean full inline. isDeferred already guarded that class of hazard for bodies that run later, and is already gated on a write existing, which is exactly when a loop matters, so widening it is the smaller change. It over-excludes when the write sits after the loop; that leaves a reference alone rather than rewriting it wrongly. --- .../utils/refactor/InlineVariablePlanner.kt | 23 +++-- .../InlineVariablePlanEndToEndTest.kt | 84 +++++++++++++++++++ 2 files changed, 101 insertions(+), 6 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index 9900ddccd8..bf0dc468cf 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -29,6 +29,7 @@ import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtForExpression import org.jetbrains.kotlin.psi.KtFunctionLiteral import org.jetbrains.kotlin.psi.KtLambdaExpression +import org.jetbrains.kotlin.psi.KtLoopExpression import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtObjectDeclaration @@ -162,7 +163,7 @@ private fun KaSession.planFor( when { span.start >= cutoff -> InlineExclusion.PastCutoff - cutoff != Int.MAX_VALUE && isDeferred(read, target) -> InlineExclusion.DeferredExecution + cutoff != Int.MAX_VALUE && runsOutOfTextualOrder(read, target) -> InlineExclusion.DeferredExecution isShadowedAt(read, target, initializerNames) -> InlineExclusion.Shadowed @@ -548,17 +549,27 @@ private fun KaSession.isSmartCast(reference: KtSimpleNameExpression): Boolean = private fun isCallee(reference: KtSimpleNameExpression): Boolean = (reference.parent as? KtCallExpression)?.calleeExpression === reference /** - * Whether [reference] sits inside a lambda, local function or anonymous object between it and - * [target]. A body that runs later does not read the value at the offset where its text sits, so once - * a write exists at all, the cutoff's textual position cannot judge whether such a reference is safe. + * Whether [reference] sits inside a body that does not run once, in order, at the offset where its + * text sits: a lambda, a local function, an anonymous object -- all of which run later -- or a loop + * body, which runs again after everything textually below it. Once a write exists at all, the cutoff's + * textual position cannot judge such a reference, so it is excluded outright. + * + * A loop that contains the declaration is not one of these: the walk stops at the first ancestor of + * [target], so the value is recomputed on every iteration alongside the reference. */ -private fun isDeferred( +private fun runsOutOfTextualOrder( reference: KtSimpleNameExpression, target: KtProperty, ): Boolean { var current: PsiElement? = reference.parent while (current != null && !PsiTreeUtil.isAncestor(current, target, false)) { - if (current is KtFunctionLiteral || current is KtNamedFunction || current is KtObjectDeclaration) return true + if (current is KtFunctionLiteral || + current is KtNamedFunction || + current is KtObjectDeclaration || + current is KtLoopExpression + ) { + return true + } current = current.parent } return false diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index 8abbbe689a..0fd4e0b396 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -577,6 +577,90 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { assertEquals(0, result.cursorReferenceIndex) } + @Test + fun `a reference in a while body is excluded when the loop writes what the initializer reads`() { + val content = + """ + package p + fun println(n: Int) {} + fun demo() { + var i = 0 + val step = i + 1 + while (i < 10) { + println(step) + i += 2 + } + } + """.trimIndent() + + val result = plan(content, at(content, "val step") + "val ".length) + + /* + * The reference is textually before the write, so a purely textual cutoff would call it + * inlinable and delete the declaration too, turning "1 1 1 1 1" into "1 3 5 7 9". + */ + assertEquals(InlineExclusion.DeferredExecution, result.references.single().exclusion) + assertEquals(false, result.canDeleteDeclaration) + } + + @Test + fun `a reference in a for body is excluded when the loop writes what the initializer reads`() { + val content = + """ + package p + fun println(n: Int) {} + fun demo(items: List) { + var i = 0 + val step = i + 1 + for (item in items) { + println(step) + i += item + } + } + """.trimIndent() + + val result = plan(content, at(content, "val step") + "val ".length) + + assertEquals(InlineExclusion.DeferredExecution, result.references.single().exclusion) + } + + @Test + fun `a declaration inside the loop body still inlines`() { + val content = + """ + package p + fun println(n: Int) {} + fun demo(items: List) { + var i = 0 + for (item in items) { + val step = i + 1 + println(step) + i += item + } + } + """.trimIndent() + + val result = plan(content, at(content, "val step") + "val ".length) + + // The value is recomputed on every iteration alongside the reference, so the back edge is moot. + assertNull(result.references.single().exclusion) + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + assertEquals( + """ + package p + fun println(n: Int) {} + fun demo(items: List) { + var i = 0 + for (item in items) { + println((i + 1)) + i += item + } + } + """.trimIndent(), + apply(content, rewrites!!), + ) + } + @Test fun `a callable reference initializer in call position is left untouched`() { val content = From 7691fba29e8d8a8e1969a75a7dcb83379239610e Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 06:13:54 +0000 Subject: [PATCH 53/62] ADFA-4827: Detect the receiver shift a class or object body causes The walk between declaration and reference matched only KtFunctionLiteral, so an anonymous object or local class in between was invisible: `val label = toString()` referenced inside `object : Any() { ... }` inlined to `println(toString())`, now resolving to the object's own toString. Shadowing does not cover it either, since that test compares declared names and an inherited member is declared nowhere. The bare-`this` half of the same test had to move out of the simple-name scan's predicate. `this` contributes no KtSimpleNameExpression -- its instance reference is a plain KtReferenceExpression -- so `val v = this` left that scan with an empty list and the nested check never ran, letting `with(other) { f(v) }` rewrite to `f(this)` against a different receiver. Hoisting it also stops the initializer being rescanned once per reference on the interactive path. --- .../utils/refactor/InlineVariablePlanner.kt | 57 ++++++++++++------- .../InlineVariablePlanEndToEndTest.kt | 48 ++++++++++++++++ 2 files changed, 83 insertions(+), 22 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index bf0dc468cf..c8e3984e2e 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -21,6 +21,7 @@ import org.jetbrains.kotlin.psi.KtCallableReferenceExpression import org.jetbrains.kotlin.psi.KtCatchClause import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassLiteralExpression +import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtCollectionLiteralExpression import org.jetbrains.kotlin.psi.KtConstantExpression import org.jetbrains.kotlin.psi.KtDestructuringDeclaration @@ -168,7 +169,7 @@ private fun KaSession.planFor( isShadowedAt(read, target, initializerNames) -> InlineExclusion.Shadowed initializerUsesImplicitReceiver && - hasReceiverLambdaBetween(target, read) -> InlineExclusion.ReceiverShift + changesImplicitReceiverBetween(target, read) -> InlineExclusion.ReceiverShift isSmartCast(read) -> InlineExclusion.SmartCast @@ -492,39 +493,51 @@ private fun lambdaParameterNames(lambda: KtFunctionLiteral): Set { * Whether the initializer reaches a member through an implicit receiver. Half of the receiver-shift * test; on its own it is perfectly fine. */ -private fun KaSession.readsThroughImplicitReceiver(initializer: KtExpression): Boolean = - PsiTreeUtil.collectElementsOfType(initializer, KtSimpleNameExpression::class.java).any { reference -> +private fun KaSession.readsThroughImplicitReceiver(initializer: KtExpression): Boolean { + /* + * A bare `this` names the receiver without going through a call, and must be asked *before* the + * simple-name scan rather than inside it: `this` contributes no KtSimpleNameExpression -- its + * instance reference is a plain KtReferenceExpression -- so `val v = this` leaves that scan with an + * empty list, and a check nested in its predicate would never run. + */ + if (PsiTreeUtil.collectElementsOfType(initializer, KtThisExpression::class.java).isNotEmpty()) return true + + return PsiTreeUtil.collectElementsOfType(initializer, KtSimpleNameExpression::class.java).any { reference -> // A qualified selector already has its receiver written out next to it. if ((reference.parent as? KtQualifiedExpression)?.selectorExpression === reference) { - false - } else { - runCatching { - val callSource = - (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference - val applied = - callSource - .resolveToCall() - ?.successfulCallOrNull>() - ?.partiallyAppliedSymbol - applied?.dispatchReceiver is KaImplicitReceiverValue || - applied?.extensionReceiver is KaImplicitReceiverValue - }.getOrDefault(false) - } || - // A bare `this` names the receiver without going through a call. - PsiTreeUtil.collectElementsOfType(initializer, KtThisExpression::class.java).isNotEmpty() + return@any false + } + runCatching { + val callSource = + (reference.parent as? KtCallExpression)?.takeIf { it.calleeExpression === reference } ?: reference + val applied = + callSource + .resolveToCall() + ?.successfulCallOrNull>() + ?.partiallyAppliedSymbol + applied?.dispatchReceiver is KaImplicitReceiverValue || + applied?.extensionReceiver is KaImplicitReceiverValue + }.getOrDefault(false) } +} /** - * Whether a receiver-introducing lambda -- `with`, `apply`, `run`, `buildString`, a Compose scope -- - * sits between the declaration and [reference]. The other half of the receiver-shift test. + * Whether anything between the declaration and [reference] puts a different implicit receiver in + * scope. The other half of the receiver-shift test. + * + * Two shapes do it: a receiver-introducing lambda -- `with`, `apply`, `run`, `buildString`, a Compose + * scope -- and a class or object body, whose own `this` displaces the enclosing one. The latter is not + * covered by shadowing: `isShadowedAt` compares *declared* names, and an inherited member such as + * `toString` is declared nowhere. */ -private fun KaSession.hasReceiverLambdaBetween( +private fun KaSession.changesImplicitReceiverBetween( target: KtProperty, reference: KtSimpleNameExpression, ): Boolean { var current: PsiElement? = reference.parent while (current != null && !PsiTreeUtil.isAncestor(current, target, false)) { if (current is KtFunctionLiteral && introducesReceiver(current)) return true + if (current is KtClassOrObject) return true current = current.parent } return false diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index 0fd4e0b396..46744dc5eb 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -661,6 +661,54 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `a reference inside an anonymous object body is left untouched`() { + val content = + """ + package p + fun println(s: String) {} + class A { + fun f() { + val label = toString() + val o = object : Any() { + fun g() = println(label) + } + println(o.toString()) + } + } + """.trimIndent() + + val result = plan(content, at(content, "val label") + "val ".length) + + /* + * The object's own `this` displaces the enclosing one, so `println(toString())` there would + * resolve to the object's inherited toString. Shadowing does not catch it: that test compares + * declared names, and toString is declared nowhere. + */ + assertEquals(InlineExclusion.ReceiverShift, result.references.single().exclusion) + } + + @Test + fun `a bare this initializer is excluded under a receiver lambda`() { + val content = + """ + package p + class Other + fun f(a: Any) {} + class Holder { + fun demo(other: Other) { + val v = this + with(other) { f(v) } + } + } + """.trimIndent() + + val result = plan(content, at(content, "val v") + "val ".length) + + // `f(this)` inside with(other) would resolve to `other`, a silent meaning change. + assertEquals(InlineExclusion.ReceiverShift, result.references.single().exclusion) + } + @Test fun `a callable reference initializer in call position is left untouched`() { val content = From 6edf66fe6098575f1b1b9e261261fed52d33f34e Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 06:14:10 +0000 Subject: [PATCH 54/62] ADFA-4827: Brace keyword initializers substituted into string templates isPlainIdentifier accepted `true`, `false` and `null`, so `val flag = true` referenced as "$flag" emitted "$true", which does not parse. `this` stays in the short form, being the one keyword a template accepts after a bare `$`. The test helper now fails on a fragment it cannot find, as its sibling in ExtractVariableEditTest already does; without that a typo produced a plausible span from -1 rather than an error. --- .../utils/refactor/InlineVariableEdit.kt | 9 +++ .../utils/refactor/InlineVariableEditTest.kt | 69 ++++++++++++++++++- 2 files changed, 77 insertions(+), 1 deletion(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt index a18d1fd6c6..d7d53c9ac3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEdit.kt @@ -62,9 +62,18 @@ internal fun substitutionTextFor( return if (plan.initializerNeedsParentheses) "($value)" else value } +/** + * Keywords that read as identifiers but are not: `"$true"` does not parse, so the braced form is the + * only way to substitute one. + * + * `this` is deliberately absent -- `"$this"` is legal Kotlin, the one keyword the short form accepts. + */ +private val KEYWORDS_REJECTED_AFTER_DOLLAR = setOf("true", "false", "null") + /** Whether [text] is a bare Kotlin identifier, and so legal after a `$` in a template. */ internal fun isPlainIdentifier(text: String): Boolean { if (text.isEmpty()) return false + if (text in KEYWORDS_REJECTED_AFTER_DOLLAR) return false if (!(text[0].isLetter() || text[0] == '_')) return false return text.all { it.isLetterOrDigit() || it == '_' } } diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt index 0be9ebad04..d95f8cf465 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariableEditTest.kt @@ -27,7 +27,10 @@ class InlineVariableEditTest { after: Int = 0, ): TextSpan { var start = -1 - repeat(after + 1) { start = text.indexOf(fragment, start + 1) } + repeat(after + 1) { occurrence -> + start = text.indexOf(fragment, start + 1) + require(start >= 0) { "occurrence ${occurrence + 1} of '$fragment' not found" } + } return TextSpan(start, start + fragment.length) } @@ -151,6 +154,70 @@ class InlineVariableEditTest { ) } + @Test + fun `a template entry braces a keyword initializer`() { + for (keyword in listOf("true", "false", "null")) { + val file = + "package p\n" + + "fun demo(): String {\n" + + "\tval flag = " + keyword + "\n" + + "\treturn \"flag: \$flag\"\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val flag = " + keyword, + initializerText = keyword, + references = listOf(reference(file, "\$flag", isShortTemplateEntry = true)), + name = "flag", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + // "$true" does not parse: the keyword reads as an identifier but is not one. + assertEquals( + "package p\n" + + "fun demo(): String {\n" + + "\treturn \"flag: \${" + keyword + "}\"\n" + + "}\n", + apply(file, rewrites!!), + ) + } + } + + @Test + fun `a template entry stays in short form for a bare this`() { + val file = + "package p\n" + + "class C {\n" + + "\tfun demo(): String {\n" + + "\t\tval self = this\n" + + "\t\treturn \"me: \$self\"\n" + + "\t}\n" + + "}\n" + val result = + plan( + fileText = file, + declaration = "val self = this", + initializerText = "this", + references = listOf(reference(file, "\$self", isShortTemplateEntry = true)), + name = "self", + ) + + val rewrites = buildInlineVariableRewrites(result, InlineMode.AllReferences) + + // "$this" is the one keyword the short form accepts. + assertEquals( + "package p\n" + + "class C {\n" + + "\tfun demo(): String {\n" + + "\t\treturn \"me: \$this\"\n" + + "\t}\n" + + "}\n", + apply(file, rewrites!!), + ) + } + @Test fun `a template entry stays in short form for a plain name`() { val file = From 9577afdeafbd461e5cab6cfa6a94081dc2064415 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 06:14:10 +0000 Subject: [PATCH 55/62] ADFA-4827: Report the inline sheet's dead ends and let it scroll Two paths logged and showed the user nothing: a fragment manager that cannot host the sheet, and a missing language client. Both now flash the same failure the sibling branch already did. The sheet's root Column had no scroll container, and everything in it grows - both mode labels wrap at 2x font scale, and the value renders an arbitrarily long initializer verbatim - which can push Cancel off the sheet. --- .../androidide/lsp/kotlin/actions/InlineVariableAction.kt | 2 ++ .../lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt | 8 ++++++++ 2 files changed, 10 insertions(+) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt index a494fdf3a4..d58b66fbe2 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/InlineVariableAction.kt @@ -113,6 +113,7 @@ class InlineVariableAction : BaseKotlinCodeAction() { val shown = InlineVariableSheet.show(activity, result) { mode -> applyMode(data, result, mode) } if (!shown) { logger.warn("Fragment manager unavailable. Cannot show the inline sheet.") + flashError(R.string.msg_cannot_perform_fix) } } @@ -157,6 +158,7 @@ class InlineVariableAction : BaseKotlinCodeAction() { val client = data.languageClient ?: run { logger.warn("No language client set. Cannot inline variable.") + flashError(R.string.msg_cannot_perform_fix) return } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt index 6a14341c74..c3f8d0b041 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/refactor/ui/InlineVariableSheetContent.kt @@ -6,6 +6,8 @@ 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.rememberScrollState +import androidx.compose.foundation.verticalScroll import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Text @@ -47,10 +49,16 @@ fun InlineVariableSheetContent( modifier: Modifier = Modifier, ) { Column( + /* + * Scrollable because everything here grows: at font scale 2.0 both mode labels wrap, and the + * value below renders an arbitrarily long initializer verbatim. Without it the Cancel row is + * pushed off the sheet with no way back to it. + */ modifier = modifier .fillMaxWidth() .navigationBarsPadding() + .verticalScroll(rememberScrollState()) .padding(horizontal = 24.dp, vertical = 16.dp), verticalArrangement = Arrangement.spacedBy(16.dp), ) { From d22d2c87adb3b06cdfb94c028b365d890dff9679 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 06:14:10 +0000 Subject: [PATCH 56/62] ADFA-4827: Bring the inline-variable docs in step with the fixes The plan KDoc listed two of canDeleteDeclaration's three conditions, omitting that the declaration must sit directly in a block. R2, R5, R6 and R10 cover the caret retry, the destructuring scoping, loop back edges, the class-body receiver shift and the keyword templates. The fenced diagram gets a language, which markdownlint wanted. --- docs/features/kotlin-inline-variable.md | 14 +++++++++----- .../kotlin/utils/refactor/InlineVariablePlan.kt | 5 +++-- 2 files changed, 12 insertions(+), 7 deletions(-) diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index c834ffbbaf..4b7bac62bc 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -59,6 +59,10 @@ As with both extracts: **no `prepare()` visibility gate** - deciding whether the The target must be a `KtProperty` with `isLocal` **and** an initializer. This excludes, without a single dedicated check, function parameters, lambda parameters, `it`, loop variables, `catch` parameters and destructuring entries - none of which is a `KtProperty`. It leaves three refusals to make explicitly, because each is a position a user can reasonably put the cursor in and deserves to be told about: a member or top-level property (`NotALocalVariable`), a local `val` with no initializer (`NoInitializer`, the `val x: Int` then `x = 1` shape, which Kotlin permits), and a destructuring declaration or one of its entries (`DestructuringDeclaration`). +The destructuring refusal covers the entries and the syntax around them, not the whole node: the initializer is part of it, so `val (p, q) = split(total)` with the caret on `total` resolves `total` normally. + +A caret resting immediately *after* a use - `val y = x| + 1`, `foo(x|)` - is a routine editor position, and there the leaf at the offset is the whitespace or the `)`. Resolution retries one character back, but only when the first attempt refused with `NotAVariable`: every other refusal already names something real at the caret. The declaration position needs no retry, because trailing whitespace is a child of the `KtProperty` and its name-range test still matches. + Both positions converge on the same target immediately, so there is one analysis pass and one plan shape. The *position* is still recorded on the plan, because R9's mode availability depends on it. **R3 - Live offsets and the version guard.** Identical to both extracts: analysis runs against `getCurrentKtFile(path)` fetched *before* entering `project.read`, the plan records the document version on the `RefactoringPlan` supertype, and the action re-reads the live version before emitting edits, refusing on a mismatch. @@ -80,15 +84,15 @@ Both come from the existing `writeOffsetsFor(candidate, searchRoot)`, called wit This matches IntelliJ, whose documented behaviour is that *"the variable must be initialized at declaration; if the initial value is modified somewhere in the code, only the occurrences before modification will be inlined."* The alternative - refusing the whole inline - was rejected: it discards a sound refactoring of the references before the write, and a partial result is what a user coming from a desktop IDE already expects here. -The cutoff is a purely textual position, and cannot know that a reference inside a body that runs later - a lambda, a local function, or an anonymous object - does not read the value at the offset where its text happens to sit (`button.setOnClickListener { show(label) }` before a later `index = 1`, where `label` reads `index`). Once any write exists at all, such a reference is excluded outright (`DeferredExecution`, R6) rather than judged by where its text falls relative to the cutoff. +The cutoff is a purely textual position, and cannot judge a reference whose execution does not follow the text. Two shapes break that correspondence: a body that runs *later* - a lambda, a local function, or an anonymous object (`button.setOnClickListener { show(label) }` before a later `index = 1`, where `label` reads `index`) - and a loop body, which runs *again* after everything textually below it, so a reference before the write executes after it on every iteration but the first. Once any write exists at all, such a reference is excluded outright (`DeferredExecution`, R6) rather than judged by where its text falls relative to the cutoff. **Known limitation:** the shared `writeOffsetsFor` primitive tests a simple name, so a write through a qualified access - `config.limit = 5` - is not detected as a write at all. A variable whose initializer reads a qualified mutable therefore gets no cutoff. Fixing the shared primitive is out of scope here; extract variable also depends on its current behaviour. **R6 - Per-site exclusions.** Five ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. -- **Deferred execution.** The reference sits inside a body that runs later than the offset where its text happens to sit - a lambda, a local function, or an anonymous object - so it does not read the value the cutoff (R5) would credit it with: `button.setOnClickListener { show(label) }` followed by a later `index = 1`, where `label`'s initializer read `index`. Once any write exists at all - the cutoff is no longer `Int.MAX_VALUE` - such a reference is excluded outright rather than judged by its textual position relative to the cutoff. Deliberately over-broad: a lambda invoked immediately, `run { show(label) }`, is excluded too even though it runs synchronously and would have been safe. Over-exclusion is this feature's safe direction. +- **Out of textual order.** The reference sits inside a body that does not run once, in order, at the offset where its text sits, so it does not read the value the cutoff (R5) would credit it with. Two shapes: a body that runs later - a lambda, a local function, or an anonymous object, as in `button.setOnClickListener { show(label) }` followed by a later `index = 1` where `label`'s initializer read `index`; and a **loop body**, where `val step = i + 1; while (i < 10) { println(step); i += 2 }` puts the reference textually before the write but executes it after the write on every iteration but the first. Once any write exists at all - the cutoff is no longer `Int.MAX_VALUE` - such a reference is excluded outright rather than judged by its textual position relative to the cutoff. A loop that *contains* the declaration is not one of these: the value is recomputed each iteration alongside the reference. Deliberately over-broad: a lambda invoked immediately, `run { show(label) }`, and a reference in a loop whose write sits after the loop are excluded too even though both would have been safe. Over-exclusion is this feature's safe direction. - **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries, a `when` subject variable, a class or object body - against the set of names the initializer references. The walk also checks the target's own block, restricted to declarations that come after the target's end and before the reference: a declaration before the target is exactly what the initializer legitimately resolves to, which is why the restriction exists. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. -- **Receiver shift.** The initializer accesses a member through an implicit receiver, and the reference sits inside a lambda that introduces a different one - the `with(other) { ... }` / `apply` / `run` / `buildString` case. Tested as the conjunction of two cheap questions: does the initializer contain an unqualified reference resolving through an implicit receiver, and is there a receiver-introducing lambda (a `KtFunctionLiteral` whose functional type has a receiver) between the declaration and the reference? Only both together are a problem. This is extract method's `InnerImplicitReceiver` as a per-site exclusion rather than a refusal. +- **Receiver shift.** The initializer accesses a member through an implicit receiver, and the reference sits somewhere that introduces a different one. Tested as the conjunction of two cheap questions: does the initializer contain an unqualified reference resolving through an implicit receiver, or a bare `this`; and does anything between the declaration and the reference change the implicit receiver? The bare-`this` half is asked first and separately, because `this` contributes no `KtSimpleNameExpression` - its instance reference is a plain `KtReferenceExpression` - so `val v = this` would leave the simple-name scan with nothing to iterate. The second question has two answers - a receiver-introducing lambda (a `KtFunctionLiteral` whose functional type has a receiver), the `with(other) { ... }` / `apply` / `run` / `buildString` case; and a class or object body, whose own `this` displaces the enclosing one, as in `val label = toString()` referenced inside a later `object : Any() { ... }`. The class-body half is not covered by shadowing: that test compares *declared* names, and an inherited member such as `toString` is declared nowhere. Only both questions together are a problem. This is extract method's `InnerImplicitReceiver` as a per-site exclusion rather than a refusal. - **Smart cast.** `val b = a.b; if (b != null) b.length` inlines to `a.b.length`, which does not compile - a smart cast needs a stable value, and a property read is not one. Detected with `smartCastInfo` on the reference (`KaDataFlowProvider`); non-null means excluded. - **Unsafe in callee position.** The reference is the callee of a call and the initializer is anything but a bare name. A lambda or anonymous function - `val f = { n: Int -> n * 2 }` used as `f(3)` - would need `.invoke()` to compile; the rule is not limited to those two shapes, because a callable reference - `val f = ::g` used as `f(3)` - does not parse there at all, and a qualified initializer - `val f = a.b` used as `f(3)` - would silently prefer a member function `b` over `invoke`. Passing the same `f` as an argument (`list.map(f)`) is unaffected and stays inlinable. @@ -127,7 +131,7 @@ The last row refuses rather than inlining *other* references: rewriting every si Site-sensitive precedence comparison was rejected. It emits marginally cleaner text and has to be right about every parent context - a receiver (`x.length` where `x = a ?: b`), a unary minus over a subtraction, an infix call, a `when` subject - where being wrong emits code that does not compile, and R13 shows no preview on the common path. The cost of the stricter rule is a redundant `return (a + b)`; the cost of the cleverer one is a miscompile the user did not see coming. -**String templates.** A reference inside a template appears as `$x`, and the short form only accepts a simple name, so `val x = a + b` must become `"total: ${a + b}"` - never `"total: $a + b"`, which silently changes the string. The rule: inside a template entry emit `${...}` unless the substitution text is itself a plain identifier, where `$y` stays short. This makes the template flag a per-reference property of the plan, not a property of the target. +**String templates.** A reference inside a template appears as `$x`, and the short form only accepts a simple name, so `val x = a + b` must become `"total: ${a + b}"` - never `"total: $a + b"`, which silently changes the string. The rule: inside a template entry emit `${...}` unless the substitution text is itself a plain identifier, where `$y` stays short. `true`, `false` and `null` read as identifiers but are keywords, so `"$true"` does not parse - they are rejected explicitly and take the braced form. `this` is the one keyword the short form does accept, and stays short. This makes the template flag a per-reference property of the plan, not a property of the target. **R11 - Deleting the declaration.** Three line shapes, all pure span arithmetic: @@ -220,7 +224,7 @@ Cancellation is not a refusal: the planner re-throws `CancellationException` (`A Same shape as both extracts, and the same data boundary from [ADR 0013](../adr/0013-refactoring-ui-lives-in-the-owning-lsp-module.md): one background pass produces a plain-data plan, the UI holds no PSI. -``` +```text InlineVariableAction.execAction (background) lsp/kotlin/actions server.compilationEnvironmentFor(path) ?: refusal -> buildInlineVariablePlan(...) utils/refactor/InlineVariablePlanner.kt diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt index e95065c1e0..e5d7359a53 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt @@ -128,8 +128,9 @@ sealed interface InlineRefusal { * * [initializerNeedsParentheses] is decided from the initializer alone during analysis and consumed by * the edit builder, which stays pure. [cursorReferenceIndex] is -1 when the cursor was on the - * declaration. [canDeleteDeclaration] is the conjunction of two conditions -- every reference - * inlinable *and* the target never written -- and is honoured only by [InlineMode.AllReferences]. + * declaration. [canDeleteDeclaration] is the conjunction of three conditions -- every reference + * inlinable, the target never written, *and* the declaration sitting directly in a block -- and is + * honoured only by [InlineMode.AllReferences]. */ data class InlineVariablePlan( override val fileText: String, From 8d48adc0edbbf9d0f02562f081a5b167458cac16 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 13:32:55 +0000 Subject: [PATCH 57/62] ADFA-4827: Defer references inside a local class body too runsOutOfTextualOrder listed KtObjectDeclaration while its sibling changesImplicitReceiverBetween covers KtClassOrObject, so a reference in a local class's property initializer, init block, or constructor parameter default was still judged by its textual position: `class L { val y = step }` inlined and its declaration was deleted even though L is constructed after a later write. The target is always a local, so any KtClassOrObject on the walk is a local class or object. --- docs/features/kotlin-inline-variable.md | 4 +- .../utils/refactor/InlineVariablePlanner.kt | 10 +- .../InlineVariablePlanEndToEndTest.kt | 94 +++++++++++++++++++ 3 files changed, 101 insertions(+), 7 deletions(-) diff --git a/docs/features/kotlin-inline-variable.md b/docs/features/kotlin-inline-variable.md index 4b7bac62bc..fe38480bd8 100644 --- a/docs/features/kotlin-inline-variable.md +++ b/docs/features/kotlin-inline-variable.md @@ -84,13 +84,13 @@ Both come from the existing `writeOffsetsFor(candidate, searchRoot)`, called wit This matches IntelliJ, whose documented behaviour is that *"the variable must be initialized at declaration; if the initial value is modified somewhere in the code, only the occurrences before modification will be inlined."* The alternative - refusing the whole inline - was rejected: it discards a sound refactoring of the references before the write, and a partial result is what a user coming from a desktop IDE already expects here. -The cutoff is a purely textual position, and cannot judge a reference whose execution does not follow the text. Two shapes break that correspondence: a body that runs *later* - a lambda, a local function, or an anonymous object (`button.setOnClickListener { show(label) }` before a later `index = 1`, where `label` reads `index`) - and a loop body, which runs *again* after everything textually below it, so a reference before the write executes after it on every iteration but the first. Once any write exists at all, such a reference is excluded outright (`DeferredExecution`, R6) rather than judged by where its text falls relative to the cutoff. +The cutoff is a purely textual position, and cannot judge a reference whose execution does not follow the text. Two shapes break that correspondence: a body that runs *later* - a lambda, a local function, or a class or object body, the last of these at construction time (`button.setOnClickListener { show(label) }` before a later `index = 1`, where `label` reads `index`) - and a loop body, which runs *again* after everything textually below it, so a reference before the write executes after it on every iteration but the first. Once any write exists at all, such a reference is excluded outright (`DeferredExecution`, R6) rather than judged by where its text falls relative to the cutoff. **Known limitation:** the shared `writeOffsetsFor` primitive tests a simple name, so a write through a qualified access - `config.limit = 5` - is not detected as a write at all. A variable whose initializer reads a qualified mutable therefore gets no cutoff. Fixing the shared primitive is out of scope here; extract variable also depends on its current behaviour. **R6 - Per-site exclusions.** Five ways a reference is individually unsound. Each **excludes that reference**, leaving it untouched; none refuses the inline, because each is a property of one site rather than of the target. -- **Out of textual order.** The reference sits inside a body that does not run once, in order, at the offset where its text sits, so it does not read the value the cutoff (R5) would credit it with. Two shapes: a body that runs later - a lambda, a local function, or an anonymous object, as in `button.setOnClickListener { show(label) }` followed by a later `index = 1` where `label`'s initializer read `index`; and a **loop body**, where `val step = i + 1; while (i < 10) { println(step); i += 2 }` puts the reference textually before the write but executes it after the write on every iteration but the first. Once any write exists at all - the cutoff is no longer `Int.MAX_VALUE` - such a reference is excluded outright rather than judged by its textual position relative to the cutoff. A loop that *contains* the declaration is not one of these: the value is recomputed each iteration alongside the reference. Deliberately over-broad: a lambda invoked immediately, `run { show(label) }`, and a reference in a loop whose write sits after the loop are excluded too even though both would have been safe. Over-exclusion is this feature's safe direction. +- **Out of textual order.** The reference sits inside a body that does not run once, in order, at the offset where its text sits, so it does not read the value the cutoff (R5) would credit it with. Two shapes: a body that runs later - a lambda, a local function, or a class or object body, as in `button.setOnClickListener { show(label) }` followed by a later `index = 1` where `label`'s initializer read `index`, or `var i = 0; val step = i + 1; class L { val y = step }` followed by a later `i = 5`, where `L`'s property initializer runs when `L` is constructed; and a **loop body**, where `val step = i + 1; while (i < 10) { println(step); i += 2 }` puts the reference textually before the write but executes it after the write on every iteration but the first. Once any write exists at all - the cutoff is no longer `Int.MAX_VALUE` - such a reference is excluded outright rather than judged by its textual position relative to the cutoff. A loop that *contains* the declaration is not one of these: the value is recomputed each iteration alongside the reference. Deliberately over-broad: a lambda invoked immediately, `run { show(label) }`, and a reference in a loop whose write sits after the loop are excluded too even though both would have been safe. Over-exclusion is this feature's safe direction. - **Shadowing.** The initializer reads a name that resolves to something else at the reference. `val a = 1; val x = a + 1; run { val a = 99; f(x) }` would inline to `f(a + 1)` reading the inner `a`. Detected by walking the reference's parents up to the target's own block, checking each intervening scope's declared names - block statements before the site, lambda value parameters and `it`, function parameters, loop and `catch` parameters, destructuring entries, a `when` subject variable, a class or object body - against the set of names the initializer references. The walk also checks the target's own block, restricted to declarations that come after the target's end and before the reference: a declaration before the target is exactly what the initializer legitimately resolves to, which is why the restriction exists. The converse case cannot arise: a local's scope runs to the end of its block, so everything the initializer reads is still in scope at every reference. Only shadowing bites. - **Receiver shift.** The initializer accesses a member through an implicit receiver, and the reference sits somewhere that introduces a different one. Tested as the conjunction of two cheap questions: does the initializer contain an unqualified reference resolving through an implicit receiver, or a bare `this`; and does anything between the declaration and the reference change the implicit receiver? The bare-`this` half is asked first and separately, because `this` contributes no `KtSimpleNameExpression` - its instance reference is a plain `KtReferenceExpression` - so `val v = this` would leave the simple-name scan with nothing to iterate. The second question has two answers - a receiver-introducing lambda (a `KtFunctionLiteral` whose functional type has a receiver), the `with(other) { ... }` / `apply` / `run` / `buildString` case; and a class or object body, whose own `this` displaces the enclosing one, as in `val label = toString()` referenced inside a later `object : Any() { ... }`. The class-body half is not covered by shadowing: that test compares *declared* names, and an inherited member such as `toString` is declared nowhere. Only both questions together are a problem. This is extract method's `InnerImplicitReceiver` as a per-site exclusion rather than a refusal. - **Smart cast.** `val b = a.b; if (b != null) b.length` inlines to `a.b.length`, which does not compile - a smart cast needs a stable value, and a property read is not one. Detected with `smartCastInfo` on the reference (`KaDataFlowProvider`); non-null means excluded. diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index c8e3984e2e..100aea5635 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -33,7 +33,6 @@ import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtLoopExpression import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction -import org.jetbrains.kotlin.psi.KtObjectDeclaration import org.jetbrains.kotlin.psi.KtObjectLiteralExpression import org.jetbrains.kotlin.psi.KtParenthesizedExpression import org.jetbrains.kotlin.psi.KtPostfixExpression @@ -563,9 +562,10 @@ private fun isCallee(reference: KtSimpleNameExpression): Boolean = (reference.pa /** * Whether [reference] sits inside a body that does not run once, in order, at the offset where its - * text sits: a lambda, a local function, an anonymous object -- all of which run later -- or a loop - * body, which runs again after everything textually below it. Once a write exists at all, the cutoff's - * textual position cannot judge such a reference, so it is excluded outright. + * text sits: a lambda, a local function, a class or object body -- all of which run later, the class + * body at construction time -- or a loop body, which runs again after everything textually below it. + * Once a write exists at all, the cutoff's textual position cannot judge such a reference, so it is + * excluded outright. * * A loop that contains the declaration is not one of these: the walk stops at the first ancestor of * [target], so the value is recomputed on every iteration alongside the reference. @@ -578,7 +578,7 @@ private fun runsOutOfTextualOrder( while (current != null && !PsiTreeUtil.isAncestor(current, target, false)) { if (current is KtFunctionLiteral || current is KtNamedFunction || - current is KtObjectDeclaration || + current is KtClassOrObject || current is KtLoopExpression ) { return true diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index 46744dc5eb..ff786aea36 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -661,6 +661,100 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `a reference in a local class property initializer is excluded once a write exists`() { + val content = + """ + package p + fun println(n: Int) {} + fun demo() { + var i = 0 + val step = i + 1 + class L { + val y = step + } + i = 5 + println(L().y) + } + """.trimIndent() + + val result = plan(content, at(content, "val step") + "val ".length) + + /* + * The initializer runs when L is constructed, which is after the write, so a purely textual + * cutoff would call the reference inlinable and delete the declaration too, printing 6 instead + * of 1. + */ + assertEquals(InlineExclusion.DeferredExecution, result.references.single().exclusion) + assertEquals(false, result.canDeleteDeclaration) + } + + @Test + fun `a reference in a local class init block is excluded once a write exists`() { + val content = + """ + package p + fun println(n: Int) {} + fun demo() { + var i = 0 + val step = i + 1 + class L { + init { + println(step) + } + } + i = 5 + L() + } + """.trimIndent() + + val result = plan(content, at(content, "val step") + "val ".length) + + assertEquals(InlineExclusion.DeferredExecution, result.references.single().exclusion) + } + + @Test + fun `a reference in a local class constructor parameter default is excluded once a write exists`() { + val content = + """ + package p + fun println(n: Int) {} + fun demo() { + var i = 0 + val step = i + 1 + class L(val n: Int = step) + i = 5 + println(L().n) + } + """.trimIndent() + + val result = plan(content, at(content, "val step") + "val ".length) + + assertEquals(InlineExclusion.DeferredExecution, result.references.single().exclusion) + } + + @Test + fun `a reference in a local class method body is excluded once a write exists`() { + val content = + """ + package p + fun println(n: Int) {} + fun demo() { + var i = 0 + val step = i + 1 + class L { + fun y() = println(step) + } + i = 5 + L().y() + } + """.trimIndent() + + val result = plan(content, at(content, "val step") + "val ".length) + + assertEquals(InlineExclusion.DeferredExecution, result.references.single().exclusion) + } + @Test fun `a reference inside an anonymous object body is left untouched`() { val content = From e8884337bc67126ae81a296ec6cf9f4dafdc314a Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Fri, 21 Aug 2026 13:33:01 +0000 Subject: [PATCH 58/62] ADFA-4827: Widen the exclusion KDoc to what the checks now cover --- .../lsp/kotlin/utils/refactor/InlineVariablePlan.kt | 10 +++++++--- 1 file changed, 7 insertions(+), 3 deletions(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt index e5d7359a53..e3ef65665b 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt @@ -23,7 +23,10 @@ enum class InlineExclusion { /** A name the initializer reads means something else here. */ Shadowed, - /** The initializer reads through an implicit receiver a lambda in between replaces. */ + /** + * The initializer reads through an implicit receiver that something in between replaces: a + * receiver-introducing lambda, or a class or object body whose own `this` displaces it. + */ ReceiverShift, /** The reference is used under a smart cast, which an expression cannot carry. */ @@ -37,8 +40,9 @@ enum class InlineExclusion { UnsafeInCalleePosition, /** - * The reference is inside a body that may run after a write invalidates the cutoff -- a lambda, a - * local function, or an anonymous object -- so the cutoff's textual position cannot be trusted. + * The reference is inside a body that does not run once, in order, where its text sits -- a lambda, a + * local function, a class or object body, or a loop body -- so once a write exists the cutoff's + * textual position cannot judge it. */ DeferredExecution, } From 95ac4324a2ae83e14699a2ea95a85649f36de31c Mon Sep 17 00:00:00 2001 From: davidschachterADFA Date: Fri, 21 Aug 2026 20:20:00 -0500 Subject: [PATCH 59/62] ADFA-5153: Decode Content rows against the shared Brotli dictionary (#1677) * ADFA-5153: Decode Content rows against the shared Brotli dictionary WebServer now always decompresses brotli content server-side rather than ever passing compressed bytes through to the client -- sidesteps needing WebView-side dictionary support entirely, since the client never sees compressed bytes. It loads CompressionDictionary once at startup, and again on the debug-DB swap, and attaches it via brotli4j's attachDictionary before decoding -- falling back to plain decode if the table doesn't exist (a database that predates the dictionary migration). Confirmed cross-tool compatibility empirically: content compressed by OfflineDocumentationTools' brotli-CLI pipeline decodes byte-for-byte correctly via brotli4j's attachDictionary, and the same in-memory dictionary buffer is safe to reuse across many decode calls (WebServer holds one for its whole lifetime). BrotliDictionaryDecodeTest embeds those real cross-tool-produced fixtures as permanent regression coverage. Also adds testImplementation(libs.brotli4j.linux.x64): JVM unit tests exercising brotli4j's real native decoder had no native lib to load at all before this and would fail with UnsatisfiedLinkError -- a pre-existing gap, not introduced by this change, just never hit until now. docs/documentation-database.md updated for CompressionDictionary and WebServer's always-decompress behavior. * Apply spotlessApply formatting Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Narrow no-dictionary decode test to IOException CodeRabbit flagged this test as asserting an unsupported invariant, citing docs/documentation-database.md's claim that "wrong dictionary, or none" doesn't reliably fail loudly. Verified empirically that the two cases are actually distinct: a wrong dictionary decodes silently to incorrect bytes (its distances resolve into real, just wrong, bytes), but no dictionary at all reliably throws IOException, since distances into the dictionary region are out of bounds for any spec-compliant decoder. Narrowed the assertion from Exception to IOException and corrected the doc to describe both failure modes instead of conflating them. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Address code-review findings on the dictionary compression PR Fixes 13 findings from a max-effort /code-review pass, most significant first: - Plugin-contributed Tier 3 docs (PluginDocumentationManager/BrotliCompressor) are plain brotli with no dictionary, but WebServer unconditionally attached the shared dictionary before decoding any brotli row -- every such page 500'd. Extracted decompressBrotli(): tries the dictionary first, falls back to a plain decode on IOException. Verified empirically that a dictionary attached to a stream compressed without one reliably throws rather than silently decoding wrong bytes, so this fallback never lets a real dictionary-compressed row slip through unnoticed. - loadCompressionDictionary() now wraps its whole body in one catch-all, matching DatabaseVersionResolver's existing pattern, instead of hand-anticipating individual failure cases. Fixes three related bugs this gap caused: a failed dictionary reload during the debug-DB swap left stale state with no retry; a dictionary-load failure at server startup aborted the entire server with no retry; a NULL dictionary blob threw an uncaught NPE. - Extracted switchToDatabase() so database/databaseTimestamp/ compressionDictionary/templateCache/bookshelfTemplateId are all swapped atomically in one place instead of duplicated across start() and the debug-swap block -- also fixes templateCache never being invalidated on a debug-DB swap, and a reopen-after-close ordering bug where a failed reopen left `database` referencing an already-closed handle. - Added test coverage for the previously-untested no-dictionary/plugin-content decode path. - Corrected docs/documentation-database.md's false "no dictionary-free content left" claim (contradicted by its own PluginDocumentationManager section) and the build.gradle.kts comment falsely claiming linux-x64 is the only platform this project's dev machines run JVM tests on. - Minor: deduped the byte[]->direct-ByteBuffer idiom, removed a stale Accept-Encoding comment on a header no longer read. Separately discovered (not caused by this PR, filed as ADFA-5168 instead of fixed here): :app:testV8DebugUnitTest is flaky (~50% of full-suite runs) due to Brotli4jLoader static state shared across one JVM test process between AssetsInstallationHelperTest's mockkStatic and BrotliDictionaryDecodeTest's real native load -- confirmed present on bfb3baa87 already, independent of any change in this commit. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Scope shared-dictionary claim to migrated brotli rows CodeRabbit caught a self-contradiction: line 34 already says non-Brotli content uses format-specific compression, but the prior wording said 'every row' is dictionary-compressed. Scoped to migrated Content rows with ContentTypes.compression = 'brotli'. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Add test proving the compression dictionary loads once Per ticket comment: verifies WebServer fetches CompressionDictionary only at startup and reuses the cached instance across every request, never re-querying it per-request. Drives 3 real HTTP requests over a socket against a mocked SQLiteDatabase and asserts the dictionary query fired exactly once while the Content query fired 3 times. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Reload compression dictionary per-request, not at swap time Moved loadCompressionDictionary() out of switchToDatabase() (called at startup and on the debug-DB swap) to right before the content fetch in handleClient(). A database swap can bring in a database with a different dictionary or none at all, so loading it right where it's consumed -- rather than caching it at swap time -- keeps it directly tied to whichever database is actually active when a request needs it. Updated the WebServerTest coverage added for the prior (now-reversed) "load once, cache for app lifetime" behavior: it now asserts zero dictionary queries before any request and one dictionary query per content fetch (3 requests -> 3 queries). Updated docs/comments to match. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Load compression dictionary lazily, once per database change Corrects the prior commit, which reloaded the dictionary on every single request instead of only when the database actually changes. Added compressionDictionaryStale, set by switchToDatabase() (startup and the debug-DB swap) instead of eagerly loading the dictionary there. The content-fetch site in handleClient() -- the one place the dictionary is actually consumed -- checks the flag and only loads when stale, clearing it once loaded. Net effect: loaded lazily (not merely from starting the server), but cached across every request against the same database, and reloaded exactly once when a swap brings in a database with a different dictionary (or none). Replaced the WebServerTest coverage accordingly: one test proves the dictionary loads on first use and stays cached across repeated requests against the same database; a second drives an actual debug-DB swap and proves it reloads exactly once for the new database, not on every subsequent request. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Run the brotli tests on any host, and cover the buffer helper Review of PR #1677 found three things worth fixing. The test native was pinned to linux-x64, so :app:testV8DebugUnitTest failed with UnsatisfiedLinkError in @BeforeClass for anyone on macOS, Windows, or linux-arm64 - a comment documented the breakage rather than fixing it. Dispatch on the host's OS/arch instead, reusing the pattern already proven in build-logic/plugins' build.gradle.kts. All six natives are already in the version catalog. BrotliDictionaryDecodeTest allocated its own direct buffer, a byte-for-byte copy of production's toDirectByteBuffer, leaving the only code that builds the runtime dictionary buffer untested. The two agree today, so this is a regression risk rather than a live bug: attachDictionary reads the buffer's capacity and ignores position/limit, so a later over-allocation there (pooling, rounding, padding) would break every doc page on device while the suite stayed green. The test now calls the production helper, and that helper's KDoc records the exact-capacity requirement. loadCompressionDictionary validated a missing table, an empty table, and a NULL data column, but not a zero-length blob. That yields a 0-capacity buffer, which attachDictionary rejects, so every row would fail its dictionary decode, fall through to a plain decode that also fails, and return HTTP 500 - with nothing above DEBUG to explain it. Added to the same ladder so it gets the same one-line warning. Left alone: peak heap on the chunked PDFs (always-decompress holds the accumulator, its copy, and the output live at once) and the debug-DB swap retrying every request after a failure. Both are pre-existing design questions rather than regressions from this PR. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y4t7fFLNPJq9EiXr9S9LxF * ADFA-5153: Cut peak heap on chunked rows, and stop retrying a bad debug DB Two findings from the PR #1677 review that were deferred as design questions. Peak heap on the largest bundled PDFs. Serving a >1 MB row concatenated its chunks into a ByteArrayOutputStream and then called toByteArray(), so the doubling buffer and its full copy were both live alongside the decompressed output - roughly 35 MB transient for AndroidNotesForProfessionals.pdf (8.8 MB over 9 chunks), a plausible OOM on a low-heap device. The chunks now stay a list: brotli rows decode from a SequenceInputStream over them, and non-brotli rows are joined once into an exactly-sized array. That drops the two largest transients, leaving the compressed chunks and the decompressed output. Fully streaming the response would remove the last one too, but that means giving up Content-Length, so it is left alone. A failed debug-database swap left databaseTimestamp unadvanced, and the swap is checked per request - so a corrupt or unreadable debug DB newer than the primary was reopened on every single request, logging an ERROR each time. The failing timestamp is now remembered and skipped; a newer copy has a different timestamp and is retried, which is the case that matters, since replacing the file is how a developer fixes it. joinChunks and chunksAsStream are internal top-level functions next to toDirectByteBuffer so the tests exercise the real code, with three new cases: a compressed stream decodes identically when split at uneven chunk boundaries, joinChunks concatenates in order at an exact size, and a lone chunk comes back without a copy. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01Y4t7fFLNPJq9EiXr9S9LxF * ADFA-5153: Address CodeRabbit findings on the dictionary tests - Assert the sqlite_master existence-check query count alongside the data query in both dictionary tests, not just the data query -- a regression that re-ran only the existence check every request would otherwise pass unnoticed. - Set socket.soTimeout before reading the response in sendRawGetRequestAndAwaitClose, so a server that fails to close the connection fails the test instead of hanging the JVM indefinitely. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Address jatezzz's review on PR #1677 (3 of 5 findings) - loadCompressionDictionary no longer swallows exceptions into "no dictionary." It only returns null for a definitive absence (missing table, empty table, null/empty blob); an unexpected SQLiteException now propagates to the call site, which leaves compressionDictionaryStale set so the next request retries instead of permanently caching a transient failure as "no dictionary" for the rest of the database's lifetime. - brotli4jNativeForHost() in app/build.gradle.kts no longer throws on an unrecognized host. That ran at configuration time, so throwing failed every task in the build -- including :app:assembleV8Debug, which needs no desktop native at all -- not just the JVM unit-test tasks that consume it. Degrades to a logged warning and no test native instead. - Softened the chunked-content comment's memory-savings claim: the decompressed output still goes through a comparable accumulate-then-copy in decompressBrotli's own readBytes() call, so the saving from keeping compressed chunks as a list is real but doesn't eliminate that separate transient the way the prior wording implied. The two remaining findings (dictionary-first decode's theoretical silent-wrong-bytes risk, and the resulting double-decode cost for dictionary-free rows) need a design discussion, not a quick fix -- see the PR thread reply. Co-Authored-By: Claude Sonnet 5 * ADFA-5153: Warm the brotli loader before a test mocks it Order-dependent test failure between this PR's BrotliDictionaryDecodeTest and the pre-existing AssetsInstallationHelperTest: whichever runs first in a JVM decides whether the second one works. AssetsInstallationHelperTest does mockkStatic(Brotli4jLoader::class) and stubs ensureAvailability() to do nothing, since a unit test has no native library to load. brotli4j caches its availability in a static field, so a JVM whose first sight of that class is the mocked one keeps a "never loaded" state -- and BrotliDictionaryDecodeTest's @BeforeClass, which calls the real ensureAvailability(), then throws UnsatisfiedLinkError. unmockkAll() in teardown does not undo it: the damage is the cached state, not the mock. Loading it for real once, before anything mocks it, fixes it. runCatching because a host with no matching native is a legitimate configuration -- this PR's own brotli4jNativeForHost() degrades to a warning rather than failing the build -- so the warming is best-effort. CI is green on this PR because its test set happens to order favourably. The pair reproduces the failure deterministically: ./gradlew :app:testV8DebugUnitTest \ --tests "com.itsaky.androidide.assets.AssetsInstallationHelperTest" \ --tests "com.itsaky.androidide.localWebServer.BrotliDictionaryDecodeTest" Found while stacking ADFA-5176 and ADFA-5179 on this branch, where the added test class shifted the order enough to expose it. Landing the fix here keeps it with the test it protects, rather than leaving stage briefly broken after this merges. * ADFA-5153: Absorb only UnsatisfiedLinkError when warming the brotli loader Review was right that runCatching was too broad: it swallows every Throwable, so an unrelated failure in this setup would disappear silently. ensureAvailability() raises UnsatisfiedLinkError when there is no native for the host -- the one case the warming exists to tolerate -- so that is all it catches now, and it says so on stdout rather than passing in silence. * ADFA-5153: Gate the compression dictionary on the declared database version WebServer inferred the content format from whether a CompressionDictionary table happened to exist -- the heuristic ADFA-5220's version table exists to retire. It gets the answer wrong in both directions: a database carrying the table with unmigrated content makes every plain row pay a failed dictionary decode before its plain one, on every request, and a migrated database that lost the table fails quietly rather than loudly. Gate on DocumentationDatabaseVersion instead. At MAJOR >= 2 the dictionary is read and attached as before; below that, or with no version table at all, it is neither fetched nor used. The version read lives in DatabaseVersionResolver (common), so ADFA-5176's in-process transport can share the same gate rather than growing a second copy. It returns null for a definitively unversioned database and lets exceptions propagate, matching loadCompressionDictionary's existing contract: callers cache the answer per database, so a transient SQLiteException must stay distinguishable from a real absence or one hiccup would pin the database at unversioned until the next swap. The table is an append-only log, so the current version is the row inserted last, not MAX(major) -- rebuilding from an older content set is a downgrade and has to read as one. The CompressionDictionary probes stay, for a database that declares a new-enough version but has no usable dictionary row: without them the data query raises "no such table", which the caller correctly treats as transient and would then retry on every request. Tests: three new WebServer cases (major 1, no version table, major 3) asserting the dictionary queries are or are not issued -- with the dictionary cursors stubbed as available in every case, so they test the gate rather than a missing table -- and five DatabaseVersionResolver cases covering absent table, empty table, declared version, last-row-wins, and a downgrade. The two existing dictionary tests now declare a version; without that they would have kept passing while silently testing nothing. Co-Authored-By: Claude Opus 5 (1M context) * ADFA-5153: Load brotli4j's native library before decoding, not by luck Nothing in WebServer owned that load: it happened as a side effect of AssetsInstallationHelper's install or ToolsManager's tooling-jar update, neither of which runs on an ordinary cold start. A process that skipped both reached the first brotli row with the natives unregistered, and DecoderJNI.nativeCreate raised UnsatisfiedLinkError -- an Error, not an Exception, so it escaped handleClient's catch and killed the app from a coroutine worker instead of failing one request. Reproduced on device: force-stop, launch MainActivity directly (skipping SplashActivity, whose startup path happens to warm the loader), request a brotli row. The app died and restarted -- pid 10550 -> 10785, with FATAL EXCEPTION and UnsatisfiedLinkError in the log. Android restarting a killed process straight into the editor would take the same path. Referencing Brotli4jLoader triggers the static init that performs the load, so calling ensureAvailability() before the decode *is* the warm-up; afterwards it is a single static null-check on UNAVAILABILITY_CAUSE (verified against brotli4j 1.18.0's bytecode), cheap enough to leave on the per-decode path rather than tracking warmed state of our own. Its UnsatisfiedLinkError becomes an IOException so a genuinely broken environment costs one 500 rather than the process. After the fix, the same sequence returns the full 50,440-byte page, the pid is unchanged, and the log has no fatal or link-error lines. The version gate still behaves: a database declaring 1.0.0 serves 500 for a brotli row and 200 for a compression = 'none' row, without crashing. Also documents a trap that cost real debugging time: the debug-database swap compares modification times, and `adb push` preserves the source file's mtime, so pushing a database saved earlier than the one already on the device silently does not swap and the app keeps serving the old one with no error anywhere. Co-Authored-By: Claude Opus 5 (1M context) --------- Co-authored-by: Claude Sonnet 5 --- app/build.gradle.kts | 57 ++++ .../androidide/localWebServer/WebServer.kt | 304 ++++++++++++++++-- .../assets/AssetsInstallationHelperTest.kt | 13 + .../BrotliDictionaryDecodeTest.kt | 251 +++++++++++++++ .../localWebServer/WebServerTest.kt | 293 +++++++++++++++++ .../utils/DatabaseVersionResolverTest.kt | 71 +++- .../utils/DatabaseVersionResolver.kt | 54 +++- docs/documentation-database.md | 8 +- 8 files changed, 1004 insertions(+), 47 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 21c52a5fc2..acff8f5ee7 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -3,6 +3,7 @@ import com.itsaky.androidide.build.config.BuildConfig import com.itsaky.androidide.desugaring.utils.JavaIOReplacements.applyJavaIOReplacements import com.itsaky.androidide.plugins.AndroidIDEAssetsPlugin +import org.gradle.nativeplatform.platform.internal.DefaultNativePlatform import org.json.JSONObject import java.io.BufferedOutputStream import java.io.ByteArrayInputStream @@ -214,6 +215,55 @@ configurations.configureEach { exclude(group = "com.google.auto.value", module = "auto-value") } +// brotli4j ships its native decoder as a per-OS/arch artifact, so the JVM unit tests need the one +// matching whoever is building. Mirrors build-logic/plugins' dispatch, but degrades to null on an +// unrecognized host instead of throwing: this runs at configuration time, so throwing would fail +// every task in the build -- including :app:assembleV8Debug, which needs no desktop native at all +// -- rather than only the JVM unit-test tasks that actually consume this dependency. +fun brotli4jNativeForHost(): Provider? { + val arch = DefaultNativePlatform.getCurrentArchitecture() + val os = DefaultNativePlatform.getCurrentOperatingSystem() + val native = + when { + os.isMacOsX -> { + when { + arch.isArm64 -> libs.brotli4j.osx.aarch64 + arch.isAmd64 -> libs.brotli4j.osx.x64 + else -> null + } + } + + os.isWindows -> { + when { + arch.isArm64 -> libs.brotli4j.windows.aarch64 + arch.isAmd64 -> libs.brotli4j.windows.x64 + else -> null + } + } + + os.isLinux -> { + when { + arch.isArm64 -> libs.brotli4j.linux.aarch64 + arch.isAmd64 -> libs.brotli4j.linux.x64 + else -> null + } + } + + else -> { + null + } + } + if (native == null) { + logger.warn( + "brotli4j: no native decoder for {}/{} -- brotli4j-backed JVM unit tests " + + "(e.g. BrotliDictionaryDecodeTest) will fail with UnsatisfiedLinkError on this host.", + os, + arch, + ) + } + return native +} + dependencies { debugImplementation(libs.common.leakcanary) @@ -353,6 +403,13 @@ dependencies { // brotli4j implementation(libs.brotli4j) + // JVM unit tests (e.g. BrotliDictionaryDecodeTest) run brotli4j's real native decoder, not an + // Android target -- without a desktop native on the test classpath, Brotli4jLoader has nothing + // to load and every such test fails with UnsatisfiedLinkError. Pick the native for whoever is + // building, so the suite runs off a Linux x64 CI runner too (same dispatch as build-logic/plugins'). + // Null on an unrecognized host just means those specific tests fail there -- see + // brotli4jNativeForHost's own warning -- not that this whole build should refuse to configure. + brotli4jNativeForHost()?.let { testImplementation(it) } implementation(libs.common.markwon.core) implementation(libs.common.markwon.linkify) diff --git a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt index 0b76b64d2d..a978a8f286 100644 --- a/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt +++ b/app/src/main/java/com/itsaky/androidide/localWebServer/WebServer.kt @@ -4,6 +4,7 @@ import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.net.TrafficStats import android.os.Environment.getExternalStorageDirectory +import com.aayushatharva.brotli4j.Brotli4jLoader import com.aayushatharva.brotli4j.decoder.BrotliInputStream import com.google.gson.Gson import com.google.gson.GsonBuilder @@ -18,15 +19,19 @@ import org.slf4j.LoggerFactory import java.io.ByteArrayInputStream import java.io.ByteArrayOutputStream import java.io.File +import java.io.IOException import java.io.InputStream import java.io.PrintWriter +import java.io.SequenceInputStream import java.io.StringWriter import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket import java.net.URLDecoder +import java.nio.ByteBuffer import java.sql.Date import java.text.SimpleDateFormat +import java.util.Collections import java.util.Locale import java.util.concurrent.ConcurrentHashMap import java.util.concurrent.TimeUnit @@ -62,6 +67,45 @@ data class JavaExecutionResult( val timeoutLimit: Long, ) +/** + * Copies [bytes] into a direct [ByteBuffer] -- brotli4j's `attachDictionary` requires a direct + * buffer, a heap-backed one throws `IllegalArgumentException`. + * + * The capacity must be exactly [bytes]`.size`: `attachDictionary` reads the whole capacity and + * ignores position/limit, so trailing slack from an over-allocated buffer is treated as dictionary + * content and every decode then fails with `IOException: corrupted input`. + */ +internal fun toDirectByteBuffer(bytes: ByteArray): ByteBuffer = + ByteBuffer.allocateDirect(bytes.size).apply { + put(bytes) + flip() + } + +/** + * Reads [chunks] back to back as one stream, without concatenating them into a new array. + * Cheap to build twice, which the no-dictionary retry in `decompressBrotli` relies on. + */ +internal fun chunksAsStream(chunks: List): InputStream = + SequenceInputStream(Collections.enumeration(chunks.map { ByteArrayInputStream(it) })) + +/** + * Joins [chunks] into one exactly-sized array. A ByteArrayOutputStream would repeatedly double its + * buffer and then hand back a second full copy -- avoidable here since the total is known up front. + * Returns the sole element as-is when there is nothing to join. + */ +internal fun joinChunks(chunks: List): ByteArray { + if (chunks.size == 1) { + return chunks[0] + } + val joined = ByteArray(chunks.sumOf { it.size }) + var offset = 0 + for (chunk in chunks) { + chunk.copyInto(joined, offset) + offset += chunk.size + } + return joined +} + class WebServer( private val config: ServerConfig, ) { @@ -76,6 +120,27 @@ class WebServer( private lateinit var serverSocket: ServerSocket private lateinit var database: SQLiteDatabase private var databaseTimestamp: Long = -1 + + // Timestamp of a debug database whose swap already failed, so a corrupt or unreadable one + // isn't reopened on every single request (it is checked per request). A newer copy has a + // different timestamp and is retried, which is the case that matters -- the developer + // replacing the file is exactly how they'd fix it. + private var failedDebugSwapTimestamp: Long = -1 + + // The shared dictionary Content's brotli-compressed rows are compressed against (see + // ADFA-5153). Lazily (re)loaded on demand, right before the first content fetch that needs + // it after `database` changes -- see compressionDictionaryStale -- rather than eagerly at + // database-open/swap time, but still cached (not reloaded per-request) once loaded for the + // currently active database. Null (no dictionary attached, plain-brotli decode) unless the + // active database declares MAJOR >= MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY in ADFA-5220's + // version table. + private var compressionDictionary: ByteBuffer? = null + + // Set whenever `database` changes (see switchToDatabase); cleared once compressionDictionary + // has been (re)loaded for that database. Lets the dictionary stay lazily loaded -- only right + // before the first content fetch that actually needs it -- while still loading at most once + // per database change rather than once per request. + private var compressionDictionaryStale = true private val log = LoggerFactory.getLogger(WebServer::class.java) private val debugEnabled: Boolean = File(config.debugEnablePath).exists() @@ -85,8 +150,6 @@ class WebServer( // Frozen at startup; restart the server to pick up a change. private val clearCacheEnabled: Boolean = File(config.clearCacheEnablePath).exists() - private val encodingHeader: String = "Accept-Encoding" - private val brotliCompression: String = "br" private val pebbleEngine = PebbleEngine.Builder().loader(StringLoader()).build() private val templateCache = ConcurrentHashMap() private val gson: Gson = @@ -130,6 +193,152 @@ class WebServer( } } + /** + * Loads the shared Brotli dictionary most Content rows are compressed against (see ADFA-5153). + * Returns null (logged) when the database *definitively* has no dictionary -- so callers fall + * back to plain, dictionary-free brotli decode (see [decompressBrotli]). + * + * The gate is the MAJOR version the database declares in ADFA-5220's version table, not the + * presence of a `CompressionDictionary` table: table sniffing infers a whole content format + * from one table's existence, and gets it wrong in both directions -- a database carrying the + * table but *unmigrated* content makes every plain row pay a failed dictionary decode before + * its plain one, on every request. Below + * [DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY] the dictionary is neither + * read nor attached. + * + * The `CompressionDictionary` checks below still run, for a database that declares a new-enough + * version but has no usable dictionary row: without them the data query would raise "no such + * table", which the caller correctly reads as transient and would then retry on every request. + * + * Deliberately does *not* catch exceptions itself: an unexpected `SQLiteException`/IO failure is + * likely transient, and the caller (see [handleClient]) must not cache that as "no dictionary" + * the way it does a definitive absence, or a transient failure would permanently disable + * dictionary decoding for the rest of this database's lifetime. + */ + private fun loadCompressionDictionary(db: SQLiteDatabase): ByteBuffer? { + val majorVersion = DatabaseVersionResolver.resolveMajorVersion(db) + if (majorVersion == null || majorVersion < DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY) { + log.warn( + "Database declares documentation version {}, below {}; decoding brotli content without a dictionary.", + majorVersion ?: "none", + DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY, + ) + return null + } + + val tableExists = + db + .rawQuery( + "SELECT 1 FROM sqlite_master WHERE type = 'table' AND name = 'CompressionDictionary'", + null, + ).use { it.moveToFirst() } + if (!tableExists) { + log.warn("CompressionDictionary table not found; decoding brotli content without a dictionary.") + return null + } + + return db.rawQuery("SELECT data FROM CompressionDictionary WHERE id = 1", null).use { cursor -> + if (!cursor.moveToFirst()) { + log.warn("CompressionDictionary table is empty; decoding brotli content without a dictionary.") + return null + } + val bytes = cursor.getBlob(0) + if (bytes == null) { + log.warn("CompressionDictionary row has a NULL data column; decoding brotli content without a dictionary.") + return null + } + // An empty blob would yield a 0-capacity buffer, which attachDictionary rejects -- + // every row's dictionary decode would then fail with nothing above DEBUG to say why. + if (bytes.isEmpty()) { + log.warn("CompressionDictionary row has an empty data column; decoding brotli content without a dictionary.") + return null + } + toDirectByteBuffer(bytes) + } + } + + /** + * Opens [path] as the active database, refreshing every piece of state that depends on which + * database file is active -- [databaseTimestamp] and the per-database caches + * [bookshelfTemplateId]/[templateCache] -- as one atomic operation. Does *not* load + * [compressionDictionary] itself -- a different database can have a different dictionary (or + * none) -- it only marks [compressionDictionaryStale] so the next content fetch that needs it + * loads it lazily then (see [handleClient]), at most once per database change rather than + * once per request. Only closes the previous database once the new one has opened + * successfully, so a failed swap (this throws) leaves the previous, still-open database + * serving requests rather than leaving [database] referencing an already-closed handle. + */ + private fun switchToDatabase( + path: String, + timestamp: Long, + ) { + val newDatabase = SQLiteDatabase.openDatabase(path, null, SQLiteDatabase.OPEN_READONLY) + if (::database.isInitialized) { + try { + database.close() + } catch (e: Exception) { + log.error("Cannot close previous database: {}", e.message) + } + } + database = newDatabase + databaseTimestamp = timestamp + compressionDictionaryStale = true + bookshelfTemplateId = -1 + templateCache.clear() + } + + /** + * Loads brotli4j's native library if nothing else has yet, and turns its absence into a failed + * request rather than a dead app. + * + * Nothing here owns that load: it happens as a side effect of `AssetsInstallationHelper`'s + * install or `ToolsManager`'s tooling-jar update, neither of which runs on an ordinary cold + * start. A process that skips both -- Android restarting the app straight into the editor, say -- + * reaches the first brotli row with the natives unregistered, and `DecoderJNI.nativeCreate` + * raises `UnsatisfiedLinkError`. Being an Error rather than an Exception, that escapes + * [handleClient]'s catch and kills the app from a coroutine worker instead of failing one + * request (observed on-device, 20-Aug). + * + * Referencing [Brotli4jLoader] triggers the static init that performs the load, so this call is + * the warm-up; afterwards `ensureAvailability` is a single static null-check, cheap enough to + * leave on the per-decode path rather than tracking "warmed" state of our own. + */ + private fun ensureBrotliAvailable() { + try { + Brotli4jLoader.ensureAvailability() + } catch (e: UnsatisfiedLinkError) { + throw IOException("brotli4j's native library is unavailable, so brotli content cannot be decoded", e) + } + } + + /** + * Decompresses one Brotli-compressed Content row. Tries the shared dictionary first, since every + * ADFA-5153-migrated row requires it, then falls back to a plain decode for rows that were never + * dictionary-compressed: plugin-contributed Tier 3 docs (PluginDocumentationManager/BrotliCompressor + * compress with no dictionary) or any row served from a pre-migration database. Attaching a + * dictionary to a stream that wasn't compressed against one reliably fails to decode rather than + * silently producing wrong bytes (verified empirically -- see docs/documentation-database.md), so + * this ordering never lets a dictionary-compressed row fall through to the plain path by accident. + */ + private fun decompressBrotli(chunks: List): ByteArray { + ensureBrotliAvailable() + val dictionary = compressionDictionary + if (dictionary != null) { + try { + return BrotliInputStream(chunksAsStream(chunks)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + } catch (e: IOException) { + log.debug( + "Dictionary decode failed for a brotli row (likely dictionary-free plugin content); retrying without a dictionary: {}", + e.message, + ) + } + } + return BrotliInputStream(chunksAsStream(chunks)).use { it.readBytes() } + } + /** * Stops the server by closing the listening socket. Safe to call from any thread. * Causes [start]'s accept loop to exit. If [start] hasn't bound the socket yet -- @@ -165,10 +374,8 @@ class WebServer( config.experimentsEnablePath, ) - databaseTimestamp = getDatabaseTimestamp(config.databasePath) - try { - database = SQLiteDatabase.openDatabase(config.databasePath, null, SQLiteDatabase.OPEN_READONLY) + switchToDatabase(config.databasePath, getDatabaseTimestamp(config.databasePath)) } catch (e: Exception) { log.error("Cannot open database: {}", e.message) return @@ -284,8 +491,6 @@ class WebServer( val writer = PrintWriter(output, true) if (debugEnabled) log.debug(" writer is {}.", writer) - var brotliSupported = false // assume nothing - // Read the request method line, it is always the first line of the request var requestLine = readLineFromStream(input) if (requestLine == null) { @@ -306,7 +511,7 @@ class WebServer( var path = parts[1].split("?")[0] // Discard any HTTP query parameters. path = path.substring(1) - // Read all headers until blank line (needed for Content-Length on POST and Accept-Encoding on GET) + // Read all headers until blank line (needed for Content-Length on POST) val headers = mutableMapOf() while (true) { requestLine = readLineFromStream(input) ?: break @@ -317,7 +522,6 @@ class WebServer( headers[requestLine.substring(0, colon).trim().lowercase()] = requestLine.substring(colon + 1).trim() } } - brotliSupported = headers["accept-encoding"]?.contains(brotliCompression) == true // Playground endpoint: POST only, handled before GET-only check if (false && path == "playground/execute") { @@ -332,11 +536,18 @@ class WebServer( // check to see if there is a newer version of the documentation.db database on the sdcard // if there is use that for our responses val debugDatabaseTimestamp = getDatabaseTimestamp(config.debugDatabasePath, true) - if (debugDatabaseTimestamp > databaseTimestamp) { - bookshelfTemplateId = -1 - database.close() - database = SQLiteDatabase.openDatabase(config.debugDatabasePath, null, SQLiteDatabase.OPEN_READONLY) - databaseTimestamp = debugDatabaseTimestamp + if (debugDatabaseTimestamp > databaseTimestamp && debugDatabaseTimestamp != failedDebugSwapTimestamp) { + try { + switchToDatabase(config.debugDatabasePath, debugDatabaseTimestamp) + failedDebugSwapTimestamp = -1 + } catch (e: Exception) { + failedDebugSwapTimestamp = debugDatabaseTimestamp + log.error( + "Cannot swap to debug database '{}'; ignoring it until it changes: {}", + config.debugDatabasePath, + e.message, + ) + } } // Handle the special "pr" endpoint with highest priority @@ -352,6 +563,22 @@ class WebServer( } } + // Lazily (re)loaded here -- the one place the dictionary is actually consumed (see + // decompressBrotli) -- rather than eagerly at database-open/swap time, but only once per + // database change: a swap (just above) marks compressionDictionaryStale rather than + // reloading immediately, so this only hits the database again when that flag is set. + // Only clears the flag on a clean load (definitive dictionary or definitive absence) -- + // an unexpected exception leaves it set so the next request retries, rather than caching + // a transient failure as "no dictionary" for the rest of this database's lifetime. + if (compressionDictionaryStale) { + try { + compressionDictionary = loadCompressionDictionary(database) + compressionDictionaryStale = false + } catch (e: Exception) { + log.error("Could not load compression dictionary; will retry on the next request: {}", e.message) + } + } + // Database fetch val query = """ SELECT C.content, CT.value, CT.compression, C.templateId @@ -377,24 +604,31 @@ class WebServer( } cursor.moveToFirst() - var dbContent = cursor.getBlob(0) + val firstChunk = cursor.getBlob(0) val dbMimeType = cursor.getString(1) var compression = cursor.getString(2) val templateId = cursor.getInt(3) - // Fragment handling for large content (> 1MB) - if (dbContent.size == contentChunkSize) { + // Fragment handling for large content (> 1MB). The chunks stay a list rather than + // being eagerly concatenated: the old accumulate-into-a-ByteArrayOutputStream-then-copy + // held both the doubling buffer and its toByteArray() copy of the *compressed* chunks + // live at once, on top of the decompressed output that follows -- for the largest + // bundled PDF (8.8 MB over 9 chunks) that's a real, if partial, reduction: the + // decompressed output still goes through a comparable accumulate-then-copy in + // decompressBrotli's own readBytes() call, so the compressed-side saving here doesn't + // eliminate that separate transient. + val chunks = mutableListOf(firstChunk) + if (firstChunk.size == contentChunkSize) { val query2 = "SELECT content FROM Content WHERE path = ? AND languageId = 1" var fragmentNumber = 1 - val combined = ByteArrayOutputStream().apply { write(dbContent) } - var dbContent2 = dbContent - while (dbContent2.size == contentChunkSize) { + var nextChunk = firstChunk + while (nextChunk.size == contentChunkSize) { val path2 = "$path-$fragmentNumber" val cursor2 = database.rawQuery(query2, arrayOf(path2)) try { if (cursor2.moveToFirst()) { - dbContent2 = cursor2.getBlob(0) - combined.write(dbContent2) + nextChunk = cursor2.getBlob(0) + chunks.add(nextChunk) fragmentNumber++ } else { break @@ -403,19 +637,20 @@ class WebServer( cursor2.close() } } - dbContent = combined.toByteArray() } - // If a document is stored in brotli form and the client doesn't support that encoding - // decompress and send that to the client. - // Pebble templates have to be in string form so the retrieved database content may need to be - // decompressed. - if (compression == "brotli" && (!brotliSupported || templateId > 0)) { - dbContent = BrotliInputStream(ByteArrayInputStream(dbContent)).use { it.readBytes() } - compression = "none" - } else if (compression == "brotli") { - compression = "br" - } + // Content is compressed at rest with brotli -- most rows against the shared dictionary + // loaded into compressionDictionary (see ADFA-5153), but plugin-contributed Tier 3 docs + // (PluginDocumentationManager/BrotliCompressor) are plain brotli with no dictionary. + // This server always decompresses before responding, so it never needs to negotiate + // Content-Encoding with the client. + var dbContent = + if (compression == "brotli") { + compression = "none" + decompressBrotli(chunks) + } else { + joinChunks(chunks) + } // If the file is associated with a template, instantiate that template and send the result to the client if (templateId > 0) { @@ -425,7 +660,6 @@ class WebServer( writer.println("HTTP/1.1 200 OK") writer.println("Content-Type: $dbMimeType") writer.println("Content-Length: ${dbContent.size}") - if (compression != "none") writer.println("Content-Encoding: $compression") writer.println("Connection: close") writer.println() writer.flush() @@ -446,7 +680,7 @@ class WebServer( * @param dbContent JSON bytes that will be parsed and supplied as the template context. * @param path The request/content path associated with this template (used for diagnostic/logging purposes). * @param dbMimeType The MIME type of the stored content (used for diagnostic/logging purposes). - * @param compression The compression label of the stored content (e.g., "br", "none") (used for diagnostic/logging purposes). + * @param compression The compression label of the stored content (always "none" by this point, since decompression already happened) (used for diagnostic/logging purposes). * @return The rendered template encoded as UTF-8 bytes. * @throws Exception If the template ID is not found, is duplicated in the database, or if template lookup/instantiation fails. */ diff --git a/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt b/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt index dda5ce2954..9bdd5b52a6 100644 --- a/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt +++ b/app/src/test/java/com/itsaky/androidide/assets/AssetsInstallationHelperTest.kt @@ -41,6 +41,19 @@ class AssetsInstallationHelperTest { @Before fun setup() { + // Load the brotli native for real before anything here mocks Brotli4jLoader. brotli4j caches + // its availability in a static field, so a JVM whose first sight of that class is a mocked + // one keeps a "never loaded" state -- and a later *real* ensureAvailability(), which + // BrotliDictionaryDecodeTest does in @BeforeClass, then throws UnsatisfiedLinkError even + // after unmockkAll(). Only UnsatisfiedLinkError is absorbed -- that is what the loader raises + // when there is no native for this host, which is a legitimate configuration (see + // brotli4jNativeForHost) -- so any other setup failure here still surfaces. + try { + Brotli4jLoader.ensureAvailability() + } catch (e: UnsatisfiedLinkError) { + println("brotli native unavailable on this host, continuing: ${e.message}") + } + mockkObject(helper) every { helper["checkStorageAccessibility"](any(), any()) diff --git a/app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt b/app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt new file mode 100644 index 0000000000..80a1ac152c --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/BrotliDictionaryDecodeTest.kt @@ -0,0 +1,251 @@ +package com.itsaky.androidide.localWebServer + +import com.aayushatharva.brotli4j.Brotli4jLoader +import com.aayushatharva.brotli4j.decoder.BrotliInputStream +import com.aayushatharva.brotli4j.encoder.BrotliOutputStream +import com.aayushatharva.brotli4j.encoder.Encoder +import org.junit.Assert.assertArrayEquals +import org.junit.Assert.assertEquals +import org.junit.Assert.assertSame +import org.junit.Assert.assertThrows +import org.junit.BeforeClass +import org.junit.Test +import java.io.ByteArrayInputStream +import java.io.ByteArrayOutputStream +import java.io.IOException +import java.nio.ByteBuffer +import java.nio.charset.StandardCharsets +import java.util.Base64 + +// Deliberately routed through production's toDirectByteBuffer rather than allocating here: +// attachDictionary reads the buffer's capacity, so an over-allocated buffer fails every decode. +// Duplicating the allocation would leave that helper untested and let the two drift apart. +private fun decodeBase64ToDirectBuffer(base64: String): ByteBuffer = toDirectByteBuffer(Base64.getDecoder().decode(base64)) + +// Regression coverage for ADFA-5153: documentation.db's Content rows are Brotli-compressed +// against a shared dictionary trained by OfflineDocumentationTools' zstd/brotli CLI pipeline +// (see populate_db.py's DictionaryCompressor), not by brotli4j itself. These fixtures were +// produced by that exact pipeline, so this test is what protects the cross-tool contract: a +// brotli4j upgrade (or native lib change) that silently broke compatibility with the CLI-produced +// wire format would otherwise only surface as garbled content on-device. +class BrotliDictionaryDecodeTest { + companion object { + // Unlike on-device (where ToolsManager/AssetsInstallationHelper already load it before + // WebServer ever runs), nothing loads brotli4j's native lib in a plain JVM unit test -- + // without this, every test below fails with UnsatisfiedLinkError instead of exercising + // real decode behavior. + @JvmStatic + @BeforeClass + fun loadNativeLibrary() { + Brotli4jLoader.ensureAvailability() + } + } + + // A ~3.3 KB zstd fast-cover dictionary trained on synthetic doc-page-like text, and a small + // payload Brotli-compressed against it via the `brotli` CLI's `-D` flag (OfflineDocumentationTools' + // actual encode path) -- see ADFA-5153. + private val dictionaryBase64 = + "N6Qw7OTyEGgfENCSpAP//////49QsrssRMqWGsnNSkLy/zfL/Ef3/zMAADhYoPCcRptTLgAEQIEAAMAS" + + "pykQlqZI41QGmTEGEAIAAAAAAAAAAAAAAABkXQEAAAAAAAAAAAAAAAAAAAABAAAABAAAAAgAAABhY2Ug" + + "dG9jLWVsZW1lbnQgZG9jcy1zaWRlYmFyIGludGVyZmFjZSB2YWwgZnVuIG9iamxlbWVudCB0b2MtZWxl" + + "bWVudCBrb3RsaW4gb3ZlcnJpZGUgdG9jLWVsZW1lbnQgb3ZlciBrb3RsaW4ga290bGluIHZhciBkb2Nz" + + "LXNpZGViYXIgdmFsIGNvbXBhbmlvbiBjb21wZSBmdW4gcGFnZS5wZWIga290bGluIGZ1biB2YXIgb2Jq" + + "ZWN0IHRlbXBsYXRlIGRvY24gdmFyIHRlbXBsYXRlIGludGVyZmFjZSBjb21wYW5pb24gcGFnZS5wZWIg" + + "dmFyIGlua290bGluIENvbnRlbnQtVHlwZSBkb2NzLXNpZGViYXIgbmF2IGludGVyZmFjZSBjb20gdG9j" + + "LWVsZW1lbnQgY29tcGFuaW9uIG9iamVjdCBpbnRlcmZhY2Uga290bGluIGRvY2RlYmFyIG5hdiB0b2Mt" + + "ZWxlbWVudCBDb250ZW50LVR5cGUgdGVtcGxhdGUgdmFyIGNsbiBzaWRlYmFyIHNpZGViYXIgdG9jLWVs" + + "ZW1lbnQgb2JqZWN0IGNvbXBhbmlvbiBpbnRycmlkZSB0b2MtZWxlbWVudCBmdW4gY2xhc3MgdGVtcGxh" + + "dGUgaW50ZXJmYWNlIGRvYyB0b2MtZWxlbWVudCBmdW4gdG9jLWVsZW1lbnQgdmFsIG9iamVjdCBvYmpl" + + "Y3QgdG9jYmplY3QgbmF2IGZ1biBzaWRlYmFyIG92ZXJyaWRlIG9iamVjdCBmdW4gdmFsIG92ZXJhdGUg" + + "aW50ZXJmYWNlIHZhciB0ZW1wbGF0ZSB0ZW1wbGF0ZSB2YXIgb2JqZWN0IGtvdGUga290bGluIG92ZXJy" + + "aWRlIHBhZ2UucGViIG92ZXJyaWRlIGZ1biBjbGFzcyB2YXIgaW50ZXJmYWNlIGNsYXNzIHRlbXBsYXRl" + + "IHNpZGViYXIgZnVuIHBhZ2UucGViIGRvY3NlIENvbnRlbnQtVHlwZSBpbnRlcmZhY2UgdGVtcGxhdGUg" + + "aW50ZXJmYWNlIHZhciB0ZWVudC1UeXBlIENvbnRlbnQtVHlwZSBvYmplY3QgcGFnZS5wZWIgdGVtcGxh" + + "dGUgb3ZlZW50LVR5cGUgb3ZlcnJpZGUgQ29udGVudC1UeXBlIHBhZ2UucGViIGNsYXNzIHNpZGVyIHRv" + + "Yy1lbGVtZW50IHZhciBzaWRlYmFyIG5hdiBmdW4gY2xhc3Mga290bGluIHBhZyBvdmVycmlkZSBpbnRl" + + "cmZhY2UgbmF2IHZhciBvdmVycmlkZSBjb21wYW5pb24gcGFnY2xhc3MgdmFsIGNsYXNzIENvbnRlbnQt" + + "VHlwZSBkb2NzLXNpZGViYXIgbmF2IGNvbXAgZnVuIHRlbXBsYXRlIHBhZ2UucGViIGNsYXNzIG5hdiBw" + + "YWdlLnBlYiBuYXYgQ29udCBjb21wYW5pb24gb3ZlcnJpZGUgdGVtcGxhdGUga290bGluIHNpZGViYXIg" + + "dmFyIHBhdmFsIG5hdiBjbGFzcyBmdW4gb3ZlcnJpZGUgaW50ZXJmYWNlIGludGVyZmFjZSBrb3RudGVu" + + "dC1UeXBlIENvbnRlbnQtVHlwZSBjbGFzcyBvYmplY3QgcGFnZS5wZWIgQ29udGJhciBzaWRlYmFyIHBh" + + "Z2UucGViIHZhbCBDb250ZW50LVR5cGUgdGVtcGxhdGUgdmFsbCBjb21wYW5pb24gZnVuIGRvY3Mtc2lk" + + "ZWJhciBjbGFzcyB0b2MtZWxlbWVudCBDb25kZWJhciB2YWwgZG9jcy1zaWRlYmFyIHZhciBDb250ZW50" + + "LVR5cGUgY2xhc3MgcGFnZXVuIHNpZGViYXIgQ29udGVudC1UeXBlIHZhbCBvYmplY3QgdGVtcGxhdGUg" + + "bmF2IG92ZmFjZSBDb250ZW50LVR5cGUgcGFnZS5wZWIga290bGluIGZ1biBvdmVycmlkZSB2YXJuaW9u" + + "IENvbnRlbnQtVHlwZSBrb3RsaW4gbmF2IHRvYy1lbGVtZW50IG9iamVjdCBvYmF2IG92ZXJyaWRlIHRv" + + "Yy1lbGVtZW50IHZhbCB2YWwgbmF2IG5hdiBvYmplY3QgcGFnbGluIGZ1biB2YWwgY2xhc3MgaW50ZXJm" + + "YWNlIHRvYy1lbGVtZW50IHNpZGViYXIgY29hdGUgc2lkZWJhciB2YXIgQ29udGVudC1UeXBlIGNvbXBh" + + "bmlvbiB2YXIgZnVuIHNpZCBrb3RsaW4gZnVuIENvbnRlbnQtVHlwZSBpbnRlcmZhY2UgdG9jLWVsZW1l" + + "bnQgZnVuYWdlLnBlYiB0ZW1wbGF0ZSBjb21wYW5pb24gdmFyIG92ZXJyaWRlIGtvdGxpbiBuYXZpbnRl" + + "cmZhY2UgZnVuIGludGVyZmFjZSBvYmplY3QgdGVtcGxhdGUgY2xhc3MgZG9jc2xpbiB0ZW1wbGF0ZSB0" + + "b2MtZWxlbWVudCB0b2MtZWxlbWVudCBuYXYga290bGluIGRvbmlvbiB0ZW1wbGF0ZSBvYmplY3QgY2xh" + + "c3Mgb2JqZWN0IENvbnRlbnQtVHlwZSBmdW5lY3QgY2xhc3MgY2xhc3MgdG9jLWVsZW1lbnQgY2xhc3Mg" + + "bmF2IHRlbXBsYXRlIENvbiBuYXYgdGVtcGxhdGUgZnVuIG5hdiBzaWRlYmFyIG92ZXJyaWRlIHZhbCBm" + + "dW4gdmFsZW50IGNsYXNzIHZhbCB2YXIgb2JqZWN0IGNsYXNzIGZ1biBrb3RsaW4gdmFsIGludGVvbXBh" + + "bmlvbiBjbGFzcyBrb3RsaW4gZnVuIGRvY3Mtc2lkZWJhciBrb3RsaW4gQ29udG4gZG9jcy1zaWRlYmFy" + + "IHRvYy1lbGVtZW50IG9iamVjdCB2YWwgbmF2IG5hdiBzaWRlciBDb250ZW50LVR5cGUgbmF2IHBhZ2Uu" + + "cGViIG5hdiBjbGFzcyBvdmVycmlkZSBzaWRpZGViYXIgb2JqZWN0IHNpZGViYXIgdmFsIG5hdiBpbnRl" + + "cmZhY2Ugb2JqZWN0IGRvYyBpbnRlcmZhY2Ugb3ZlcnJpZGUgcGFnZS5wZWIgb3ZlcnJpZGUgb3ZlcnJp" + + "ZGUgY2xhb2NzLXNpZGViYXIgY2xhc3MgY29tcGFuaW9uIGtvdGxpbiB0b2MtZWxlbWVudCBpbnQucGVi" + + "IHRvYy1lbGVtZW50IGNvbXBhbmlvbiBzaWRlYmFyIGRvY3Mtc2lkZWJhciBuYW1lbnQgcGFnZS5wZWIg" + + "dmFsIGtvdGxpbiBvYmplY3QgdmFyIHZhciBvYmplY3QgdGVtYWwgcGFnZS5wZWIgdmFyIHRvYy1lbGVt" + + "ZW50IHRlbXBsYXRlIHBhZ2UucGViIHNpZGVuYXYgcGFnZS5wZWIgdmFyIGtvdGxpbiBpbnRlcmZhY2Ug" + + "c2lkZWJhciB2YXIgY29tcGUga290bGluIGNsYXNzIHZhbCBzaWRlYmFyIHBhZ2UucGViIGludGVyZmFj" + + "ZSBwYWdlZ2UucGViIGNvbXBhbmlvbiBuYXYgb2JqZWN0IGNsYXNzIENvbnRlbnQtVHlwZSB0b2NiYXIg" + + "b3ZlcnJpZGUgdGVtcGxhdGUgdmFyIHNpZGViYXIga290bGluIGZ1biB2YXIgQ25pb24gdmFsIHBhZ2Uu" + + "cGViIGZ1biB0ZW1wbGF0ZSB0b2MtZWxlbWVudCB2YWwgY29tbnRlcmZhY2UgdmFsIGNsYXNzIGNvbXBh" + + "bmlvbiBzaWRlYmFyIHRlbXBsYXRlIGludGV2YWwgdGVtcGxhdGUgdGVtcGxhdGUgb2JqZWN0IG5hdiBk" + + "b2NzLXNpZGViYXIgc2lkZWUgY29tcGFuaW9uIG9iamVjdCBvdmVycmlkZSBmdW4gZnVuIGNvbXBhbmlv" + + "biB0b2MtVHlwZSBvdmVycmlkZSBuYXYgdmFsIHRvYy1lbGVtZW50IGtvdGxpbiB2YXIgbmF2IHBudC1U" + + "eXBlIHZhciBkb2NzLXNpZGViYXIgQ29udGVudC1UeXBlIHNpZGViYXIgcGFnZWViYXIgdmFsIHBhZ2Uu" + + "cGViIG9iamVjdCBmdW4gcGFnZS5wZWIgcGFnZS5wZWIgZG9jbiBvdmVycmlkZSBkb2NzLXNpZGViYXIg" + + "b2JqZWN0IGludGVyZmFjZSBjbGFzcyBrb3RhciB0ZW1wbGF0ZSB2YXIga290bGluIGNvbXBhbmlvbiBk" + + "b2NzLXNpZGViYXIgZnVuICB0b2MtZWxlbWVudCBkb2NzLXNpZGViYXIgaW50ZXJmYWNlIENvbnRlbnQt" + + "VHlwZSBj" + + private val compressedBase64 = + "H6AEIBypU5+7WdgVm1yEUcQuEA0twSdtb3qRIOfy83EJ6BCu9aGiz72LjySb9TQmV4wATYW9JhfwdjwI" + + "woRvurJjIaNH/hC6U59+QaiVFTX9XajztuGO9hS2C2GJEnZn+6vh0spFMR6RDFwzXTjCHWzxThsHAcW2" + + "9ev+Wau/71qnhgYFy8JNHS3F87DOOc02MhMXA9ZP9Ti9LOWqrKld7hlsgT8bDn888jGY1CPGtwU=" + + private val expectedBase64 = + "dmFsIG92ZXJyaWRlIGZ1biB2YXIgaW50ZXJmYWNlIHNpZGViYXIgaW50ZXJmYWNlIHNpZGViYXIgb2Jq" + + "ZWN0IGNsYXNzIGZ1biBDb250ZW50LVR5cGUgcGFnZS5wZWIgZnVuIHNpZGViYXIgaW50ZXJmYWNlIG92" + + "ZXJyaWRlIHNpZGViYXIgb3ZlcnJpZGUgZG9jcy1zaWRlYmFyIGtvdGxpbiBDb250ZW50LVR5cGUgdG9j" + + "LWVsZW1lbnQgb2JqZWN0IG92ZXJyaWRlIGNvbXBhbmlvbiBrb3RsaW4gZG9jcy1zaWRlYmFyIGtvdGxp" + + "biB2YWwgdG9jLWVsZW1lbnQgbmF2IGNvbXBhbmlvbiB2YXIgQ29udGVudC1UeXBlIG92ZXJyaWRlIGNs" + + "YXNzIGtvdGxpbiBuYXYgcGFnZS5wZWIgc2lkZWJhciBDb250ZW50LVR5cGUgb3ZlcnJpZGUgaW50ZXJm" + + "YWNlIHRvYy1lbGVtZW50IGludGVyZmFjZSBzaWRlYmFyIHNpZGViYXIgaW50ZXJmYWNlIG92ZXJyaWRl" + + "IHNpZGViYXIgc2lkZWJhciBmdW4gZG9jcy1zaWRlYmFyIHZhciB2YWwgY2xhc3MgZnVuIHBhZ2UucGVi" + + "IENvbnRlbnQtVHlwZSB2YWwgc2lkZWJhciB2YXIgaW50ZXJmYWNlIGNsYXNzIHRlbXBsYXRlIGludGVy" + + "ZmFjZSBmdW4gdG9jLWVsZW1lbnQgY2xhc3MgdmFsIHRlbXBsYXRlIHNpZGViYXIgY2xhc3MgbmF2IHNp" + + "ZGViYXIgdmFyIG9iamVjdCB2YXIgZG9jcy1zaWRlYmFyIHZhciBpbnRlcmZhY2UgdmFyIHRvYy1lbGVt" + + "ZW50IHRlbXBsYXRlIG9iamVjdCBjb21wYW5pb24ga290bGluIGNvbXBhbmlvbiBvdmVycmlkZSBpbnRl" + + "cmZhY2UgdmFsIG9iamVjdCB0ZW1wbGF0ZSBkb2NzLXNpZGViYXIgZG9jcy1zaWRlYmFyIGludGVyZmFj" + + "ZSBzaWRlYmFyIGRvY3Mtc2lkZWJhciBrb3RsaW4gdmFsIGZ1biBpbnRlcmZhY2UgdGVtcGxhdGUgaW50" + + "ZXJmYWNlIGludGVyZmFjZSBvdmVycmlkZSBkb2NzLXNpZGViYXIgc2lkZWJhciB2YWwgdmFsIG9iamVj" + + "dCBvYmplY3QgdGVtcGxhdGUgdmFsIGtvdGxpbiBuYXYgdGVtcGxhdGUgdGVtcGxhdGUgZnVuIHRvYy1l" + + "bGVtZW50IG92ZXJyaWRlIHRlbXBsYXRlIGludGVyZmFjZSB2YWwgb3ZlcnJpZGUgdmFyIHBhZ2UucGVi" + + "IHZhciBrb3RsaW4gdGVtcGxhdGUgdmFyIHRlbXBsYXRlIG5hdiBuYXYgdGVtcGxhdGUgQ29udGVudC1U" + + "eXBlIGtvdGxpbiB2YWwgaW50ZXJmYWNlIGRvY3Mtc2lkZWJhciBwYWdlLnBlYiBvYmplY3Qgb2JqZWN0" + + "IGZ1biBrb3RsaW4gc2lkZWJhciB2YXIgdGVtcGxhdGUgZG9jcy1zaWRlYmFy" + + @Test + fun `decodes CLI dictionary-compressed content correctly`() { + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val compressed = Base64.getDecoder().decode(compressedBase64) + val expected = Base64.getDecoder().decode(expectedBase64) + + val result = + BrotliInputStream(ByteArrayInputStream(compressed)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + + assertArrayEquals(expected, result) + } + + @Test + fun `the same dictionary buffer instance is safe to reuse across multiple decodes`() { + // WebServer holds one long-lived dictionary buffer across many requests -- + // this guards against a brotli4j change that mutates buffer position/limit + // state in a way that would break the second decode. + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val compressed = Base64.getDecoder().decode(compressedBase64) + val expected = Base64.getDecoder().decode(expectedBase64) + + repeat(3) { + val result = + BrotliInputStream(ByteArrayInputStream(compressed)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + assertArrayEquals(expected, result) + } + } + + @Test + fun `decoding dictionary-compressed content without attaching a dictionary fails`() { + // Unlike a *wrong* dictionary (whose backward distances resolve into real, + // just incorrect, bytes -- silently wrong output, no error), decoding with + // no dictionary at all leaves distances that reach into the dictionary + // region out of bounds for any spec-compliant decoder, which must reject + // the stream as corrupt. Verified empirically: brotli4j throws IOException + // here, not an arbitrary Exception subtype. + val compressed = Base64.getDecoder().decode(compressedBase64) + + assertThrows(IOException::class.java) { + BrotliInputStream(ByteArrayInputStream(compressed)).use { it.readBytes() } + } + } + + @Test + fun `dictionary-free plugin content fails with a dictionary attached but decodes plain`() { + // Regression coverage for the WebServer.decompressBrotli fallback: plugin-contributed + // Tier 3 docs (PluginDocumentationManager/BrotliCompressor) are compressed with the same + // encoder params (quality 11, window 24) but no dictionary, coexisting in the same Content + // table as ADFA-5153-migrated, dictionary-compressed rows. + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val plaintext = "plugin-contributed Tier 3 content, compressed with no dictionary" + val expected = plaintext.toByteArray(StandardCharsets.UTF_8) + val compressed = + ByteArrayOutputStream() + .apply { + BrotliOutputStream(this, Encoder.Parameters().setQuality(11).setWindow(24)).use { it.write(expected) } + }.toByteArray() + + assertThrows(IOException::class.java) { + BrotliInputStream(ByteArrayInputStream(compressed)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + } + + val plainResult = BrotliInputStream(ByteArrayInputStream(compressed)).use { it.readBytes() } + assertArrayEquals(expected, plainResult) + } + + @Test + fun `content split across chunks decodes the same as one contiguous array`() { + // Rows over 1 MB are stored as several Content rows and were previously concatenated + // before decoding; they are now fed to the decoder as a stream over the chunk list, so + // a compressed stream must decode identically no matter where the chunk boundaries fall. + val dictionary = decodeBase64ToDirectBuffer(dictionaryBase64) + val compressed = Base64.getDecoder().decode(compressedBase64) + val expected = Base64.getDecoder().decode(expectedBase64) + + // Deliberately uneven, and not aligned to anything in the brotli stream. + val chunks = + listOf( + compressed.copyOfRange(0, 7), + compressed.copyOfRange(7, 8), + compressed.copyOfRange(8, compressed.size - 1), + compressed.copyOfRange(compressed.size - 1, compressed.size), + ) + + val result = + BrotliInputStream(chunksAsStream(chunks)).use { stream -> + stream.attachDictionary(dictionary) + stream.readBytes() + } + + assertArrayEquals(expected, result) + } + + @Test + fun `joinChunks concatenates in order and sizes the result exactly`() { + val chunks = listOf(byteArrayOf(1, 2, 3), byteArrayOf(), byteArrayOf(4), byteArrayOf(5, 6)) + + val joined = joinChunks(chunks) + + assertArrayEquals(byteArrayOf(1, 2, 3, 4, 5, 6), joined) + assertEquals(6, joined.size) + } + + @Test + fun `joinChunks hands back a lone chunk without copying it`() { + val only = byteArrayOf(7, 8, 9) + + assertSame(only, joinChunks(listOf(only))) + } +} diff --git a/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt index ef1e18de8f..e68b2e05e4 100644 --- a/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt +++ b/app/src/test/java/com/itsaky/androidide/localWebServer/WebServerTest.kt @@ -1,16 +1,20 @@ package com.itsaky.androidide.localWebServer +import android.database.Cursor import android.database.sqlite.SQLiteDatabase import android.net.TrafficStats +import com.itsaky.androidide.utils.DatabaseVersionResolver import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic import io.mockk.unmockkAll +import io.mockk.verify import org.junit.After import org.junit.Assert.assertFalse import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Test +import java.io.File import java.net.InetSocketAddress import java.net.ServerSocket import java.net.Socket @@ -54,6 +58,29 @@ class WebServerTest { projectDatabasePath = "/nonexistent/recent-projects.db", ) + // ADFA-5153/ADFA-5220: the dictionary is gated on the MAJOR version the database declares, so + // every test that expects the dictionary to load has to declare one. A relaxed mock answers the + // existence probe with moveToFirst() = false, i.e. "no version table", which would silently turn + // the dictionary tests below into no-ops rather than failing them. + private fun stubDeclaredMajorVersion( + db: SQLiteDatabase, + major: Int?, + ) { + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("DocumentationDatabaseVersion") }, any()) + } returns mockk(relaxed = true) { every { moveToFirst() } returns (major != null) } + if (major != null) { + every { + db.rawQuery(match { it.contains("FROM DocumentationDatabaseVersion") }, any()) + } returns + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { isNull(0) } returns false + every { getInt(0) } returns major + } + } + } + private fun freePort(): Int = ServerSocket(0).use { it.localPort } private fun assertPortIsFree(port: Int) { @@ -102,6 +129,272 @@ class WebServerTest { assertPortIsFree(port) } + // ADFA-5153: the compression dictionary is loaded lazily -- not merely from starting the + // server -- but only once per database, cached across every subsequent request against that + // same database rather than re-fetched per-request. + @Test + fun `compression dictionary loads lazily on first use, once per database, not once per request`() { + val port = freePort() + + val dictionaryExistsCursor = + mockk(relaxed = true) { + every { moveToFirst() } returns true + } + val dictionaryDataCursor = + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { getBlob(0) } returns "test-dictionary-bytes".toByteArray() + } + val contentCursor = + mockk(relaxed = true) { + every { count } returns 1 + every { moveToFirst() } returns true + every { getBlob(0) } returns "hello".toByteArray() + every { getString(1) } returns "text/plain" + every { getString(2) } returns "none" + every { getInt(3) } returns 0 + } + + val db = mockk(relaxed = true) + every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db + stubDeclaredMajorVersion(db, DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY) + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } returns dictionaryExistsCursor + every { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } returns dictionaryDataCursor + every { + db.rawQuery(match { it.contains("FROM Content") }, any()) + } returns contentCursor + + val server = WebServer(testConfig(port)) + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + + // Nothing fetches the dictionary merely from starting the server -- only a content + // fetch does, so before any request there should be no dictionary query at all yet -- + // neither the sqlite_master existence check nor the data fetch. + verify(exactly = 0) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 0) { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + + repeat(3) { sendRawGetRequestAndAwaitClose(port, "/some/path") } + + // Exactly one dictionary load across all 3 requests against the same, unchanged + // database -- the first request's lazy load, cached for the other two. Both queries + // loadCompressionDictionary issues (the sqlite_master existence check, then the data + // fetch) must be checked, or a regression re-running just the existence check on + // every request would pass unnoticed. + verify(exactly = 1) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + } finally { + server.stop() + serverThread.join(2_000) + } + } + + // ADFA-5153: a database swap (the debug-DB override) must invalidate the cached dictionary -- + // the new database can have a different one, or none -- causing exactly one fresh reload on + // the first content fetch against the new database, not a reload on every later request too. + @Test + fun `database swap invalidates the cached dictionary, reloading it once for the new database`() { + val port = freePort() + val debugDbFile = File.createTempFile("webserver-test-debug", ".db") + debugDbFile.delete() // must not exist yet -- the first request should stay on the primary db + + fun contentCursorFor(marker: String) = + mockk(relaxed = true) { + every { count } returns 1 + every { moveToFirst() } returns true + every { getBlob(0) } returns marker.toByteArray() + every { getString(1) } returns "text/plain" + every { getString(2) } returns "none" + every { getInt(3) } returns 0 + } + + fun stubDatabase( + db: SQLiteDatabase, + dictionaryBytes: String, + ) { + stubDeclaredMajorVersion(db, DatabaseVersionResolver.MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY) + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } returns mockk(relaxed = true) { every { moveToFirst() } returns true } + every { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } returns + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { getBlob(0) } returns dictionaryBytes.toByteArray() + } + every { + db.rawQuery(match { it.contains("FROM Content") }, any()) + } returns contentCursorFor(dictionaryBytes) + } + + val primaryDb = mockk(relaxed = true) + val debugDb = mockk(relaxed = true) + stubDatabase(primaryDb, "dict-primary") + stubDatabase(debugDb, "dict-debug") + + val config = testConfig(port).copy(debugDatabasePath = debugDbFile.absolutePath) + every { SQLiteDatabase.openDatabase(config.databasePath, isNull(), any()) } returns primaryDb + every { SQLiteDatabase.openDatabase(config.debugDatabasePath, isNull(), any()) } returns debugDb + + val server = WebServer(config) + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + + sendRawGetRequestAndAwaitClose(port, "/some/path") + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + verify(exactly = 0) { + debugDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 0) { + debugDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + + // Now make the debug override newer than the primary database -- the swap check in + // handleClient() picks this up on the very next request. + debugDbFile.createNewFile() + debugDbFile.setLastModified(System.currentTimeMillis() + 60_000) + + repeat(2) { sendRawGetRequestAndAwaitClose(port, "/some/path") } + + // Exactly one reload for the new (debug) database, across both post-swap requests -- + // not zero (it must invalidate), not two (it must still cache after the first reload). + // Both queries loadCompressionDictionary issues must be checked (see the sibling test). + verify(exactly = 1) { + debugDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + debugDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + // The primary database's dictionary is never touched again after the swap. + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = 1) { + primaryDb.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + } finally { + server.stop() + serverThread.join(2_000) + debugDbFile.delete() + } + } + + // ADFA-5153/ADFA-5220: below MAJOR 2 the dictionary is neither read nor attached, and the + // CompressionDictionary probe does not even run -- table sniffing is precisely what the version + // gate replaces, since a database can carry the table while its content is still plain brotli. + @Test + fun `a database declaring a version below 2 is never asked for a dictionary`() { + assertDictionaryLoads(declaredMajor = 1, expected = 0) + } + + @Test + fun `a database with no version table is never asked for a dictionary`() { + assertDictionaryLoads(declaredMajor = null, expected = 0) + } + + // A later format is still expected to carry the dictionary, so the gate is a floor, not a match. + @Test + fun `a database declaring a version above 2 still loads the dictionary`() { + assertDictionaryLoads(declaredMajor = 3, expected = 1) + } + + // The CompressionDictionary cursors are stubbed as *available* in every case, including the + // ones expecting zero queries: that is what makes this a test of the gate rather than of a + // missing table -- the queries are not skipped for want of an answer. + private fun assertDictionaryLoads( + declaredMajor: Int?, + expected: Int, + ) { + val port = freePort() + val db = mockk(relaxed = true) + every { SQLiteDatabase.openDatabase(any(), isNull(), any()) } returns db + stubDeclaredMajorVersion(db, declaredMajor) + every { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } returns mockk(relaxed = true) { every { moveToFirst() } returns true } + every { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } returns + mockk(relaxed = true) { + every { moveToFirst() } returns true + every { getBlob(0) } returns "test-dictionary-bytes".toByteArray() + } + every { + db.rawQuery(match { it.contains("FROM Content") }, any()) + } returns + mockk(relaxed = true) { + every { count } returns 1 + every { moveToFirst() } returns true + every { getBlob(0) } returns "hello".toByteArray() + every { getString(1) } returns "text/plain" + every { getString(2) } returns "none" + every { getInt(3) } returns 0 + } + + val server = WebServer(testConfig(port)) + val serverThread = Thread { server.start() }.apply { isDaemon = true } + serverThread.start() + try { + awaitPortBound(port) + sendRawGetRequestAndAwaitClose(port, "/some/path") + + verify(exactly = expected) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("CompressionDictionary") }, null) + } + verify(exactly = expected) { + db.rawQuery(match { it.contains("SELECT data FROM CompressionDictionary") }, null) + } + // The version itself is read once per database either way -- the gate is consulted, and + // its answer cached, exactly like the dictionary it guards. + verify(exactly = 1) { + db.rawQuery(match { it.contains("FROM sqlite_master") && it.contains("DocumentationDatabaseVersion") }, any()) + } + } finally { + server.stop() + serverThread.join(2_000) + } + } + + // Blocks until the server closes the connection (every response sends "Connection: close"), + // so by the time this returns the server has fully finished processing this one request -- + // making repeated calls a reliable way to serialize several full request/response cycles. + private fun sendRawGetRequestAndAwaitClose( + port: Int, + path: String, + ) { + Socket().use { socket -> + socket.connect(InetSocketAddress("localhost", port), 2_000) + socket.soTimeout = 2_000 + socket.getOutputStream().apply { + write("GET $path HTTP/1.1\r\n\r\n".toByteArray(Charsets.ISO_8859_1)) + flush() + } + socket.getInputStream().readBytes() + } + } + // Polls by attempting an actual TCP connect rather than sleeping a fixed // duration: as soon as WebServer's accept() loop is listening, the connect // succeeds, which is the readiness signal. (A bind-then-unbind probe was diff --git a/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt b/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt index 1ba7c2eab1..1761c0e4e5 100644 --- a/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt +++ b/common/src/androidTest/java/com/itsaky/androidide/utils/DatabaseVersionResolverTest.kt @@ -4,13 +4,13 @@ import android.database.sqlite.SQLiteDatabase import androidx.test.ext.junit.runners.AndroidJUnit4 import org.junit.After import org.junit.Assert.assertEquals +import org.junit.Assert.assertNull import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @RunWith(AndroidJUnit4::class) class DatabaseVersionResolverTest { - private lateinit var db: SQLiteDatabase @Before @@ -28,17 +28,82 @@ class DatabaseVersionResolverTest { "CREATE TABLE LastChange (" + "documentationSet TEXT, " + "changeTime TEXT, " + - "who TEXT)" + "who TEXT)", ) } - private fun insertRow(documentationSet: String, changeTime: String, who: String?) { + private fun insertRow( + documentationSet: String, + changeTime: String, + who: String?, + ) { db.execSQL( "INSERT INTO LastChange (documentationSet, changeTime, who) VALUES (?, ?, ?)", arrayOf(documentationSet, changeTime, who), ) } + private fun createVersionTable() { + db.execSQL( + "CREATE TABLE DocumentationDatabaseVersion (" + + "major INT NOT NULL, " + + "minor INT NOT NULL, " + + "patch INT NOT NULL, " + + "who TEXT NOT NULL, " + + "comment TEXT NOT NULL, " + + "changeTime TIMESTAMP DEFAULT CURRENT_TIMESTAMP)", + ) + } + + private fun insertVersion( + major: Int, + minor: Int, + patch: Int, + ) { + db.execSQL( + "INSERT INTO DocumentationDatabaseVersion (major, minor, patch, who, comment) VALUES (?, ?, ?, 'test', 'test')", + arrayOf(major, minor, patch), + ) + } + + // ADFA-5220: a database built before the version table existed has to read as unversioned, not + // as an error -- that is how WebServer decides not to look for a compression dictionary. + @Test + fun majorVersionIsNull_whenVersionTableMissing() { + assertNull(DatabaseVersionResolver.resolveMajorVersion(db)) + } + + @Test + fun majorVersionIsNull_whenVersionTableEmpty() { + createVersionTable() + assertNull(DatabaseVersionResolver.resolveMajorVersion(db)) + } + + @Test + fun majorVersionIsRead_whenDeclared() { + createVersionTable() + insertVersion(2, 0, 0) + assertEquals(2, DatabaseVersionResolver.resolveMajorVersion(db)) + } + + // The table is an append-only log, so the row inserted last is the current version... + @Test + fun majorVersionIsTheLastRowInserted() { + createVersionTable() + insertVersion(2, 0, 0) + insertVersion(3, 1, 4) + assertEquals(3, DatabaseVersionResolver.resolveMajorVersion(db)) + } + + // ...including when that row is a downgrade, which MAX(major) would read as still current. + @Test + fun majorVersionFollowsADowngrade() { + createVersionTable() + insertVersion(3, 0, 0) + insertVersion(2, 0, 0) + assertEquals(2, DatabaseVersionResolver.resolveMajorVersion(db)) + } + @Test fun returnsWholedbRow_whenPresent() { createTable() diff --git a/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt b/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt index 711905eadd..225ffd6a39 100644 --- a/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt +++ b/common/src/main/java/com/itsaky/androidide/utils/DatabaseVersionResolver.kt @@ -4,7 +4,6 @@ import android.database.sqlite.SQLiteDatabase import android.util.Log object DatabaseVersionResolver { - const val VERSION_UNKNOWN = "Version Unknown" private const val TAG = "DatabaseVersionResolver" @@ -16,6 +15,28 @@ object DatabaseVersionResolver { LIMIT 1 """ + // ADFA-5220's DocumentationDatabaseVersion table. A database declaring at least this MAJOR + // version has its brotli `Content` rows compressed against `CompressionDictionary` (ADFA-5153); + // one declaring less -- or carrying no version table at all -- predates that migration, and its + // rows are plain Brotli. + const val MAJOR_VERSION_WITH_COMPRESSION_DICTIONARY = 2 + + private const val QUERY_VERSION_TABLE_EXISTS = """ + SELECT 1 + FROM sqlite_master + WHERE type = 'table' AND name = 'DocumentationDatabaseVersion' + """ + + // The table is an append-only log -- ADFA-5220 records each change as another INSERT -- so the + // current version is the row inserted last, not the highest one ever recorded: rebuilding from + // an older content set is a downgrade and has to read as one. + private const val QUERY_MAJOR_VERSION = """ + SELECT major + FROM DocumentationDatabaseVersion + ORDER BY rowid DESC + LIMIT 1 + """ + private const val QUERY_FALLBACK_LATEST = """ SELECT changeTime, documentationSet, who FROM LastChange @@ -36,11 +57,12 @@ object DatabaseVersionResolver { db.rawQuery(QUERY_FALLBACK_LATEST, arrayOf()).use { c -> if (c.moveToFirst()) { - val result = formatVersion( - changeTime = c.getString(0), - who = c.getString(2), - documentationSet = c.getString(1), - ) + val result = + formatVersion( + changeTime = c.getString(0), + who = c.getString(2), + documentationSet = c.getString(1), + ) Log.e( TAG, "Missing 'wholedb' record in LastChange table; falling back to $result", @@ -57,6 +79,26 @@ object DatabaseVersionResolver { } } + /** + * The MAJOR version [db] declares in `DocumentationDatabaseVersion` (ADFA-5220), or null when + * that table is absent or empty -- which is how every database built before it existed + * identifies itself. + * + * Deliberately does *not* catch exceptions, unlike [resolveDatabaseVersion]: callers cache the + * answer for the lifetime of a database (see `WebServer.loadCompressionDictionary`), so a + * transient `SQLiteException` has to stay distinguishable from a definitive "no version table", + * or one hiccup would pin the database at unversioned until it is swapped. + */ + fun resolveMajorVersion(db: SQLiteDatabase): Int? { + val tableExists = db.rawQuery(QUERY_VERSION_TABLE_EXISTS, arrayOf()).use { it.moveToFirst() } + if (!tableExists) { + return null + } + return db.rawQuery(QUERY_MAJOR_VERSION, arrayOf()).use { cursor -> + if (cursor.moveToFirst() && !cursor.isNull(0)) cursor.getInt(0) else null + } + } + private fun formatVersion( changeTime: String?, who: String?, diff --git a/docs/documentation-database.md b/docs/documentation-database.md index 566703ad1b..3055c955f0 100644 --- a/docs/documentation-database.md +++ b/docs/documentation-database.md @@ -8,7 +8,7 @@ This is a **read-only, prebuilt** database — CoGo never creates or migrates it - Installed path: `context.getDatabasePath("documentation.db")` (`Environment.DOC_DB` in `common/.../utils/Environment.java`), i.e. the app's private `databases/` dir. - Bundled as an asset and extracted on install/update by `BundledAssetsInstaller` / `SplitAssetsInstaller`. -- **Debug override:** if `/sdcard/Download/documentation.db` exists and is newer than the installed copy, `WebServer` and `ToolTipManager` swap to it at request time (timestamp-compared per request, not just at startup) — a fast way to test a new database on-device without reinstalling. `WebServer`'s debug logging and experiment flags are also file-flag-gated under `/sdcard/Download/` (`CodeOnTheGo.webserver.debug`, `CodeOnTheGo.exp`, `CodeOnTheGo.webserver.cs0`). +- **Debug override:** if `/sdcard/Download/documentation.db` exists and is newer than the installed copy, `WebServer` and `ToolTipManager` swap to it at request time (timestamp-compared per request, not just at startup) — a fast way to test a new database on-device without reinstalling. **The comparison is on modification time, and `adb push` preserves the *source* file's mtime** -- so pushing a database saved earlier than the one already on the device silently does not swap, and the app keeps serving the old one with no error anywhere. Follow a push with `adb shell touch /sdcard/Download/documentation.db` (this cost real debugging time on ADFA-5153). `WebServer`'s debug logging and experiment flags are also file-flag-gated under `/sdcard/Download/` (`CodeOnTheGo.webserver.debug`, `CodeOnTheGo.exp`, `CodeOnTheGo.webserver.cs0`). - **Don't trust a local copy's on-disk schema or row content as ground truth without checking freshness first.** Any manually downloaded or debug-override copy is independent of git history — a stale one can have a different schema (e.g. missing `UNIQUE(path)` or `templateId`) or be missing rows that already exist in the current, maintained database. A stale copy caused a real near-miss in ADFA-5088: a SQL script validated against it would have silently overwritten curated production tooltip content for several tags. Diff or re-download before authoring SQL against a local copy's state, not just before shipping it. ## Schema @@ -34,7 +34,7 @@ CREATE TABLE Content ( One row per file the web server can serve (HTML, CSS, JS, image, video, PDF, ...) — 30,000+ rows. Key points: - **`path`** is the lookup key (indexed via the `UNIQUE` constraint) and is what `WebServer` matches the HTTP request path against. Paths carry a short source prefix to avoid collisions between doc sets, e.g. `k/index.html` (Kotlin) vs `j/index.html` (Java). -- **`content`** is compressed — Brotli for text-like formats, format-specific compression otherwise (images/video/fonts). `ContentTypes.compression` says which. Content over 1 MB is split across multiple rows: the first row's path is the base path, continuation rows are `path-1`, `path-2`, ... (`languageId = 1`), reassembled by `WebServer` before returning. +- **`content`** is compressed — Brotli for text-like formats, format-specific compression otherwise (images/video/fonts). `ContentTypes.compression` says which. Every migrated `Content` row with `ContentTypes.compression = 'brotli'` is Brotli-compressed against the single shared dictionary in `CompressionDictionary` (see below), converted in one pass by ADFA-5153 — but plugin-contributed Tier 3 rows (`PluginDocumentationManager`/`BrotliCompressor`, see below) are plain, dictionary-free Brotli, and there is no per-row flag distinguishing the two, because a dictionary-compressed stream and a plain one are not distinguishable at decode time by inspection. They *are* distinguishable by attempting the decode: attaching the *wrong* dictionary decodes without error to different bytes than were compressed (its backward distances resolve into real, just incorrect, bytes) — but attaching *no* dictionary to a stream that needs one reliably throws (`IOException`, "corrupted input"), since distances into the dictionary region are then out of bounds for any spec-compliant decoder. `WebServer` relies on exactly this: it tries the dictionary first and falls back to a plain decode on `IOException`, which correctly handles both dictionary-compressed and plain rows — but never rely on decode success/failure to detect a *wrong* dictionary, since that case is silent. Content over 1 MB is split across multiple rows: the first row's path is the base path, continuation rows are `path-1`, `path-2`, ... (`languageId = 1`), reassembled by `WebServer` before returning. - **`templateId`**: `0` (or unset) means `content` is legacy HTML with presentation baked in (the pre-CMS Release 0/1 format). A positive value means `content` is JSON *facts only*, rendered through the matching row in `Templates` (a Pebble template) — the ongoing move to a proper CMS that de-duplicates presentation across near-identical pages (e.g. `sin`/`cos` docs). - The `UNIQUE(path)` constraint rejects any duplicate `path`, regardless of `languageID` — a second language for an existing path isn't supported yet (only `EN-us` currently exists). Getting there needs an upstream schema change to composite uniqueness on `(path, languageID)` (see *Known rough edges* below). @@ -62,6 +62,8 @@ CREATE TABLE Tooltips ( ### Supporting tables +- **`DocumentationDatabaseVersion(major, minor, patch, who, comment, changeTime)`** — the database's own semver (ADFA-5220), replacing the heuristics that used to infer the format from which tables happened to exist. Append-only: each change is another `INSERT`, so the **row inserted last** is the current version, not the highest one ever recorded — a rebuild from an older content set is a downgrade and has to read as one (`DatabaseVersionResolver.resolveMajorVersion`, which returns null for a database predating the table). `MAJOR >= 2` is what tells the app its brotli `Content` rows are dictionary-compressed; below that, `WebServer` neither reads nor attaches `CompressionDictionary`. Gating on the declared version rather than on the table's presence matters in both directions: a database can carry the dictionary table while its content is still plain brotli (every row would then pay a failed dictionary decode before its plain one, on every request), and a migrated database that lost the table fails loudly instead of quietly. +- **`CompressionDictionary(id, data)`** — single-row table (`id INTEGER PRIMARY KEY CHECK (id = 1)`) holding the raw Brotli dictionary every ADFA-5153-migrated `compression = 'brotli'` `Content` row is compressed against. Trained once, from a representative sample across the whole `Content` table, by `OfflineDocumentationTools`' `migrate_content_to_dictionary_brotli.py` / `populate_db.py` (never retrained after that — a dictionary-compressed row is only decodable against the exact dictionary it was compressed with, so replacing it would silently orphan every already-migrated row). Shipping the dictionary inside `documentation.db` itself, rather than as a separate bundled asset, keeps it version-locked to the content compressed against it. `WebServer` loads it lazily -- not merely from starting the server or swapping databases, but on the first content fetch that needs it after `database` changes, and only when `DocumentationDatabaseVersion` declares `MAJOR >= 2` (see above) -- and caches it from then on, reloading again only on the next database change (a swap can bring in a database with a different dictionary or none, so it can't stay cached across one). Per row, it tries decoding with the dictionary attached first via brotli4j's `attachDictionary`, falling back to a plain decode on failure — needed both for a database predating this migration (no `CompressionDictionary` table at all) and for plugin-contributed rows within an otherwise-migrated database (see `PluginDocumentationManager` below). - **`Templates(id, name, content)`** — Pebble template source, keyed by id (and by `name` for well-known templates like `bookshelf`). Referenced by `Content.templateId`. - **`Bookshelf(contentID, bookCategoryID, title, description)`** / **`BookCategories(id, category, description)`** — the Dynamic Bookshelf: one row per "book" (PDF or similar), linked to its Tier 3 page via `contentID` -> `Content.id`. Two DB triggers keep `Bookshelf` in sync when a PDF row is inserted/deleted from `Content`; `title`/`description` don't come from those triggers and must be set by hand. Non-PDF books need a separate ingestion path (plugin-provided, e.g. via `PluginDocumentationManager`). - **`LastChange(documentationSet, changeTime, who)`** — audit trail for edits made through `docdb-studio`; not shown to end users. `DatabaseVersionResolver` reads the `documentationSet = 'wholedb'` row to report the DB's build/edit stamp in debug logging, falling back to the most recent row of any set if `'wholedb'` is missing. @@ -80,7 +82,7 @@ All three sites below open the file with `SQLiteDatabase.openDatabase(..., OPEN_ AND C.path = ? ``` - then reassembles chunked blobs, decompresses Brotli when the client can't accept it (or when a Pebble template needs a string to render), and instantiates the template if `templateId > 0`. Also serves a Dynamic Bookshelf JSON payload (joining `Content`/`Bookshelf`/`BookCategories`, rendered through the `bookshelf` template) and debug-only HTML dumps at `/pr/db` (`LastChange`, last 20 rows) and `/pr/pr` (recent projects, from a *different* database). + then reassembles chunked blobs, always decompresses Brotli content (attaching `CompressionDictionary`'s bytes first, if loaded — see above) since this server never negotiates `Content-Encoding` with the client, and instantiates the template if `templateId > 0`. Also serves a Dynamic Bookshelf JSON payload (joining `Content`/`Bookshelf`/`BookCategories`, rendered through the `bookshelf` template) and debug-only HTML dumps at `/pr/db` (`LastChange`, last 20 rows) and `/pr/pr` (recent projects, from a *different* database). - **`idetooltips/.../ToolTipManager.kt`** — serves Tier 1/2. Looks up `Tooltips` joined to `TooltipCategories` by `(category, tag)`, then `TooltipButtons` for the Tier 3 links shown at the bottom. - **`plugin-manager/.../documentation/PluginDocumentationManager.kt`** (with `Tier3AssetWalker.kt`, and the `DocumentationExtension` contract in `plugin-api`) — lets plugins contribute their own help content into the same lookup paths. From 24cc73e268eb1ac64f42b738e1aa9df36c54b5d7 Mon Sep 17 00:00:00 2001 From: Hal Eisen Date: Mon, 24 Aug 2026 15:43:17 -0700 Subject: [PATCH 60/62] ADFA-4510: Fix missing tooltips on code actions (#1712) * docs(ADFA-4510): design for code action tooltip fix * docs(ADFA-4510): implementation plan for code action tooltip fix * style(ADFA-4510): reformat files to tabs ahead of edits Spotless ratchets whole files, so reformatting these four up front keeps the following commits pure logic. ktlint normalisations only -- tabs, trailing commas, expression bodies. No behaviour change; both modules compile. * docs(ADFA-4510): correct Task 1 verification step git diff -w can never be empty: ktlint normalises trailing commas, expression bodies and blank lines, not just indentation. Replace with a hunk-by-hunk review plus a compile of both modules. * fix(ADFA-4510): resolve tooltip tags from either ActionItem member retrieveTooltipTag() defaulted to "" while every LSP code action overrides the tooltipTag property, so the code-actions renderer always read an empty tag. Default the function to the property instead. Add ActionMenu.findAction(itemId) so a submenu's renderer can reach children, which are never registered with the ActionsRegistry. * fix(ADFA-4510): pin java code action tooltip tags VariableToStatementAction and FieldToBlockAction carried the fiximports tag by copy-paste; neither touches imports. They were silent before this branch and would have started showing wrong help. Drop both overrides. Add JavaCodeActionTooltipTagTest, reading through retrieveTooltipTag() so it exercises the member the renderer actually calls. * fix(ADFA-4510): resolve code action tooltips at the bind site Pass the parent ActionMenu to the submenu adapter so code actions resolve; the registry only holds top-level actions. Drop the contentDescription fallback. It read the action's label, which can never match a tag, so it converted a missing tooltip into a silent DB miss. Log a warning instead. Use the action's own tooltip category rather than hardcoding 'ide', so plugin-contributed code actions hit their plugin_ rows. * fix(ADFA-4510): use the dialog tooltip tag in the override dialog The method-selection dialog passed the menu item's tag, so it showed the menu tooltip instead of its own. EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG was declared but referenced nowhere. * docs(ADFA-4510): add missing assets side-load step to Task 6 :app:assembleV8Debug does not bundle the large assets. Without building :app:assembleV8Assets and pushing the payload to /sdcard/Download, a debug install has no templates, no bootstrap, no SDK and no documentation.db, so nothing about the fix can be verified on device. * fix(ADFA-4510): keep the documentation fallback for untagged actions Dropping the contentDescription fallback also dropped the ADFA-4754 popup. That fallback made the tag non-empty for every action, so a long-press on an untagged action reached showTooltip(), missed in the DB, and rendered "Sorry, we don't have a tooltip for that. Explore the documentation." Returning early on an empty tag turned that into a dead gesture for the eight untagged Java actions and the two this branch un-tagged. Still log the warning, but let the empty tag through so the miss renders the fallback. * test(ADFA-4510): cover the try/catch action and close the kotlin blind spot Rebasing onto stage brought SurroundWithTryCatchAction into JavaCodeActionsMenu, which the expected map did not list, so the suite failed on 23 actual vs 22 expected entries. Pin it to EDITOR_CODE_ACTIONS_TRY_CATCH. Point the Kotlin twin at retrieveTooltipTag() too. Reading the property is the exact hole that let ADFA-4510 through on the Java side while that suite stayed green. Add the GPL header both new test files were missing. * test(ADFA-4510): drop Robolectric, assert through Truth ActionTooltipResolutionTest exercises findAction(Int) and retrieveTooltipTag() -- an id.hashCode() lookup and a String property. Its only Android type is a Drawable? assigned null and never called, so every class in it bootstrapped an SDK sandbox for nothing. JavaCodeActionTooltipTagTest used raw JUnit asserts. ARCHITECTURE.md prefers Truth, and containsExactlyEntriesIn names the offending key instead of dumping both maps -- which is what the missing try/catch entry cost to read. * docs(ADFA-4510): derive the repo root, validate the Firebase donor path The plan hardcoded /Users/eisen/src/cogo/ADFA-4510, so the commands only ran on one machine. Derive it with git rev-parse --show-toplevel. The google-services.json fallback was worse: it copied from a hardcoded sibling checkout with no check that the path existed or belonged to this project. Require the donor as GOOGLE_SERVICES_SRC and verify it is a file first. * docs(ADFA-4510): mark the try/catch tag as reserved ahead of content The suite grouped surroundWithTryCatch with the tags that have authored tooltips, but documentation.db has no editor.codeactions.trycatch row, so long-press renders the documentation fallback. Its Kotlin twin, editor.codeactions.kotlin.trycatch, is authored - this is an authoring gap, not a wiring one. Verified against the current documentation.db (46,105 tooltips, wholedb 2026-08-20), not the stale local asset copy. Comment-only. The tag stays pinned: dropping it would change production behavior, and the tag is correctly wired. * fix(ADFA-4510): give the Kotlin import chooser a tooltip tag AddImportAction opens a chooser dialog when a reference resolves to more than one importable classifier, but wired no tooltip tag, so long-pressing anywhere in that dialog did nothing. Same defect this branch already fixed on the Java side for the override-superclass dialog. Follows that precedent: applyLongPressRecursively bails out of ListView subtrees, so the rows get their own OnItemLongClickListener and the dialog chrome is wired in setOnShowListener. The chooser construction moves into showImportChooser() because the listener needs the created dialog, not the builder. New tag editor.codeactions.kotlin.importclass.dialog has no row in documentation.db yet, so long-press renders the ADFA-4754 documentation fallback until content is authored - a live link, not a dead press. 471 tests across actions, idetooltips, lsp/java, lsp/kotlin: 0 failures. * style(ADFA-4510): reindent Java AddImportAction to tabs Space-indented, so the file-level Spotless ratchet reformats it whole the moment it is touched. Isolating that churn here keeps the tooltip fix that follows reviewable. Whitespace plus the usual ktlint normalisations, verified with git diff -w: two blank lines removed after a declaration opens, one trailing comma added, and postExec's parameter list exploded one-per-line. No identifier, literal, condition, or call argument changed. * fix(ADFA-4510): give the Java import chooser a tooltip tag Java's AddImportAction has the same gap just fixed on the Kotlin side: the chooser shown when a simple name resolves to several importable types wired no tooltip tag, so long-pressing it did nothing. Same shape as the Kotlin fix and the override-superclass dialog already on this branch: build, create(), wire the rows via OnItemLongClickListener and the chrome via setOnShowListener, then show. applyLongPressRecursively bails out of ListView subtrees, which is why both are needed. New tag editor.codeactions.fiximports.dialog has no row in documentation.db yet, so long-press renders the ADFA-4754 documentation fallback until content is authored. 471 tests across actions, idetooltips, lsp/java, lsp/kotlin: 0 failures. * fix(ADFA-4510): give the Kotlin null-safety chooser a tooltip tag NullSafetyAction offers three fixes for an UNSAFE_CALL - assert non-null, safe call, Elvis fallback - in a chooser dialog that wired no tooltip tag, so long-pressing it did nothing. The action tag itself is authored, making the dialog the only dead surface on this path. Same pattern as the two import choosers: create(), rows via OnItemLongClickListener, chrome via setOnShowListener. New tag editor.codeactions.kotlin.nullsafetyfix.dialog has no row in documentation.db yet, so long-press renders the ADFA-4754 documentation fallback until content is authored. * style(ADFA-4510): reindent AutoFixImportsAction to tabs Space-indented, so the file-level Spotless ratchet reformats it whole on the first touch. Isolating that churn keeps the tooltip fix that follows small. Whitespace plus the usual ktlint normalisations, verified with git diff -w: two blank lines removed after a declaration opens, five parameter lists exploded one-per-line, getFileImports collapsed to an expression body, the dialog builder chain rewrapped, and a redundant "${klass}" reduced to "$klass". No identifier, condition, or call argument changed. * fix(ADFA-4510): give the Java class chooser a tooltip tag AutoFixImportsAction asks which class to import when a simple name is ambiguous, one dialog per name. It wired no tooltip tag, so long-pressing it did nothing. Last of the four unwired code-action dialogs. Reuses editor.codeactions.fiximports.dialog rather than minting a new tag: same question asked of the user as AddImportAction's chooser, and the two actions already share an action tag. Note this dialog is built through DialogUtils.newMaterialDialogBuilder directly, not the newDialogBuilder helper the other three use - which is why it did not turn up in the first sweep for unwired dialogs. The nullable `e` is captured into a local `entry` so the listener body does not smart-cast a var across a lambda boundary. 481 tests across actions, editor, idetooltips, lsp/java, lsp/kotlin: 0 failures. --- actions/build.gradle.kts | 1 + .../itsaky/androidide/actions/ActionItem.kt | 486 +++++----- .../itsaky/androidide/actions/ActionMenu.kt | 125 +-- .../actions/ActionTooltipResolutionTest.kt | 100 ++ ...026-08-06-adfa-4510-codeaction-tooltips.md | 891 ++++++++++++++++++ ...06-adfa-4510-codeaction-tooltips-design.md | 200 ++++ .../androidide/editor/ui/EditorActionsMenu.kt | 41 +- .../androidide/idetooltips/TooltipTag.kt | 5 + .../actions/diagnostics/AddImportAction.kt | 416 ++++---- .../diagnostics/AutoFixImportsAction.kt | 381 ++++---- .../actions/diagnostics/FieldToBlockAction.kt | 176 ++-- .../diagnostics/VariableToStatementAction.kt | 180 ++-- .../OverrideSuperclassMethodsAction.kt | 4 +- .../actions/JavaCodeActionTooltipTagTest.kt | 92 ++ .../lsp/kotlin/actions/AddImportAction.kt | 66 +- .../lsp/kotlin/actions/NullSafetyAction.kt | 62 +- .../kotlin/KotlinCodeActionTooltipTagTest.kt | 2 +- 17 files changed, 2374 insertions(+), 854 deletions(-) create mode 100644 actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt create mode 100644 docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md create mode 100644 docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md create mode 100644 lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt diff --git a/actions/build.gradle.kts b/actions/build.gradle.kts index 981e779eb1..759aac46c9 100644 --- a/actions/build.gradle.kts +++ b/actions/build.gradle.kts @@ -44,4 +44,5 @@ dependencies { implementation(libs.androidx.core.ktx) implementation(libs.google.material) + testImplementation(projects.testing.unit) } diff --git a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt index 5597f0ac0c..82c9f5506d 100644 --- a/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt +++ b/actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt @@ -1,243 +1,243 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.actions - -import android.graphics.ColorFilter -import android.graphics.PorterDuff -import android.graphics.PorterDuffColorFilter -import android.graphics.drawable.Drawable -import android.view.Menu -import android.view.View -import androidx.annotation.CallSuper -import com.itsaky.androidide.idetooltips.TooltipCategory -import com.itsaky.androidide.utils.resolveAttr - -/** - * An action that can be registered using the [ActionsRegistry] - * [com.itsaky.androidide.actions.ActionsRegistry] - * - * @author Akash Yadav - */ -interface ActionItem { - - /** - * A unique ID for this action. - */ - val id: String - - /** - * The label for this action. - */ - var label: String - - /** - * Whether the action should be visible to the user or not. - */ - var visible: Boolean - - /** - * Whether the action should be enabled. - */ - var enabled: Boolean - - /** - * Icon for this action. - */ - var icon: Drawable? - - /** - * Whether the [execAction] method of this action must be executed on UI thread. - */ - var requiresUIThread: Boolean - - /** - * The location of this [ActionItem]. - */ - var location: Location - - /** - * The tooltip tag of this [ActionItem]. - */ - var tooltipTag: String - get() = "" - set(_) {} - - /** - * Retrieves the tooltip tag for this [ActionItem]. - * - * This function allows the action to provide a context-specific tooltip. For example, - * the "Copy" action can have a different tooltip in a standard code editor - * versus a read-only output panel where the user can only view, copy, and share content. - * - * @param isReadOnlyContext `true` if the action is displayed in a context where the - * content is read-only (e.g., a build output or logcat panel), `false` otherwise. - * @return The appropriate tooltip tag for the given context, or an empty string if - * no tooltip is available. - */ - fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = "" - - /** - * Retrieves the tooltip category for this [ActionItem]. The default is - * [TooltipCategory.CATEGORY_IDE]; plugin-contributed actions override this - * to point at their own `plugin_` category so the lookup hits - * tooltip rows the plugin installed via [DocumentationExtension]. - */ - fun retrieveTooltipCategory(): String = TooltipCategory.CATEGORY_IDE - - /** - * The order of this action item. This is used only at some locations and not everywhere. - * - * @see android.view.MenuItem.getOrder - */ - val order: Int - get() = Menu.NONE - - /** - * The item ID that will be set to the menu item. - */ - val itemId: Int - get() = id.hashCode() - - /** - * Whether the editor toolbar should fully remove this action when [visible] is false, - * instead of the legacy behaviour of keeping it and only greying out when disabled. - * Built-in actions keep the legacy behaviour (default false); plugin-contributed - * toolbar actions opt in by overriding this to true. - */ - val honorVisibility: Boolean - get() = false - - /** - * Prepare the action. Subclasses can modify the visual properties of this action here. - * - * @param data The data containing various information about the event. - */ - @CallSuper - fun prepare(data: ActionData) { - visible = true - enabled = true - } - - /** - * Execute the action. The action executed in a background thread by default. - * - * @param data The data containing various information about the event. - * @return `true` if this action was executed successfully, `false` otherwise. - */ - suspend fun execAction(data: ActionData): Any - - /** - * Called just after the [execAction] method executes **successfully** (i.e. returns `true`). - * Subclasses are free to do UI related work here as this method is called on UI thread. - * - * @param data The data containing various information about the event. - */ - fun postExec(data: ActionData, result: Any) = Unit - - /** - * Called when the action item is to be destroyed. Any resource references must be released if - * held. - */ - fun destroy() = Unit - - /** - * Return the show as action flags for the menu item. - * - * @return The show as action flags. - */ - fun getShowAsActionFlags(data: ActionData): Int = -1 - - /** - * Create custom action view for this action item. - * - * @return The custom action view or `null`. - */ - fun createActionView(data: ActionData): View? = null - - /** - * Creates the color filter for this action's icon drawable. - * - * The default implementation returns a [PorterDuffColorFilter] instance with color [R.attr.colorOnSurface]. - */ - fun createColorFilter(data: ActionData): ColorFilter? { - return data.getContext()?.let { - PorterDuffColorFilter(it.resolveAttr(R.attr.colorOnSurface), PorterDuff.Mode.SRC_ATOP) - } - } - - /** Location where an action item will be shown. */ - enum class Location(val id: String) { - - /** - * Location marker for the action items shown in the debugger (both overlay window and the - * bottom sheet). - */ - DEBUGGER_ACTIONS("ide.debugger"), - - /** Location marker for action items shown in editor activity's toolbar. */ - EDITOR_TOOLBAR("ide.editor.toolbar"), - - /** Location marker for action items shown in editor activity's toolbar submenu. - * FindInFileAction and FindInProjectAction will use this location so - * they don't show in the editor activity's toolbar*/ - EDITOR_FIND_ACTION_MENU("ide.editor.toolbar.find.menu"), - - /** - * Location marker for action items shown in editor activity's sidebar (navigation rail in the drawer). - */ - EDITOR_SIDEBAR("ide.editor.sidebar"), - EDITOR_RIGHT_SIDEBAR("ide.editor.right.sidebar"), - - /** - * Location marker for action items shown in the default category of editor activity's sidebar (navigation rail in the drawer). - */ - EDITOR_SIDEBAR_DEFAULT_ITEMS("ide.editor.sidebar.defaultItems"), - - /** Location marker for action items shown in editor's text action menu. */ - EDITOR_TEXT_ACTIONS("ide.editor.textActions"), - - /** - * Location marker for action items shown in 'Code actions' submenu in editor's text action - * menu. - */ - EDITOR_CODE_ACTIONS("ide.editor.codeActions"), - - /** Location marker for action items shown when file tabs are reselected. */ - EDITOR_FILE_TABS("ide.editor.fileTabs"), - - /** - * Location marker for action items that are shown when the files in the editor activity's file - * tree are long clicked. - */ - EDITOR_FILE_TREE("ide.editor.fileTree"), - - /** Location marker for action items shown in UI Designer activity's toolbar. */ - UI_DESIGNER_TOOLBAR("ide.uidesigner.toolbar"), - - /** Location marker for action items shown on the main screen. */ - MAIN_SCREEN("ide.main.screen"); - - override fun toString(): String { - return id - } - - fun forId(id: String): Location { - return entries.first { it.id == id } - } - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.actions + +import android.graphics.ColorFilter +import android.graphics.PorterDuff +import android.graphics.PorterDuffColorFilter +import android.graphics.drawable.Drawable +import android.view.Menu +import android.view.View +import androidx.annotation.CallSuper +import com.itsaky.androidide.idetooltips.TooltipCategory +import com.itsaky.androidide.utils.resolveAttr + +/** + * An action that can be registered using the [ActionsRegistry] + * [com.itsaky.androidide.actions.ActionsRegistry] + * + * @author Akash Yadav + */ +interface ActionItem { + /** + * A unique ID for this action. + */ + val id: String + + /** + * The label for this action. + */ + var label: String + + /** + * Whether the action should be visible to the user or not. + */ + var visible: Boolean + + /** + * Whether the action should be enabled. + */ + var enabled: Boolean + + /** + * Icon for this action. + */ + var icon: Drawable? + + /** + * Whether the [execAction] method of this action must be executed on UI thread. + */ + var requiresUIThread: Boolean + + /** + * The location of this [ActionItem]. + */ + var location: Location + + /** + * The tooltip tag of this [ActionItem]. + */ + var tooltipTag: String + get() = "" + set(_) {} + + /** + * Retrieves the tooltip tag for this [ActionItem]. + * + * This function allows the action to provide a context-specific tooltip. For example, + * the "Copy" action can have a different tooltip in a standard code editor + * versus a read-only output panel where the user can only view, copy, and share content. + * + * @param isReadOnlyContext `true` if the action is displayed in a context where the + * content is read-only (e.g., a build output or logcat panel), `false` otherwise. + * @return The appropriate tooltip tag for the given context, or an empty string if + * no tooltip is available. Defaults to [tooltipTag], so an action may override either + * member and every consumer sees the same value (ADFA-4510). + */ + fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = tooltipTag + + /** + * Retrieves the tooltip category for this [ActionItem]. The default is + * [TooltipCategory.CATEGORY_IDE]; plugin-contributed actions override this + * to point at their own `plugin_` category so the lookup hits + * tooltip rows the plugin installed via [DocumentationExtension]. + */ + fun retrieveTooltipCategory(): String = TooltipCategory.CATEGORY_IDE + + /** + * The order of this action item. This is used only at some locations and not everywhere. + * + * @see android.view.MenuItem.getOrder + */ + val order: Int + get() = Menu.NONE + + /** + * The item ID that will be set to the menu item. + */ + val itemId: Int + get() = id.hashCode() + + /** + * Whether the editor toolbar should fully remove this action when [visible] is false, + * instead of the legacy behaviour of keeping it and only greying out when disabled. + * Built-in actions keep the legacy behaviour (default false); plugin-contributed + * toolbar actions opt in by overriding this to true. + */ + val honorVisibility: Boolean + get() = false + + /** + * Prepare the action. Subclasses can modify the visual properties of this action here. + * + * @param data The data containing various information about the event. + */ + @CallSuper + fun prepare(data: ActionData) { + visible = true + enabled = true + } + + /** + * Execute the action. The action executed in a background thread by default. + * + * @param data The data containing various information about the event. + * @return `true` if this action was executed successfully, `false` otherwise. + */ + suspend fun execAction(data: ActionData): Any + + /** + * Called just after the [execAction] method executes **successfully** (i.e. returns `true`). + * Subclasses are free to do UI related work here as this method is called on UI thread. + * + * @param data The data containing various information about the event. + */ + fun postExec( + data: ActionData, + result: Any, + ) = Unit + + /** + * Called when the action item is to be destroyed. Any resource references must be released if + * held. + */ + fun destroy() = Unit + + /** + * Return the show as action flags for the menu item. + * + * @return The show as action flags. + */ + fun getShowAsActionFlags(data: ActionData): Int = -1 + + /** + * Create custom action view for this action item. + * + * @return The custom action view or `null`. + */ + fun createActionView(data: ActionData): View? = null + + /** + * Creates the color filter for this action's icon drawable. + * + * The default implementation returns a [PorterDuffColorFilter] instance with color [R.attr.colorOnSurface]. + */ + fun createColorFilter(data: ActionData): ColorFilter? = + data.getContext()?.let { + PorterDuffColorFilter(it.resolveAttr(R.attr.colorOnSurface), PorterDuff.Mode.SRC_ATOP) + } + + /** Location where an action item will be shown. */ + enum class Location( + val id: String, + ) { + /** + * Location marker for the action items shown in the debugger (both overlay window and the + * bottom sheet). + */ + DEBUGGER_ACTIONS("ide.debugger"), + + /** Location marker for action items shown in editor activity's toolbar. */ + EDITOR_TOOLBAR("ide.editor.toolbar"), + + /** Location marker for action items shown in editor activity's toolbar submenu. + * FindInFileAction and FindInProjectAction will use this location so + * they don't show in the editor activity's toolbar*/ + EDITOR_FIND_ACTION_MENU("ide.editor.toolbar.find.menu"), + + /** + * Location marker for action items shown in editor activity's sidebar (navigation rail in the drawer). + */ + EDITOR_SIDEBAR("ide.editor.sidebar"), + EDITOR_RIGHT_SIDEBAR("ide.editor.right.sidebar"), + + /** + * Location marker for action items shown in the default category of editor activity's sidebar (navigation rail in the drawer). + */ + EDITOR_SIDEBAR_DEFAULT_ITEMS("ide.editor.sidebar.defaultItems"), + + /** Location marker for action items shown in editor's text action menu. */ + EDITOR_TEXT_ACTIONS("ide.editor.textActions"), + + /** + * Location marker for action items shown in 'Code actions' submenu in editor's text action + * menu. + */ + EDITOR_CODE_ACTIONS("ide.editor.codeActions"), + + /** Location marker for action items shown when file tabs are reselected. */ + EDITOR_FILE_TABS("ide.editor.fileTabs"), + + /** + * Location marker for action items that are shown when the files in the editor activity's file + * tree are long clicked. + */ + EDITOR_FILE_TREE("ide.editor.fileTree"), + + /** Location marker for action items shown in UI Designer activity's toolbar. */ + UI_DESIGNER_TOOLBAR("ide.uidesigner.toolbar"), + + /** Location marker for action items shown on the main screen. */ + MAIN_SCREEN("ide.main.screen"), + ; + + override fun toString(): String = id + + fun forId(id: String): Location = entries.first { it.id == id } + } +} diff --git a/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt b/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt index d3c1884c54..8b773e5c2f 100644 --- a/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt +++ b/actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt @@ -1,59 +1,66 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ - -package com.itsaky.androidide.actions - -/** - * An action menu is an action which can contain child actions. - * @author Akash Yadav - */ -interface ActionMenu : ActionItem { - - val children: MutableSet - - fun addAction(action: ActionItem) = children.add(action) - - fun removeAction(action: ActionItem) = children.remove(action) - - /** - * Find the action item with the given action ID. - * - * @return The action item or `null` if not found. - */ - fun findAction(id: String): ActionItem? { - return children.find { it.id == id } - } - - override fun prepare(data: ActionData) { - super.prepare(data) - visible = children.isNotEmpty() && isAtLeastOneChildVisible(data) - enabled = visible - } - - /** Action menus are not supposed to perform any action */ - override suspend fun execAction(data: ActionData): Boolean { - return false - } - - /** - * Calls [ActionItem.prepare] on each child action and returns `true` if at least one of them - * is [visible][ActionItem.visible]. - */ - fun isAtLeastOneChildVisible(data: ActionData) : Boolean { - return children.firstOrNull { it.prepare(data); it.visible } != null - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.actions + +/** + * An action menu is an action which can contain child actions. + * @author Akash Yadav + */ +interface ActionMenu : ActionItem { + val children: MutableSet + + fun addAction(action: ActionItem) = children.add(action) + + fun removeAction(action: ActionItem) = children.remove(action) + + /** + * Find the action item with the given action ID. + * + * @return The action item or `null` if not found. + */ + fun findAction(id: String): ActionItem? = children.find { it.id == id } + + /** + * Find the child action with the given menu item ID. + * + * Child actions are not registered with the [ActionsRegistry], so the registry cannot resolve + * them; a submenu's renderer must look them up here (ADFA-4510). + * + * @return The action item or `null` if not found. + */ + fun findAction(itemId: Int): ActionItem? = children.find { it.itemId == itemId } + + override fun prepare(data: ActionData) { + super.prepare(data) + visible = children.isNotEmpty() && isAtLeastOneChildVisible(data) + enabled = visible + } + + /** Action menus are not supposed to perform any action */ + override suspend fun execAction(data: ActionData): Boolean = false + + /** + * Calls [ActionItem.prepare] on each child action and returns `true` if at least one of them + * is [visible][ActionItem.visible]. + */ + fun isAtLeastOneChildVisible(data: ActionData): Boolean = + children.firstOrNull { + it.prepare(data) + it.visible + } != null +} diff --git a/actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt b/actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt new file mode 100644 index 0000000000..b1340fbc4b --- /dev/null +++ b/actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt @@ -0,0 +1,100 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.actions + +import android.graphics.drawable.Drawable +import com.google.common.truth.Truth.assertThat +import org.junit.Test + +/** + * Covers the two halves of code-action tooltip resolution that failed in ADFA-4510: finding a + * submenu child by its menu item id, and reading a tag from whichever member the action overrode. + * + * Code actions are children of CodeActionsMenu and are never registered with the registry, so the + * render path can only reach them through [ActionMenu.findAction]. They override the `tooltipTag` + * property while the render path reads `retrieveTooltipTag()`, so both must resolve to the same + * value. + */ +class ActionTooltipResolutionTest { + private open class FakeAction( + override val id: String, + ) : ActionItem { + override var label: String = id + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS + + override suspend fun execAction(data: ActionData): Any = true + } + + private class PropertyOnlyAction : FakeAction("fake.propertyOnly") { + override var tooltipTag: String = "editor.codeactions.comment" + } + + private class FunctionOnlyAction : FakeAction("fake.functionOnly") { + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = "editor.codeactions.gotodef" + } + + private class UntaggedAction : FakeAction("fake.untagged") + + private class FakeMenu : ActionMenu { + override val children: MutableSet = mutableSetOf() + override val id: String = "fake.menu" + override var label: String = "Fake menu" + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_TEXT_ACTIONS + } + + private fun menuOf(vararg actions: ActionItem) = FakeMenu().apply { actions.forEach(::addAction) } + + @Test + fun `findAction by itemId returns the matching child`() { + val child = PropertyOnlyAction() + val menu = menuOf(UntaggedAction(), child) + + assertThat(menu.findAction(child.itemId)).isSameInstanceAs(child) + } + + @Test + fun `findAction by itemId returns null when no child matches`() { + val menu = menuOf(UntaggedAction()) + + assertThat(menu.findAction("nothing.registered".hashCode())).isNull() + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the property`() { + assertThat(PropertyOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.comment") + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the function`() { + assertThat(FunctionOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.gotodef") + } + + @Test + fun `retrieveTooltipTag is empty when the action overrides neither member`() { + assertThat(UntaggedAction().retrieveTooltipTag(false)).isEmpty() + } +} diff --git a/docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md b/docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md new file mode 100644 index 0000000000..2231dca879 --- /dev/null +++ b/docs/superpowers/plans/2026-08-06-adfa-4510-codeaction-tooltips.md @@ -0,0 +1,891 @@ +# ADFA-4510 Code Action Tooltips 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:** Make long-press show a tooltip on every tagged item in the editor's Code Actions menu. + +**Architecture:** `ActionItem` carries two members meaning the same thing (`tooltipTag` property, `retrieveTooltipTag()` function); the code-action render path reads the function while all 22 LSP actions override the property. We unify them at the interface, teach `ActionMenu` to look up a child by `itemId` (the registry cannot see submenu children), hand the submenu adapter its parent menu, and delete a fallback that guaranteed a failed lookup. Two mis-copied tags are dropped and one dead dialog constant is wired up. + +**Tech Stack:** Kotlin, Android (`com.android.library` modules with `v7`/`v8` ABI flavors), JUnit 4 + Truth + Robolectric via `projects.testing.unit`, Gradle wrapped in `flox`, Spotless/ktlint with a `ratchetFrom = "origin/stage"` file-level ratchet. + +**Spec:** `docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md` + +## Global Constraints + +- **Indentation is TABS, line endings LF.** Enforced by Spotless. Every Kotlin snippet below is already tab-indented — preserve it. +- **The Spotless ratchet is file-level, not line-level.** Touching one line of a space-indented file pulls the *whole file* under the ratchet and reformats it to tabs. Task 1 exists solely to get that churn into its own commit. Do not skip it. +- **Never run bare `./gradlew`.** Always `flox activate -d flox/local -- ./gradlew `. +- **Unit test task for these modules is `testV8DebugUnitTest`**, not `test`. The aggregate `test` task rejects `--tests`. +- **Do not add tooltip tag constants.** `TooltipTag.kt` is untouched by this plan — open PR #1624 edits it and we must not collide. +- **Do not edit anything under `lsp/kotlin/`.** Same conflict reason. +- **New test files carry no license header** — match `KotlinCodeActionTooltipTagTest.kt`, which starts directly with `package`. +- **Branch:** `bugfix/ADFA-4510-missing-tooltips-code-actions`. Commit after every task. + +--- + +### Task 1: Reindent space-indented target files to tabs + +Four files we must edit are space-indented. Reformatting them is mechanical and must not be mixed with logic changes. The ratchet only reformats files that differ from `origin/stage`, so we make a throwaway whitespace change first to make Spotless see them. + +**Files:** +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt` +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: nothing. This task is whitespace-only by construction and is verified as such. + +- [ ] **Step 1: Make each file differ from `origin/stage` so the ratchet picks it up** + +```bash +cd "$(git rev-parse --show-toplevel)" +for f in actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt \ + actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt; do + printf '\n' >> "$f" +done +git diff --stat +``` + +Expected: 4 files listed, 1 insertion each. + +- [ ] **Step 2: Run Spotless** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +``` + +Expected: BUILD SUCCESSFUL. The trailing blank lines are removed and all four files are reindented to tabs. + +- [ ] **Step 3: Prove the change is formatting-only** + +ktlint does more than reindent, so `git diff -w` will NOT be empty. Expect these +behaviour-preserving normalisations, and nothing else: + +- blank line removed after a declaration opens +- parameter lists exploded one-per-line with a trailing comma +- block bodies collapsed to expression bodies (`{ return x }` becomes `= x`) +- enum entries gaining a trailing comma and `;` +- a `a; b` one-liner split onto two lines + +```bash +git diff -w +``` + +Read every hunk. Each must fall into the list above. If you see a changed +identifier, literal, condition, or call argument — anything that could alter +behaviour — STOP and report BLOCKED without committing. + +Then prove it compiles: + +```bash +flox activate -d flox/local -- ./gradlew :actions:compileV8DebugKotlin :lsp:java:compileV8DebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 4: Confirm the files are now tab-indented** + +```bash +grep -c $'^\t' actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt +``` + +Expected: a non-zero count (was 0 before). + +- [ ] **Step 5: Commit** + +```bash +git add actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt \ + actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt +git commit -m "style(ADFA-4510): reformat files to tabs ahead of edits + +Spotless ratchets whole files, so reformatting these four up front keeps the +following commits pure logic. ktlint normalisations only -- tabs, trailing +commas, expression bodies. No behaviour change; both modules compile." +``` + +--- + +### Task 2: Unify the tag members and add `ActionMenu.findAction(itemId)` + +The two fixes at the heart of the bug, developed test-first. This is also the `actions` module's first unit test, so it needs test wiring. + +**Files:** +- Modify: `actions/build.gradle.kts` (add `testImplementation`) +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt` (line ~92 after Task 1) +- Modify: `actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt` +- Create: `actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt` + +**Interfaces:** +- Consumes: nothing +- Produces: + - `ActionMenu.findAction(itemId: Int): ActionItem?` — returns the child whose `itemId` matches, else `null`. Used by Task 4. + - `ActionItem.retrieveTooltipTag(isReadOnlyContext: Boolean): String` now defaults to `tooltipTag` instead of `""`. Used by Task 3 and Task 4. + +- [ ] **Step 1: Add the test dependency** + +In `actions/build.gradle.kts`, inside the existing `dependencies { ... }` block, add this line after `implementation(libs.google.material)`: + +```kotlin + testImplementation(projects.testing.unit) +``` + +`testing/unit` brings JUnit 4, Truth, MockK and Robolectric. It depends only on `buildInfo`, `common`, `shared` and `testing/common`, so there is no dependency cycle with `actions`. + +- [ ] **Step 2: Write the failing test** + +Create `actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt`: + +```kotlin +package com.itsaky.androidide.actions + +import android.graphics.drawable.Drawable +import com.google.common.truth.Truth.assertThat +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Covers the two halves of code-action tooltip resolution that failed in ADFA-4510: finding a + * submenu child by its menu item id, and reading a tag from whichever member the action overrode. + * + * Code actions are children of CodeActionsMenu and are never registered with the registry, so the + * render path can only reach them through [ActionMenu.findAction]. They override the `tooltipTag` + * property while the render path reads `retrieveTooltipTag()`, so both must resolve to the same + * value. + */ +@RunWith(RobolectricTestRunner::class) +class ActionTooltipResolutionTest { + private open class FakeAction( + override val id: String, + ) : ActionItem { + override var label: String = id + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_CODE_ACTIONS + + override suspend fun execAction(data: ActionData): Any = true + } + + private class PropertyOnlyAction : FakeAction("fake.propertyOnly") { + override var tooltipTag: String = "editor.codeactions.comment" + } + + private class FunctionOnlyAction : FakeAction("fake.functionOnly") { + override fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = + "editor.codeactions.gotodef" + } + + private class UntaggedAction : FakeAction("fake.untagged") + + private class FakeMenu : ActionMenu { + override val children: MutableSet = mutableSetOf() + override val id: String = "fake.menu" + override var label: String = "Fake menu" + override var visible: Boolean = true + override var enabled: Boolean = true + override var icon: Drawable? = null + override var requiresUIThread: Boolean = false + override var location: ActionItem.Location = ActionItem.Location.EDITOR_TEXT_ACTIONS + } + + private fun menuOf(vararg actions: ActionItem) = FakeMenu().apply { actions.forEach(::addAction) } + + @Test + fun `findAction by itemId returns the matching child`() { + val child = PropertyOnlyAction() + val menu = menuOf(UntaggedAction(), child) + + assertThat(menu.findAction(child.itemId)).isSameInstanceAs(child) + } + + @Test + fun `findAction by itemId returns null when no child matches`() { + val menu = menuOf(UntaggedAction()) + + assertThat(menu.findAction("nothing.registered".hashCode())).isNull() + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the property`() { + assertThat(PropertyOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.comment") + } + + @Test + fun `retrieveTooltipTag reads an action that overrides only the function`() { + assertThat(FunctionOnlyAction().retrieveTooltipTag(false)) + .isEqualTo("editor.codeactions.gotodef") + } + + @Test + fun `retrieveTooltipTag is empty when the action overrides neither member`() { + assertThat(UntaggedAction().retrieveTooltipTag(false)).isEmpty() + } +} +``` + +- [ ] **Step 3: Run the test to verify it fails** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.actions.ActionTooltipResolutionTest" +``` + +Expected: FAIL. Two distinct failures: +- a compile error, `Unresolved reference: findAction` (the `Int` overload does not exist yet) +- once that compiles, `retrieveTooltipTag reads an action that overrides only the property` fails with `expected: editor.codeactions.comment but was: ` (empty) + +- [ ] **Step 4: Add the `itemId` lookup to `ActionMenu`** + +In `actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt`, directly below the existing `findAction(id: String)` function, add: + +```kotlin + /** + * Find the child action with the given menu item ID. + * + * Child actions are not registered with the [ActionsRegistry], so the registry cannot resolve + * them; a submenu's renderer must look them up here (ADFA-4510). + * + * @return The action item or `null` if not found. + */ + fun findAction(itemId: Int): ActionItem? { + return children.find { it.itemId == itemId } + } +``` + +- [ ] **Step 5: Unify the tag members in `ActionItem`** + +In `actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt`, change the body of `retrieveTooltipTag`. Replace: + +```kotlin + fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = "" +``` + +with: + +```kotlin + fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = tooltipTag +``` + +Then extend the existing KDoc's `@return` line so the delegation is documented. Replace: + +```kotlin + * @return The appropriate tooltip tag for the given context, or an empty string if + * no tooltip is available. + */ +``` + +with: + +```kotlin + * @return The appropriate tooltip tag for the given context, or an empty string if + * no tooltip is available. Defaults to [tooltipTag], so an action may override either + * member and every consumer sees the same value (ADFA-4510). + */ +``` + +- [ ] **Step 6: Run the test to verify it passes** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.actions.ActionTooltipResolutionTest" +``` + +Expected: PASS, 5 tests. + +- [ ] **Step 7: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add actions/build.gradle.kts \ + actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt \ + actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt \ + actions/src/test/java/com/itsaky/androidide/actions/ActionTooltipResolutionTest.kt +git commit -m "fix(ADFA-4510): resolve tooltip tags from either ActionItem member + +retrieveTooltipTag() defaulted to \"\" while every LSP code action overrides the +tooltipTag property, so the code-actions renderer always read an empty tag. +Default the function to the property instead. + +Add ActionMenu.findAction(itemId) so a submenu's renderer can reach children, +which are never registered with the ActionsRegistry." +``` + +--- + +### Task 3: Pin Java code action tags and drop two mis-copied ones + +`VariableToStatementAction` (converts a field to a local variable) and `FieldToBlockAction` both carry `EDITOR_CODE_ACTIONS_FIX_IMPORTS` by copy-paste. Neither touches imports. Before Task 2 they were silent; after it they would show import-fixing help on unrelated actions. Dropping the overrides keeps them silent, which is correct. + +The pinning test reads through `retrieveTooltipTag(false)` — the member the render path uses — unlike the Kotlin test which reads the property. All 22 actions are asserted as one map so a newly registered untagged action fails automatically. + +**Files:** +- Create: `lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt` +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt` + +**Interfaces:** +- Consumes: `ActionItem.retrieveTooltipTag(isReadOnlyContext: Boolean)` from Task 2, which must already delegate to `tooltipTag`. +- Produces: nothing consumed by later tasks. + +No new dependency is needed: `lsp/java/build.gradle.kts:69` already has `testImplementation(projects.testing.lsp)`, which re-exports `testing/unit`. + +- [ ] **Step 1: Write the failing test** + +Create `lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt`: + +```kotlin +package com.itsaky.androidide.lsp.java.actions + +import com.itsaky.androidide.idetooltips.TooltipTag +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Test + +/** + * Pins each Java code action to its tooltip tag. Tooltip content is authored per tag and looked up + * by that tag, so a wrong tag fails silently at runtime: the action shows another action's tooltip + * or none at all (ADFA-4510). + * + * Tags are read through retrieveTooltipTag(), the member the code-actions renderer calls. The + * Kotlin equivalent asserts on the tooltipTag property instead, which is why it kept passing while + * ADFA-4510 was live. + * + * Actions pinned to "" have no authored tooltip yet. Tagging one later must be a deliberate edit + * here, not a silent drift. + */ +class JavaCodeActionTooltipTagTest { + private val actualTags + get() = JavaCodeActionsMenu.actions.associate { it.id to it.retrieveTooltipTag(false) } + + @Test + fun `every java code action maps to its own tooltip tag`() { + val expected = + mapOf( + "ide.editor.lsp.java.commentLine" to TooltipTag.EDITOR_CODE_ACTIONS_COMMENT, + "ide.editor.lsp.java.uncommentLine" to TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT, + "ide.editor.lsp.java.gotoDefinition" to TooltipTag.EDITOR_CODE_ACTIONS_GOTO_DEF, + "ide.editor.lsp.java.findReferences" to TooltipTag.EDITOR_CODE_ACTIONS_FIND_REFS, + "ide.editor.lsp.java.diagnostics.addImport" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.autoFixImports" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.implementAbstractMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.settersAndGetters" to + TooltipTag.EDITOR_CODE_ACTIONS_SETTER_GETTER, + "ide.editor.lsp.java.generator.overrideSuperclassMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.missingConstructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.constructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.toString" to TooltipTag.EDITOR_CODE_ACTIONS_GEN_TO_STRING, + "ide.editor.lsp.java.removeUnusedImports" to + TooltipTag.EDITOR_CODE_ACTIONS_UNUSED_IMPORTS, + "lsp_java_organizeImports" to TooltipTag.EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS, + // No authored tooltip yet. + "ide.editor.lsp.java.diagnostics.variableToStatement" to "", + "ide.editor.lsp.java.diagnostics.fieldToBlock" to "", + "ide.editor.lsp.java.diagnostics.removeClass" to "", + "ide.editor.lsp.java.diagnostics.removeMethod" to "", + "ide.editor.lsp.java.diagnostics.removeUnusedThrows" to "", + "ide.editor.lsp.java.diagnostics.createMissingMethod" to "", + "ide.editor.lsp.java.diagnostics.suppressUncheckedWarning" to "", + "ide.editor.lsp.java.diagnostics.addThrows" to "", + ) + assertEquals(expected, actualTags) + } + + /** Guards a Java action drifting onto a Kotlin tag or some unrelated namespace. */ + @Test + fun `no java code action borrows a non java code action tag`() { + actualTags.forEach { (id, tag) -> + if (tag.isEmpty()) return@forEach + assertTrue( + "$id uses tag '$tag' outside the java code actions namespace", + tag.startsWith("editor.codeactions.") && !tag.startsWith("editor.codeactions.kotlin."), + ) + } + } +} +``` + +- [ ] **Step 2: Run the test to verify it fails** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:java:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.java.actions.JavaCodeActionTooltipTagTest" +``` + +Expected: FAIL on `every java code action maps to its own tooltip tag`. The map differs at two keys — `variableToStatement` and `fieldToBlock` return `editor.codeactions.fiximports` where `""` is expected. + +- [ ] **Step 3: Drop the mis-copied tag from `VariableToStatementAction`** + +In `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt`, delete this line: + +```kotlin + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS +``` + +Also remove the now-unused `import com.itsaky.androidide.idetooltips.TooltipTag` if no other reference to `TooltipTag` remains in the file (check with `grep -n TooltipTag` on that file). + +- [ ] **Step 4: Drop the mis-copied tag from `FieldToBlockAction`** + +In `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt`, delete this line: + +```kotlin + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS +``` + +Remove the now-unused `TooltipTag` import on the same condition as Step 3. + +- [ ] **Step 5: Run the test to verify it passes** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:java:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.java.actions.JavaCodeActionTooltipTagTest" +``` + +Expected: PASS, 2 tests. + +- [ ] **Step 6: Confirm the Kotlin pinning test still passes** + +Task 2 changed a shared interface default, so re-run the neighbouring suite. Do not edit it. + +```bash +flox activate -d flox/local -- ./gradlew :lsp:kotlin:testV8DebugUnitTest \ + --tests "com.itsaky.androidide.lsp.kotlin.KotlinCodeActionTooltipTagTest" +``` + +Expected: PASS, 2 tests. + +- [ ] **Step 7: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt \ + lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt +git commit -m "fix(ADFA-4510): pin java code action tooltip tags + +VariableToStatementAction and FieldToBlockAction carried the fiximports tag by +copy-paste; neither touches imports. They were silent before this branch and +would have started showing wrong help. Drop both overrides. + +Add JavaCodeActionTooltipTagTest, reading through retrieveTooltipTag() so it +exercises the member the renderer actually calls." +``` + +--- + +### Task 4: Resolve tag and category at the code actions bind site + +The render-path fix. `ActionsListAdapter` gains an optional parent menu so submenu children resolve, the `contentDescription` fallback is deleted, and the hardcoded `ide` category is replaced by the action's own category so plugin-contributed code actions look up their `plugin_` rows. + +**Files:** +- Modify: `editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt` + +**Interfaces:** +- Consumes: `ActionMenu.findAction(itemId: Int): ActionItem?` and the `retrieveTooltipTag` delegation, both from Task 2. +- Produces: nothing consumed by later tasks. + +This file is already tab-indented, so no reformat churn. It has no logger yet; we add one to the existing companion object following the module idiom (`IDEEditor.kt:231`). + +- [ ] **Step 1: Add the imports** + +In `editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt`, add to the import block, each in its existing alphabetical position: + +```kotlin +import com.itsaky.androidide.actions.ActionMenu +import com.itsaky.androidide.idetooltips.TooltipCategory +``` + +`org.slf4j.LoggerFactory` goes with the other non-`com.itsaky` imports at the bottom of the block: + +```kotlin +import org.slf4j.LoggerFactory +``` + +- [ ] **Step 2: Add a logger to the companion object** + +Replace the existing companion object (around line 81): + +```kotlin + companion object { + const val DELAY: Long = 200 + } +``` + +with: + +```kotlin + companion object { + const val DELAY: Long = 200 + + private val log = LoggerFactory.getLogger(EditorActionsMenu::class.java) + } +``` + +- [ ] **Step 3: Give `ActionsListAdapter` an optional parent menu** + +Replace the adapter's constructor (around line 403): + +```kotlin + private class ActionsListAdapter( + val menu: Menu?, + val forceShowTitle: Boolean = false, + val editor: IDEEditor, + val location: ActionItem.Location, + ) : RecyclerView.Adapter() { +``` + +with: + +```kotlin + private class ActionsListAdapter( + val menu: Menu?, + val forceShowTitle: Boolean = false, + val editor: IDEEditor, + val location: ActionItem.Location, + // Children of a submenu are not registered with the ActionsRegistry, so they can only be + // resolved through their parent menu (ADFA-4510). Null for the top-level actions row. + val actionMenu: ActionMenu? = null, + ) : RecyclerView.Adapter() { +``` + +- [ ] **Step 4: Resolve the action, tag and category in `onBindViewHolder`** + +Replace these three lines (around line 432): + +```kotlin + val action = getInstance().findAction(location, item.itemId) + val tooltipTag = action?.retrieveTooltipTag(false) ?: "" + val tag = tooltipTag.ifEmpty { item.contentDescription?.toString() ?: "" } +``` + +with: + +```kotlin + val action = + actionMenu?.findAction(item.itemId) + ?: getInstance().findAction(location, item.itemId) + val tag = action?.retrieveTooltipTag(false) ?: "" + val category = action?.retrieveTooltipCategory() ?: TooltipCategory.CATEGORY_IDE +``` + +The dropped fallback read `item.contentDescription`, which `DefaultActionsRegistry.kt:217` sets to the action's human-readable label. It could never match a tag, so it only turned "no tooltip" into a silent database miss. + +- [ ] **Step 5: Show the tooltip in the action's own category, and log an untagged action** + +Replace the long-click listener (around line 458): + +```kotlin + button.setOnLongClickListener { + if (tag.isNotEmpty()) { + TooltipManager.showIdeCategoryTooltip( + context = editor.context, + anchorView = editor, + tag = tag, + ) + } + true + } +``` + +with: + +```kotlin + button.setOnLongClickListener { + if (tag.isEmpty()) { + log.warn("No tooltip tag for action '{}'", item.title) + } else { + TooltipManager.showTooltip( + context = editor.context, + anchorView = editor, + category = category, + tag = tag, + ) + } + true + } +``` + +- [ ] **Step 6: Pass the parent menu when building the submenu adapter** + +Replace these lines in `onMenuItemSelected` (around line 490): + +```kotlin + this.list.layoutManager = LinearLayoutManager(editor.context) + this.list.adapter = + ActionsListAdapter(item.subMenu, true, editor, location = onGetActionLocation()) +``` + +with: + +```kotlin + this.list.layoutManager = LinearLayoutManager(editor.context) + val parentMenu = getInstance().findAction(onGetActionLocation(), item.itemId) as? ActionMenu + this.list.adapter = + ActionsListAdapter( + item.subMenu, + true, + editor, + location = onGetActionLocation(), + actionMenu = parentMenu, + ) +``` + +`CodeActionsMenu` *is* registered at `DefaultActionsRegistry.kt:61`, so this lookup succeeds — it is only its children that the registry cannot see. + +- [ ] **Step 7: Compile the module** + +```bash +flox activate -d flox/local -- ./gradlew :editor:compileV8DebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 8: Re-run both pinning suites and the resolver suite** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + :lsp:java:testV8DebugUnitTest :lsp:kotlin:testV8DebugUnitTest +``` + +Expected: BUILD SUCCESSFUL, no failures. + +- [ ] **Step 9: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt +git commit -m "fix(ADFA-4510): resolve code action tooltips at the bind site + +Pass the parent ActionMenu to the submenu adapter so code actions resolve; the +registry only holds top-level actions. + +Drop the contentDescription fallback. It read the action's label, which can +never match a tag, so it converted a missing tooltip into a silent DB miss. +Log a warning instead. + +Use the action's own tooltip category rather than hardcoding 'ide', so +plugin-contributed code actions hit their plugin_ rows." +``` + +--- + +### Task 5: Point the override-superclass dialog at its own tooltip tag + +`EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG` is declared but referenced nowhere. The dialog passes the menu-item tag instead, so long-pressing it shows the wrong tooltip. The three sibling dialogs in `FieldBasedAction.kt` already do this correctly. + +**Files:** +- Modify: `lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt` + +**Interfaces:** +- Consumes: nothing from earlier tasks. +- Produces: nothing consumed by later tasks. + +This file is already tab-indented. The change is behavioural only inside a dialog callback, which no unit test can reach without an Android dialog; it is verified manually in Task 6. + +- [ ] **Step 1: Confirm the constant is currently unreferenced** + +```bash +grep -rn 'EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG' --include=*.kt . +``` + +Expected: exactly one hit, the declaration in `idetooltips/.../TooltipTag.kt`. + +- [ ] **Step 2: Point both dialog long-press handlers at the dialog tag** + +In `OverrideSuperclassMethodsAction.kt` (around lines 211-224), replace: + +```kotlin + val listView = dialog.listView + listView.setOnItemLongClickListener { _, view, position, _ -> + showTooltip(context, view, tooltipTag) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + + root.applyLongPressRecursively { + showTooltip(context, root, tooltipTag) + true + } + } +``` + +with: + +```kotlin + val listView = dialog.listView + listView.setOnItemLongClickListener { _, view, position, _ -> + showTooltip(context, view, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + + root.applyLongPressRecursively { + showTooltip(context, root, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) + true + } + } +``` + +`TooltipTag` is already imported in this file (it is used for the `tooltipTag` override); confirm with `grep -n 'import com.itsaky.androidide.idetooltips.TooltipTag' ` and add the import if absent. + +- [ ] **Step 3: Verify the constant is now referenced** + +```bash +grep -rn 'EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG' --include=*.kt . +``` + +Expected: three hits — the declaration plus the two call sites. + +- [ ] **Step 4: Compile the module** + +```bash +flox activate -d flox/local -- ./gradlew :lsp:java:compileV8DebugKotlin +``` + +Expected: BUILD SUCCESSFUL. + +- [ ] **Step 5: Format and commit** + +```bash +flox activate -d flox/local -- ./gradlew spotlessApply +git add lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt +git commit -m "fix(ADFA-4510): use the dialog tooltip tag in the override dialog + +The method-selection dialog passed the menu item's tag, so it showed the menu +tooltip instead of its own. EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG was +declared but referenced nowhere." +``` + +--- + +### Task 6: Build and verify on the emulator + +Static analysis and unit tests cannot prove a popup renders. This task confirms the fix end to end. + +**Files:** none modified. + +**Interfaces:** +- Consumes: all previous tasks. +- Produces: the evidence needed to close the ticket. + +- [ ] **Step 1: Copy in the gitignored Firebase config if absent** + +Fresh worktrees lack `app/google-services.json`, and `:app:processV8DebugGoogleServices` fails without it. It should already be present from worktree setup; confirm. + +```bash +repo_root="$(git rev-parse --show-toplevel)" + +# Already there? Nothing to do. +if [ ! -f "$repo_root/app/google-services.json" ]; then + # Name the donor checkout explicitly -- never guess a sibling path, or you can + # copy Firebase config from an unrelated project into this build. + : "${GOOGLE_SERVICES_SRC:?set GOOGLE_SERVICES_SRC to an existing app/google-services.json}" + [ -f "$GOOGLE_SERVICES_SRC" ] || { + echo "not a file: $GOOGLE_SERVICES_SRC" >&2 + exit 1 + } + cp "$GOOGLE_SERVICES_SRC" "$repo_root/app/google-services.json" +fi + +ls -la "$repo_root/app/google-services.json" +``` + +- [ ] **Step 2: Run the full unit test sweep for the touched modules** + +```bash +flox activate -d flox/local -- ./gradlew :actions:testV8DebugUnitTest \ + :lsp:java:testV8DebugUnitTest :lsp:kotlin:testV8DebugUnitTest :editor:testV8DebugUnitTest +``` + +Expected: BUILD SUCCESSFUL. Record the test counts. + +- [ ] **Step 3: Verify formatting is clean** + +```bash +flox activate -d flox/local -- ./gradlew spotlessCheck +``` + +Expected: BUILD SUCCESSFUL. If it fails, run `spotlessApply` and amend the relevant commit. + +- [ ] **Step 4: Build the debug APK** + +```bash +flox activate -d flox/local -- ./gradlew :app:assembleV8Debug --parallel --max-workers=6 +``` + +Expected: BUILD SUCCESSFUL. This takes several minutes. + +- [ ] **Step 4b: Build and side-load the assets payload** + +`:app:assembleV8Debug` does NOT bundle the large assets. A debug install reads them from a +side-loaded zip, and without it the app comes up with no project templates, no Termux bootstrap, no +Android SDK, and no `documentation.db` — so no project can be opened and no tooltip can ever +resolve. `SplitAssetsInstaller` reads `Environment.SPLIT_ASSETS_ZIP` +(`common/.../Environment.java:143`), which is `/sdcard/Download/assets-.zip`. + +```bash +flox activate -d flox/local -- ./gradlew :app:assembleV8Assets +adb -s emulator-5554 push app/build/outputs/assets/assets-arm64-v8a.zip \ + /sdcard/Download/assets-arm64-v8a.zip +``` + +The payload is ~1.1GB and the on-device install runs at next launch. Confirm afterwards: +`adb -s emulator-5554 shell run-as com.itsaky.androidide ls files/home/.cg/templates` must be +non-empty, and `databases/documentation.db` must exist. + +- [ ] **Step 5: Confirm the emulator is up and install** + +```bash +adb devices -l | grep -v offline +``` + +Expected: `emulator-5554` listed. The app is arm-only (`v7`/`v8`), so this must be an arm or arm-translation device. Then install the APK produced in Step 4: + +```bash +adb -s emulator-5554 install -r app/build/outputs/apk/v8/debug/*.apk +``` + +- [ ] **Step 6: Verify tooltips render** + +Open a Java file in the IDE, select some text to raise the editor actions row, tap the Code actions item, then long-press menu entries. Note that the emulator's bottom gesture-exclusion zone swallows coordinate taps — drive the UI with `ACTION_CLICK` via accessibility (`mcp__android__tap_element`) rather than raw coordinates. + +Check: +- Long-pressing a tagged entry (for example **Comment line**) shows a tooltip popup. +- Long-pressing an untagged entry (for example **Remove class**) shows nothing and logs `No tooltip tag for action` — confirm with `adb -s emulator-5554 logcat -d | grep "No tooltip tag"`. +- Open the **Override superclass methods** dialog and long-press it; the text should describe selecting methods to override, not the menu item's description. + +Take a screenshot of a rendered tooltip as evidence for the ticket. + +- [ ] **Step 7: Post progress to Jira** + +```bash +jira issue comment add ADFA-4510 "Fixed in bugfix/ADFA-4510-missing-tooltips-code-actions. Root cause was the render path, not missing tags: code actions are children of CodeActionsMenu and were never resolvable through the registry, and the bind site read retrieveTooltipTag() while every action overrides the tooltipTag property. All 11 menu tags and all 4 dialog tags now resolve. Added unit tests in the actions and lsp/java modules." +``` + +Also confirm the ticket's assignee and status are correct while you are there. + +--- + +## Self-Review + +**Spec coverage.** Every design section maps to a task: unify members (Task 2, Step 5), `ActionMenu.findAction(itemId)` (Task 2, Step 4), submenu adapter parent (Task 4, Steps 3 and 6), drop the `contentDescription` fallback (Task 4, Step 4), tag and category resolution (Task 4, Steps 4-5), the two tag corrections (Task 3), the dialog tag (Task 5), both test files (Tasks 2 and 3), manual verification (Task 6). The spec's "out of scope" items are correctly absent. + +**Type consistency.** `findAction(itemId: Int): ActionItem?` is defined in Task 2 Step 4 and consumed in Task 4 Step 4 with the same name and signature. `retrieveTooltipTag(isReadOnlyContext: Boolean): String` keeps its existing signature throughout. `actionMenu` is the constructor parameter name in Task 4 Steps 3, 4 and 6. `TooltipManager.showTooltip(context, anchorView, category, tag)` matches the signature at `ToolTipManager.kt:187`. + +**Known gap.** Task 4's changes have no automated coverage; `ActionsListAdapter` is a private nested class requiring an `IDEEditor`. Its two ingredients are unit-tested in Task 2, and the wiring is verified manually in Task 6. Chosen deliberately over a brittle Robolectric test that would need heavy sora-editor mocking. diff --git a/docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md b/docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md new file mode 100644 index 0000000000..561df79123 --- /dev/null +++ b/docs/superpowers/specs/2026-08-06-adfa-4510-codeaction-tooltips-design.md @@ -0,0 +1,200 @@ +# ADFA-4510: Missing tooltips on code actions + +**Ticket:** [ADFA-4510](https://appdevforall.atlassian.net/browse/ADFA-4510) (Bug, Important 4/10, `R2-bugs`) +**Branch:** `bugfix/ADFA-4510-missing-tooltips-code-actions` + +## Problem + +Long-pressing an item in the editor's Code Actions menu shows nothing. The ticket attributes this to +unimplemented tooltip tags. That diagnosis is wrong: 14 of the 15 tags Elissa listed are already +wired to their actions, and all 15 exist in `documentation.db`. The tooltips fail in the render path. + +Note on the database: our local copy may be stale, so DB contents are not treated as authoritative +here. This spec changes only code. Any tag that still shows nothing after this work is a content +hand-off item, not a code defect. + +### Root cause + +Every code action renders through one bind site, `editor/.../EditorActionsMenu.kt:426`: + +```kotlin +val action = getInstance().findAction(location, item.itemId) +val tooltipTag = action?.retrieveTooltipTag(false) ?: "" +val tag = tooltipTag.ifEmpty { item.contentDescription?.toString() ?: "" } +``` + +Three defects stack here. + +**1. `action` is always null for code actions.** Code actions are never registered with the registry; +they are added as children of `CodeActionsMenu` (`lsp/api/.../LSPEditorActions.java:47`). +`DefaultActionsRegistry.findAction` (`:117-125`) scans only the flat per-location map and never +recurses into `ActionMenu.children`. The submenu adapter also receives `onGetActionLocation()` = +`EDITOR_TEXT_ACTIONS` (`:493`), the parent's location. Since `itemId = id.hashCode()` +(`ActionItem.kt:113`), no match is possible. + +**2. A successful lookup would still return `""`.** `ActionItem` carries two members meaning the same +thing: the property `tooltipTag` (`:73-78`) and the function `retrieveTooltipTag()` (`:92`), both +defaulting to `""`. The bind site calls the function. Across `lsp/` there are **0** overrides of the +function and **22** of the property. + +**3. The fallback guarantees a miss.** `item.contentDescription` is set to `action.label` +(`DefaultActionsRegistry.kt:217`) and by nothing else, so the code queries the tooltip DB for a tag +named e.g. `"Comment line"`. `ToolTipManager.kt:211` logs and shows nothing — silence on long-press, +which `REVIEW.md:164` forbids. + +`editor.toolbar.codeactions` works because `CodeActionsMenu` is registered *and* overrides the +function (`CodeActionsMenu.kt:41`) — the opposite of its own children on both counts. + +### Current state of the 15 tags + +| Status | Count | Why | +| --- | --- | --- | +| Show nothing | 11 | Menu items, blocked by defects 1 and 2 | +| Work | 3 | `genconstructor.dialog`, `gentostring.dialog`, `settergetter.dialog` — `FieldBasedAction.kt:250-263` calls `TooltipManager` directly, bypassing the bind site | +| Dead constant | 1 | `overridesuper.dialog` is referenced nowhere; `OverrideSuperclassMethodsAction.kt:212-224` passes the menu-item tag, so the dialog shows the wrong tooltip | + +## Design + +### 1. Unify the two tag members + +`actions/src/main/java/com/itsaky/androidide/actions/ActionItem.kt:92` + +```kotlin +fun retrieveTooltipTag(isReadOnlyContext: Boolean): String = tooltipTag +``` + +Fixes defect 2 for every consumer at once. The change is one-directional and cannot regress: + +| Action overrides | Before | After | +| --- | --- | --- | +| neither member | `""` | `""` | +| the function | function value | function value | +| the property | `""` | property value | + +### 2. Let an `ActionMenu` find a child by `itemId` + +`actions/src/main/java/com/itsaky/androidide/actions/ActionMenu.kt`, mirroring the existing +`findAction(id: String)`: + +```kotlin +fun findAction(itemId: Int): ActionItem? = children.find { it.itemId == itemId } +``` + +Flat, one level. Nested action menus do not occur in this codebase; recursion would be speculative. + +### 3. Give the submenu adapter its parent menu + +`editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt` + +`ActionsListAdapter` gains `val actionMenu: ActionMenu? = null`. At `:493` the submenu adapter is +constructed with the resolved parent — `findAction(location, item.itemId)` already returns +`CodeActionsMenu` correctly, so no registry change is needed: + +```kotlin +val parent = getInstance().findAction(location, item.itemId) as? ActionMenu +this.list.adapter = + ActionsListAdapter(item.subMenu, true, editor, location = onGetActionLocation(), actionMenu = parent) +``` + +The top-level adapter at `:309` passes `null` and behaves exactly as today. + +### 4. Resolve tag and category at the bind site + +Replaces `:432-434` and `:458-467`. Drops the `contentDescription` fallback and stops hardcoding the +`ide` category, so plugin-contributed code actions resolve against their own `plugin_` category: + +```kotlin +val action = actionMenu?.findAction(item.itemId) + ?: getInstance().findAction(location, item.itemId) +val tag = action?.retrieveTooltipTag(false) ?: "" +val category = action?.retrieveTooltipCategory() ?: TooltipCategory.CATEGORY_IDE +... +button.setOnLongClickListener { + if (tag.isEmpty()) { + log.warn("No tooltip tag for action '{}'", item.title) + } else { + TooltipManager.showTooltip(editor.context, editor, category, tag) + } + true +} +``` + +A logger is added to the existing companion object (`:81`) following the module idiom, +`LoggerFactory.getLogger(...)` as in `IDEEditor.kt:231`. The warn makes an untagged action visible in +logcat instead of silently absent. + +### 5. Tag corrections + +- **Drop** `tooltipTag` from `VariableToStatementAction.kt:42` and `FieldToBlockAction.kt:41`. Both + carry `EDITOR_CODE_ACTIONS_FIX_IMPORTS` by copy-paste; neither touches imports. Unifying the + members would turn them from silent into actively wrong. They stay silent, correctly. +- **Fix** `OverrideSuperclassMethodsAction.kt:212-224` to pass + `EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG` for the dialog long-press instead of the menu-item tag. + Retires the dead constant and completes the fourth dialog tag. + +## Testing + +Both existing tooltip tests pass despite this bug: they assert on the property while the render path +reads the function. New coverage targets the seam that actually failed. + +### `actions/src/test/.../ActionTooltipResolutionTest.kt` + +First tests in the `actions` module; adds `testImplementation(projects.testing.unit)`. No circular +dependency — `testing/unit` depends on `buildInfo`, `common`, `shared`, `testing/common` only. +Plain JVM, no Robolectric. Covers both halves of the resolution chain with hand-rolled fake +`ActionItem` / `ActionMenu` implementations. + +`ActionMenu.findAction(itemId)`: + +- returns the matching child +- returns null for an unknown itemId + +`ActionItem.retrieveTooltipTag(false)`: + +- returns the property value when only `tooltipTag` is overridden (the ADFA-4510 regression) +- returns the function value when only `retrieveTooltipTag` is overridden +- returns `""` when neither is overridden + +### `lsp/java/src/test/.../JavaCodeActionTooltipTagTest.kt` + +Mirrors `KotlinCodeActionTooltipTagTest`, into an existing test source set. No new dependencies: +`lsp/java/build.gradle.kts:69` already has `testImplementation(projects.testing.lsp)`, which +re-exports `testing/unit` (JUnit, Truth, MockK). + +- Whole-map `assertEquals` over all 22 actions in `JavaCodeActionsMenu`, read through + `retrieveTooltipTag(false)` — the member the render path uses. A whole-map comparison means a newly + registered untagged action fails automatically. +- Every non-empty tag `startsWith("editor.codeactions.")`. +- The 8 untagged actions pin explicitly to `""`, so tagging one later is a deliberate test edit + rather than silent drift. + +### Manual verification + +Build `:app:assembleV8Debug`, install on `emulator-5554`, open a Java file, and long-press each code +action to confirm a popup renders. + +## Outcome + +14 of 22 Java code actions resolve a tag. All 11 menu tags and all 4 dialog tags from the ticket are +reachable from code. + +The 8 actions with no tag — `RemoveClassAction`, `RemoveMethodAction`, `RemoveUnusedThrowsAction`, +`CreateMissingMethodAction`, `SuppressUncheckedWarningAction`, `AddThrowsAction`, plus the two +corrected above — stay silent. They are outside the ticket's scope and need authored content before +tagging is meaningful. + +## Out of scope + +Filed or noted, not addressed here: + +- All 8 Kotlin code-action tags (`editor.codeactions.kotlin.*`) appear to have no DB rows. Content + hand-off, not code. +- `idetooltips/README.md` documents a Room database and an API that no longer exist (ADFA-4382). +- Tooltip tag/DB reconciliation in CI. The DB lives outside the repo and our copy may be stale, so a + meaningful check is not possible from this worktree. + +## Conflict risk + +Open PR #1624 (ADFA-4824) edits `TooltipTag.kt`, `KotlinCodeActionsMenu.kt`, and +`KotlinCodeActionTooltipTagTest.kt`. This work adds no constants to `TooltipTag.kt` and touches no +Kotlin LSP file, so the surfaces do not overlap. diff --git a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt index 715de8484e..10cf6101c6 100644 --- a/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt +++ b/editor/src/main/java/com/itsaky/androidide/editor/ui/EditorActionsMenu.kt @@ -36,6 +36,7 @@ import androidx.transition.ChangeBounds import androidx.transition.TransitionManager import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.ActionItem +import com.itsaky.androidide.actions.ActionMenu import com.itsaky.androidide.actions.ActionsRegistry import com.itsaky.androidide.actions.ActionsRegistry.Companion.getInstance import com.itsaky.androidide.actions.EditorActionItem @@ -44,6 +45,7 @@ import com.itsaky.androidide.actions.TextTarget import com.itsaky.androidide.editor.adapters.IdeEditorAdapter import com.itsaky.androidide.editor.databinding.LayoutPopupMenuItemBinding import com.itsaky.androidide.editor.ui.EditorActionsMenu.ActionsListAdapter.VH +import com.itsaky.androidide.idetooltips.TooltipCategory import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.api.ILanguageServerRegistry @@ -63,6 +65,7 @@ import io.github.rosemoe.sora.event.SubscriptionReceipt import io.github.rosemoe.sora.text.Cursor import io.github.rosemoe.sora.widget.CodeEditor import io.github.rosemoe.sora.widget.EditorTouchEventHandler +import org.slf4j.LoggerFactory import java.io.File import kotlin.math.max import kotlin.math.min @@ -80,6 +83,8 @@ open class EditorActionsMenu( MenuBuilder.Callback { companion object { const val DELAY: Long = 200 + + private val log = LoggerFactory.getLogger(EditorActionsMenu::class.java) } private val touchHandler: EditorTouchEventHandler = editor.eventHandler @@ -406,6 +411,9 @@ open class EditorActionsMenu( val forceShowTitle: Boolean = false, val editor: IDEEditor, val location: ActionItem.Location, + // Children of a submenu are not registered with the ActionsRegistry, so they can only be + // resolved through their parent menu (ADFA-4510). Null for the top-level actions row. + val actionMenu: ActionMenu? = null, ) : RecyclerView.Adapter() { override fun getItemCount(): Int = menu?.size() ?: 0 @@ -429,9 +437,11 @@ open class EditorActionsMenu( ) { val item = getItem(position) ?: return - val action = getInstance().findAction(location, item.itemId) - val tooltipTag = action?.retrieveTooltipTag(false) ?: "" - val tag = tooltipTag.ifEmpty { item.contentDescription?.toString() ?: "" } + val action = + actionMenu?.findAction(item.itemId) + ?: getInstance().findAction(location, item.itemId) + val tag = action?.retrieveTooltipTag(false) ?: "" + val category = action?.retrieveTooltipCategory() ?: TooltipCategory.CATEGORY_IDE val button = holder.binding.root button.text = if (forceShowTitle) item.title else "" @@ -456,13 +466,17 @@ open class EditorActionsMenu( } button.setOnLongClickListener { - if (tag.isNotEmpty()) { - TooltipManager.showIdeCategoryTooltip( - context = editor.context, - anchorView = editor, - tag = tag, - ) + if (tag.isEmpty()) { + log.warn("No tooltip tag for action '{}'", item.title) } + // An empty tag still goes through: a DB miss renders the documentation + // fallback (ADFA-4754), which beats a dead long-press. + TooltipManager.showTooltip( + context = editor.context, + anchorView = editor, + category = category, + tag = tag, + ) true } } @@ -489,8 +503,15 @@ open class EditorActionsMenu( this.editor.post { TransitionManager.beginDelayedTransition(this.list, ChangeBounds()) this.list.layoutManager = LinearLayoutManager(editor.context) + val parentMenu = getInstance().findAction(onGetActionLocation(), item.itemId) as? ActionMenu this.list.adapter = - ActionsListAdapter(item.subMenu, true, editor, location = onGetActionLocation()) + ActionsListAdapter( + item.subMenu, + true, + editor, + location = onGetActionLocation(), + actionMenu = parentMenu, + ) this.list.post { measureActionsList() 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 5a4777b9bb..f9695bb9eb 100644 --- a/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt +++ b/idetooltips/src/main/java/com/itsaky/androidide/idetooltips/TooltipTag.kt @@ -71,6 +71,7 @@ object TooltipTag { const val EDITOR_CODE_ACTIONS_GOTO_DEF = "editor.codeactions.gotodef" const val EDITOR_CODE_ACTIONS_FIND_REFS = "editor.codeactions.findrefs" const val EDITOR_CODE_ACTIONS_FIX_IMPORTS = "editor.codeactions.fiximports" + const val EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG = "editor.codeactions.fiximports.dialog" const val EDITOR_CODE_ACTIONS_SETTER_GETTER = "editor.codeactions.settergetter" const val EDITOR_CODE_ACTIONS_SETTER_GETTER_DIALOG = "editor.codeactions.settergetter.dialog" const val EDITOR_CODE_ACTIONS_OVERRIDE_SUPER = "editor.codeactions.overridesuper" @@ -89,9 +90,13 @@ object TooltipTag { const val EDITOR_CODE_ACTIONS_KT_COMMENT = "editor.codeactions.kotlin.comment" const val EDITOR_CODE_ACTIONS_KT_UNCOMMENT = "editor.codeactions.kotlin.uncomment" const val EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS = "editor.codeactions.kotlin.importclass" + const val EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS_DIALOG = + "editor.codeactions.kotlin.importclass.dialog" const val EDITOR_CODE_ACTIONS_KT_ORGANIZE_IMPORTS = "editor.codeactions.kotlin.organizeimports" const val EDITOR_CODE_ACTIONS_KT_IMPLEMENT_MEMBERS = "editor.codeactions.kotlin.implementmembers" const val EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX = "editor.codeactions.kotlin.nullsafetyfix" + const val EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX_DIALOG = + "editor.codeactions.kotlin.nullsafetyfix.dialog" 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" diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt index de002a09bd..00e1e31549 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AddImportAction.kt @@ -1,183 +1,233 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.diagnostics - -import com.google.common.collect.Iterables.toArray -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.hasRequiredData -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.actions.newDialogBuilder -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.javac.services.util.JavaDiagnosticUtils -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.lsp.java.models.DiagnosticCode -import com.itsaky.androidide.lsp.java.rewrite.AddImport -import com.itsaky.androidide.lsp.java.rewrite.Rewrite -import com.itsaky.androidide.lsp.models.CodeActionItem -import com.itsaky.androidide.lsp.models.DiagnosticItem -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import jdkx.tools.Diagnostic -import jdkx.tools.JavaFileObject -import org.slf4j.LoggerFactory - -/** @author Akash Yadav */ -class AddImportAction : BaseJavaCodeAction() { - - override val id: String = "ide.editor.lsp.java.diagnostics.addImport" - override var label: String = "" - private val diagnosticCode = DiagnosticCode.NOT_IMPORTED.id - - override val titleTextRes: Int = R.string.action_import_classes - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - companion object { - - private val log = LoggerFactory.getLogger(AddImportAction::class.java) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if (!visible || !data.hasRequiredData(DiagnosticItem::class.java)) { - markInvisible() - return - } - - val diagnostic = data.get(DiagnosticItem::class.java)!! - if (diagnosticCode != diagnostic.code || diagnostic.extra !is Diagnostic<*>) { - markInvisible() - return - } - - val file = data.requireFile() - val module = - IProjectManager.getInstance().findModuleForFile(file, false) - ?: run { - markInvisible() - return - } - - val compiler = JavaCompilerProvider.get(module) - - @Suppress("UNCHECKED_CAST") - val jcDiagnostic = - JavaDiagnosticUtils.asJCDiagnostic(diagnostic.extra as Diagnostic) - if (jcDiagnostic == null) { - markInvisible() - return - } - - val found = - jcDiagnostic.args[1]?.toString()?.let { compiler.findQualifiedNames(it, true).isNotEmpty() } - ?: false - - visible = found - enabled = found - } - - override suspend fun execAction(data: ActionData): Any { - @Suppress("UNCHECKED_CAST") - val diagnostic = - JavaDiagnosticUtils.asUnwrapper( - data.get(DiagnosticItem::class.java)!!.extra as Diagnostic - )!! - val file = data.requireFile() - val module = - IProjectManager.getInstance().findModuleForFile(file, false) - ?: run { - markInvisible() - return Any() - } - - val compiler = JavaCompilerProvider.get(module) - - val titles = mutableListOf() - val rewrites = mutableListOf() - val simpleName = diagnostic.d.args[1] - for (name in compiler.publicTopLevelTypes()) { - var klass = name - if (klass.contains('/')) { - klass = klass.replace('/', '.') - } - - if (!klass.endsWith(".$simpleName")) { - continue - } - - titles.add(klass) - rewrites.add(AddImport(data.requirePath(), klass)) - } - - if (rewrites.isEmpty()) { - return false - } - - return Pair(titles, rewrites) - } - - @Suppress("UNCHECKED_CAST") - override fun postExec(data: ActionData, result: Any) { - - if (result !is Pair<*, *>) { - return - } - - val file = data.requireFile() - val module = - IProjectManager.getInstance().findModuleForFile(file, false) - ?: run { - markInvisible() - return - } - - val compiler = JavaCompilerProvider.get(module) - val client = data.getLanguageClient() ?: return - val actions = mutableListOf() - val titles = result.first as List - val rewrites = result.second as List - - for (index in rewrites.indices) { - val name = titles[index] - val rewrite = rewrites[index] - rewrite.asCodeActions(compiler, name)?.let { actions.add(it) } - } - - when (actions.size) { - 0 -> { - log.warn("No rewrites found. Cannot perform action") - } - - 1 -> { - client.performCodeAction(actions[0]) - } - - else -> { - val builder = newDialogBuilder(data) - builder.setTitle(label) - builder.setItems(toArray(titles, String::class.java)) { d, w -> - d.dismiss() - client.performCodeAction(actions[w]) - } - builder.show() - } - } - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.java.actions.diagnostics + +import android.content.Context +import android.view.View +import android.widget.ListView +import com.google.common.collect.Iterables.toArray +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.newDialogBuilder +import com.itsaky.androidide.actions.requireContext +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipManager +import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.javac.services.util.JavaDiagnosticUtils +import com.itsaky.androidide.lsp.api.ILanguageClient +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.models.DiagnosticCode +import com.itsaky.androidide.lsp.java.rewrite.AddImport +import com.itsaky.androidide.lsp.java.rewrite.Rewrite +import com.itsaky.androidide.lsp.models.CodeActionItem +import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.applyLongPressRecursively +import jdkx.tools.Diagnostic +import jdkx.tools.JavaFileObject +import org.slf4j.LoggerFactory + +/** @author Akash Yadav */ +class AddImportAction : BaseJavaCodeAction() { + override val id: String = "ide.editor.lsp.java.diagnostics.addImport" + override var label: String = "" + private val diagnosticCode = DiagnosticCode.NOT_IMPORTED.id + + override val titleTextRes: Int = R.string.action_import_classes + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS + + companion object { + private val log = LoggerFactory.getLogger(AddImportAction::class.java) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!visible || !data.hasRequiredData(DiagnosticItem::class.java)) { + markInvisible() + return + } + + val diagnostic = data.get(DiagnosticItem::class.java)!! + if (diagnosticCode != diagnostic.code || diagnostic.extra !is Diagnostic<*>) { + markInvisible() + return + } + + val file = data.requireFile() + val module = + IProjectManager.getInstance().findModuleForFile(file, false) + ?: run { + markInvisible() + return + } + + val compiler = JavaCompilerProvider.get(module) + + @Suppress("UNCHECKED_CAST") + val jcDiagnostic = + JavaDiagnosticUtils.asJCDiagnostic(diagnostic.extra as Diagnostic) + if (jcDiagnostic == null) { + markInvisible() + return + } + + val found = + jcDiagnostic.args[1]?.toString()?.let { compiler.findQualifiedNames(it, true).isNotEmpty() } + ?: false + + visible = found + enabled = found + } + + override suspend fun execAction(data: ActionData): Any { + @Suppress("UNCHECKED_CAST") + val diagnostic = + JavaDiagnosticUtils.asUnwrapper( + data.get(DiagnosticItem::class.java)!!.extra as Diagnostic, + )!! + val file = data.requireFile() + val module = + IProjectManager.getInstance().findModuleForFile(file, false) + ?: run { + markInvisible() + return Any() + } + + val compiler = JavaCompilerProvider.get(module) + + val titles = mutableListOf() + val rewrites = mutableListOf() + val simpleName = diagnostic.d.args[1] + for (name in compiler.publicTopLevelTypes()) { + var klass = name + if (klass.contains('/')) { + klass = klass.replace('/', '.') + } + + if (!klass.endsWith(".$simpleName")) { + continue + } + + titles.add(klass) + rewrites.add(AddImport(data.requirePath(), klass)) + } + + if (rewrites.isEmpty()) { + return false + } + + return Pair(titles, rewrites) + } + + @Suppress("UNCHECKED_CAST") + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is Pair<*, *>) { + return + } + + val file = data.requireFile() + val module = + IProjectManager.getInstance().findModuleForFile(file, false) + ?: run { + markInvisible() + return + } + + val compiler = JavaCompilerProvider.get(module) + val client = data.getLanguageClient() ?: return + val actions = mutableListOf() + val titles = result.first as List + val rewrites = result.second as List + + for (index in rewrites.indices) { + val name = titles[index] + val rewrite = rewrites[index] + rewrite.asCodeActions(compiler, name)?.let { actions.add(it) } + } + + when (actions.size) { + 0 -> { + log.warn("No rewrites found. Cannot perform action") + } + + 1 -> { + client.performCodeAction(actions[0]) + } + + else -> { + showImportChooser(data, titles, actions, client) + } + } + } + + /** + * Shows the import chooser and makes every part of it long-pressable for help. + * + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own + * listener -- the dialog chrome and the rows are wired separately (ADFA-4510). + */ + private fun showImportChooser( + data: ActionData, + titles: List, + actions: List, + client: ILanguageClient, + ) { + val context = data.requireContext() + val builder = newDialogBuilder(data) + builder.setTitle(label) + builder.setItems(toArray(titles, String::class.java)) { d, w -> + d.dismiss() + client.performCodeAction(actions[w]) + } + + val dialog = builder.create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG, + ) + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt index b00001c582..e89567953e 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/AutoFixImportsAction.kt @@ -17,9 +17,13 @@ package com.itsaky.androidide.lsp.java.actions.diagnostics +import android.content.Context +import android.view.View +import android.widget.ListView import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.requireContext import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag import com.itsaky.androidide.lsp.java.R import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction @@ -32,6 +36,7 @@ import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.models.Range import com.itsaky.androidide.utils.DialogUtils +import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashInfo import org.slf4j.LoggerFactory import java.nio.file.Path @@ -42,165 +47,221 @@ import java.nio.file.Path * @author Akash Yadav */ class AutoFixImportsAction : BaseJavaCodeAction() { + override val titleTextRes: Int = R.string.title_fix_imports + override val id: String = "ide.editor.lsp.java.diagnostics.autoFixImports" + override var label: String = "" + override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - override val titleTextRes: Int = R.string.title_fix_imports - override val id: String = "ide.editor.lsp.java.diagnostics.autoFixImports" - override var label: String = "" - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - companion object { - - private val log = LoggerFactory.getLogger(AutoFixImportsAction::class.java) - } - - override suspend fun execAction(data: ActionData): Result { - val path = data.requirePath() - val compiler = data.requireCompiler() - return compiler.compile(path).get { task -> - val classes = mutableMapOf>() - - // find all unresolved simple names - unresolvedNames(path, task).forEach { simpleName -> - - // if we have already looked for this simple name - // we do not need to look it up again - if (classes[simpleName] != null) return@forEach - - // find classes with those names - compiler.findQualifiedNames(simpleName).let { names -> - - // if we find classes with that specific simple name, map them to the simple name - if (names.isNotEmpty()) { - classes[simpleName] = names - } - } - } - - // return the result - Result(getFileImports(task, path), classes) - } - } - - override fun postExec(data: ActionData, result: Any) { - if (result !is Result) { - log.error("Invalid result returned from execAction: {}", result) - return - } - - if (result.classes.isEmpty()) { - flashInfo(R.string.msg_no_unresolved_classes) - return - } - - // if there are multiple classes with same simple name - // ask the user to choose the appropriate class - if (result.classes.any { it.value.size > 1 }) { - finalizeClassNames(data, result) - } else { - performEdits(data, result) - } - } - - private fun finalizeClassNames(data: ActionData, result: Result) { - var e: Map.Entry>? = null - for (entry in result.classes) { - if (entry.value.size > 1) { - e = entry - break - } - } - - if (e == null) { - performEdits(data, result) - return - } - - val context = data.requireContext() - DialogUtils.newMaterialDialogBuilder(context) - .setCancelable(true) - .setItems(e.value.toTypedArray()) { dialog, which -> - dialog.dismiss() - result.classes[e.key] = listOf(e.value[which]) - - // once the user decides which class to import for this simple name, - // call this method again to see if there any other simple names with multiple options - finalizeClassNames(data, result) - } - .setTitle(context.getString(R.string.title_class_chooser, e.key)) - .show() - } - - private fun performEdits(data: ActionData, result: Result) { - val path = data.requirePath() - val compiler = data.requireCompiler() - val client = - data.getLanguageClient() - ?: run { - log.warn("No language client found. Cannot perform edits.") - return - } - - val classes = result.classes.mapNotNull { it.value.firstOrNull() } - - if (classes.isEmpty()) { - flashInfo(R.string.msg_no_unresolved_classes) - return - } - - val insertText = StringBuilder() - if (result.fileImports.isEmpty() && classes.isNotEmpty()) { - // if there are no file imports, the new imports will be added just after the package - // declaration. To avoid this, add a new line before the imports - insertText.append("\n") - } - - for (klass in classes) { - insertText.append("import ${klass};\n") - } - - val position = compiler.compile(path).get { positionForImports(classes[0], it) } - - val change = DocumentChange() - change.file = path - change.edits = listOf(TextEdit(Range.pointRange(position), insertText.toString())) - - val action = CodeActionItem() - action.title = data.requireContext().getString(R.string.title_fix_imports) - action.kind = CodeActionKind.QuickFix - action.changes = listOf(change) - client.performCodeAction(action) - } - - /** - * Walks through the diagnostics of the compilation task, looks for [DiagnosticCode.NOT_IMPORTED] - * errors and returns a list of simple names of all not imported classes. - */ - private fun unresolvedNames(file: Path, task: CompileTask): List { - val names = mutableListOf() - var docContents: CharSequence? = null - val diagnostics = - task.diagnostics.filter { - it.source.toUri() == file.toUri() && it.code == DiagnosticCode.NOT_IMPORTED.id - } - for (diagnostic in diagnostics) { - val content = - try { - docContents ?: diagnostic.source.getCharContent(true).also { docContents = it } - } catch (e: Exception) { - log.error("Failed to get contents of file {}", file, e) - continue - } - - val name = - content.subSequence(diagnostic.startPosition.toInt(), diagnostic.endPosition.toInt()) - names.add(name.toString()) - } - return names - } - - private fun getFileImports(task: CompileTask, file: Path): Set { - return task.root(file).imports.map { it.qualifiedIdentifier }.map { it.toString() }.toSet() - } - - inner class Result(val fileImports: Set, val classes: MutableMap>) + companion object { + private val log = LoggerFactory.getLogger(AutoFixImportsAction::class.java) + } + + override suspend fun execAction(data: ActionData): Result { + val path = data.requirePath() + val compiler = data.requireCompiler() + return compiler.compile(path).get { task -> + val classes = mutableMapOf>() + + // find all unresolved simple names + unresolvedNames(path, task).forEach { simpleName -> + + // if we have already looked for this simple name + // we do not need to look it up again + if (classes[simpleName] != null) return@forEach + + // find classes with those names + compiler.findQualifiedNames(simpleName).let { names -> + + // if we find classes with that specific simple name, map them to the simple name + if (names.isNotEmpty()) { + classes[simpleName] = names + } + } + } + + // return the result + Result(getFileImports(task, path), classes) + } + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is Result) { + log.error("Invalid result returned from execAction: {}", result) + return + } + + if (result.classes.isEmpty()) { + flashInfo(R.string.msg_no_unresolved_classes) + return + } + + // if there are multiple classes with same simple name + // ask the user to choose the appropriate class + if (result.classes.any { it.value.size > 1 }) { + finalizeClassNames(data, result) + } else { + performEdits(data, result) + } + } + + private fun finalizeClassNames( + data: ActionData, + result: Result, + ) { + var e: Map.Entry>? = null + for (entry in result.classes) { + if (entry.value.size > 1) { + e = entry + break + } + } + + if (e == null) { + performEdits(data, result) + return + } + + val context = data.requireContext() + val entry = e + val dialog = + DialogUtils + .newMaterialDialogBuilder(context) + .setCancelable(true) + .setItems(entry.value.toTypedArray()) { dialog, which -> + dialog.dismiss() + result.classes[entry.key] = listOf(entry.value[which]) + + // once the user decides which class to import for this simple name, + // call this method again to see if there any other simple names with multiple options + finalizeClassNames(data, result) + }.setTitle(context.getString(R.string.title_class_chooser, entry.key)) + .create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + /** + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own listener + * -- the dialog chrome and the rows are wired separately (ADFA-4510). + * + * Shares [TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG] with AddImportAction's chooser: same + * question asked of the user, same answer, and the two actions already share an action tag. + */ + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS_DIALOG, + ) + } + + private fun performEdits( + data: ActionData, + result: Result, + ) { + val path = data.requirePath() + val compiler = data.requireCompiler() + val client = + data.getLanguageClient() + ?: run { + log.warn("No language client found. Cannot perform edits.") + return + } + + val classes = result.classes.mapNotNull { it.value.firstOrNull() } + + if (classes.isEmpty()) { + flashInfo(R.string.msg_no_unresolved_classes) + return + } + + val insertText = StringBuilder() + if (result.fileImports.isEmpty() && classes.isNotEmpty()) { + // if there are no file imports, the new imports will be added just after the package + // declaration. To avoid this, add a new line before the imports + insertText.append("\n") + } + + for (klass in classes) { + insertText.append("import $klass;\n") + } + + val position = compiler.compile(path).get { positionForImports(classes[0], it) } + + val change = DocumentChange() + change.file = path + change.edits = listOf(TextEdit(Range.pointRange(position), insertText.toString())) + + val action = CodeActionItem() + action.title = data.requireContext().getString(R.string.title_fix_imports) + action.kind = CodeActionKind.QuickFix + action.changes = listOf(change) + client.performCodeAction(action) + } + + /** + * Walks through the diagnostics of the compilation task, looks for [DiagnosticCode.NOT_IMPORTED] + * errors and returns a list of simple names of all not imported classes. + */ + private fun unresolvedNames( + file: Path, + task: CompileTask, + ): List { + val names = mutableListOf() + var docContents: CharSequence? = null + val diagnostics = + task.diagnostics.filter { + it.source.toUri() == file.toUri() && it.code == DiagnosticCode.NOT_IMPORTED.id + } + for (diagnostic in diagnostics) { + val content = + try { + docContents ?: diagnostic.source.getCharContent(true).also { docContents = it } + } catch (e: Exception) { + log.error("Failed to get contents of file {}", file, e) + continue + } + + val name = + content.subSequence(diagnostic.startPosition.toInt(), diagnostic.endPosition.toInt()) + names.add(name.toString()) + } + return names + } + + private fun getFileImports( + task: CompileTask, + file: Path, + ): Set = + task + .root(file) + .imports + .map { + it.qualifiedIdentifier + }.map { it.toString() } + .toSet() + + inner class Result( + val fileImports: Set, + val classes: MutableMap>, + ) } diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt index 62bf0b0f33..b9588f52d5 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/FieldToBlockAction.kt @@ -1,89 +1,87 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.diagnostics - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.hasRequiredData -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.lsp.java.models.DiagnosticCode -import com.itsaky.androidide.lsp.java.rewrite.ConvertFieldToBlock -import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition -import com.itsaky.androidide.lsp.models.DiagnosticItem -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import org.slf4j.LoggerFactory - -/** @author Akash Yadav */ -class FieldToBlockAction : BaseJavaCodeAction() { - - override val id: String = "ide.editor.lsp.java.diagnostics.fieldToBlock" - override var label: String = "" - private val diagnosticCode = DiagnosticCode.UNUSED_FIELD.id - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - override val titleTextRes: Int = R.string.action_convert_to_block - - companion object { - - private val log = LoggerFactory.getLogger(FieldToBlockAction::class.java) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if (!visible) { - return - } - - if (!data.hasRequiredData(DiagnosticItem::class.java)) { - markInvisible() - return - } - - val diagnostic = data.get(DiagnosticItem::class.java)!! - if (diagnosticCode != diagnostic.code) { - markInvisible() - return - } - } - - override suspend fun execAction(data: ActionData): Any { - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) - val diagnostic = data[DiagnosticItem::class.java]!! - val file = data.requirePath() - - return compiler.compile(file).get { - ConvertFieldToBlock(file, findPosition(it, diagnostic.range.start)) - } - } - - override fun postExec(data: ActionData, result: Any) { - if (result !is ConvertFieldToBlock) { - log.warn("Unable to convert field to block") - return - } - - performCodeAction(data, result) - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.java.actions.diagnostics + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.models.DiagnosticCode +import com.itsaky.androidide.lsp.java.rewrite.ConvertFieldToBlock +import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition +import com.itsaky.androidide.lsp.models.DiagnosticItem +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +/** @author Akash Yadav */ +class FieldToBlockAction : BaseJavaCodeAction() { + override val id: String = "ide.editor.lsp.java.diagnostics.fieldToBlock" + override var label: String = "" + private val diagnosticCode = DiagnosticCode.UNUSED_FIELD.id + + override val titleTextRes: Int = R.string.action_convert_to_block + + companion object { + private val log = LoggerFactory.getLogger(FieldToBlockAction::class.java) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!visible) { + return + } + + if (!data.hasRequiredData(DiagnosticItem::class.java)) { + markInvisible() + return + } + + val diagnostic = data.get(DiagnosticItem::class.java)!! + if (diagnosticCode != diagnostic.code) { + markInvisible() + return + } + } + + override suspend fun execAction(data: ActionData): Any { + val compiler = + JavaCompilerProvider.get(IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) + val diagnostic = data[DiagnosticItem::class.java]!! + val file = data.requirePath() + + return compiler.compile(file).get { + ConvertFieldToBlock(file, findPosition(it, diagnostic.range.start)) + } + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is ConvertFieldToBlock) { + log.warn("Unable to convert field to block") + return + } + + performCodeAction(data, result) + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt index f15b38cac6..d8288c81da 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/diagnostics/VariableToStatementAction.kt @@ -1,91 +1,89 @@ -/* - * This file is part of AndroidIDE. - * - * AndroidIDE is free software: you can redistribute it and/or modify - * it under the terms of the GNU General Public License as published by - * the Free Software Foundation, either version 3 of the License, or - * (at your option) any later version. - * - * AndroidIDE is distributed in the hope that it will be useful, - * but WITHOUT ANY WARRANTY; without even the implied warranty of - * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the - * GNU General Public License for more details. - * - * You should have received a copy of the GNU General Public License - * along with AndroidIDE. If not, see . - */ -package com.itsaky.androidide.lsp.java.actions.diagnostics - -import com.itsaky.androidide.actions.ActionData -import com.itsaky.androidide.actions.hasRequiredData -import com.itsaky.androidide.actions.markInvisible -import com.itsaky.androidide.actions.requireFile -import com.itsaky.androidide.actions.requirePath -import com.itsaky.androidide.idetooltips.TooltipTag -import com.itsaky.androidide.lsp.java.JavaCompilerProvider -import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction -import com.itsaky.androidide.lsp.java.models.DiagnosticCode -import com.itsaky.androidide.lsp.java.rewrite.ConvertVariableToStatement -import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition -import com.itsaky.androidide.projects.IProjectManager -import com.itsaky.androidide.resources.R -import org.slf4j.LoggerFactory - -/** @author Akash Yadav */ -class VariableToStatementAction : BaseJavaCodeAction() { - - override val id: String = "ide.editor.lsp.java.diagnostics.variableToStatement" - override var label: String = "" - private val diagnosticCode = DiagnosticCode.UNUSED_LOCAL.id - - override val titleTextRes: Int = R.string.action_convert_to_statement - override var tooltipTag: String = TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS - - companion object { - - private val log = LoggerFactory.getLogger(VariableToStatementAction::class.java) - } - - override fun prepare(data: ActionData) { - super.prepare(data) - - if (!visible) { - return - } - - if (!data.hasRequiredData(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)) { - markInvisible() - return - } - - val diagnostic = data.get(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)!! - if (diagnosticCode != diagnostic.code) { - markInvisible() - return - } - - visible = true - enabled = true - } - - override suspend fun execAction(data: ActionData): Any { - val diagnostic = data[com.itsaky.androidide.lsp.models.DiagnosticItem::class.java]!! - val compiler = - JavaCompilerProvider.get( - IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) - val path = data.requirePath() - - return compiler.compile(path).get { - ConvertVariableToStatement(path, findPosition(it, diagnostic.range.start)) - } - } - - override fun postExec(data: ActionData, result: Any) { - if (result !is ConvertVariableToStatement) { - log.warn("Unable to convert variable to statement") - return - } - - performCodeAction(data, result) - } -} +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.java.actions.diagnostics + +import com.itsaky.androidide.actions.ActionData +import com.itsaky.androidide.actions.hasRequiredData +import com.itsaky.androidide.actions.markInvisible +import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.actions.requirePath +import com.itsaky.androidide.lsp.java.JavaCompilerProvider +import com.itsaky.androidide.lsp.java.actions.BaseJavaCodeAction +import com.itsaky.androidide.lsp.java.models.DiagnosticCode +import com.itsaky.androidide.lsp.java.rewrite.ConvertVariableToStatement +import com.itsaky.androidide.lsp.java.utils.CodeActionUtils.findPosition +import com.itsaky.androidide.projects.IProjectManager +import com.itsaky.androidide.resources.R +import org.slf4j.LoggerFactory + +/** @author Akash Yadav */ +class VariableToStatementAction : BaseJavaCodeAction() { + override val id: String = "ide.editor.lsp.java.diagnostics.variableToStatement" + override var label: String = "" + private val diagnosticCode = DiagnosticCode.UNUSED_LOCAL.id + + override val titleTextRes: Int = R.string.action_convert_to_statement + + companion object { + private val log = LoggerFactory.getLogger(VariableToStatementAction::class.java) + } + + override fun prepare(data: ActionData) { + super.prepare(data) + + if (!visible) { + return + } + + if (!data.hasRequiredData(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)) { + markInvisible() + return + } + + val diagnostic = data.get(com.itsaky.androidide.lsp.models.DiagnosticItem::class.java)!! + if (diagnosticCode != diagnostic.code) { + markInvisible() + return + } + + visible = true + enabled = true + } + + override suspend fun execAction(data: ActionData): Any { + val diagnostic = data[com.itsaky.androidide.lsp.models.DiagnosticItem::class.java]!! + val compiler = + JavaCompilerProvider.get(IProjectManager.getInstance().findModuleForFile(data.requireFile(), false) ?: return Any()) + val path = data.requirePath() + + return compiler.compile(path).get { + ConvertVariableToStatement(path, findPosition(it, diagnostic.range.start)) + } + } + + override fun postExec( + data: ActionData, + result: Any, + ) { + if (result !is ConvertVariableToStatement) { + log.warn("Unable to convert variable to statement") + return + } + + performCodeAction(data, result) + } +} diff --git a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt index f1f71dfe6e..3229a89f09 100644 --- a/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt +++ b/lsp/java/src/main/java/com/itsaky/androidide/lsp/java/actions/generators/OverrideSuperclassMethodsAction.kt @@ -210,7 +210,7 @@ class OverrideSuperclassMethodsAction : BaseJavaCodeAction() { val listView = dialog.listView listView.setOnItemLongClickListener { _, view, position, _ -> - showTooltip(context, view, tooltipTag) + showTooltip(context, view, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) true } @@ -218,7 +218,7 @@ class OverrideSuperclassMethodsAction : BaseJavaCodeAction() { val root = dialog.window?.decorView ?: return@setOnShowListener root.applyLongPressRecursively { - showTooltip(context, root, tooltipTag) + showTooltip(context, root, TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER_DIALOG) true } } diff --git a/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt b/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt new file mode 100644 index 0000000000..f8d2cb363f --- /dev/null +++ b/lsp/java/src/test/java/com/itsaky/androidide/lsp/java/actions/JavaCodeActionTooltipTagTest.kt @@ -0,0 +1,92 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ +package com.itsaky.androidide.lsp.java.actions + +import com.google.common.truth.Truth.assertThat +import com.google.common.truth.Truth.assertWithMessage +import com.itsaky.androidide.idetooltips.TooltipTag +import org.junit.Test + +/** + * Pins each Java code action to its tooltip tag. Tooltip content is authored per tag and looked up + * by that tag, so a wrong tag fails silently at runtime: the action shows another action's tooltip + * or none at all (ADFA-4510). + * + * Tags are read through retrieveTooltipTag(), the member the code-actions renderer calls. The + * Kotlin equivalent asserts on the tooltipTag property instead, which is why it kept passing while + * ADFA-4510 was live. + * + * Actions pinned to "" carry no tag at all. A pinned tag means the tag is wired, not that + * documentation.db holds content for it -- see surroundWithTryCatch below. Either way, a change + * here must be a deliberate edit, not silent drift. + */ +class JavaCodeActionTooltipTagTest { + private val actualTags + get() = JavaCodeActionsMenu.actions.associate { it.id to it.retrieveTooltipTag(false) } + + @Test + fun `every java code action maps to its own tooltip tag`() { + val expected = + mapOf( + "ide.editor.lsp.java.commentLine" to TooltipTag.EDITOR_CODE_ACTIONS_COMMENT, + "ide.editor.lsp.java.uncommentLine" to TooltipTag.EDITOR_CODE_ACTIONS_UNCOMMENT, + "ide.editor.lsp.java.gotoDefinition" to TooltipTag.EDITOR_CODE_ACTIONS_GOTO_DEF, + "ide.editor.lsp.java.findReferences" to TooltipTag.EDITOR_CODE_ACTIONS_FIND_REFS, + "ide.editor.lsp.java.diagnostics.addImport" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.autoFixImports" to TooltipTag.EDITOR_CODE_ACTIONS_FIX_IMPORTS, + "ide.editor.lsp.java.diagnostics.implementAbstractMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.settersAndGetters" to + TooltipTag.EDITOR_CODE_ACTIONS_SETTER_GETTER, + "ide.editor.lsp.java.generator.overrideSuperclassMethods" to + TooltipTag.EDITOR_CODE_ACTIONS_OVERRIDE_SUPER, + "ide.editor.lsp.java.generator.missingConstructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.constructor" to + TooltipTag.EDITOR_CODE_ACTIONS_GEN_CONSTRUCTOR, + "ide.editor.lsp.java.generator.toString" to TooltipTag.EDITOR_CODE_ACTIONS_GEN_TO_STRING, + "ide.editor.lsp.java.removeUnusedImports" to + TooltipTag.EDITOR_CODE_ACTIONS_UNUSED_IMPORTS, + "lsp_java_organizeImports" to TooltipTag.EDITOR_CODE_ACTIONS_ORGANIZE_IMPORTS, + // Tag is reserved ahead of content: documentation.db has no + // editor.codeactions.trycatch row, so long-press renders the documentation + // fallback. The Kotlin twin editor.codeactions.kotlin.trycatch is authored. + "ide.editor.lsp.java.surroundWithTryCatch" to TooltipTag.EDITOR_CODE_ACTIONS_TRY_CATCH, + // No tag pinned. + "ide.editor.lsp.java.diagnostics.variableToStatement" to "", + "ide.editor.lsp.java.diagnostics.fieldToBlock" to "", + "ide.editor.lsp.java.diagnostics.removeClass" to "", + "ide.editor.lsp.java.diagnostics.removeMethod" to "", + "ide.editor.lsp.java.diagnostics.removeUnusedThrows" to "", + "ide.editor.lsp.java.diagnostics.createMissingMethod" to "", + "ide.editor.lsp.java.diagnostics.suppressUncheckedWarning" to "", + "ide.editor.lsp.java.diagnostics.addThrows" to "", + ) + assertThat(actualTags).containsExactlyEntriesIn(expected) + } + + /** Guards a Java action drifting onto a Kotlin tag or some unrelated namespace. */ + @Test + fun `no java code action borrows a non java code action tag`() { + actualTags.forEach { (id, tag) -> + if (tag.isEmpty()) return@forEach + assertWithMessage("$id uses tag '$tag' outside the java code actions namespace") + .that(tag.startsWith("editor.codeactions.") && !tag.startsWith("editor.codeactions.kotlin.")) + .isTrue() + } + } +} diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt index ca820ac7ac..33ed711df8 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/AddImportAction.kt @@ -1,11 +1,16 @@ package com.itsaky.androidide.lsp.kotlin.actions +import android.content.Context +import android.view.View +import android.widget.ListView import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.has import com.itsaky.androidide.actions.markInvisible import com.itsaky.androidide.actions.newDialogBuilder import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.kotlin.compiler.AbstractCompilationEnvironment import com.itsaky.androidide.lsp.kotlin.compiler.index.findSymbolBySimpleName import com.itsaky.androidide.lsp.kotlin.diagnostic.DiagnosticAction @@ -17,6 +22,7 @@ import com.itsaky.androidide.lsp.models.DiagnosticItem import com.itsaky.androidide.lsp.models.DocumentChange import com.itsaky.androidide.lsp.models.TextEdit import com.itsaky.androidide.resources.R +import com.itsaky.androidide.utils.applyLongPressRecursively import com.itsaky.androidide.utils.flashError import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -138,16 +144,58 @@ class AddImportAction : BaseKotlinCodeAction() { } else -> { - newDialogBuilder(data) - .setTitle(label) - .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> - dialog.dismiss() - actions.getOrNull(which)?.also { client.performCodeAction(it) } - ?: run { - logger.error("Index $which is out of bounds for actions of size ${actions.size}") - } - }.show() + showImportChooser(data, actions, client) } } } + + /** + * Shows the import chooser and makes every part of it long-pressable for help. + * + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own + * listener -- the dialog chrome and the rows are wired separately (ADFA-4510). + */ + private fun showImportChooser( + data: ActionData, + actions: List, + client: ILanguageClient, + ) { + val context = data[Context::class.java] ?: return + val dialog = + newDialogBuilder(data) + .setTitle(label) + .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> + dialog.dismiss() + actions.getOrNull(which)?.also { client.performCodeAction(it) } + ?: run { + logger.error("Index $which is out of bounds for actions of size ${actions.size}") + } + }.create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_KT_IMPORT_CLASS_DIALOG, + ) + } } diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt index 7327aa9d2d..85f4702a2c 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/actions/NullSafetyAction.kt @@ -1,11 +1,16 @@ package com.itsaky.androidide.lsp.kotlin.actions +import android.content.Context +import android.view.View +import android.widget.ListView import com.itsaky.androidide.actions.ActionData import com.itsaky.androidide.actions.markInvisible import com.itsaky.androidide.actions.newDialogBuilder import com.itsaky.androidide.actions.requireContext import com.itsaky.androidide.actions.requireFile +import com.itsaky.androidide.idetooltips.TooltipManager import com.itsaky.androidide.idetooltips.TooltipTag +import com.itsaky.androidide.lsp.api.ILanguageClient import com.itsaky.androidide.lsp.kotlin.compiler.read import com.itsaky.androidide.lsp.kotlin.diagnostic.DiagnosticAction import com.itsaky.androidide.lsp.kotlin.utils.NullSafetyKind @@ -17,6 +22,7 @@ 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.resources.R +import com.itsaky.androidide.utils.applyLongPressRecursively import kotlinx.coroutines.CancellationException import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -122,16 +128,58 @@ class NullSafetyAction : BaseKotlinCodeAction() { } else -> { - newDialogBuilder(data) - .setTitle(label) - .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> - dialog.dismiss() - actions.getOrNull(which)?.also { client.performCodeAction(it) } - ?: logger.error("Index $which is out of bounds for actions of size ${actions.size}") - }.show() + showFixChooser(data, context, actions, client) } } } + + /** + * Shows the fix chooser and makes every part of it long-pressable for help. + * + * [applyLongPressRecursively] skips [ListView] subtrees, so the item list needs its own + * listener -- the dialog chrome and the rows are wired separately (ADFA-4510). + */ + private fun showFixChooser( + data: ActionData, + context: Context, + actions: List, + client: ILanguageClient, + ) { + val dialog = + newDialogBuilder(data) + .setTitle(label) + .setItems(actions.map { it.title }.toTypedArray()) { dialog, which -> + dialog.dismiss() + actions.getOrNull(which)?.also { client.performCodeAction(it) } + ?: logger.error("Index $which is out of bounds for actions of size ${actions.size}") + }.create() + + dialog.listView?.setOnItemLongClickListener { _, view, _, _ -> + showDialogTooltip(context, view) + true + } + + dialog.setOnShowListener { + val root = dialog.window?.decorView ?: return@setOnShowListener + root.applyLongPressRecursively { + showDialogTooltip(context, root) + true + } + } + + dialog.show() + } + + private fun showDialogTooltip( + context: Context, + anchor: View, + ) { + TooltipManager.showIdeCategoryTooltip( + context, + anchor, + TooltipTag.EDITOR_CODE_ACTIONS_KT_NULL_SAFETY_FIX_DIALOG, + ) + } } private val NullSafetyKind.titleRes: Int 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 f72066e69e..9c3565cb9d 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 @@ -29,7 +29,7 @@ import org.junit.Test */ class KotlinCodeActionTooltipTagTest { private val actualTags - get() = KotlinCodeActionsMenu.actions.associate { it.id to it.tooltipTag } + get() = KotlinCodeActionsMenu.actions.associate { it.id to it.retrieveTooltipTag(false) } @Test fun `every kotlin code action maps to its own tooltip tag`() { From c3535ade6889512481aec44216be7c533138191d Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 27 Aug 2026 09:43:59 +0000 Subject: [PATCH 61/62] ADFA-4827: Keep a dismiss that lands before the progress sheet attaches DialogFragment.show() only enqueues the add transaction, and the work performCodeAction wraps the sheet around can finish inside the same main-thread pass: applyActionEdits only posts each edit to the UI thread, and both CompletableFuture.whenComplete and TaskExecutor.runOnUiThread run inline when they can. So dismiss() ran before the fragment was ever attached, where the old isShowing() guard dropped it and left the sheet up for good. The guard was not gratuitous - dismissing there throws, since the fragment has no fragment manager until the transaction executes - so the dismiss is latched and replayed in onStart() rather than simply unguarded. The file is reindented to tabs by the Spotless ratchet. --- .../fragments/sheets/ProgressSheet.java | 149 ++++++++++-------- .../sheets/ProgressSheetDismissTest.kt | 84 ++++++++++ 2 files changed, 169 insertions(+), 64 deletions(-) create mode 100644 app/src/test/java/com/itsaky/androidide/fragments/sheets/ProgressSheetDismissTest.kt diff --git a/app/src/main/java/com/itsaky/androidide/fragments/sheets/ProgressSheet.java b/app/src/main/java/com/itsaky/androidide/fragments/sheets/ProgressSheet.java index c12831de81..a8cd39da12 100755 --- a/app/src/main/java/com/itsaky/androidide/fragments/sheets/ProgressSheet.java +++ b/app/src/main/java/com/itsaky/androidide/fragments/sheets/ProgressSheet.java @@ -30,68 +30,89 @@ public class ProgressSheet extends BaseBottomSheetFragment { - private LayoutProgressSheetBinding binding; - private String message = ""; - private String subMessage = ""; - private boolean subMessageEnabled = false; - - @Override - public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { - super.onViewCreated(view, savedInstanceState); - - binding.message.setText(message); - - final var params = (ConstraintLayout.LayoutParams) binding.message.getLayoutParams(); - if (subMessageEnabled) { - binding.subMessage.setText(subMessage); - binding.subMessage.setVisibility(View.VISIBLE); - params.bottomToBottom = View.NO_ID; - } else { - binding.subMessage.setVisibility(View.GONE); - params.bottomToBottom = LayoutParams.PARENT_ID; - } - } - - @Nullable - @Override - public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, - @Nullable Bundle savedInstanceState - ) { - binding = LayoutProgressSheetBinding.inflate(LayoutInflater.from(getContext())); - return binding.getRoot(); - } - - public void setSubMessageEnabled(boolean enabled) { - this.subMessageEnabled = enabled; - } - - public void setSubMessage(String msg) { - this.subMessage = msg; - if (isShowing()) { - binding.subMessage.setText(msg); - } - } - - public ProgressSheet setMessage(String message) { - this.message = message; - if (isShowing()) { - binding.message.setText(message); - } - - return this; - } - - public ProgressSheet setProgressDrawable(Drawable drawable) { - if (isShowing()) { - binding.progress.setIndeterminateDrawable(drawable); - } - return this; - } - - @Override - public void dismiss() { - if (isShowing()) { - super.dismiss(); - } - } + private LayoutProgressSheetBinding binding; + private String message = ""; + private String subMessage = ""; + private boolean subMessageEnabled = false; + + /* A dismiss that arrived before this fragment was attached, replayed in onStart. */ + private boolean dismissPending = false; + + /** + * {@inheritDoc} + * + *

+ * A dismiss that arrives before the enqueued {@code show()} transaction has run is remembered rather than dropped: the fragment is not attached to a fragment manager yet, so dismissing now would throw, but doing nothing would leave the sheet on screen with nothing left to close it. + */ + @Override + public void dismiss() { + if (!isAdded()) { + dismissPending = true; + return; + } + + dismissPending = false; + super.dismiss(); + } + + @Nullable + @Override + public View onCreateView(@NonNull LayoutInflater inflater, @Nullable ViewGroup container, + @Nullable Bundle savedInstanceState) { + binding = LayoutProgressSheetBinding.inflate(LayoutInflater.from(getContext())); + return binding.getRoot(); + } + + @Override + public void onStart() { + super.onStart(); + if (dismissPending) { + dismissPending = false; + dismissAllowingStateLoss(); + } + } + + @Override + public void onViewCreated(@NonNull View view, Bundle savedInstanceState) { + super.onViewCreated(view, savedInstanceState); + + binding.message.setText(message); + + final var params = (ConstraintLayout.LayoutParams) binding.message.getLayoutParams(); + if (subMessageEnabled) { + binding.subMessage.setText(subMessage); + binding.subMessage.setVisibility(View.VISIBLE); + params.bottomToBottom = View.NO_ID; + } else { + binding.subMessage.setVisibility(View.GONE); + params.bottomToBottom = LayoutParams.PARENT_ID; + } + } + + public ProgressSheet setMessage(String message) { + this.message = message; + if (isShowing()) { + binding.message.setText(message); + } + + return this; + } + + public ProgressSheet setProgressDrawable(Drawable drawable) { + if (isShowing()) { + binding.progress.setIndeterminateDrawable(drawable); + } + return this; + } + + public void setSubMessage(String msg) { + this.subMessage = msg; + if (isShowing()) { + binding.subMessage.setText(msg); + } + } + + public void setSubMessageEnabled(boolean enabled) { + this.subMessageEnabled = enabled; + } } diff --git a/app/src/test/java/com/itsaky/androidide/fragments/sheets/ProgressSheetDismissTest.kt b/app/src/test/java/com/itsaky/androidide/fragments/sheets/ProgressSheetDismissTest.kt new file mode 100644 index 0000000000..b9f319dc56 --- /dev/null +++ b/app/src/test/java/com/itsaky/androidide/fragments/sheets/ProgressSheetDismissTest.kt @@ -0,0 +1,84 @@ +/* + * This file is part of AndroidIDE. + * + * AndroidIDE is free software: you can redistribute it and/or modify + * it under the terms of the GNU General Public License as published by + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + * + * AndroidIDE is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with AndroidIDE. If not, see . + */ + +package com.itsaky.androidide.fragments.sheets + +import android.os.Looper +import androidx.appcompat.app.AppCompatActivity +import com.google.common.truth.Truth.assertThat +import com.itsaky.androidide.app.BaseApplication +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.Robolectric +import org.robolectric.RobolectricTestRunner +import org.robolectric.Shadows.shadowOf +import org.robolectric.annotation.Config + +/** + * `DialogFragment.show` only enqueues the add transaction, so a dismiss issued in the same + * main-thread pass lands before the sheet exists. Callers that show a progress sheet around work + * that can finish synchronously - `IDELanguageClientImpl.performCodeAction` - rely on that dismiss + * being honoured; dropping it strands the sheet on screen with nothing left to close it. + */ +@RunWith(RobolectricTestRunner::class) +@Config(application = ProgressSheetDismissTest.TestApp::class) +class ProgressSheetDismissTest { + open class TestApp : BaseApplication() + + @Test + fun `dismiss issued before the show transaction runs still closes the sheet`() { + val activity = Robolectric.buildActivity(AppCompatActivity::class.java).setup().get() + val manager = activity.supportFragmentManager + + val sheet = ProgressSheet() + sheet.isCancelable = false + sheet.show(manager, TAG) + sheet.dismiss() + + shadowOf(Looper.getMainLooper()).idle() + manager.executePendingTransactions() + + assertThat(manager.findFragmentByTag(TAG)).isNull() + assertThat(sheet.isShowing).isFalse() + } + + @Test + fun `dismiss issued once the sheet is on screen closes it`() { + val activity = Robolectric.buildActivity(AppCompatActivity::class.java).setup().get() + val manager = activity.supportFragmentManager + + val sheet = ProgressSheet() + sheet.isCancelable = false + sheet.show(manager, TAG) + + shadowOf(Looper.getMainLooper()).idle() + manager.executePendingTransactions() + assertThat(sheet.isShowing).isTrue() + + sheet.dismiss() + + shadowOf(Looper.getMainLooper()).idle() + manager.executePendingTransactions() + + assertThat(manager.findFragmentByTag(TAG)).isNull() + assertThat(sheet.isShowing).isFalse() + } + + private companion object { + const val TAG = "progress_sheet_test" + } +} From 409f48d5be51afda699503519b1752d48d782c63 Mon Sep 17 00:00:00 2001 From: Akash Yadav Date: Thu, 27 Aug 2026 14:12:25 +0000 Subject: [PATCH 62/62] ADFA-4827: Keep a trailing comment out of the declaration's span The parser binds a comment on the declaration's line into the KtProperty, so textRange.endOffset sat after it. The deletion then saw an empty suffix, read the line as having nothing to preserve, and took the comment with it. The comment-preservation branch in the edit builder was correct but unreachable: its unit tests hand-build the span from the declaration text, which stops before the comment, and the real-PSI suite had no comment case. --- .../utils/refactor/InlineVariablePlan.kt | 4 ++ .../utils/refactor/InlineVariablePlanner.kt | 17 +++++- .../InlineVariablePlanEndToEndTest.kt | 54 +++++++++++++++++++ 3 files changed, 74 insertions(+), 1 deletion(-) diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt index e3ef65665b..70d1e8cdac 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlan.kt @@ -135,6 +135,10 @@ sealed interface InlineRefusal { * declaration. [canDeleteDeclaration] is the conjunction of three conditions -- every reference * inlinable, the target never written, *and* the declaration sitting directly in a block -- and is * honoured only by [InlineMode.AllReferences]. + * + * [declarationSpan] covers the declaration's own text only: the deletion reads what follows it on the + * line to decide what to preserve, so a comment the parser bound to the declaration's tail must be + * outside the span. */ data class InlineVariablePlan( override val fileText: String, diff --git a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt index 100aea5635..d4b7a5d6d3 100644 --- a/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt +++ b/lsp/kotlin/src/main/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanner.kt @@ -11,7 +11,9 @@ import org.jetbrains.kotlin.analysis.api.resolution.KaImplicitReceiverValue import org.jetbrains.kotlin.analysis.api.resolution.successfulCallOrNull import org.jetbrains.kotlin.analysis.api.symbols.KaAnonymousFunctionSymbol import org.jetbrains.kotlin.analysis.api.types.KaFunctionType +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.psi.KtArrayAccessExpression @@ -141,7 +143,7 @@ private fun KaSession.planFor( return InlineVariablePlan.refused(InlineRefusal.NeverUsed(name), fileText, documentVersion) } - val declarationEnd = target.textRange.endOffset + val declarationEnd = declarationEndBeforeTrailingComment(target) val cutoff = cutoffAfter(initializer, searchRoot, targetWriteOffsets, declarationEnd) val initializerNames = namesReadBy(initializer) @@ -587,3 +589,16 @@ private fun runsOutOfTextualOrder( } return false } + +/** + * Where the declaration's own text ends, with any comment bound to its tail excluded. + * + * The parser binds a comment on the declaration's line into the property, so `textRange.endOffset` + * sits after it. Reporting that as the declaration's end hides the comment from the deletion, which + * then reads the line as having nothing to preserve and takes the comment with it. + */ +private fun declarationEndBeforeTrailingComment(target: KtProperty): Int { + var last: PsiElement? = target.lastChild + while (last is PsiComment || last is PsiWhiteSpace) last = last.prevSibling + return last?.textRange?.endOffset ?: target.textRange.endOffset +} diff --git a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt index ff786aea36..b1bcf8a3d9 100644 --- a/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt +++ b/lsp/kotlin/src/test/java/com/itsaky/androidide/lsp/kotlin/utils/refactor/InlineVariablePlanEndToEndTest.kt @@ -429,6 +429,60 @@ class InlineVariablePlanEndToEndTest : KtLspTest() { ) } + @Test + fun `a trailing line comment survives the declaration's removal`() { + val content = + """ + package p + fun g(n: Int) = n + fun demo(a: Int, b: Int): Int { + val running = a + b // running total + return g(running) + } + """.trimIndent() + + val result = plan(content, at(content, "running")) + + assertEquals( + """ + package p + fun g(n: Int) = n + fun demo(a: Int, b: Int): Int { + // running total + return g((a + b)) + } + """.trimIndent(), + apply(content, buildInlineVariableRewrites(result, InlineMode.AllReferences)!!), + ) + } + + @Test + fun `a trailing block comment survives the declaration's removal`() { + val content = + """ + package p + fun g(n: Int) = n + fun demo(a: Int, b: Int): Int { + val running = a + b /* running total */ + return g(running) + } + """.trimIndent() + + val result = plan(content, at(content, "running")) + + assertEquals( + """ + package p + fun g(n: Int) = n + fun demo(a: Int, b: Int): Int { + /* running total */ + return g((a + b)) + } + """.trimIndent(), + apply(content, buildInlineVariableRewrites(result, InlineMode.AllReferences)!!), + ) + } + @Test fun `a when subject variable is inlined but its declaration is never deleted`() { val content =