diff --git a/CHANGELOG.md b/CHANGELOG.md
index 8e39f14e..41dc35a0 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -1,3 +1,4 @@
+- Improved Kotlin Inline Variable: local `var` declarations with one initializer and no subsequent writes can now be inlined from either a declaration or selected usage; assignments and increment/decrement writes are rejected before changing the source with the conflicting expression identified.
- Improved Kotlin Introduce Type Alias: selecting a complete generic type now creates a concrete alias, while selecting only its type constructor creates a parameterized alias that preserves nested and qualified type structures and each same-file usage's own type arguments; star projections and local type parameters that cannot form a valid top-level alias are rejected.
- Improved Kotlin hierarchy-aware refactorings: Pull Members Up, Push Members Down, and compatible IDEA refactoring paths now discover Kotlin override chains and direct supertypes across all source files in the active Maven or Gradle build; Java/library and full IDEA-index conflict scenarios remain unavailable.
- Improved Kotlin Extract Interface and Extract Superclass: source and target Kotlin files now roll back together if an update fails, generated supertypes remain correctly separated from existing target declarations, and Undo Last Refactoring precisely restores an existing target or removes a generated one.
diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml
index f5e4af64..0377183e 100644
--- a/CoreImpl/pom.xml
+++ b/CoreImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-core-impl
diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml
index f4d5b6e4..e17d2cc2 100644
--- a/KotlinCompilerCliBase/pom.xml
+++ b/KotlinCompilerCliBase/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-compiler-cli-base
diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml
index 943a5013..fee74bee 100644
--- a/KotlinFixesImpl/pom.xml
+++ b/KotlinFixesImpl/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-fixes-impl
diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml
index 5570c011..645015f7 100644
--- a/KotlinFormatter/pom.xml
+++ b/KotlinFormatter/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-formatter
diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml
index 7efc0416..e2fa4b61 100644
--- a/KotlinHighlighting/pom.xml
+++ b/KotlinHighlighting/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-highlighting
diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml
index fbe85b1b..d5c9a59e 100644
--- a/KotlinIcons/pom.xml
+++ b/KotlinIcons/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-icons
diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml
index 543a49d0..37cb1163 100644
--- a/KotlinIntentionUtils/pom.xml
+++ b/KotlinIntentionUtils/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-intention-utils
diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml
index 5dfcee2a..12190e6d 100644
--- a/KotlinRefactoring/pom.xml
+++ b/KotlinRefactoring/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-refactoring
diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaInlineVariableComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaInlineVariableComputer.kt
index 4b4e2e70..d9ae68cc 100644
--- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaInlineVariableComputer.kt
+++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaInlineVariableComputer.kt
@@ -26,11 +26,14 @@ import org.jetbrains.kotlin.idea.k2.refactoring.inline.codeInliner.PropertyUsage
import org.jetbrains.kotlin.idea.refactoring.inline.AbstractKotlinInlinePropertyProcessor
import org.jetbrains.kotlin.idea.refactoring.inline.codeInliner.UsageReplacementStrategy
import org.jetbrains.kotlin.idea.refactoring.inline.createReplacementStrategyForProperty
+import org.jetbrains.kotlin.idea.references.ReadWriteAccessChecker
import org.jetbrains.kotlin.idea.references.mainReference
+import org.jetbrains.kotlin.psi.KtExpression
import org.jetbrains.kotlin.psi.KtFile
import org.jetbrains.kotlin.psi.KtNameReferenceExpression
import org.jetbrains.kotlin.psi.KtProperty
import org.jetbrains.kotlin.psi.KtReferenceExpression
+import org.jetbrains.kotlin.psi.KtSimpleNameExpression
import org.jetbrains.kotlin.psi.KtTreeVisitorVoid
/**
@@ -69,14 +72,16 @@ data class KaInlineVariableError(
* starting the IDEA `BaseRefactoringProcessor` pipeline.
*
* Steps performed by [compute]:
- * 1. Locate the [KtProperty] at the cursor (parent walk from `findElementAt(offset)`).
- * 2. Run IDEA's `AbstractKotlinInlinePropertyProcessor.extractInitialization` — returns either
+ * 1. Locate the [KtProperty] at the cursor (K2 reference resolution first, then a parent walk).
+ * 2. For a declaration-initialized `var`, reject any K2-resolved Kotlin write/read-write usage so
+ * only a single definition can be inlined in the standalone session.
+ * 3. Run IDEA's `AbstractKotlinInlinePropertyProcessor.extractInitialization` — returns either
* the initializer expression or an error message (e.g. "property has no initializer").
- * 3. Run IDEA's top-level `createReplacementStrategyForProperty(...)` — this builds the
+ * 4. Run IDEA's top-level `createReplacementStrategyForProperty(...)` — this builds the
* `PropertyUsageReplacementStrategy` (which wraps a `CodeInliner`) that knows how to inline
* the initializer at any reference site, with proper handling of parentheses, qualified calls,
* imports and type arguments.
- * 4. Scan every `KtFile` in [projectKtFiles] for `KtReferenceExpression`s whose `mainReference`
+ * 5. Scan every `KtFile` in [projectKtFiles] for `KtReferenceExpression`s whose `mainReference`
* resolves to [property] — these are the usages to inline.
*
* The actual PSI mutation (`strategy.createReplacer(usage).invoke()` + `property.delete()`) is
@@ -120,8 +125,8 @@ class KaInlineVariableComputer(
* (handles the cursor being on the declaration keyword/name itself).
*
* @return [Outcome.NotApplicable] when the cursor is not on a `KtProperty`,
- * [Outcome.Error] when the property cannot be inlined (missing initializer, etc.),
- * [Outcome.Ready] with the prepared plan otherwise
+ * [Outcome.Error] when the property cannot be inlined (missing initializer, multiple
+ * definitions of a mutable local, etc.), [Outcome.Ready] with the prepared plan otherwise
*/
fun compute(): Outcome {
val element = cursorKtFile.findElementAt(offset) ?: return Outcome.NotApplicable
@@ -130,6 +135,16 @@ class KaInlineVariableComputer(
?: return Outcome.NotApplicable
val declarationName = property.name ?: return Outcome.NotApplicable
+ findWriteUsage(property)?.let { writeExpression ->
+ return Outcome.Error(
+ KaInlineVariableError(
+ "Cannot inline '$declarationName': '${writeExpression.text}' changes its value, " +
+ "so it has no single definition to inline.",
+ declarationName,
+ ),
+ )
+ }
+
// IDEA's extractInitialization validates the property and returns either the initializer
// expression or an error (e.g. no initializer, multiple assignments without a dominator).
val init = AbstractKotlinInlinePropertyProcessor.extractInitialization(property)
@@ -176,6 +191,42 @@ class KaInlineVariableComputer(
)
}
+ /**
+ * Finds the first resolved Kotlin write or read-write usage of [property] in the active
+ * standalone session.
+ *
+ * IDEA normally obtains this information through an indexed [com.intellij.psi.search.PsiSearchHelper],
+ * but the standalone session deliberately provides a no-op implementation. Returning the full
+ * access expression, rather than a Boolean, lets the UI explain the exact assignment or
+ * increment that prevents a mutable local from having one definition.
+ */
+ private fun findWriteUsage(property: KtProperty): KtExpression? {
+ val checker = ReadWriteAccessChecker.getInstance(project)
+ for (file in projectKtFiles) {
+ var conflict: KtExpression? = null
+ runCatching {
+ analyze(file) {
+ file.accept(object : KtTreeVisitorVoid() {
+ override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) {
+ if (conflict != null) return
+ super.visitSimpleNameExpression(expression)
+ val symbol = runCatching {
+ expression.mainReference?.resolveToSymbol()
+ }.getOrNull() ?: return
+ if (symbol.psi != property) return
+ val access = checker.readWriteAccessWithFullExpression(expression, true)
+ if (access.first.isWrite) {
+ conflict = access.second ?: expression
+ }
+ }
+ })
+ }
+ }
+ conflict?.let { return it }
+ }
+ return null
+ }
+
/**
* If [element] is (or is inside) a [KtNameReferenceExpression], resolves it via K2 and
* returns the [KtProperty] that the reference points to. Returns `null` when [element] is
diff --git a/Nbm/pom.xml b/Nbm/pom.xml
index a56d82fb..7f7ac40d 100644
--- a/Nbm/pom.xml
+++ b/Nbm/pom.xml
@@ -5,7 +5,7 @@
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
../pom.xml
netbeans-plugin-kotlin-nbm
diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt
index 7345404f..6b3fdd2e 100644
--- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt
+++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableAction.kt
@@ -19,9 +19,15 @@ package io.github.nbplugins.kotlin.nbm.refactoring
import com.intellij.psi.util.PsiTreeUtil
import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
+import org.jetbrains.kotlin.analysis.api.analyze
+import org.jetbrains.kotlin.idea.references.mainReference
import org.jetbrains.kotlin.log.KotlinLogger
import org.jetbrains.kotlin.psi.KtNamedFunction
+import org.jetbrains.kotlin.psi.KtNameReferenceExpression
import org.jetbrains.kotlin.psi.KtProperty
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtNamedDeclaration
+import com.intellij.psi.PsiElement
import org.jetbrains.kotlin.utils.ProjectUtils
import org.netbeans.editor.BaseAction
import org.netbeans.modules.refactoring.spi.ui.UI
@@ -56,39 +62,44 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR
override fun actionPerformed(evt: ActionEvent, target: JTextComponent) {
val doc = target.document as? StyledDocument ?: return
- val caretOffset = target.caretPosition
+ val selectionStart = target.selectionStart
+ val selectionEnd = target.selectionEnd
+ val targetOffset = selectionStart
runCatching {
- // Dispatch: function first, then property. The plugin's prepare() returns a fatal
- // Problem when the symbol is not inlineable, so the action is always safe to open.
- val functionName = resolveFunctionNameAt(doc, caretOffset)
- if (functionName != null) {
- UI.openRefactoringUI(
- KotlinInlineFunctionUI(functionName, KotlinInlineFunctionRefactoring(doc, caretOffset)),
+ when (val targetDeclaration = resolveTargetAt(doc, selectionStart, selectionEnd)) {
+ is KtNamedFunction -> UI.openRefactoringUI(
+ KotlinInlineFunctionUI(targetDeclaration.name.orEmpty(), KotlinInlineFunctionRefactoring(doc, targetOffset)),
TopComponent.getRegistry().activated,
)
- return@runCatching
+ is KtProperty -> UI.openRefactoringUI(
+ KotlinInlineVariableUI(targetDeclaration.name.orEmpty(), KotlinInlineVariableRefactoring(doc, targetOffset)),
+ TopComponent.getRegistry().activated,
+ )
+ null -> return@runCatching
}
-
- val propertyName = resolvePropertyNameAt(doc, caretOffset) ?: ""
- UI.openRefactoringUI(
- KotlinInlineVariableUI(propertyName, KotlinInlineVariableRefactoring(doc, caretOffset)),
- TopComponent.getRegistry().activated,
- )
}.onFailure { e ->
KotlinLogger.INSTANCE.logException("KotlinInlineVariableAction failed", e)
}
}
/**
- * Looks up the [KtNamedFunction] at [offset] in [doc] and returns its short name, or `null`
- * when the caret is not on a function declaration or a call to a named function.
+ * Resolves the directly selected inline declaration.
+ *
+ * A reference resolves through K2 to its declaration; a declaration-position caret uses the
+ * directly selected property/function token. Unlike the former function-first lookup, this
+ * never treats an arbitrary child of an enclosing function as the function itself.
*
- * @param doc the document under the caret
- * @param offset caret offset within [doc]
- * @return the function's simple name, or `null`
+ * @param doc Kotlin editor document containing the selection
+ * @param selectionStart first selected offset, or the caret offset when empty
+ * @param selectionEnd offset immediately after the selection, or [selectionStart] when empty
+ * @return the target property/function, or `null` when the selection has no inline target
*/
- private fun resolveFunctionNameAt(doc: StyledDocument, offset: Int): String? {
+ private fun resolveTargetAt(
+ doc: StyledDocument,
+ selectionStart: Int,
+ selectionEnd: Int,
+ ): KtNamedDeclaration? {
val fo = ProjectUtils.getFileObjectForDocument(doc) ?: return null
val project = ProjectUtils.getKotlinProjectForFileObject(fo)
?: ProjectUtils.getValidProject()
@@ -96,31 +107,48 @@ class KotlinInlineVariableAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBR
return runCatching {
val session = KotlinAnalysisAPISession.getSession(project)
val ktFile = session.getKtFileForPath(fo.path) ?: return@runCatching null
- val element = ktFile.findElementAt(offset) ?: return@runCatching null
- PsiTreeUtil.getParentOfType(element, KtNamedFunction::class.java, false)?.name
+ resolveTargetAt(ktFile, selectionStart, selectionEnd)
}.getOrNull()
}
+ /** Resolves a direct caret target from a session-managed Kotlin file. */
+ internal fun resolveTargetAt(ktFile: KtFile, offset: Int): KtNamedDeclaration? =
+ resolveTargetAt(ktFile, offset, offset)
+
/**
- * Looks up the `KtProperty` at [offset] in [doc] and returns its short name for the dialog
- * title, or `null` when the caret is not on a property (the framework then shows a generic
- * "Inline Variable" title).
+ * Resolves the property/function covered by a caret or selection in a session-managed Kotlin file.
*
- * @param doc the document under the caret
- * @param offset caret offset within [doc]
- * @return the property's simple name, or `null`
+ * @param selectionStart first selected offset, or the caret offset for an empty selection
+ * @param selectionEnd offset immediately after the selection, or [selectionStart] when empty
+ * @return directly selected inline target, or `null` when the range selects no inlineable declaration
*/
- private fun resolvePropertyNameAt(doc: StyledDocument, offset: Int): String? {
- val fo = ProjectUtils.getFileObjectForDocument(doc) ?: return null
- val project = ProjectUtils.getKotlinProjectForFileObject(fo)
- ?: ProjectUtils.getValidProject()
+ internal fun resolveTargetAt(
+ ktFile: KtFile,
+ selectionStart: Int,
+ selectionEnd: Int,
+ ): KtNamedDeclaration? {
+ val offsets = if (selectionStart < selectionEnd) {
+ intArrayOf(selectionStart, selectionEnd - 1)
+ } else {
+ intArrayOf(selectionStart)
+ }
+ for (offset in offsets) {
+ val element = ktFile.findElementAt(offset) ?: continue
+ resolveReferenceTarget(element)?.let { return it }
+ ((element.parent as? KtProperty) ?: (element.parent as? KtNamedFunction))?.let { return it }
+ }
+ return null
+ }
+
+ /** Resolves a direct Kotlin reference and retains only declarations supported by Inline. */
+ private fun resolveReferenceTarget(element: PsiElement): KtNamedDeclaration? {
+ val reference = PsiTreeUtil.getParentOfType(element, KtNameReferenceExpression::class.java, false)
?: return null
return runCatching {
- val session = KotlinAnalysisAPISession.getSession(project)
- val ktFile = session.getKtFileForPath(fo.path) ?: return@runCatching null
- val element = ktFile.findElementAt(offset) ?: return@runCatching null
- PsiTreeUtil.getParentOfType(element, KtProperty::class.java, false)?.name
- }.getOrNull()
+ analyze(reference) {
+ reference.mainReference?.resolveToSymbol()?.psi as? KtNamedDeclaration
+ }
+ }.getOrNull()?.takeIf { it is KtProperty || it is KtNamedFunction }
}
companion object {
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaInlineVariableTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaInlineVariableTest.kt
index 44296077..7158ccfc 100644
--- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaInlineVariableTest.kt
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaInlineVariableTest.kt
@@ -361,6 +361,72 @@ class KaInlineVariableTest : KotlinTestCase("KaInlineVariableTest", "inlineVaria
}
}
+ /**
+ * A mutable local with a declaration initializer and no later write is still a single-definition
+ * value, so it must be eligible for inlining exactly like a `val`.
+ */
+ fun testVarWithoutWrites_readyWithOneRead() {
+ val triple = prepareWithRealSession("varNoWrite")
+ ?: run {
+ println("kotlin-stdlib not on test classpath — skipping mutable-local test")
+ return
+ }
+ val (computer, _, tmpDir) = triple
+ try {
+ val outcome = computer.compute()
+
+ assertTrue(
+ "Expected Ready for a write-free var, got $outcome",
+ outcome is KaInlineVariableComputer.Outcome.Ready,
+ )
+ val result = (outcome as KaInlineVariableComputer.Outcome.Ready).result
+ assertEquals("value", result.declarationName)
+ assertEquals("Expected one read of value", 1, result.usages.values.sumOf { it.size })
+ } finally {
+ tmpDir.toFile().deleteRecursively()
+ }
+ }
+
+ /** A later assignment gives a mutable local more than one definition and must block inlining. */
+ fun testVarWithAssignment_returnsError() {
+ val triple = prepareWithRealSession("varWithAssignment")
+ ?: run {
+ println("kotlin-stdlib not on test classpath — skipping mutable-local test")
+ return
+ }
+ val (computer, _, tmpDir) = triple
+ try {
+ val outcome = computer.compute()
+
+ assertTrue("Expected Error for a var with an assignment, got $outcome", outcome is KaInlineVariableComputer.Outcome.Error)
+ val message = (outcome as KaInlineVariableComputer.Outcome.Error).error.message
+ assertTrue("Expected the conflicting assignment in the error, got $message", message.contains("value = 99"))
+ assertTrue("Expected an explanation about a single definition, got $message", message.contains("single definition"))
+ } finally {
+ tmpDir.toFile().deleteRecursively()
+ }
+ }
+
+ /** An increment is a read-write use and must block inlining of a mutable local. */
+ fun testVarWithIncrement_returnsError() {
+ val triple = prepareWithRealSession("varWithIncrement")
+ ?: run {
+ println("kotlin-stdlib not on test classpath — skipping mutable-local test")
+ return
+ }
+ val (computer, _, tmpDir) = triple
+ try {
+ val outcome = computer.compute()
+
+ assertTrue("Expected Error for a var with an increment, got $outcome", outcome is KaInlineVariableComputer.Outcome.Error)
+ val message = (outcome as KaInlineVariableComputer.Outcome.Error).error.message
+ assertTrue("Expected the conflicting increment in the error, got $message", message.contains("value++"))
+ assertTrue("Expected an explanation about a single definition, got $message", message.contains("single definition"))
+ } finally {
+ tmpDir.toFile().deleteRecursively()
+ }
+ }
+
/**
* Cursor on an unused `val`: the computer must still return [Outcome.Ready] (the property has
* an initializer and no getter/setter), but the usages map must be empty.
diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableActionTest.kt
new file mode 100644
index 00000000..91158879
--- /dev/null
+++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinInlineVariableActionTest.kt
@@ -0,0 +1,171 @@
+/*******************************************************************************
+ * Copyright 2026 nbplugins contributors
+ *
+ * Licensed under the Apache License, Version 2.0 (the "License");
+ * you may not use this file except in compliance with the License.
+ * You may obtain a copy of the License at
+ *
+ * http://www.apache.org/licenses/LICENSE-2.0
+ *
+ * Unless required by applicable law or agreed to in writing, software
+ * distributed under the License is distributed on an "AS IS" BASIS,
+ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+ * See the License for the specific language governing permissions and
+ * limitations under the License.
+ *
+ *******************************************************************************/
+package io.github.nbplugins.kotlin.nbm.refactoring
+
+import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession
+import org.jetbrains.kotlin.psi.KtFile
+import org.jetbrains.kotlin.psi.KtNamedFunction
+import org.jetbrains.kotlin.psi.KtProperty
+import org.netbeans.junit.NbTestCase
+import java.nio.file.Files
+import java.nio.file.Path
+
+/** Regression tests for direct caret target selection by [KotlinInlineVariableAction]. */
+class KotlinInlineVariableActionTest : NbTestCase("KotlinInlineVariableActionTest") {
+
+ /** Finds Kotlin stdlib on the test classpath for a standalone K2 session. */
+ private fun findKotlinStdlib(): Path? =
+ System.getProperty("java.class.path")
+ .split(System.getProperty("path.separator"))
+ .map { Path.of(it) }
+ .firstOrNull {
+ it.fileName?.toString()?.startsWith("kotlin-stdlib") == true && it.toFile().exists()
+ }
+
+ /** Runs [action] with a temporary session-backed Kotlin source file. */
+ private fun withKtFile(source: String, action: (KtFile) -> Unit) {
+ val stdlib = findKotlinStdlib()
+ ?: run {
+ println("kotlin-stdlib not on test classpath — skipping Inline action target test")
+ return
+ }
+ val dir = Files.createTempDirectory("nbkotlin-inline-action")
+ try {
+ val sourceFile = dir.resolve("file.kt")
+ Files.writeString(sourceFile, source)
+ val session = KotlinAnalysisAPISession.createWithJars(
+ moduleName = "inline-action-target",
+ binaryJars = listOf(stdlib),
+ sourceRoots = listOf(dir),
+ )
+ val ktFile = session.getKtFileForPath(sourceFile.toString())
+ ?: error("Failed to obtain KtFile for $sourceFile")
+ action(ktFile)
+ } finally {
+ dir.toFile().deleteRecursively()
+ }
+ }
+
+ /** A local property declaration inside `main` must not be mistaken for its enclosing function. */
+ fun testPropertyDeclarationInsideFunction_resolvesProperty() {
+ val source = """
+ fun main() {
+ var value = 42
+ println(value)
+ }
+ """.trimIndent()
+ withKtFile(source) { ktFile ->
+ val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, source.indexOf("value ="))
+
+ assertTrue("Expected a property target, got $target", target is KtProperty)
+ assertEquals("value", (target as KtProperty).name)
+ }
+ }
+
+ /** A property reference must resolve to its declaration rather than the enclosing function. */
+ fun testPropertyUsageInsideFunction_resolvesProperty() {
+ val source = """
+ fun main() {
+ var value = 42
+ println(value)
+ }
+ """.trimIndent()
+ withKtFile(source) { ktFile ->
+ val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, source.lastIndexOf("value"))
+
+ assertTrue("Expected a property target, got $target", target is KtProperty)
+ assertEquals("value", (target as KtProperty).name)
+ }
+ }
+
+ /** A selected property usage resolves even when the selection end is immediately after its name. */
+ fun testSelectedPropertyUsage_resolvesProperty() {
+ val source = """
+ fun main() {
+ var value = 42
+ println(value)
+ }
+ """.trimIndent()
+ withKtFile(source) { ktFile ->
+ val start = source.lastIndexOf("value")
+ val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, start, start + "value".length)
+
+ assertTrue("Expected a property target, got $target", target is KtProperty)
+ assertEquals("value", (target as KtProperty).name)
+ }
+ }
+
+ /** A selected function call remains a valid Inline Function target. */
+ fun testSelectedFunctionCall_resolvesFunction() {
+ val source = """
+ fun greet() = "hello"
+
+ fun main() = println(greet())
+ """.trimIndent()
+ withKtFile(source) { ktFile ->
+ val start = source.lastIndexOf("greet")
+ val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, start, start + "greet".length)
+
+ assertTrue("Expected a function target, got $target", target is KtNamedFunction)
+ assertEquals("greet", (target as KtNamedFunction).name)
+ }
+ }
+
+ /** A function declaration remains a valid Inline Function target. */
+ fun testFunctionDeclaration_resolvesFunction() {
+ val source = """
+ fun greet() = "hello"
+
+ fun main() = println(greet())
+ """.trimIndent()
+ withKtFile(source) { ktFile ->
+ val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, source.indexOf("greet"))
+
+ assertTrue("Expected a function target, got $target", target is KtNamedFunction)
+ assertEquals("greet", (target as KtNamedFunction).name)
+ }
+ }
+
+ /** A function call remains a valid Inline Function target. */
+ fun testFunctionCall_resolvesFunction() {
+ val source = """
+ fun greet() = "hello"
+
+ fun main() = println(greet())
+ """.trimIndent()
+ withKtFile(source) { ktFile ->
+ val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, source.lastIndexOf("greet"))
+
+ assertTrue("Expected a function target, got $target", target is KtNamedFunction)
+ assertEquals("greet", (target as KtNamedFunction).name)
+ }
+ }
+
+ /** A body expression with no target declaration must not accidentally select `main`. */
+ fun testUnrelatedBodyExpression_returnsNull() {
+ val source = """
+ fun main() {
+ println(42)
+ }
+ """.trimIndent()
+ withKtFile(source) { ktFile ->
+ val target = KotlinInlineVariableAction().resolveTargetAt(ktFile, source.indexOf("42"))
+
+ assertNull("An unrelated expression must not select the enclosing function", target)
+ }
+ }
+}
diff --git a/Nbm/src/test/resources/projForTest/src/inlineVariable/varNoWrite/file.caret b/Nbm/src/test/resources/projForTest/src/inlineVariable/varNoWrite/file.caret
new file mode 100644
index 00000000..f7547731
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/inlineVariable/varNoWrite/file.caret
@@ -0,0 +1,6 @@
+package inlineVariable.varNoWrite
+
+fun main() {
+ var value = 42
+ println(value)
+}
diff --git a/Nbm/src/test/resources/projForTest/src/inlineVariable/varNoWrite/file.kt b/Nbm/src/test/resources/projForTest/src/inlineVariable/varNoWrite/file.kt
new file mode 100644
index 00000000..9c94cf4d
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/inlineVariable/varNoWrite/file.kt
@@ -0,0 +1,6 @@
+package inlineVariable.varNoWrite
+
+fun main() {
+ var value = 42
+ println(value)
+}
diff --git a/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithAssignment/file.caret b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithAssignment/file.caret
new file mode 100644
index 00000000..c8d1e62d
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithAssignment/file.caret
@@ -0,0 +1,7 @@
+package inlineVariable.varWithAssignment
+
+fun main() {
+ var value = 42
+ println(value)
+ value = 99
+}
diff --git a/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithAssignment/file.kt b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithAssignment/file.kt
new file mode 100644
index 00000000..d7c50837
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithAssignment/file.kt
@@ -0,0 +1,7 @@
+package inlineVariable.varWithAssignment
+
+fun main() {
+ var value = 42
+ println(value)
+ value = 99
+}
diff --git a/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithIncrement/file.caret b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithIncrement/file.caret
new file mode 100644
index 00000000..d76f9368
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithIncrement/file.caret
@@ -0,0 +1,7 @@
+package inlineVariable.varWithIncrement
+
+fun main() {
+ var value = 42
+ println(value)
+ value++
+}
diff --git a/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithIncrement/file.kt b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithIncrement/file.kt
new file mode 100644
index 00000000..afd49f38
--- /dev/null
+++ b/Nbm/src/test/resources/projForTest/src/inlineVariable/varWithIncrement/file.kt
@@ -0,0 +1,7 @@
+package inlineVariable.varWithIncrement
+
+fun main() {
+ var value = 42
+ println(value)
+ value++
+}
diff --git a/docs/development-plan.md b/docs/development-plan.md
index 7da18342..65501125 100644
--- a/docs/development-plan.md
+++ b/docs/development-plan.md
@@ -610,8 +610,8 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E
3. `KotlinInlineVariableRefactoring` — `AbstractRefactoring` carrying doc + offset.
4. `KotlinInlineVariableAction` — `BaseAction("kotlin-inline-variable")`, **Ctrl+Alt+N**;
registered in `Refactor` menu and `Editors/text/x-kotlin/Actions/` in `layer.xml`.
- - Caret resolution: works on declaration and on any usage — see *Cursor-on-declaration-or-usage* in E9 strategy above.
- - Supports `val` first; the same engine generalises to `var` and E9.4 (Inline function).
+ - Caret or a selected identifier resolution works on declaration and any usage — see *Cursor-on-declaration-or-usage* in E9 strategy above.
+ - Supports local `val` and a local `var` with its sole definition in the declaration initializer. A K2-backed standalone-source scan rejects assignments and increment/decrement writes before the IDEA engine is invoked and identifies the conflicting expression; receiver/member/accessor, Java, and index-only write coverage remain outside this adapter's scope.
- **E9.4** — Inline function (Ctrl+Alt+N)
- IDEA sources: `inline/codeInliner/CodeInliner.kt`,
diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md
index ae455431..c7d46e60 100644
--- a/docs/refactoring-coverage.md
+++ b/docs/refactoring-coverage.md
@@ -57,7 +57,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa
| `rename` | Rename | Rename (`Alt+Shift+R`) | Partial | K2 declaration/reference rename and override cascade are covered; file/package/directory renames and IDEA automatic renamers remain. | F7 |
| `safe-delete` | Safe Delete | Safe Delete (`Alt+Delete`) | Partial | Declaration usage check is available; IDEA supertype, type/value-argument, and Java-bridge cases remain. | F7 |
| `change-signature` | Change Signature | `Ctrl+F6` | Partial | K2 signature engine handles calls, overrides, constructors, destructuring and operators; every touched document now commits or rolls back through one hunk-preserving transaction, while broader IDEA scenarios remain. | F1 |
-| `inline-variable-property` | Inline property | Inline (`Ctrl+Alt+N`) | Partial | IDEA code inliner backs `val`/property scenarios; broader `var`, receiver, comment and Java cases remain. | F3 |
+| `inline-variable-property` | Inline property | Inline (`Ctrl+Alt+N`) | Partial | IDEA code inliner supports local `val` and a local `var` with one declaration initializer and no later writes, invoked from a declaration or selected usage; a later assignment or increment/decrement is rejected before mutation with its expression identified. Receiver/member/accessor, comment, Java, and broader index-backed cases remain. | F3 |
| `inline-function` | Inline function | Inline (`Ctrl+Alt+N`) | Partial | K2 engine supports named functions; complex callable-reference, recursion and Java cases need parity tests. | F3 |
| `extract-function` | Extract Function | `Ctrl+Alt+M` | Partial | Real IDEA generator supports captured parameters, return values, multi-statements and scopes; control-flow, smart-cast, receiver and duplicate matrices remain. | F6 |
| `introduce-variable` | Introduce Variable | `Ctrl+Alt+V` | Partial | Common expression extraction, duplicate replacement and type/`val`/`var` choices work; advanced contexts remain. | F6 |
@@ -107,6 +107,7 @@ The existing NetBeans tests are the initial executable sample set. They intentio
| Extract Function with captured locals in top-level scope | `KaExtractFunctionTest` | Exact parity baseline |
| Change Signature across declaration and separate call site | `KaChangeSignatureTest` | Exact parity baseline |
| Inline Function from a call site with multiple usages | `KaInlineFunctionTest` | Exact parity baseline |
+| Inline Variable for a write-free local `var`; later assignments/increments rejected | `KaInlineVariableTest` | Portable K2 single-definition baseline; receiver, Java, and index-only write coverage remain incomplete |
| Move Declaration with external usage retargeting | `KaMoveDeclarationTest` | Exact parity baseline; transactional source/target undo baseline |
| Copy Declaration into new or existing target | `KaCopyDeclarationTest`, `KotlinRefactoringTransactionTest` | Transactional target creation/replacement and undo baseline |
| Pull Members Up direct target collision | `KaPullMembersUpComputerTest` | Documented standalone limitation beyond direct collision |
diff --git a/pom.xml b/pom.xml
index 15299c1e..043b1751 100644
--- a/pom.xml
+++ b/pom.xml
@@ -4,7 +4,7 @@
4.0.0
io.github.nbplugins
netbeans-plugin-kotlin
- 0.11.228-SNAPSHOT
+ 0.11.231-SNAPSHOT
pom
Netbeans Plugin Kotlin