From 52753b8909e3ed34419514fc8419caff888ab4b0 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 30 Sep 2026 08:29:12 +0300 Subject: [PATCH 1/2] Intermediate commit --- CHANGELOG.md | 1 + CoreImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaMoveNestedMemberComputer.kt | 294 ++++++++++++++++++ Nbm/pom.xml | 2 +- .../refactoring/KotlinMoveNestedMemberUI.java | 171 ++++++++++ .../rename/KotlinRefactoringsFactory.java | 6 + .../KotlinMoveNestedMemberAction.kt | 69 ++++ .../KotlinMoveNestedMemberPlugin.kt | 170 ++++++++++ .../KotlinMoveNestedMemberRefactoring.kt | 56 ++++ .../resources/org/jetbrains/kotlin/layer.xml | 14 + .../KaMoveNestedMemberComputerTest.kt | 210 +++++++++++++ .../KotlinMoveNestedMemberRefactoringTest.kt | 49 +++ .../RefactoringCoverageBaselineTest.kt | 6 +- .../projForTest/src/moveNestedMember/.gitkeep | 0 docs/development-plan.md | 14 + docs/refactoring-coverage.md | 6 +- pom.xml | 2 +- 24 files changed, 1070 insertions(+), 16 deletions(-) create mode 100644 KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt create mode 100644 Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoring.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoringTest.kt create mode 100644 Nbm/src/test/resources/projForTest/src/moveNestedMember/.gitkeep diff --git a/CHANGELOG.md b/CHANGELOG.md index 4b356197..4bc03594 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added Move Nested Member refactoring: choose **Refactor → Move Nested Member...** on a nested Kotlin class/object or a companion function/property, select a compatible target class/object, and apply the atomic source/target change; **Undo Last Refactoring** restores both files. Instance members, import aliases, and unvalidated external qualified-use rewrites remain unavailable. - Added Inline Anonymous Function/Lambda refactoring: place the caret in an immediately invoked Kotlin lambda or anonymous function, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, preview the one-call rewrite, and apply the IntelliJ IDEA K2-generated substitution; **Undo Last Refactoring** restores the original document. - Added separate Java and Kotlin package trees in Project Explorer: conventional `src/main/java` and `src/test/java` remain under **Source Packages** and **Test Packages**, while `src/main/kotlin` and `src/test/kotlin` appear under **Kotlin Source Packages** and **Kotlin Test Packages** in Maven, Gradle, and Ant/J2SE projects. - Added Inline Type Alias refactoring: place the caret on a Kotlin `typealias` declaration or one of its type uses, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, then expand every supported Kotlin occurrence or only the selected use and optionally retain the alias declaration; generic arguments, imports, and parenthesisation use the IntelliJ IDEA K2 engine, while **Undo Last Refactoring** restores all changed Kotlin files. diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 76ef7293..2fd8dda3 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 3020573a..861f4b8f 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index a12bf71b..b30aade3 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 15535125..f967e1a1 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 8c6b9d9c..34e2930d 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 62ced0b0..c6620f12 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 372d3085..6d40de29 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 340fdc1b..03fa2309 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt new file mode 100644 index 00000000..ba47e929 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt @@ -0,0 +1,294 @@ +/******************************************************************************* + * 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.refactoring + +import com.intellij.openapi.util.TextRange +import com.intellij.psi.PsiElement +import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService +import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.usages.K2MoveRenameUsageInfo +import org.jetbrains.kotlin.idea.references.KtReference +import org.jetbrains.kotlin.idea.references.KtSimpleNameReference +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtObjectDeclaration +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import org.jetbrains.kotlin.psi.psiUtil.getNonStrictParentOfType +import org.jetbrains.kotlin.psi.psiUtil.getStrictParentOfType +import org.jetbrains.kotlin.psi.psiUtil.parentsWithSelf + +/** + * Lifecycle-free K2 adapter for the portable subset of Move Nested Kotlin Member. + * + * IDEA's generic Java Move Members processor needs indexed Java PSI, light classes, Usage View, and + * command lifecycle services unavailable in the standalone container. This adapter instead retains + * IDEA's K2 reference-retargeting engine while only accepting relocations whose receiver change is + * mechanically safe: nested classes and companion-object functions/properties. + * + * @param file K2 session-owned file containing the invocation caret. + * @param caretOffset editor offset at which the operation was invoked. + */ +class KaMoveNestedMemberComputer( + private val file: KtFile, + private val caretOffset: Int, +) { + /** Result of resolving a supported declaration. */ + sealed interface Outcome { + /** The caret does not identify a nested class or companion member with safe semantics. */ + data object NotApplicable : Outcome + + /** The UI may offer a target class/object for this declaration. */ + data class Ready( + /** Range to preview in the declaration file. */ + val declarationRange: TextRange, + /** Simple declaration name. */ + val declarationName: String, + /** Whether the declaration must stay in a companion object. */ + val requiresCompanionTarget: Boolean, + /** Source file path, used by transaction-backed persistence. */ + val sourceFilePath: String, + ) : Outcome + + /** An unexpected PSI or K2 failure occurred. */ + data class Error(/** Underlying failure. */ val error: Throwable) : Outcome + } + + /** One named class/object that the controller may offer as a move target. */ + data class TargetCandidate( + /** Absolute path of the K2 session file containing this target. */ + val filePath: String, + /** Stable offset inside [filePath] used to resolve the target immediately before apply. */ + val offset: Int, + /** Readable type label shown by the passive NetBeans view. */ + val presentation: String, + /** Whether the move destination is this target's companion object. */ + val requiresCompanionTarget: Boolean, + ) + + /** Result of a pre-mutation validation pass. */ + sealed interface ConflictCheck { + /** The requested target is valid for this portable move subset. */ + data object Clear : ConflictCheck + + /** The operation must not mutate PSI; messages are ready for NetBeans preview. */ + data class Conflicts(/** Human-readable validation failures. */ val messages: List) : ConflictCheck + + /** The declaration or target no longer resolves. */ + data object NotApplicable : ConflictCheck + + /** An unexpected PSI or K2 failure occurred. */ + data class Error(/** Underlying failure. */ val error: Throwable) : ConflictCheck + } + + /** Result of executing one K2-native nested move. */ + sealed interface Apply { + /** The declaration or target no longer resolves. */ + data object NotApplicable : Apply + + /** Validation failed without changing standalone PSI. */ + data class Conflicts(/** Human-readable validation failures. */ val messages: List) : Apply + + /** Final source, target, and external-usage texts to persist atomically. */ + data class Success( + /** Every session file changed by the K2 move/retargeting operation. */ + val changedFiles: Map, + /** Final text of the declaration's original file. */ + val sourceText: String, + /** Final text of the file containing the selected target class. */ + val targetText: String, + ) : Apply + + /** An unexpected mutation or retargeting failure occurred. */ + data class Error(/** Underlying failure. */ val error: Throwable) : Apply + } + + /** + * Resolves the declaration at the invocation caret without modifying PSI. + * + * @return a supported-move description, a non-applicability result, or an analysis error. + */ + fun compute(): Outcome = runCatching { + val declaration = findDeclaration() ?: return Outcome.NotApplicable + val kind = supportedKind(declaration) ?: return Outcome.NotApplicable + Outcome.Ready( + declarationRange = declaration.textRange, + declarationName = declaration.name ?: return Outcome.NotApplicable, + requiresCompanionTarget = kind == Kind.COMPANION_MEMBER, + sourceFilePath = declaration.containingKtFile.virtualFile?.path.orEmpty(), + ) + }.getOrElse(Outcome::Error) + + /** + * Finds named class/object targets in [files] that can safely receive the declaration at the caret. + * + * The method is intentionally K2/PSI-only: it provides immutable data for a controller-owned + * target chooser and never resolves NetBeans files, opens dialogs, or mutates source. + * + * @param files K2 session files to inspect for named class/object targets. + * @return supported targets, sorted by their readable presentation; empty when not applicable. + */ + fun discoverTargets(files: Iterable): List = runCatching { + val declaration = findDeclaration() ?: return emptyList() + val kind = supportedKind(declaration) ?: return emptyList() + val owner = declaration.getStrictParentOfType() ?: return emptyList() + val excludedOwners = buildList { + add(owner) + declaration.getStrictParentOfType() + ?.getStrictParentOfType() + ?.let(::add) + }.map { it.containingKtFile.virtualFile?.path to it.textOffset }.toSet() + files.flatMap { candidateFile -> + candidateFile.collectDescendantsOfType() + .asSequence() + .filter { target -> + target.name != null && target.body != null && + (target.containingKtFile.virtualFile?.path to target.textOffset) !in excludedOwners + } + .filter { target -> kind != Kind.COMPANION_MEMBER || target.companionObjects.singleOrNull() != null } + .mapNotNull { target -> + target.containingKtFile.virtualFile?.path?.let { path -> + TargetCandidate(path, target.textOffset, target.name!!, kind == Kind.COMPANION_MEMBER) + } + } + .toList() + }.distinctBy { it.filePath to it.offset }.sortedWith(compareBy(TargetCandidate::presentation, TargetCandidate::filePath)) + }.getOrElse { emptyList() } + + /** + * Validates [targetOffset] in [targetFile] without changing source or target PSI. + * + * @param targetFile K2 session file containing the selected target class/object. + * @param targetOffset stable offset inside that target. + * @return a clear, conflicting, non-applicable, or error result. + */ + fun checkConflicts(targetFile: KtFile, targetOffset: Int): ConflictCheck = runCatching { + val declaration = findDeclaration() ?: return ConflictCheck.NotApplicable + val target = resolveTarget(targetFile, targetOffset) ?: return ConflictCheck.NotApplicable + validationMessages(declaration, target).let { messages -> + if (messages.isEmpty()) ConflictCheck.Clear else ConflictCheck.Conflicts(messages) + } + }.getOrElse(ConflictCheck::Error) + + /** + * Moves the supported declaration into [targetFile]'s target class at [targetOffset]. + * + * The method only mutates session PSI. Its caller persists [Apply.Success.changedFiles] through + * `KotlinRefactoringTransaction`, which supplies atomic rollback and Undo Last Refactoring. + * + * @param targetFile K2 session file containing the selected target class/object. + * @param targetOffset stable offset inside that target. + * @return final file text, conflicts, non-applicability, or an error. + */ + fun move(targetFile: KtFile, targetOffset: Int): Apply = runCatching { + val declaration = findDeclaration() ?: return Apply.NotApplicable + val target = resolveTarget(targetFile, targetOffset) ?: return Apply.NotApplicable + val messages = validationMessages(declaration, target) + if (messages.isNotEmpty()) return Apply.Conflicts(messages) + + val usageInfos = KotlinMoveUsageSearchService.getInstance()?.findUsages(declaration).orEmpty() + .filterIsInstance() + .filter { reference -> !com.intellij.psi.util.PsiTreeUtil.isAncestor(declaration, reference.element, false) } + .mapNotNull { reference -> + val element = reference.element as? org.jetbrains.kotlin.psi.KtElement ?: return@mapNotNull null + K2MoveRenameUsageInfo.Source(element, reference, declaration, false) + } + val usageFiles = usageInfos.mapNotNull { usage -> + usage.element?.containingFile as? KtFile + }.distinct() + + K2MoveRenameUsageInfo.markInternalUsages(declaration, declaration) + val targetContainer = targetContainer(declaration, target) ?: return Apply.NotApplicable + // A KtClassOrObject accepts an arbitrary PsiElement through PsiElement.add(), which puts + // the standalone copy in JavaDummyHolder. Adding through the Kotlin class body preserves the + // target KtFile context required by K2MoveRenameUsageInfo. + val sourceFile = declaration.containingKtFile + val targetBody = targetContainer.body ?: return Apply.NotApplicable + val declarationName = declaration.name ?: return Apply.NotApplicable + targetBody.add(declaration.copy()) + // PsiElement.add() returns the raw inserted element in this standalone implementation. Read + // the declaration back from the target class body so K2 only sees an element attached to a + // real KtFile rather than the transient Java dummy holder. + val copied = targetBody.declarations.filterIsInstance() + .lastOrNull { it.name == declarationName } ?: return Apply.NotApplicable + val oldToNew = declaration.collectDescendantsOfType() + .zip(copied.collectDescendantsOfType()) + .toMap(mutableMapOf().apply { put(declaration, copied) }) + declaration.delete() + K2MoveRenameUsageInfo.retargetUsages(usageInfos, oldToNew) + + val changed = (listOf(sourceFile, targetFile) + usageFiles) + .distinct() + .mapNotNull { changedFile -> changedFile.virtualFile?.path?.let { it to changedFile.text } } + .toMap() + Apply.Success(changed, sourceFile.text, targetFile.text) + }.getOrElse(Apply::Error) + + /** Resolves a declaration directly at the caret; usage-site invocation is deliberately excluded. */ + private fun findDeclaration(): KtNamedDeclaration? = sequenceOf(caretOffset, caretOffset + 1) + .mapNotNull(file::findElementAt) + .flatMap { it.parentsWithSelf } + .filterIsInstance() + .firstOrNull { supportedKind(it) != null } + + /** Identifies only relocations whose receiver/qualification change is safe in standalone K2. */ + private fun supportedKind(declaration: KtNamedDeclaration): Kind? = when (declaration) { + is KtClassOrObject -> declaration.takeIf { it.getStrictParentOfType() != null } + ?.let { Kind.NESTED_CLASS } + is KtNamedFunction, is KtProperty -> declaration.takeIf { + it.getStrictParentOfType()?.isCompanion() == true + }?.let { Kind.COMPANION_MEMBER } + else -> null + } + + /** Locates the named class/object chosen by the NetBeans target-selection controller. */ + private fun resolveTarget(targetFile: KtFile, targetOffset: Int): KtClassOrObject? = + targetFile.findElementAt(targetOffset)?.getNonStrictParentOfType() + + /** Checks target kind, same-owner no-op, duplicate declarations, and Kotlin import aliases. */ + private fun validationMessages(declaration: KtNamedDeclaration, target: KtClassOrObject): List { + val kind = supportedKind(declaration) ?: return listOf("This declaration cannot be moved safely.") + val sourceOwner = declaration.getStrictParentOfType() + ?: return listOf("Move source owner is unavailable.") + val messages = mutableListOf() + if (sourceOwner == target) messages += "The target class already owns ${declaration.name}." + if (kind == Kind.COMPANION_MEMBER && target.companionObjects.singleOrNull() == null) { + messages += "Target ${target.name ?: "class"} must declare exactly one companion object." + } + val destination = targetContainer(declaration, target) + val name = declaration.name + if (destination != null && name != null && destination.declarations.filterIsInstance().any { it.name == name }) { + messages += "Target ${target.name ?: "class"} already contains $name." + } + val aliases = KotlinMoveUsageSearchService.getInstance()?.findUsages(declaration).orEmpty() + .filterIsInstance() + .any { it.getImportAlias() != null } + if (aliases) messages += "Kotlin import aliases are not supported by Move Nested Member." + return messages + } + + /** Selects the target class body or its companion body to match the declaration's source kind. */ + private fun targetContainer(declaration: KtNamedDeclaration, target: KtClassOrObject): KtClassOrObject? = when (supportedKind(declaration)) { + Kind.NESTED_CLASS -> target + Kind.COMPANION_MEMBER -> target.companionObjects.singleOrNull() + null -> null + } + + /** Supported source ownership kinds. */ + private enum class Kind { NESTED_CLASS, COMPANION_MEMBER } +} diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 6cfc81a4..10d5b70c 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java new file mode 100644 index 00000000..1ecfbca5 --- /dev/null +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java @@ -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.refactoring.KaMoveNestedMemberComputer; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.ListCellRenderer; +import javax.swing.event.ChangeListener; +import org.netbeans.modules.refactoring.api.AbstractRefactoring; +import org.netbeans.modules.refactoring.api.Problem; +import org.netbeans.modules.refactoring.spi.ui.CustomRefactoringPanel; +import org.netbeans.modules.refactoring.spi.ui.RefactoringUI; +import org.openide.util.HelpCtx; + +/** + * Passive NetBeans view for choosing a Move Nested Member destination. + * + * It receives K2 discovery data from its controller and writes only the selected file/offset to the + * carrier; semantic conflict checks remain in {@link KotlinMoveNestedMemberPlugin}. + */ +public final class KotlinMoveNestedMemberUI implements RefactoringUI { + private final KaMoveNestedMemberComputer.Outcome.Ready ready; + private final List candidates; + private final KotlinMoveNestedMemberRefactoring refactoring; + private TargetPanel panel; + + /** + * Creates a target-selection view. + * + * @param ready source declaration presentation from the K2 controller + * @param candidates compatible targets computed by the K2 controller + * @param refactoring carrier populated on confirmation + */ + public KotlinMoveNestedMemberUI(KaMoveNestedMemberComputer.Outcome.Ready ready, + List candidates, + KotlinMoveNestedMemberRefactoring refactoring) { + this.ready = ready; + this.candidates = candidates; + this.refactoring = refactoring; + } + + /** @return operation label shown by NetBeans. */ + @Override public String getName() { return "Move Nested Member"; } + + /** @return description naming the declaration selected at the editor caret. */ + @Override public String getDescription() { return "Move nested member '" + ready.getDeclarationName() + "'"; } + + /** @return {@code false}; this operation changes Kotlin source. */ + @Override public boolean isQuery() { return false; } + + /** @return the reusable passive target chooser. */ + @Override public CustomRefactoringPanel getPanel(ChangeListener parent) { + if (panel == null) panel = new TargetPanel(ready, candidates, parent); + return panel; + } + + /** Copies the selected target candidate to the controller carrier. */ + @Override public Problem setParameters() { + if (panel != null) panel.copyTo(refactoring); + return checkParameters(); + } + + /** @return fatal validation problem when no target was selected. */ + @Override public Problem checkParameters() { + if (panel != null && panel.selected() == null) { + return new Problem(true, "Select a target class or object."); + } + return null; + } + + /** @return {@code true}; the user chooses a destination. */ + @Override public boolean hasParameters() { return true; } + + /** @return refactoring carrier accepted by the plugin factory. */ + @Override public AbstractRefactoring getRefactoring() { return refactoring; } + + /** @return no dedicated help page currently exists. */ + @Override public HelpCtx getHelpCtx() { return null; } + + /** Swing-only target chooser; deliberately contains no K2/project/session calls. */ + private static final class TargetPanel implements CustomRefactoringPanel { + private final JPanel component; + private final JComboBox targets; + + /** Creates the target chooser from immutable controller-supplied candidate data. */ + TargetPanel(KaMoveNestedMemberComputer.Outcome.Ready ready, + List candidates, + ChangeListener changeListener) { + targets = new JComboBox<>(candidates.toArray(new KaMoveNestedMemberComputer.TargetCandidate[0])); + targets.setRenderer(new TargetRenderer()); + targets.addActionListener(event -> changeListener.stateChanged(null)); + + JPanel form = new JPanel(new GridBagLayout()); + form.setBorder(BorderFactory.createEmptyBorder(8, 12, 8, 12)); + GridBagConstraints label = new GridBagConstraints(); + label.anchor = GridBagConstraints.WEST; + label.insets = new Insets(2, 0, 2, 8); + GridBagConstraints field = new GridBagConstraints(); + field.fill = GridBagConstraints.HORIZONTAL; + field.weightx = 1.0; + field.gridwidth = GridBagConstraints.REMAINDER; + field.insets = new Insets(2, 0, 2, 0); + + form.add(new JLabel("Member:"), label); + form.add(new JLabel(ready.getDeclarationName()), field); + JLabel targetLabel = new JLabel(ready.getRequiresCompanionTarget() + ? "Target class/object with companion:" : "Target class/object:"); + targetLabel.setLabelFor(targets); + form.add(targetLabel, label); + form.add(targets, field); + component = new JPanel(new BorderLayout()); + component.add(form, BorderLayout.NORTH); + } + + /** Copies only the selection; semantic validation is deferred to the plugin. */ + void copyTo(KotlinMoveNestedMemberRefactoring refactoring) { + KaMoveNestedMemberComputer.TargetCandidate selected = selected(); + refactoring.setTargetFilePath(selected == null ? "" : selected.getFilePath()); + refactoring.setTargetOffset(selected == null ? -1 : selected.getOffset()); + } + + /** @return selected immutable target candidate, if any. */ + KaMoveNestedMemberComputer.TargetCandidate selected() { + return (KaMoveNestedMemberComputer.TargetCandidate) targets.getSelectedItem(); + } + + /** Focuses the target selector when NetBeans opens the dialog. */ + @Override public void initialize() { targets.requestFocusInWindow(); } + + /** @return panel root component. */ + @Override public Component getComponent() { return component; } + } + + /** Renders a target type with a compact, readable label. */ + private static final class TargetRenderer extends JLabel + implements ListCellRenderer { + @Override public Component getListCellRendererComponent( + JList list, + KaMoveNestedMemberComputer.TargetCandidate value, int index, boolean selected, boolean focus) { + setText(value == null ? "" : value.getPresentation()); + setOpaque(true); + setBackground(selected ? list.getSelectionBackground() : list.getBackground()); + setForeground(selected ? list.getSelectionForeground() : list.getForeground()); + return this; + } + } +} diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java index 015ee5de..3f471373 100644 --- a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java +++ b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java @@ -42,6 +42,8 @@ import io.github.nbplugins.kotlin.nbm.refactoring.KotlinCopyDeclarationRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDeclarationPlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDeclarationRefactoring; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveNestedMemberPlugin; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveNestedMemberRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFilePlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFileRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackagePlugin; @@ -99,6 +101,7 @@ *
  • {@link KotlinIntroduceFunctionalParameterRefactoring} — delegates to {@link KotlinIntroduceFunctionalParameterPlugin}
  • *
  • {@link KotlinCopyDeclarationRefactoring} — delegates to {@link KotlinCopyDeclarationPlugin}
  • *
  • {@link KotlinMoveDeclarationRefactoring} — delegates to {@link KotlinMoveDeclarationPlugin}
  • + *
  • {@link KotlinMoveNestedMemberRefactoring} — delegates to {@link KotlinMoveNestedMemberPlugin}
  • *
  • {@link KotlinMoveFileRefactoring} — delegates to {@link KotlinMoveFilePlugin}
  • *
  • {@link KotlinChangePackageRefactoring} — delegates to {@link KotlinChangePackagePlugin}
  • *
  • {@link KotlinChangeSignatureRefactoring} — delegates to {@link KotlinChangeSignaturePlugin}
  • @@ -180,6 +183,9 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) { if (refactoring instanceof KotlinMoveDeclarationRefactoring) { return new KotlinMoveDeclarationPlugin((KotlinMoveDeclarationRefactoring) refactoring); } + if (refactoring instanceof KotlinMoveNestedMemberRefactoring) { + return new KotlinMoveNestedMemberPlugin((KotlinMoveNestedMemberRefactoring) refactoring); + } if (refactoring instanceof KotlinChangeSignatureRefactoring) { return new KotlinChangeSignaturePlugin((KotlinChangeSignatureRefactoring) refactoring); } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt new file mode 100644 index 00000000..3976c930 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt @@ -0,0 +1,69 @@ +/******************************************************************************* + * 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 io.github.nbplugins.kotlin.refactoring.KaMoveNestedMemberComputer +import org.jetbrains.kotlin.log.KotlinLogger +import org.jetbrains.kotlin.utils.ProjectUtils +import org.netbeans.editor.BaseAction +import org.netbeans.modules.refactoring.spi.ui.UI +import org.openide.windows.TopComponent +import java.awt.event.ActionEvent +import javax.swing.text.JTextComponent +import javax.swing.text.StyledDocument + +/** Opens the target-selection UI for the portable K2 Move Nested Member subset. */ +class KotlinMoveNestedMemberAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) { + init { + putValue(NAME, "Move Nested Member...") + putValue(SHORT_DESCRIPTION, "Move Nested Kotlin Class or Companion Member") + putValue(POPUP_MENU_TEXT, "Move Nested Member...") + } + + /** Resolves the source declaration and controller-owned target candidates before opening the view. */ + override fun actionPerformed(evt: ActionEvent, target: JTextComponent) { + val document = target.document as? StyledDocument ?: return + runCatching { + val source = ProjectUtils.getFileObjectForDocument(document) + ?: error("Move Nested Member requires a saved Kotlin source file.") + val project = ProjectUtils.getKotlinProjectForFileObject(source) + ?: ProjectUtils.getValidProject() + ?: error("Move Nested Member could not resolve the Kotlin project.") + val session = KotlinAnalysisAPISession.getSession(project) + val sourcePsi = session.getKtFileForPath(source.path) + ?: error("Move Nested Member could not resolve the Kotlin source file.") + val computer = KaMoveNestedMemberComputer(sourcePsi, target.caretPosition) + val ready = computer.compute() as? KaMoveNestedMemberComputer.Outcome.Ready + ?: error("Move Nested Member is not available at this caret location.") + val candidates = computer.discoverTargets( + session.fileMap.keys.mapNotNull(session::getKtFileForPath), + ) + if (candidates.isEmpty()) error("Move Nested Member found no compatible target class or object.") + val refactoring = KotlinMoveNestedMemberRefactoring(document, target.caretPosition) + UI.openRefactoringUI( + KotlinMoveNestedMemberUI(ready, candidates, refactoring), + TopComponent.getRegistry().activated, + ) + }.onFailure { KotlinLogger.INSTANCE.logException("Move Nested Member action failed", it) } + } + + companion object { + /** Layer action identifier. */ + const val ACTION_NAME = "kotlin-move-nested-member" + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt new file mode 100644 index 00000000..77c65f08 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt @@ -0,0 +1,170 @@ +/******************************************************************************* + * 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.navigation.KotlinFindUsagesResultElement +import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaMoveNestedMemberComputer +import org.jetbrains.kotlin.log.KotlinLogger +import org.jetbrains.kotlin.utils.ProjectUtils +import org.netbeans.modules.csl.api.OffsetRange +import org.netbeans.modules.refactoring.api.Problem +import org.netbeans.modules.refactoring.spi.ProgressProviderAdapter +import org.netbeans.modules.refactoring.spi.RefactoringElementsBag +import org.netbeans.modules.refactoring.spi.RefactoringPlugin +import org.netbeans.modules.refactoring.spi.SimpleRefactoringElementImplementation +import org.openide.filesystems.FileObject +import org.openide.filesystems.FileUtil +import org.openide.text.CloneableEditorSupport +import org.openide.text.PositionBounds +import org.openide.util.Lookup +import org.openide.util.lookup.Lookups +import java.io.File +import javax.swing.text.Position.Bias + +/** Integrates the safe K2 Move Nested Member subset with NetBeans preview, apply, and undo. */ +class KotlinMoveNestedMemberPlugin( + private val refactoring: KotlinMoveNestedMemberRefactoring, +) : ProgressProviderAdapter(), RefactoringPlugin { + override fun preCheck(): Problem? = null + override fun fastCheckParameters(): Problem? = null + override fun checkParameters(): Problem? = null + override fun cancelRequest() {} + + /** Re-resolves the selection and adds preview/apply elements only after a conflict-free validation. */ + override fun prepare(bag: RefactoringElementsBag): Problem? { + val source = ProjectUtils.getFileObjectForDocument(refactoring.document) + ?: return Problem(true, "Move Nested Member requires a saved Kotlin source file.") + val project = ProjectUtils.getKotlinProjectForFileObject(source) + ?: ProjectUtils.getValidProject() + ?: return Problem(true, "Move Nested Member could not resolve the Kotlin project.") + val request = refactoring.request() + ?: return Problem(true, "Select a target class or object.") + val target = fileObject(request.filePath) + ?: return Problem(true, "Move Nested Member could not resolve the target Kotlin file.") + + KotlinAnalysisAPISession.invalidate(project) + val session = KotlinAnalysisAPISession.getSession(project) + val sourcePsi = session.getKtFileForPath(source.path) + ?: return Problem(true, "Move Nested Member could not resolve the Kotlin source PSI.") + val targetPsi = session.getKtFileForPath(target.path) + ?: return Problem(true, "Move Nested Member could not resolve the target Kotlin PSI.") + val computer = KaMoveNestedMemberComputer(sourcePsi, refactoring.caretOffset) + return when (val ready = computer.compute()) { + KaMoveNestedMemberComputer.Outcome.NotApplicable -> + Problem(true, "Move Nested Member is no longer applicable at this caret location.") + is KaMoveNestedMemberComputer.Outcome.Error -> Problem(true, ready.error.message ?: "Move Nested Member failed.") + is KaMoveNestedMemberComputer.Outcome.Ready -> when (val conflicts = computer.checkConflicts(targetPsi, request.offset)) { + KaMoveNestedMemberComputer.ConflictCheck.Clear -> { + runCatching { + bag.add( + refactoring, + KotlinFindUsagesResultElement( + OffsetRange(ready.declarationRange.startOffset, ready.declarationRange.endOffset), + source, + ), + ) + } + bag.add(refactoring, KotlinMoveNestedMemberApplyElement(source, project, refactoring)) + null + } + is KaMoveNestedMemberComputer.ConflictCheck.Conflicts -> Problem(true, conflicts.messages.joinToString("\n")) + KaMoveNestedMemberComputer.ConflictCheck.NotApplicable -> + Problem(true, "Move Nested Member target is no longer available.") + is KaMoveNestedMemberComputer.ConflictCheck.Error -> + Problem(true, conflicts.error.message ?: "Move Nested Member validation failed.") + } + } + } + + /** Resolves an absolute K2 virtual-file path to the matching NetBeans file object. */ + private fun fileObject(path: String): FileObject? = + FileUtil.toFileObject(FileUtil.normalizeFile(File(path))) +} + +/** Applies all K2-mutated file texts through one atomic transaction and retains it for undo. */ +private class KotlinMoveNestedMemberApplyElement( + private val sourceFile: FileObject, + private val project: org.netbeans.api.project.Project, + private val refactoring: KotlinMoveNestedMemberRefactoring, +) : SimpleRefactoringElementImplementation() { + private var transaction: KotlinRefactoringTransaction? = null + + override fun getText(): String = "Move nested member" + override fun getDisplayText(): String = text + override fun getLookup(): Lookup = Lookups.fixed(sourceFile) + override fun getParentFile(): FileObject = sourceFile + override fun getPosition(): PositionBounds? = try { + val support = org.openide.loaders.DataObject.find(sourceFile) + .lookup.lookup(CloneableEditorSupport::class.java) ?: return null + PositionBounds(support.createPositionRef(0, Bias.Forward), support.createPositionRef(0, Bias.Backward)) + } catch (_: Exception) { null } + + /** Refreshes K2 PSI, stages every returned final text, and commits them atomically. */ + override fun performChange() { + var pending: KotlinRefactoringTransaction? = null + runCatching { + val request = refactoring.request() ?: error("Move Nested Member target is incomplete.") + val target = fileObject(request.filePath) ?: error("Move Nested Member could not resolve target file.") + KotlinAnalysisAPISession.invalidate(project) + val session = KotlinAnalysisAPISession.getSession(project) + val sourcePsi = session.getKtFileForPath(sourceFile.path) + ?: error("Move Nested Member could not refresh source Kotlin PSI.") + val targetPsi = session.getKtFileForPath(target.path) + ?: error("Move Nested Member could not refresh target Kotlin PSI.") + val current = KotlinRefactoringTransaction() + pending = current + current.captureExisting(sourceFile) + if (target != sourceFile) current.captureExisting(target) + + when (val result = KaMoveNestedMemberComputer(sourcePsi, refactoring.caretOffset).move(targetPsi, request.offset)) { + is KaMoveNestedMemberComputer.Apply.Success -> { + result.changedFiles.forEach { (path, text) -> + val changed = fileObject(path) + ?: error("Move Nested Member could not resolve changed file $path.") + current.captureExisting(changed) + current.stageText(changed, text) + } + current.commit() + transaction = current + pending = null + } + is KaMoveNestedMemberComputer.Apply.Conflicts -> + error(result.messages.joinToString("\n")) + KaMoveNestedMemberComputer.Apply.NotApplicable -> + error("Move Nested Member is no longer applicable.") + is KaMoveNestedMemberComputer.Apply.Error -> throw result.error + } + }.onFailure { KotlinLogger.INSTANCE.logException("Move Nested Member apply failed", it) } + runCatching { pending?.rollback() } + .onFailure { KotlinLogger.INSTANCE.logException("Move Nested Member rollback failed", it) } + KotlinAnalysisAPISession.invalidate(project) + } + + /** Restores every transaction participant for NetBeans Undo Last Refactoring. */ + override fun undoChange() { + runCatching { + transaction?.undo() + transaction = null + KotlinAnalysisAPISession.invalidate(project) + }.onFailure { KotlinLogger.INSTANCE.logException("Undo Move Nested Member failed", it) } + } + + /** Resolves a session virtual-file path to NetBeans persistence infrastructure. */ + private fun fileObject(path: String): FileObject? = + FileUtil.toFileObject(FileUtil.normalizeFile(File(path))) +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoring.kt new file mode 100644 index 00000000..d4c24716 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoring.kt @@ -0,0 +1,56 @@ +/******************************************************************************* + * 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 org.netbeans.modules.refactoring.api.AbstractRefactoring +import org.openide.util.lookup.Lookups +import javax.swing.text.StyledDocument + +/** Immutable target selection used by the move-nested-member controller and plugin. */ +data class KotlinMoveNestedMemberTarget( + /** Absolute path of the Kotlin file containing the selected target. */ + val filePath: String, + /** Stable PSI offset inside [filePath] identifying the selected class/object. */ + val offset: Int, +) + +/** + * NetBeans refactoring carrier for one Kotlin Move Nested Member operation. + * + * @param document editor document containing the declaration at invocation time. + * @param caretOffset caret position used to resolve the source declaration again before apply. + */ +class KotlinMoveNestedMemberRefactoring( + /** Editor document containing the source declaration. */ + val document: StyledDocument, + /** Caret position used to resolve the source declaration. */ + val caretOffset: Int, +) : AbstractRefactoring(Lookups.fixed(document)) { + /** Absolute selected target-file path, populated by the passive target chooser. */ + var targetFilePath: String = "" + + /** Selected target class/object offset, populated by the passive target chooser. */ + var targetOffset: Int = -1 + + /** + * Returns the complete immutable target selection, or `null` when the dialog is incomplete. + * + * @return selected target file/offset when valid; otherwise `null`. + */ + fun request(): KotlinMoveNestedMemberTarget? = targetFilePath.takeIf(String::isNotBlank) + ?.let { path -> targetOffset.takeIf { it >= 0 }?.let { KotlinMoveNestedMemberTarget(path, it) } } +} diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml index b8420384..ee855042 100644 --- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml +++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml @@ -111,6 +111,10 @@ + + + + @@ -149,6 +153,10 @@ + + + + @@ -540,6 +548,12 @@ + + + + + diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt new file mode 100644 index 00000000..11b5d24a --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt @@ -0,0 +1,210 @@ +/******************************************************************************* + * 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 io.github.nbplugins.kotlin.refactoring.KaMoveNestedMemberComputer +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType +import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** Tests the portable standalone K2 contract for moving nested Kotlin declarations. */ +class KaMoveNestedMemberComputerTest : KotlinTestCase("KaMoveNestedMemberComputerTest", "moveNestedMember") { + + /** Moves a nested class between two named Kotlin classes. */ + fun testMove_nestedClass_movesToTargetClass() { + val fixture = createFixture( + sourceBody = "class Nested(val value: Int)", + targetBody = "", + ) ?: return + try { + val result = fixture.computer("Nested").move(fixture.targetFile, fixture.target.textOffset) + + assertTrue("Expected successful nested-class move, got $result", result is KaMoveNestedMemberComputer.Apply.Success) + result as KaMoveNestedMemberComputer.Apply.Success + assertFalse("Source must no longer contain moved nested class:\n${result.sourceText}", result.sourceText.contains("class Nested")) + assertTrue("Target must contain moved nested class:\n${result.targetText}", result.targetText.contains("class Nested")) + } finally { + fixture.directory.toFile().deleteRecursively() + } + } + + /** Discovers named class/object targets other than the nested class's current owner. */ + fun testDiscoverTargets_nestedClass_listsEligibleNamedContainers() { + val fixture = createFixture( + sourceBody = "class Nested(val value: Int)", + targetBody = "", + ) ?: return + try { + val targets = fixture.computer("Nested").discoverTargets(listOf(fixture.sourceFile, fixture.targetFile)) + + assertEquals(1, targets.size) + assertEquals("Target", targets.single().presentation) + assertEquals(fixture.targetFile.virtualFile?.path, targets.single().filePath) + assertEquals(fixture.target.textOffset, targets.single().offset) + } finally { + fixture.directory.toFile().deleteRecursively() + } + } + + /** Discovers only class/object targets with exactly one companion for companion-member moves. */ + fun testDiscoverTargets_companionMember_requiresSingleTargetCompanion() { + val fixture = createFixture( + sourceBody = "companion object { fun greet(): String = \"hello\" }", + targetBody = "companion object { }", + ) ?: return + try { + val withoutCompanionPath = fixture.directory.resolve("NoCompanion.kt") + Files.writeString(withoutCompanionPath, "package nestedmove\n\nclass NoCompanion { }\n") + val withTwoCompanionsPath = fixture.directory.resolve("TwoCompanions.kt") + Files.writeString( + withTwoCompanionsPath, + "package nestedmove\n\nclass TwoCompanions { companion object First { } companion object Second { } }\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "move-nested-member-target-discovery", + binaryJars = listOf(findKotlinStdlib() ?: return), + sourceRoots = listOf(fixture.directory), + ) + val sourceFile = session.getKtFileForPath(fixture.directory.resolve("Source.kt").toString()) ?: return + val targetFile = session.getKtFileForPath(fixture.directory.resolve("Target.kt").toString()) ?: return + val noCompanionFile = session.getKtFileForPath(withoutCompanionPath.toString()) ?: return + val twoCompanionsFile = session.getKtFileForPath(withTwoCompanionsPath.toString()) ?: return + val source = sourceFile.declarations.filterIsInstance().single { it.name == "Source" } + val computer = KaMoveNestedMemberComputer( + sourceFile, + source.collectDescendantsOfType().first { it.name == "greet" }.textOffset, + ) + + val targets = computer.discoverTargets(listOf(sourceFile, targetFile, noCompanionFile, twoCompanionsFile)) + + assertEquals(listOf("Target"), targets.map { it.presentation }) + assertTrue(targets.single().requiresCompanionTarget) + } finally { + fixture.directory.toFile().deleteRecursively() + } + } + + /** Moves a companion function into the selected target companion object. */ + fun testMove_companionFunction_movesToTargetCompanion() { + val fixture = createFixture( + sourceBody = "companion object { fun greet(): String = \"hello\" }", + targetBody = "companion object { }", + ) ?: return + try { + val result = fixture.computer("greet").move(fixture.targetFile, fixture.target.textOffset) + + assertTrue("Expected successful companion-function move, got $result", result is KaMoveNestedMemberComputer.Apply.Success) + result as KaMoveNestedMemberComputer.Apply.Success + assertFalse("Source must no longer contain moved function:\n${result.sourceText}", result.sourceText.contains("fun greet")) + assertTrue("Target must contain moved function:\n${result.targetText}", result.targetText.contains("fun greet")) + } finally { + fixture.directory.toFile().deleteRecursively() + } + } + + /** Moves a companion property into the target companion object. */ + fun testMove_companionProperty_movesToTargetCompanion() { + val fixture = createFixture( + sourceBody = "companion object { val answer: Int = 42 }", + targetBody = "companion object { }", + ) ?: return + try { + val result = fixture.computer("answer").move(fixture.targetFile, fixture.target.textOffset) + + assertTrue("Expected successful companion-property move, got $result", result is KaMoveNestedMemberComputer.Apply.Success) + result as KaMoveNestedMemberComputer.Apply.Success + assertFalse("Source must no longer contain moved property:\n${result.sourceText}", result.sourceText.contains("val answer")) + assertTrue("Target must contain moved property:\n${result.targetText}", result.targetText.contains("val answer")) + } finally { + fixture.directory.toFile().deleteRecursively() + } + } + + /** Reports a target member collision before mutating standalone K2 PSI. */ + fun testCheckConflicts_duplicateTargetMember_reportsConflict() { + val fixture = createFixture( + sourceBody = "class Nested", + targetBody = "class Nested", + ) ?: return + try { + val result = fixture.computer("Nested").checkConflicts(fixture.targetFile, fixture.target.textOffset) + + assertTrue("Expected target collision, got $result", result is KaMoveNestedMemberComputer.ConflictCheck.Conflicts) + result as KaMoveNestedMemberComputer.ConflictCheck.Conflicts + assertTrue("Expected a duplicate-member message, got ${result.messages}", result.messages.any { it.contains("Nested") }) + } finally { + fixture.directory.toFile().deleteRecursively() + } + } + + /** Rejects an ordinary instance member because relocating its receiver is not mechanically safe. */ + fun testCompute_instanceFunction_isNotApplicable() { + val fixture = createFixture(sourceBody = "fun greet(): String = \"hello\"", targetBody = "") ?: return + try { + assertSame(KaMoveNestedMemberComputer.Outcome.NotApplicable, fixture.computer("greet").compute()) + } finally { + fixture.directory.toFile().deleteRecursively() + } + } + + /** Creates a standalone K2 project with source, target, and optional external usage files. */ + private fun createFixture(sourceBody: String, targetBody: String, usage: String? = null): Fixture? { + val stdlib = findKotlinStdlib() ?: return null + val directory = Files.createTempDirectory("nbkotlin-move-nested-member") + val sourcePath = directory.resolve("Source.kt") + val targetPath = directory.resolve("Target.kt") + Files.writeString(sourcePath, "package nestedmove\n\nclass Source {\n $sourceBody\n}\n") + Files.writeString(targetPath, "package nestedmove\n\nclass Target {\n $targetBody\n}\n") + usage?.let { Files.writeString(directory.resolve("Usage.kt"), "package nestedmove\n\n$it\n") } + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "move-nested-member-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(directory), + ) + val sourceFile = session.getKtFileForPath(sourcePath.toString()) ?: return null + val targetFile = session.getKtFileForPath(targetPath.toString()) ?: return null + val source = sourceFile.declarations.filterIsInstance().single { it.name == "Source" } + val target = targetFile.declarations.filterIsInstance().single { it.name == "Target" } + return Fixture(directory, sourceFile, targetFile, source, target) + } + + /** Finds the Kotlin standard library required by standalone Analysis API fixtures. */ + private fun findKotlinStdlib(): Path? = System.getProperty("java.class.path") + .split(System.getProperty("path.separator")) + .map(Path::of) + .firstOrNull { it.fileName?.toString()?.startsWith("kotlin-stdlib") == true && it.toFile().exists() } + + /** Holds standalone K2 source and target inputs for one narrow move request. */ + private data class Fixture( + val directory: Path, + val sourceFile: KtFile, + val targetFile: KtFile, + val source: KtClass, + val target: KtClass, + ) { + /** Resolves one source declaration by name into the move computer. */ + fun computer(memberName: String): KaMoveNestedMemberComputer = KaMoveNestedMemberComputer( + sourceFile, + source.collectDescendantsOfType().first { it.name == memberName }.textOffset, + ) + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoringTest.kt new file mode 100644 index 00000000..4e7bbb58 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberRefactoringTest.kt @@ -0,0 +1,49 @@ +/******************************************************************************* + * 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 org.netbeans.junit.NbTestCase +import javax.swing.text.DefaultStyledDocument + +/** Tests selection validation in [KotlinMoveNestedMemberRefactoring]. */ +class KotlinMoveNestedMemberRefactoringTest : NbTestCase("KotlinMoveNestedMemberRefactoringTest") { + + /** Returns `null` until the target file path and offset are both supplied. */ + fun testRequest_requiresCompleteTargetSelection() { + val refactoring = KotlinMoveNestedMemberRefactoring(DefaultStyledDocument(), 12) + + assertNull(refactoring.request()) + refactoring.targetFilePath = "/project/Target.kt" + assertNull(refactoring.request()) + refactoring.targetOffset = 25 + + assertEquals( + KotlinMoveNestedMemberTarget("/project/Target.kt", 25), + refactoring.request(), + ) + } + + /** Verifies the plugin lifecycle guards are available before NetBeans resolves project PSI. */ + fun testPlugin_lifecycleHooks_doNotThrow() { + val plugin = KotlinMoveNestedMemberPlugin(KotlinMoveNestedMemberRefactoring(DefaultStyledDocument(), 0)) + + assertNull(plugin.preCheck()) + assertNull(plugin.fastCheckParameters()) + assertNull(plugin.checkParameters()) + plugin.cancelRequest() + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt index 7b00d3ac..b4ef56bf 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/RefactoringCoverageBaselineTest.kt @@ -54,10 +54,10 @@ class RefactoringCoverageBaselineTest : NbTestCase("RefactoringCoverageBaselineT } } - val totals = "**Baseline counts:** 0 complete, 22 partial, 2 absent, 24 total." + val totals = "**Baseline counts:** 0 complete, 23 partial, 1 absent, 24 total." assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals)) - assertEquals(22, records.count { it.status == "partial" }) - assertEquals(2, records.count { it.status == "absent" }) + assertEquals(23, records.count { it.status == "partial" }) + assertEquals(1, records.count { it.status == "absent" }) } /** Finds the checkout root from Surefire's module working directory. */ diff --git a/Nbm/src/test/resources/projForTest/src/moveNestedMember/.gitkeep b/Nbm/src/test/resources/projForTest/src/moveNestedMember/.gitkeep new file mode 100644 index 00000000..e69de29b diff --git a/docs/development-plan.md b/docs/development-plan.md index 2c1715e3..ac0bf24e 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -779,6 +779,20 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E document. Java/comment/text/index-only references, full IDEA project-model conflicts, and arbitrary multi-selection remain outside the standalone scope. + - [x] **F4.3** — Move Nested Kotlin Member (Refactor menu) + - Adds **Refactor → Move Nested Member...** for the portable K2-native subset: a nested named + class/object can move to a selected named class/object; a companion-object function/property + can move only to a selected target declaring exactly one companion object. + - `KaMoveNestedMemberComputer` retains IDEA K2 internal-reference marking, rebinding, and + shortening support while the passive NetBeans target chooser receives controller-computed + candidates and never invokes K2 directly. + - `KotlinMoveNestedMemberPlugin` validates a freshly resolved source/target immediately before + mutation, stages every returned changed file through `KotlinRefactoringTransaction`, rolls back + on a persistence failure, and supports **Refactor → Undo Last Refactoring** for source and target. + - The intentionally narrow standalone boundary rejects instance members, constructors, enum entries, + initializers, alias imports, duplicate targets, Java/non-code/comment/index-only scenarios, and + external qualified usages whose rewrite has not been validated. + - [x] **E9.8** — Change Signature (Ctrl+F6) — PRs #114, #115 - IDEA sources: `changeSignature/KotlinChangeSignatureUsageSearcher.kt`, `changeSignature/KotlinChangeInfo.kt`, `changeSignature/KotlinChangeInfoBase.kt`, diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md index f7297e1d..9f467193 100644 --- a/docs/refactoring-coverage.md +++ b/docs/refactoring-coverage.md @@ -30,7 +30,7 @@ netbeans=; test=; milestone= - + ## Purpose and scope @@ -48,7 +48,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | **Absent** | IDEA exposes the Kotlin refactoring family but the NetBeans plugin has no corresponding command. | | **Not applicable** | IDEA-only integration which is not a Kotlin refactoring target for this plugin. No such rows are currently tracked. | -**Baseline counts:** 0 complete, 22 partial, 2 absent, 24 total. +**Baseline counts:** 0 complete, 23 partial, 1 absent, 24 total. ## Current NetBeans refactorings @@ -72,6 +72,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | `move-declaration` | Move top-level declaration | Refactor → Move Declaration | Partial | Real K2 move/retargeting engine is used; source/target mutations now roll back atomically and support Undo Last Refactoring, while only top-level declarations are exposed. | F1 | | `move-file` | Move Kotlin file/directory | Refactor → Move Kotlin File / folder → Move Kotlin Directory | Partial | Moves one Kotlin file or recursively moves Kotlin descendants of a selected folder to a selectable source root/package, retaining the selected folder and nested hierarchy; matching package directives and supported Kotlin code references are updated, and Undo Last Refactoring restores every original path/text. Generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA filesystem behavior remain. | F4 | | `change-package` | Change Package | Refactor → Change Kotlin Package... / folder → Change Kotlin Package... | Partial | Changes the active Kotlin file or recursively selected folder's Kotlin descendants without moving physical paths; nested descendants retain relative package segments below the entered package, supported Kotlin code/direct imports update atomically, and Undo Last Refactoring restores every original text. Java/comment/text/index-only references, full IDEA project-model conflicts, and arbitrary multi-selection remain unavailable. | F4 | +| `move-nested-member-method` | Move nested class/member/method | Refactor → Move Nested Member... | Partial | Moves a nested named Kotlin class/object to a selected named class/object, or a companion function/property to a selected target with exactly one companion object. Source/target changes commit atomically and support Undo Last Refactoring. Instance members, constructors, enum entries, aliases, Java/non-code/comment/index-only cases, and unvalidated external qualified-use rewrites remain unavailable. | F4 | | `copy-declaration` | Copy declaration | Refactor → Copy / F5 | Partial | Top-level copy supports selectable source-root/package targets, internal retargeting, atomic rollback, and Undo Last Refactoring; nested declarations and broader IDEA target workflows remain. | F1 | | `extract-interface-superclass` | Extract Interface / Superclass | Refactor menu | Partial | K2 Extract Super engine atomically creates or restores its target together with the source, including Undo Last Refactoring; advanced constructors, generics and full conflicts remain. | F6 | | `pull-members-up` | Pull Members Up | `Ctrl+Alt+U` | Partial | Build-wide K2 hierarchy discovery covers Kotlin override chains; visibility, accidental-override, Java and full IDEA-index conflict checks remain. | F2 | @@ -81,7 +82,6 @@ The corresponding record comments above are deliberately machine-readable. `Refa | ID | IDEA family | Why absent | Target | |---|---|---|---| -| `move-nested-member-method` | Move nested class/member/method | Requires receiver/visibility/hierarchy conflict support. | F4 | | `rename-file-package-directory` | Rename file/package/directory | Requires NetBeans filesystem refactoring integration and Kotlin-aware update rules. | F7 | ## Cross-cutting milestones diff --git a/pom.xml b/pom.xml index 48feaee7..86e17709 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.11.256-SNAPSHOT + 0.11.257-SNAPSHOT pom Netbeans Plugin Kotlin From c0cd18dd96bbb28ff5f8db5709549a3863f50f29 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sat, 3 Oct 2026 22:58:01 +0300 Subject: [PATCH 2/2] Added Move Nested Member refactoring Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- CoreImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaMoveNestedMemberComputer.kt | 77 +++++-- Nbm/pom.xml | 6 +- .../refactoring/KotlinMoveNestedMemberUI.java | 208 +++++++++++++++--- .../KotlinMoveNestedMemberAction.kt | 4 + .../KotlinMoveNestedMemberPlugin.kt | 2 +- .../resources/org/jetbrains/kotlin/layer.xml | 6 - .../KaMoveNestedMemberComputerTest.kt | 49 ++++- pom.xml | 7 +- 17 files changed, 310 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 4bc03594..9bd339ad 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Added Move Nested Member refactoring: choose **Refactor → Move Nested Member...** on a nested Kotlin class/object or a companion function/property, select a compatible target class/object, and apply the atomic source/target change; **Undo Last Refactoring** restores both files. Instance members, import aliases, and unvalidated external qualified-use rewrites remain unavailable. +- Added Move Nested Member refactoring: choose **Refactor → Move Nested Member...** on a nested Kotlin class/object or a companion function/property, select a compatible target from a structured package/class tree, and apply the atomic source/target change; nested classes/objects can move into a target class/object without a body, which gains one automatically, while companion functions/properties require an existing companion object. **Undo Last Refactoring** restores both files. Instance members, import aliases, and unvalidated external qualified-use rewrites remain unavailable. - Added Inline Anonymous Function/Lambda refactoring: place the caret in an immediately invoked Kotlin lambda or anonymous function, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, preview the one-call rewrite, and apply the IntelliJ IDEA K2-generated substitution; **Undo Last Refactoring** restores the original document. - Added separate Java and Kotlin package trees in Project Explorer: conventional `src/main/java` and `src/test/java` remain under **Source Packages** and **Test Packages**, while `src/main/kotlin` and `src/test/kotlin` appear under **Kotlin Source Packages** and **Kotlin Test Packages** in Maven, Gradle, and Ant/J2SE projects. - Added Inline Type Alias refactoring: place the caret on a Kotlin `typealias` declaration or one of its type uses, press **Ctrl+Alt+N** or choose **Refactor → Inline...**, then expand every supported Kotlin occurrence or only the selected use and optionally retain the alias declaration; generic arguments, imports, and parenthesisation use the IntelliJ IDEA K2 engine, while **Undo Last Refactoring** restores all changed Kotlin files. diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 2fd8dda3..fa88e892 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 861f4b8f..242bf08d 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index b30aade3..2fed4f62 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index f967e1a1..13a1b95d 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 34e2930d..13b07995 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index c6620f12..696be996 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 6d40de29..ace7696e 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 03fa2309..2a1469fa 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt index ba47e929..bd78d257 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveNestedMemberComputer.kt @@ -18,6 +18,7 @@ package io.github.nbplugins.kotlin.refactoring import com.intellij.openapi.util.TextRange import com.intellij.psi.PsiElement +import com.intellij.psi.PsiWhiteSpace import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.usages.K2MoveRenameUsageInfo import org.jetbrains.kotlin.idea.references.KtReference @@ -28,6 +29,7 @@ import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtObjectDeclaration import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPsiFactory import org.jetbrains.kotlin.psi.psiUtil.collectDescendantsOfType import org.jetbrains.kotlin.psi.psiUtil.getNonStrictParentOfType import org.jetbrains.kotlin.psi.psiUtil.getStrictParentOfType @@ -63,6 +65,10 @@ class KaMoveNestedMemberComputer( val requiresCompanionTarget: Boolean, /** Source file path, used by transaction-backed persistence. */ val sourceFilePath: String, + /** Kotlin package containing the source declaration. */ + val sourcePackageName: String, + /** Top-level-to-owner source class/object path used to expand the target tree. */ + val sourceContainerPath: List, ) : Outcome /** An unexpected PSI or K2 failure occurred. */ @@ -75,8 +81,12 @@ class KaMoveNestedMemberComputer( val filePath: String, /** Stable offset inside [filePath] used to resolve the target immediately before apply. */ val offset: Int, - /** Readable type label shown by the passive NetBeans view. */ + /** Readable simple type label shown by the passive NetBeans view. */ val presentation: String, + /** Kotlin package containing the target, empty for the default package. */ + val packageName: String, + /** Top-level-to-leaf Kotlin class/object path used to build the passive target tree. */ + val containerPath: List, /** Whether the move destination is this target's companion object. */ val requiresCompanionTarget: Boolean, ) @@ -126,11 +136,18 @@ class KaMoveNestedMemberComputer( fun compute(): Outcome = runCatching { val declaration = findDeclaration() ?: return Outcome.NotApplicable val kind = supportedKind(declaration) ?: return Outcome.NotApplicable + val owner = declaration.getStrictParentOfType() ?: return Outcome.NotApplicable Outcome.Ready( declarationRange = declaration.textRange, declarationName = declaration.name ?: return Outcome.NotApplicable, requiresCompanionTarget = kind == Kind.COMPANION_MEMBER, sourceFilePath = declaration.containingKtFile.virtualFile?.path.orEmpty(), + sourcePackageName = declaration.containingKtFile.packageFqName.asString(), + sourceContainerPath = owner.parentsWithSelf + .filterIsInstance() + .mapNotNull(KtClassOrObject::getName) + .toList() + .asReversed(), ) }.getOrElse(Outcome::Error) @@ -148,6 +165,7 @@ class KaMoveNestedMemberComputer( val kind = supportedKind(declaration) ?: return emptyList() val owner = declaration.getStrictParentOfType() ?: return emptyList() val excludedOwners = buildList { + add(declaration) add(owner) declaration.getStrictParentOfType() ?.getStrictParentOfType() @@ -157,13 +175,29 @@ class KaMoveNestedMemberComputer( candidateFile.collectDescendantsOfType() .asSequence() .filter { target -> - target.name != null && target.body != null && + target.name != null && (target.containingKtFile.virtualFile?.path to target.textOffset) !in excludedOwners } - .filter { target -> kind != Kind.COMPANION_MEMBER || target.companionObjects.singleOrNull() != null } + .filter { target -> + when (kind) { + Kind.NESTED_CLASS -> true + Kind.COMPANION_MEMBER -> target.companionObjects.singleOrNull() != null + } + } .mapNotNull { target -> target.containingKtFile.virtualFile?.path?.let { path -> - TargetCandidate(path, target.textOffset, target.name!!, kind == Kind.COMPANION_MEMBER) + TargetCandidate( + filePath = path, + offset = target.textOffset, + presentation = target.name!!, + packageName = target.containingKtFile.packageFqName.asString(), + containerPath = target.parentsWithSelf + .filterIsInstance() + .mapNotNull(KtClassOrObject::getName) + .toList() + .asReversed(), + requiresCompanionTarget = kind == Kind.COMPANION_MEMBER, + ) } } .toList() @@ -214,22 +248,31 @@ class KaMoveNestedMemberComputer( K2MoveRenameUsageInfo.markInternalUsages(declaration, declaration) val targetContainer = targetContainer(declaration, target) ?: return Apply.NotApplicable - // A KtClassOrObject accepts an arbitrary PsiElement through PsiElement.add(), which puts - // the standalone copy in JavaDummyHolder. Adding through the Kotlin class body preserves the - // target KtFile context required by K2MoveRenameUsageInfo. val sourceFile = declaration.containingKtFile - val targetBody = targetContainer.body ?: return Apply.NotApplicable + val psiFactory = KtPsiFactory(targetContainer.project) + val targetBody = targetContainer.body ?: run { + targetContainer.add(psiFactory.createWhiteSpace(" ")) + targetContainer.add(psiFactory.createEmptyClassBody()) as? org.jetbrains.kotlin.psi.KtClassBody + ?: return Apply.NotApplicable + } val declarationName = declaration.name ?: return Apply.NotApplicable - targetBody.add(declaration.copy()) - // PsiElement.add() returns the raw inserted element in this standalone implementation. Read - // the declaration back from the target class body so K2 only sees an element attached to a - // real KtFile rather than the transient Java dummy holder. + val targetRightBrace = targetBody.rBrace ?: return Apply.NotApplicable + if (targetBody.declarations.isEmpty()) targetBody.addBefore(psiFactory.createNewLine(), targetRightBrace) + // `KtClassBody.add()` accepts an arbitrary PSI element in standalone mode and can attach it + // to JavaDummyHolder instead of the Kotlin body. Anchoring immediately before the real `}` + // keeps the copy inside the destination KtFile and gives K2 a stable parent chain. + val inserted = targetBody.addBefore(declaration.copy(), targetRightBrace) as? KtNamedDeclaration + ?: return Apply.NotApplicable + targetBody.addAfter(psiFactory.createNewLine(), inserted) val copied = targetBody.declarations.filterIsInstance() - .lastOrNull { it.name == declarationName } ?: return Apply.NotApplicable + .lastOrNull { it === inserted || it.name == declarationName } + ?: return Apply.NotApplicable val oldToNew = declaration.collectDescendantsOfType() .zip(copied.collectDescendantsOfType()) .toMap(mutableMapOf().apply { put(declaration, copied) }) + val sourceBody = declaration.getStrictParentOfType()?.body declaration.delete() + normalizeEmptyBody(sourceBody) K2MoveRenameUsageInfo.retargetUsages(usageInfos, oldToNew) val changed = (listOf(sourceFile, targetFile) + usageFiles) @@ -239,6 +282,14 @@ class KaMoveNestedMemberComputer( Apply.Success(changed, sourceFile.text, targetFile.text) }.getOrElse(Apply::Error) + /** Restores one well-formed newline before an empty source body's closing brace. */ + private fun normalizeEmptyBody(body: org.jetbrains.kotlin.psi.KtClassBody?) { + if (body == null || body.declarations.isNotEmpty()) return + val whitespace = body.rBrace?.prevSibling as? PsiWhiteSpace ?: return + if (whitespace.text == "\n") return + whitespace.replace(KtPsiFactory(body.project).createWhiteSpace("\n")) + } + /** Resolves a declaration directly at the caret; usage-site invocation is deliberately excluded. */ private fun findDeclaration(): KtNamedDeclaration? = sequenceOf(caretOffset, caretOffset + 1) .mapNotNull(file::findElementAt) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 10d5b70c..3c9d2ed1 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm @@ -453,6 +453,10 @@ org.netbeans.api org-openide-nodes + + org.netbeans.api + org-openide-explorer + org.netbeans.api org-netbeans-modules-editor-indent diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java index 1ecfbca5..f50ba3a4 100644 --- a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberUI.java @@ -22,25 +22,35 @@ import java.awt.GridBagConstraints; import java.awt.GridBagLayout; import java.awt.Insets; +import java.beans.PropertyChangeEvent; +import java.util.ArrayList; +import java.util.Comparator; +import java.util.LinkedHashMap; import java.util.List; +import java.util.Map; import javax.swing.BorderFactory; -import javax.swing.JComboBox; import javax.swing.JLabel; -import javax.swing.JList; import javax.swing.JPanel; -import javax.swing.ListCellRenderer; import javax.swing.event.ChangeListener; import org.netbeans.modules.refactoring.api.AbstractRefactoring; import org.netbeans.modules.refactoring.api.Problem; import org.netbeans.modules.refactoring.spi.ui.CustomRefactoringPanel; import org.netbeans.modules.refactoring.spi.ui.RefactoringUI; +import org.openide.explorer.ExplorerManager; +import org.openide.explorer.view.BeanTreeView; +import org.openide.nodes.AbstractNode; +import org.openide.nodes.Children; +import org.openide.nodes.Node; import org.openide.util.HelpCtx; +import org.openide.util.Lookup; +import org.openide.util.lookup.Lookups; /** * Passive NetBeans view for choosing a Move Nested Member destination. * * It receives K2 discovery data from its controller and writes only the selected file/offset to the - * carrier; semantic conflict checks remain in {@link KotlinMoveNestedMemberPlugin}. + * carrier; semantic conflict checks remain in {@link KotlinMoveNestedMemberPlugin}. The chooser uses + * NetBeans' explorer tree to retain the package and class-container structure of the supplied data. */ public final class KotlinMoveNestedMemberUI implements RefactoringUI { private final KaMoveNestedMemberComputer.Outcome.Ready ready; @@ -51,7 +61,7 @@ public final class KotlinMoveNestedMemberUI implements RefactoringUI { /** * Creates a target-selection view. * - * @param ready source declaration presentation from the K2 controller + * @param ready source declaration presentation and source-tree location from the K2 controller * @param candidates compatible targets computed by the K2 controller * @param refactoring carrier populated on confirmation */ @@ -84,7 +94,7 @@ public KotlinMoveNestedMemberUI(KaMoveNestedMemberComputer.Outcome.Ready ready, return checkParameters(); } - /** @return fatal validation problem when no target was selected. */ + /** @return fatal validation problem when no concrete target was selected. */ @Override public Problem checkParameters() { if (panel != null && panel.selected() == null) { return new Problem(true, "Select a target class or object."); @@ -101,21 +111,23 @@ public KotlinMoveNestedMemberUI(KaMoveNestedMemberComputer.Outcome.Ready ready, /** @return no dedicated help page currently exists. */ @Override public HelpCtx getHelpCtx() { return null; } - /** Swing-only target chooser; deliberately contains no K2/project/session calls. */ - private static final class TargetPanel implements CustomRefactoringPanel { - private final JPanel component; - private final JComboBox targets; + /** Swing-only target chooser; deliberately contains no K2/project/session/filesystem calls. */ + private static final class TargetPanel extends JPanel + implements CustomRefactoringPanel, ExplorerManager.Provider { + private final ExplorerManager explorerManager = new ExplorerManager(); + private final BeanTreeView targets = new BeanTreeView(); + private final Map nodesByPath = new LinkedHashMap<>(); + private final KaMoveNestedMemberComputer.Outcome.Ready ready; - /** Creates the target chooser from immutable controller-supplied candidate data. */ + /** Creates a structured chooser from immutable controller-supplied candidate data. */ TargetPanel(KaMoveNestedMemberComputer.Outcome.Ready ready, List candidates, ChangeListener changeListener) { - targets = new JComboBox<>(candidates.toArray(new KaMoveNestedMemberComputer.TargetCandidate[0])); - targets.setRenderer(new TargetRenderer()); - targets.addActionListener(event -> changeListener.stateChanged(null)); + this.ready = ready; + setLayout(new BorderLayout()); JPanel form = new JPanel(new GridBagLayout()); - form.setBorder(BorderFactory.createEmptyBorder(8, 12, 8, 12)); + form.setBorder(BorderFactory.createEmptyBorder(8, 12, 4, 12)); GridBagConstraints label = new GridBagConstraints(); label.anchor = GridBagConstraints.WEST; label.insets = new Insets(2, 0, 2, 8); @@ -131,11 +143,91 @@ private static final class TargetPanel implements CustomRefactoringPanel { ? "Target class/object with companion:" : "Target class/object:"); targetLabel.setLabelFor(targets); form.add(targetLabel, label); - form.add(targets, field); - component = new JPanel(new BorderLayout()); - component.add(form, BorderLayout.NORTH); + form.add(new JLabel("Choose a class or object in the tree below."), field); + add(form, BorderLayout.NORTH); + + TargetNode root = targetTree(candidates); + explorerManager.setRootContext(root); + targets.setRootVisible(false); + targets.setSelectionMode(javax.swing.tree.TreeSelectionModel.SINGLE_TREE_SELECTION); + targets.setBorder(BorderFactory.createEmptyBorder(0, 12, 8, 12)); + add(targets, BorderLayout.CENTER); + explorerManager.addPropertyChangeListener((PropertyChangeEvent event) -> { + if (ExplorerManager.PROP_SELECTED_NODES.equals(event.getPropertyName())) { + changeListener.stateChanged(null); + } + }); + } + + /** @return this panel's explorer context required by {@link BeanTreeView}. */ + @Override public ExplorerManager getExplorerManager() { return explorerManager; } + + /** + * Builds every explorer node only after all structural paths and selectable candidates are known. + * + * Nodes may have exactly one parent; constructing a replacement node after its structural node + * has acquired children makes {@code Children.Array} recursively assign already-owned nodes. + */ + private TargetNode targetTree(List candidates) { + Map entries = new LinkedHashMap<>(); + entries.put("", new TreeEntry("", "Targets", null, null)); + candidates.forEach(candidate -> addCandidate(entries, candidate)); + addSourcePath(entries); + + List ordered = new ArrayList<>(entries.values()); + ordered.sort(Comparator.comparingInt(TreeEntry::depth)); + for (TreeEntry entry : ordered) { + TargetNode node = new TargetNode(entry.displayName, entry.candidate); + entry.node = node; + nodesByPath.put(entry.path, node); + if (entry.parentPath != null) entries.get(entry.parentPath).node.add(node); + } + return entries.get("").node; } + /** Adds one selectable candidate and its package/container path to the immutable tree model. */ + private void addCandidate(Map entries, + KaMoveNestedMemberComputer.TargetCandidate candidate) { + String parentPath = ""; + String packagePath = packageKey(candidate.getPackageName()); + final String rootPath = parentPath; + entries.computeIfAbsent(packagePath, path -> new TreeEntry(path, + candidate.getPackageName().isEmpty() ? "(default package)" : candidate.getPackageName(), + rootPath, null)); + parentPath = packagePath; + List containers = candidate.getContainerPath(); + for (int index = 0; index < containers.size(); index++) { + String container = containers.get(index); + String path = parentPath + '/' + container; + boolean leaf = index == containers.size() - 1; + final String finalParentPath = parentPath; + entries.compute(path, (ignored, existing) -> existing == null + ? new TreeEntry(path, container, finalParentPath, leaf ? candidate : null) + : leaf ? existing.withCandidate(candidate) : existing); + parentPath = path; + } + } + + /** Adds the source path as structural entries so its package and top-level owner can expand. */ + private void addSourcePath(Map entries) { + String parentPath = ""; + String packagePath = packageKey(ready.getSourcePackageName()); + final String rootPath = parentPath; + entries.computeIfAbsent(packagePath, path -> new TreeEntry(path, + ready.getSourcePackageName().isEmpty() ? "(default package)" : ready.getSourcePackageName(), + rootPath, null)); + parentPath = packagePath; + for (String container : ready.getSourceContainerPath()) { + String path = parentPath + '/' + container; + final String finalParentPath = parentPath; + entries.computeIfAbsent(path, ignored -> new TreeEntry(path, container, finalParentPath, null)); + parentPath = path; + } + } + + /** Produces a collision-free internal path prefix for one package. */ + private String packageKey(String packageName) { return "package:" + packageName; } + /** Copies only the selection; semantic validation is deferred to the plugin. */ void copyTo(KotlinMoveNestedMemberRefactoring refactoring) { KaMoveNestedMemberComputer.TargetCandidate selected = selected(); @@ -143,29 +235,75 @@ void copyTo(KotlinMoveNestedMemberRefactoring refactoring) { refactoring.setTargetOffset(selected == null ? -1 : selected.getOffset()); } - /** @return selected immutable target candidate, if any. */ + /** @return selected immutable target candidate, if exactly one selectable node is active. */ KaMoveNestedMemberComputer.TargetCandidate selected() { - return (KaMoveNestedMemberComputer.TargetCandidate) targets.getSelectedItem(); + Node[] selectedNodes = explorerManager.getSelectedNodes(); + return selectedNodes.length == 1 + ? selectedNodes[0].getLookup().lookup(KaMoveNestedMemberComputer.TargetCandidate.class) + : null; + } + + /** Expands the source package and top-level container, then focuses the tree. */ + @Override public void initialize() { + StringBuilder path = new StringBuilder(packageKey(ready.getSourcePackageName())); + TargetNode packageNode = nodesByPath.get(path.toString()); + if (packageNode != null) targets.expandNode(packageNode); + if (!ready.getSourceContainerPath().isEmpty()) { + path.append('/').append(ready.getSourceContainerPath().get(0)); + TargetNode topLevel = nodesByPath.get(path.toString()); + if (topLevel != null) targets.expandNode(topLevel); + } + targets.requestFocusInWindow(); } - /** Focuses the target selector when NetBeans opens the dialog. */ - @Override public void initialize() { targets.requestFocusInWindow(); } + /** @return this panel itself. */ + @Override public Component getComponent() { return this; } + } + + /** Immutable path record used to allocate every NetBeans node with its final parent and lookup. */ + private static final class TreeEntry { + private final String path; + private final String displayName; + private final String parentPath; + private final KaMoveNestedMemberComputer.TargetCandidate candidate; + private TargetNode node; - /** @return panel root component. */ - @Override public Component getComponent() { return component; } + /** Creates one immutable package/container path record. */ + TreeEntry(String path, String displayName, String parentPath, + KaMoveNestedMemberComputer.TargetCandidate candidate) { + this.path = path; + this.displayName = displayName; + this.parentPath = parentPath; + this.candidate = candidate; + } + + /** @return a copy whose final path component is selectable. */ + TreeEntry withCandidate(KaMoveNestedMemberComputer.TargetCandidate target) { + return new TreeEntry(path, displayName, parentPath, target); + } + + /** @return component count used to construct parents before descendants. */ + int depth() { return path.isEmpty() ? 0 : path.split("/").length; } } - /** Renders a target type with a compact, readable label. */ - private static final class TargetRenderer extends JLabel - implements ListCellRenderer { - @Override public Component getListCellRendererComponent( - JList list, - KaMoveNestedMemberComputer.TargetCandidate value, int index, boolean selected, boolean focus) { - setText(value == null ? "" : value.getPresentation()); - setOpaque(true); - setBackground(selected ? list.getSelectionBackground() : list.getBackground()); - setForeground(selected ? list.getSelectionForeground() : list.getForeground()); - return this; + /** Explorer node that is selectable only when it contains a concrete target candidate. */ + private static final class TargetNode extends AbstractNode { + private final Children.Array children; + + /** Creates one package/container/target node backed by immutable controller data. */ + TargetNode(String displayName, KaMoveNestedMemberComputer.TargetCandidate candidate) { + this(new Children.Array(), displayName, candidate); } + + /** Creates the node after allocating mutable children for the target hierarchy. */ + private TargetNode(Children.Array children, String displayName, + KaMoveNestedMemberComputer.TargetCandidate candidate) { + super(children, candidate == null ? Lookup.EMPTY : Lookups.fixed(candidate)); + this.children = children; + setDisplayName(displayName); + } + + /** Appends one child while retaining the controller-determined sort order. */ + void add(TargetNode child) { children.add(new Node[] { child }); } } } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt index 3976c930..69ed6e08 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberAction.kt @@ -44,7 +44,11 @@ class KotlinMoveNestedMemberAction : BaseAction(ACTION_NAME, SAVE_POSITION or AB val project = ProjectUtils.getKotlinProjectForFileObject(source) ?: ProjectUtils.getValidProject() ?: error("Move Nested Member could not resolve the Kotlin project.") + // Target discovery scans the session's immutable source-root snapshot. Rebuild it now so + // Kotlin files created or saved since the previous editor analysis are selectable. + KotlinAnalysisAPISession.invalidate(project) val session = KotlinAnalysisAPISession.getSession(project) + session.updateFileContent(source.path, document.getText(0, document.length)) val sourcePsi = session.getKtFileForPath(source.path) ?: error("Move Nested Member could not resolve the Kotlin source file.") val computer = KaMoveNestedMemberComputer(sourcePsi, target.caretPosition) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt index 77c65f08..23901079 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveNestedMemberPlugin.kt @@ -137,7 +137,7 @@ private class KotlinMoveNestedMemberApplyElement( val changed = fileObject(path) ?: error("Move Nested Member could not resolve changed file $path.") current.captureExisting(changed) - current.stageText(changed, text) + current.stageHunkText(changed, text, project) } current.commit() transaction = current diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml index ee855042..0bbac40d 100644 --- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml +++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml @@ -548,12 +548,6 @@ - - - - - diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt index 11b5d24a..d5b6a39f 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveNestedMemberComputerTest.kt @@ -41,7 +41,12 @@ class KaMoveNestedMemberComputerTest : KotlinTestCase("KaMoveNestedMemberCompute assertTrue("Expected successful nested-class move, got $result", result is KaMoveNestedMemberComputer.Apply.Success) result as KaMoveNestedMemberComputer.Apply.Success assertFalse("Source must no longer contain moved nested class:\n${result.sourceText}", result.sourceText.contains("class Nested")) - assertTrue("Target must contain moved nested class:\n${result.targetText}", result.targetText.contains("class Nested")) + assertTrue("Source closing brace must remain on its own line:\n${result.sourceText}", result.sourceText.contains("class Source {\n}")) + assertTrue( + "Target must contain the moved class inside its body:\n${result.targetText}", + result.targetText.indexOf("class Target {") < result.targetText.indexOf("class Nested") && + result.targetText.indexOf("class Nested") < result.targetText.lastIndexOf("}"), + ) } finally { fixture.directory.toFile().deleteRecursively() } @@ -58,6 +63,8 @@ class KaMoveNestedMemberComputerTest : KotlinTestCase("KaMoveNestedMemberCompute assertEquals(1, targets.size) assertEquals("Target", targets.single().presentation) + assertEquals("nestedmove", targets.single().packageName) + assertEquals(listOf("Target"), targets.single().containerPath) assertEquals(fixture.targetFile.virtualFile?.path, targets.single().filePath) assertEquals(fixture.target.textOffset, targets.single().offset) } finally { @@ -65,6 +72,46 @@ class KaMoveNestedMemberComputerTest : KotlinTestCase("KaMoveNestedMemberCompute } } + /** Discovers and populates a bodyless top-level target declared in the same Kotlin file. */ + fun testMove_nestedClass_populatesBodylessTargetInSameFile() { + val stdlib = findKotlinStdlib() ?: return + val directory = Files.createTempDirectory("nbkotlin-move-nested-member-bodyless-target") + val sourcePath = directory.resolve("Source.kt") + Files.writeString( + sourcePath, + "package nestedmove\n\nclass Source {\n class Nested(val value: Int)\n}\n\nclass Target\n", + ) + try { + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "move-nested-member-bodyless-target", + binaryJars = listOf(stdlib), + sourceRoots = listOf(directory), + ) + val file = session.getKtFileForPath(sourcePath.toString()) ?: return + val source = file.declarations.filterIsInstance().single { it.name == "Source" } + val target = file.declarations.filterIsInstance().single { it.name == "Target" } + val computer = KaMoveNestedMemberComputer( + file, + source.collectDescendantsOfType().first { it.name == "Nested" }.textOffset, + ) + + val candidates = computer.discoverTargets(listOf(file)) + assertEquals(listOf("Target"), candidates.map { it.presentation }) + + val result = computer.move(file, target.textOffset) + assertTrue("Expected successful move to a bodyless target, got $result", result is KaMoveNestedMemberComputer.Apply.Success) + result as KaMoveNestedMemberComputer.Apply.Success + assertTrue("Target must gain a body:\n${result.targetText}", result.targetText.contains("class Target {")) + assertTrue( + "Moved class must be inside Target's body:\n${result.targetText}", + result.targetText.indexOf("class Target {") < result.targetText.indexOf("class Nested") && + result.targetText.indexOf("class Nested") < result.targetText.lastIndexOf("}"), + ) + } finally { + directory.toFile().deleteRecursively() + } + } + /** Discovers only class/object targets with exactly one companion for companion-member moves. */ fun testDiscoverTargets_companionMember_requiresSingleTargetCompanion() { val fixture = createFixture( diff --git a/pom.xml b/pom.xml index 86e17709..f1ed97fd 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.11.257-SNAPSHOT + 0.11.260-SNAPSHOT pom Netbeans Plugin Kotlin @@ -582,6 +582,11 @@ org-openide-nodes ${netbeans.version} + + org.netbeans.api + org-openide-explorer + ${netbeans.version} + org.netbeans.api org-netbeans-modules-editor-indent