diff --git a/CHANGELOG.md b/CHANGELOG.md index 9bd339ad..8708da2e 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,8 +1,10 @@ +- Fixed Kotlin package nodes under **Kotlin Source Packages** and **Kotlin Test Packages** retaining the standard **Refactor** submenu in the Projects window, so Rename opens **Rename Kotlin Package Directory...** for a non-empty Kotlin package; Java, resource, generated, empty, and non-Kotlin folders retain their existing handling, while package-tree headings are unchanged. +- Added Kotlin filesystem rename commands: choose **Refactor → Rename Kotlin File...** to rename the active `.kt` basename, or right-click a Kotlin-containing folder and choose **Rename Kotlin Package Directory...** to rename the physical directory and all descendants while preserving Kotlin package directives and code references; use **Change Kotlin Package...** for package/reference changes. **Undo Last Refactoring** restores original paths and text. Java file-facade, automatic-renamer, Java/comment/text/index-only, Unicode/quoted-name, and full IDEA project-model cases 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. -- Added Change Kotlin Package refactoring: use **Refactor → Change Kotlin Package...** for the active Kotlin file or a folder's recursive Kotlin descendants to update package directives in place; nested files retain their relative package hierarchy, supported Kotlin code references and direct imports update, and **Undo Last Refactoring** restores the original contents. +- Improved Change Kotlin Package refactoring: **Refactor → Change Kotlin Package...** now opens from Kotlin package nodes as well as the active Kotlin file and reliably updates package directives in place for a file or its recursive Kotlin descendants; nested files retain their relative package hierarchy, supported Kotlin code references and direct imports update, physical paths remain unchanged, and **Undo Last Refactoring** restores the original contents. - Added Move Kotlin File and Directory refactoring: move the active `.kt` file from **Refactor → Move Kotlin File...**, or right-click a folder and choose **Move Kotlin Directory...** to recursively move its Kotlin files while retaining the selected directory and nested structure below a selected source root/package; matching package directives and supported Kotlin code references are updated, and **Undo Last Refactoring** restores the original locations and contents. - Improved Kotlin Inline Variable: local `var` declarations with one initializer and no subsequent writes can now be inlined from either a declaration or selected usage; assignments and increment/decrement writes are rejected before changing the source with the conflicting expression identified. - Improved Kotlin Introduce Type Alias: selecting a complete generic type now creates a concrete alias, while selecting only its type constructor creates a parameterized alias that preserves nested and qualified type structures and each same-file usage's own type arguments; star projections and local type parameters that cannot form a valid top-level alias are rejected. diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index fa88e892..417fb27e 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 242bf08d..71b62852 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 2fed4f62..97b74f31 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 13a1b95d..3d015215 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 13b07995..d124dc5e 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 696be996..01235d77 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index ace7696e..71366dea 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 2a1469fa..b5c41082 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring @@ -1637,7 +1637,7 @@ if (extractableDescriptor.exists()) { } // 14. K2MoveRenameUsageInfo.kt: the IDEA Copy Declaration retargeting engine (E9.19). -// Three adaptations for standalone use: +// Four adaptations for standalone use: // a. Drop the Java-reference `Light` path and the external-usage helpers // (find/findExternalUsages/preProcessUsages). They pull in platform move handlers // (MoveClassHandler/MoveMemberHandler/MoveMembersProcessor) and asJava light classes @@ -1647,6 +1647,10 @@ if (extractableDescriptor.exists()) { // (same as patch #1 for shortenUtils.kt). // c. ProgressManager.getInstance().progressIndicator is null in the standalone container — // fall back to an EmptyProgressIndicator so pushState()/text/fraction are no-ops. +// d. Standalone FIR can reject a shortening range containing local declarations with +// "ClassId should not be local" after usages have already been rebound. Retain the valid +// qualified reference for that known failure, but always remove the internal root prefix. +// Regression coverage: KaChangePackageTest.local-shortener fixture in the Nbm module. def k2MoveUsage = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/usages/K2MoveRenameUsageInfo.kt') if (k2MoveUsage.exists()) { def text = k2MoveUsage.text @@ -1703,8 +1707,47 @@ if (k2MoveUsage.exists()) { text = text.replace(' val mainReference = expr.mainReference\n', ' val mainReference = expr.references.filterIsInstance().firstOrNull() ?: return\n') + // e. K2's standalone reference shortener cannot establish a FIR designation for a range that + // contains local declarations (it throws "ClassId should not be local"). At this point + // Source.retarget has already rebound every affected reference in NO_SHORTENING mode, so the + // fully-qualified text is semantically correct. Keep that fallback narrow: only this known + // shortener exception is absorbed, while all other K2 failures still abort the refactoring. + // Root-prefix removal must run whether shortening succeeds or falls back, otherwise the + // internal IDE-resolution marker could be written to a user document. + text = text.replace( +''' if (file is KtFile) { + shortenReferences(usageMap.keys.filterIsInstance()) + } + // There are some circumstances where the reference shortener does not shorten an expression we added the root prefix to. + // This can happen if the reference shortener is unable to add an import because the declaration's name is already + // being used in the target package. + // In these cases, we need to manually remove the added root prefix. + for (pointer in qualifiedExpressionPointers) { + val qualifiedExpression = pointer.element ?: continue + qualifiedExpression.removeRootPrefix() + }''', +''' try { + if (file is KtFile) { + try { + shortenReferences(usageMap.keys.filterIsInstance()) + } catch (error: Throwable) { + if (error.message != "ClassId should not be local") throw error + } + } + } finally { + // There are some circumstances where the reference shortener does not shorten an expression we added the root prefix to. + // This can happen if the reference shortener is unable to add an import because the declaration's name is already + // being used in the target package. + // In these cases, we need to manually remove the added root prefix. + for (pointer in qualifiedExpressionPointers) { + val qualifiedExpression = pointer.element ?: continue + qualifiedExpression.removeRootPrefix() + } + }''' + ) + k2MoveUsage.text = text - log.info("Patched K2MoveRenameUsageInfo.kt: dropped Light/external paths, analyze swap, null-safe progress") + log.info("Patched K2MoveRenameUsageInfo.kt: dropped Light/external paths, standalone shortener fallback, analyze swap, null-safe progress") } // 15. K2ReferenceMutateService is `internal`; make it public so the Nbm session can register it diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 3c9d2ed1..8bd32ac0 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java new file mode 100644 index 00000000..e611d9d8 --- /dev/null +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java @@ -0,0 +1,211 @@ +/******************************************************************************* + * 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 java.awt.BorderLayout; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import javax.swing.BorderFactory; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.event.ChangeListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +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; + +/** + * Configures an explicit physical Kotlin file or package-directory rename. + * + * @param refactoring mutable typed rename carrier + * @param directory whether the selected source is a directory rather than one Kotlin file + * @param displayName selected source name shown in the panel + */ +public final class KotlinFilesystemRenameUI implements RefactoringUI { + + private final AbstractRefactoring refactoring; + private final boolean directory; + private final String displayName; + private RenamePanel panel; + + /** + * Creates a filesystem rename form. + * + * @param refactoring typed file or directory carrier + * @param directory whether this renames a package directory + * @param displayName source name initially displayed to the user + */ + public KotlinFilesystemRenameUI(AbstractRefactoring refactoring, boolean directory, String displayName) { + this.refactoring = refactoring; + this.directory = directory; + this.displayName = displayName; + } + + /** @return user-visible operation title. */ + @Override + public String getName() { + return directory ? "Rename Kotlin Package Directory" : "Rename Kotlin File"; + } + + /** @return concise description of the physical source to rename. */ + @Override + public String getDescription() { + return (directory ? "Rename Kotlin package directory '" : "Rename Kotlin file '") + displayName + "'"; + } + + /** @return false because the operation changes project files. */ + @Override + public boolean isQuery() { + return false; + } + + /** + * Creates the rename form on first use. + * + * @param parent listener notified after a field or option changes + * @return cached configuration panel + */ + @Override + public CustomRefactoringPanel getPanel(ChangeListener parent) { + if (panel == null) { + String currentName = directory + ? ((KotlinRenameDirectoryRefactoring) refactoring).getNewName() + : ((KotlinRenameFileRefactoring) refactoring).getNewName(); + panel = new RenamePanel(directory, displayName, currentName, parent); + } + return panel; + } + + /** + * Copies and validates panel settings into the typed carrier. + * + * @return a fatal problem for an empty or invalid filesystem name, otherwise {@code null} + */ + @Override + public Problem setParameters() { + if (panel == null) { + return null; + } + String newName = panel.getNewName().trim(); + if (!newName.matches("[A-Za-z_][A-Za-z0-9_]*")) { + return new Problem(true, "The new Kotlin file or package-directory name must be a Kotlin identifier."); + } + if (directory) { + ((KotlinRenameDirectoryRefactoring) refactoring).setNewName(newName); + } else { + ((KotlinRenameFileRefactoring) refactoring).setNewName(newName); + } + return null; + } + + /** @return current validation result. */ + @Override + public Problem checkParameters() { + return setParameters(); + } + + /** @return true because physical rename requires a target name. */ + @Override + public boolean hasParameters() { + return true; + } + + /** @return the typed refactoring handled by this UI. */ + @Override + public AbstractRefactoring getRefactoring() { + return refactoring; + } + + /** @return no dedicated help page. */ + @Override + public HelpCtx getHelpCtx() { + return null; + } + + /** Swing parameters panel shared by file and directory rename. */ + private static final class RenamePanel implements CustomRefactoringPanel { + private final JPanel component; + private final JTextField nameField; + + /** + * Builds the form. + * + * @param directory whether the form represents a package directory + * @param displayName source name shown to the user + * @param currentName initial basename or folder name + * @param listener recipient of state-change notifications + */ + RenamePanel(boolean directory, String displayName, String currentName, ChangeListener listener) { + nameField = new JTextField(currentName, 30); + 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, 6); + 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(directory ? "Kotlin package directory:" : "Kotlin file:"), label); + form.add(new JLabel(displayName), field); + JLabel nameLabel = new JLabel(directory ? "New directory name:" : "New file name:"); + nameLabel.setLabelFor(nameField); + form.add(nameLabel, label); + form.add(nameField, field); + form.add(new JLabel(""), label); + form.add( + new JLabel(directory + ? "The directory and its descendants are renamed; Kotlin packages and references are unchanged." + : "The .kt basename changes; packages and Java file-facade references are unchanged."), + field + ); + component = new JPanel(new BorderLayout()); + component.add(form, BorderLayout.NORTH); + DocumentListener documentListener = new DocumentListener() { + @Override public void insertUpdate(DocumentEvent event) { listener.stateChanged(null); } + @Override public void removeUpdate(DocumentEvent event) { listener.stateChanged(null); } + @Override public void changedUpdate(DocumentEvent event) { listener.stateChanged(null); } + }; + nameField.getDocument().addDocumentListener(documentListener); + } + + /** @return requested basename or folder name. */ + String getNewName() { + return nameField.getText(); + } + + /** Requests initial focus for the editable name. */ + @Override + public void initialize() { + nameField.requestFocusInWindow(); + nameField.selectAll(); + } + + /** @return component displayed by NetBeans. */ + @Override + public Component getComponent() { + return component; + } + } +} diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java index d11194a7..6a4428cd 100644 --- a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java +++ b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java @@ -21,6 +21,11 @@ import com.intellij.psi.PsiElement; import io.github.nbplugins.kotlin.nbm.navigation.KotlinWhereUsedRefactoringUI; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinFilesystemRenameUI; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveSourceSelection; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinProjectFolderRenameSelection; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryRefactoring; +import java.awt.EventQueue; import javax.swing.JEditorPane; import javax.swing.text.StyledDocument; import org.jetbrains.kotlin.builder.KotlinPsiManager; @@ -29,6 +34,7 @@ import org.netbeans.modules.refactoring.api.RenameRefactoring; import org.netbeans.modules.refactoring.api.SafeDeleteRefactoring; import org.netbeans.modules.refactoring.api.WhereUsedQuery; +import org.netbeans.modules.refactoring.api.ui.ExplorerContext; import org.netbeans.modules.refactoring.spi.ui.ActionsImplementationProvider; import org.netbeans.modules.refactoring.spi.ui.UI; import org.openide.cookies.EditorCookie; @@ -45,25 +51,33 @@ * annotation processing is disabled by {@code -proc:none} and {@code @ServiceProvider} alone * does not generate {@code META-INF/services} entries in this mixed Kotlin+Java build. */ -@org.openide.util.lookup.ServiceProvider(service = ActionsImplementationProvider.class, position = 400) +@org.openide.util.lookup.ServiceProvider(service = ActionsImplementationProvider.class, position = 99) public class KotlinActionsImplementationProvider extends ActionsImplementationProvider { + /** + * Returns whether the context is an eligible Kotlin package folder or an open Kotlin editor. + * + * @param lookup lookup supplied by the Refactor action + * @return {@code true} when this provider owns the Rename operation + */ @Override public boolean canRename(Lookup lookup) { - EditorCookie ec = lookup.lookup(EditorCookie.class); - if (ec == null) { - return false; - } - StyledDocument doc = ec.getDocument(); - if (doc == null) { - return false; - } - FileObject fo = ProjectUtils.getFileObjectForDocument(doc); - return fo != null && fo.hasExt("kt"); + return KotlinProjectFolderRenameSelection.INSTANCE.resolve(lookup) != null || canRenameEditor(lookup); } + /** + * Opens physical directory rename for Kotlin package folders or symbol rename for Kotlin editors. + * + * @param lookup lookup supplied by the Refactor action + */ @Override public void doRename(Lookup lookup) { + KotlinMoveSourceSelection folderSelection = KotlinProjectFolderRenameSelection.INSTANCE.resolve(lookup); + if (folderSelection != null) { + openDirectoryRename(lookup, folderSelection); + return; + } + EditorCookie ec = lookup.lookup(EditorCookie.class); JEditorPane pane = ec.getOpenedPanes()[0]; // Use selection start so that renaming a fully-selected word positions inside the word, @@ -80,6 +94,47 @@ public void doRename(Lookup lookup) { TopComponent.getRegistry().getActivated()); } + /** + * Tests the pre-existing declaration-rename editor context without involving folder selection. + * + * @param lookup lookup supplied by NetBeans + * @return whether the lookup identifies an open Kotlin document + */ + private boolean canRenameEditor(Lookup lookup) { + EditorCookie ec = lookup.lookup(EditorCookie.class); + if (ec == null) { + return false; + } + StyledDocument doc = ec.getDocument(); + if (doc == null) { + return false; + } + FileObject fo = ProjectUtils.getFileObjectForDocument(doc); + return fo != null && fo.hasExt("kt"); + } + + /** + * Opens the existing physical directory-rename lifecycle on the event-dispatch thread. + * + * @param lookup lookup that may contain an inline Explorer rename request + * @param selection eligible Kotlin package directory and its Kotlin descendants + */ + private void openDirectoryRename(Lookup lookup, KotlinMoveSourceSelection selection) { + FileObject directory = selection.getSelectedDirectory(); + if (directory == null) { + return; + } + ExplorerContext explorerContext = lookup.lookup(ExplorerContext.class); + String requestedName = explorerContext == null ? null : explorerContext.getNewName(); + String initialName = KotlinProjectFolderRenameSelection.INSTANCE.initialName(directory, requestedName); + EventQueue.invokeLater(() -> UI.openRefactoringUI( + new KotlinFilesystemRenameUI( + new KotlinRenameDirectoryRefactoring(directory, initialName, selection), + true, + directory.getName()), + TopComponent.getRegistry().getActivated())); + } + /** * Returns {@code true} when the active editor contains a {@code .kt} file. * 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 3f471373..7fc3cc8c 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 @@ -46,6 +46,10 @@ 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.KotlinRenameFilePlugin; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFileRefactoring; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryPlugin; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackagePlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackageRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignaturePlugin; @@ -118,6 +122,12 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) { if (refactoring instanceof KotlinMoveFileRefactoring) { return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring); } + if (refactoring instanceof KotlinRenameFileRefactoring) { + return new KotlinRenameFilePlugin((KotlinRenameFileRefactoring) refactoring); + } + if (refactoring instanceof KotlinRenameDirectoryRefactoring) { + return new KotlinRenameDirectoryPlugin((KotlinRenameDirectoryRefactoring) refactoring); + } if (refactoring instanceof KotlinChangePackageRefactoring) { return new KotlinChangePackagePlugin((KotlinChangePackageRefactoring) refactoring); } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt index fca038dc..4f842cc5 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt @@ -45,8 +45,7 @@ class KotlinChangePackageAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBRE val document = target.document as? StyledDocument ?: return runCatching { val source = ProjectUtils.getFileObjectForDocument(document) ?: return@runCatching - val project = ProjectUtils.getKotlinProjectForFileObject(source) - ?: ProjectUtils.getValidProject() + val project = ProjectUtils.getKotlinProjectForFileObject(source) ?: ProjectUtils.getValidProject() ?: return@runCatching val selection = KotlinChangePackageSourceSelection.from(source) ?: return@runCatching val packageTarget = KotlinPackageTarget(project, source) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt index fd67a885..055a7cc7 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt @@ -22,7 +22,6 @@ import org.jetbrains.kotlin.utils.ProjectUtils import org.netbeans.modules.refactoring.spi.ui.UI import org.openide.awt.ActionID import org.openide.awt.ActionRegistration -import org.openide.loaders.DataObject import org.openide.nodes.Node import org.openide.util.HelpCtx import org.openide.util.actions.NodeAction @@ -53,7 +52,7 @@ open class KotlinChangePackageDirectoryAction : NodeAction() { runCatching { val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) ?: ProjectUtils.getValidProject() - ?: return@runCatching + ?: error("Change Kotlin Package could not resolve the Kotlin project.") val packageTarget = KotlinPackageTarget(project, selection.representativeFile) openRefactoringUi( KotlinChangePackageUI( @@ -72,8 +71,9 @@ open class KotlinChangePackageDirectoryAction : NodeAction() { */ internal fun openRefactoringUi(ui: KotlinChangePackageUI) { EventQueue.invokeLater { - runCatching { UI.openRefactoringUI(ui, TopComponent.getRegistry().activated) } - .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction UI opening failed", error) } + runCatching { + UI.openRefactoringUI(ui, TopComponent.getRegistry().activated) + }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction UI opening failed", error) } } } @@ -82,16 +82,16 @@ open class KotlinChangePackageDirectoryAction : NodeAction() { ?.let(KotlinChangePackageSourceSelection::from) ?.isDirectory == true + /** Allows selection and project preparation to run off the Event Dispatch Thread. */ + override fun asynchronous(): Boolean = true + /** @return NetBeans default help context. */ override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP /** @return stable action name used by the manual layer registration. */ override fun getName(): String = "Change Kotlin Package" - /** Resolves exactly one physical folder from NetBeans' selected node. */ - private fun selectedFolder(nodes: Array): org.openide.filesystems.FileObject? { - if (nodes.size != 1) return null - val dataObject = nodes.single().lookup.lookup(DataObject::class.java) ?: return null - return dataObject.primaryFile.takeIf { it.isValid && it.isFolder } - } + /** Resolves exactly one physical or package-tree folder node. */ + private fun selectedFolder(nodes: Array) = KotlinFolderNodeSelection.resolve(nodes) + } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt index ed7b9cc1..99a65604 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt @@ -118,9 +118,16 @@ class KotlinChangePackageApplyElement( val current = KotlinRefactoringTransaction() pending = current when (val outcome = KaChangePackageComputer(targets.keys.first()).apply(targets, refactoring.updateReferences)) { - is KaChangePackageComputer.ApplyOutcome.Conflicts -> + is KaChangePackageComputer.ApplyOutcome.Conflicts -> { throw IllegalStateException(outcome.messages.joinToString("\n")) - is KaChangePackageComputer.ApplyOutcome.Error -> throw outcome.error + } + is KaChangePackageComputer.ApplyOutcome.Error -> { + KotlinLogger.INSTANCE.logException( + "KotlinChangePackageApplyElement.performChange: semantic analysis failed before transaction commit", + outcome.error, + ) + throw outcome.error + } is KaChangePackageComputer.ApplyOutcome.Success -> { outcome.changedFiles.forEach { (path, text) -> val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path))) @@ -145,13 +152,9 @@ class KotlinChangePackageApplyElement( override fun undoChange() { runCatching { val current = transaction - if (current == null) { - KotlinLogger.INSTANCE.logWarning("KotlinChangePackageApplyElement.undoChange: no retained transaction") - } else { - KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: restoring retained transaction") + if (current != null) { current.undo() transaction = null - KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: transaction restored") } KotlinAnalysisAPISession.invalidate(project) }.onFailure { error -> diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelection.kt new file mode 100644 index 00000000..b07cadcd --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelection.kt @@ -0,0 +1,44 @@ +/******************************************************************************* + * 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.openide.filesystems.FileObject +import org.openide.loaders.DataFolder +import org.openide.loaders.DataObject +import org.openide.nodes.Node + +/** Resolves a single folder selected through a physical or package-tree NetBeans node. */ +object KotlinFolderNodeSelection { + + /** + * Resolves exactly one valid physical folder from selected nodes. + * + * Package-tree nodes wrap a [DataFolder], while physical explorer nodes can expose a direct + * [FileObject]. The ordered fallbacks keep folder refactorings independent of either view. + * + * @param nodes NetBeans nodes selected by the user + * @return the selected valid folder, or `null` unless exactly one folder was selected + */ + fun resolve(nodes: Array): FileObject? { + if (nodes.size != 1) return null + val lookup = nodes.single().lookup + val file = lookup.lookup(FileObject::class.java) + ?: lookup.lookup(DataFolder::class.java)?.primaryFile + ?: lookup.lookup(DataObject::class.java)?.primaryFile + return file?.takeIf { it.isValid && it.isFolder } + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt index 1a2160fc..43e8f114 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt @@ -24,9 +24,9 @@ import org.jetbrains.kotlin.utils.ProjectUtils import org.netbeans.modules.refactoring.spi.ui.UI import org.openide.awt.ActionID import org.openide.awt.ActionRegistration -import org.openide.loaders.DataObject import org.openide.nodes.Node import org.openide.util.HelpCtx +import org.openide.util.RequestProcessor import org.openide.util.actions.NodeAction import org.openide.windows.TopComponent @@ -54,22 +54,24 @@ open class KotlinMoveDirectoryAction : NodeAction() { */ override fun performAction(nodes: Array) { val folder = selectedFolder(nodes) ?: return - val selection = KotlinMoveSourceSelection.from(folder) ?: return - runCatching { - val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) - ?: ProjectUtils.getValidProject() - ?: return@runCatching - val file = KotlinAnalysisAPISession.getSession(project).getKtFileForPath(selection.representativeFile.path) - ?: return@runCatching - val result = (KaMoveFileComputer(file).compute() as? KaMoveFileComputer.Outcome.Ready)?.result - ?: return@runCatching - val refactoring = KotlinMoveFileRefactoring(selection) - val target = KotlinPackageTarget(project, selection.representativeFile) - openRefactoringUi( - KotlinMoveFileUI(result, refactoring, target, target.packageForFolder(folder.parent ?: folder)), - ) - }.onFailure { error -> - KotlinLogger.INSTANCE.logException("KotlinMoveDirectoryAction failed", error) + REQUEST_PROCESSOR.post { + val selection = KotlinMoveSourceSelection.from(folder) ?: return@post + runCatching { + val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) + ?: ProjectUtils.getValidProject() + ?: return@runCatching + val file = KotlinAnalysisAPISession.getSession(project).getKtFileForPath(selection.representativeFile.path) + ?: return@runCatching + val result = (KaMoveFileComputer(file).compute() as? KaMoveFileComputer.Outcome.Ready)?.result + ?: return@runCatching + val refactoring = KotlinMoveFileRefactoring(selection) + val target = KotlinPackageTarget(project, selection.representativeFile) + openRefactoringUi( + KotlinMoveFileUI(result, refactoring, target, target.packageForFolder(folder.parent ?: folder)), + ) + }.onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinMoveDirectoryAction failed", error) + } } } @@ -95,16 +97,20 @@ open class KotlinMoveDirectoryAction : NodeAction() { ?.let(KotlinMoveSourceSelection::from) ?.isDirectory == true + /** Declares the legacy action dispatcher contract; UI work is explicitly marshalled to EDT. */ + override fun asynchronous(): Boolean = false + /** @return no dedicated help page. */ override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP /** @return stable action name used by the manual layer registration. */ override fun getName(): String = "Move Kotlin Directory" - /** Resolves exactly one folder node's physical file object. */ - private fun selectedFolder(nodes: Array): org.openide.filesystems.FileObject? { - if (nodes.size != 1) return null - val dataObject = nodes.single().lookup.lookup(DataObject::class.java) ?: return null - return dataObject.primaryFile.takeIf { it.isValid && it.isFolder } + /** Resolves exactly one physical or package-tree folder node. */ + private fun selectedFolder(nodes: Array) = KotlinFolderNodeSelection.resolve(nodes) + + private companion object { + /** Worker used for source discovery and K2 preparation before the Swing UI is shown. */ + val REQUEST_PROCESSOR = RequestProcessor(KotlinMoveDirectoryAction::class.java.name, 1) } } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt new file mode 100644 index 00000000..fdee9c15 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt @@ -0,0 +1,90 @@ +/******************************************************************************* + * 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.projectsextensions.KotlinProjectSourceGroups +import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceRoot +import org.netbeans.api.project.FileOwnerQuery +import org.openide.filesystems.FileObject +import org.openide.filesystems.FileUtil +import org.openide.nodes.Node +import org.openide.util.Lookup + +/** + * Selects a non-empty Kotlin package folder from a Projects-popup lookup. + * + * Only conventional Kotlin source and test trees are eligible. This keeps the Kotlin refactoring + * provider from taking Java, resource, generated, or arbitrary filesystem folders that belong to + * another NetBeans rename implementation. + */ +object KotlinProjectFolderRenameSelection { + + /** + * Resolves one eligible conventional-Kotlin folder from [lookup]. + * + * @param lookup Projects popup lookup containing exactly one selected [Node] + * @return non-empty directory selection, or `null` when the lookup is not a supported Kotlin + * package folder + */ + fun resolve(lookup: Lookup): KotlinMoveSourceSelection? { + val folder = KotlinFolderNodeSelection.resolve(lookup.lookupAll(Node::class.java).toTypedArray()) + ?: return null + val project = FileOwnerQuery.getOwner(folder) ?: return null + return resolve(folder, KotlinProjectSourceGroups.findRoots(project.projectDirectory)) + } + + /** + * Resolves [lookup] against explicitly supplied roots. + * + * This overload isolates the folder/root predicate for unit testing without a loaded project + * owner. Production code must use [resolve] so root discovery remains project-scoped. + * + * @param lookup Projects popup lookup containing exactly one selected [Node] + * @param roots conventional Kotlin roots belonging to the selected folder's project + * @return non-empty directory selection, or `null` when not below [roots] + */ + fun resolve(lookup: Lookup, roots: Collection): KotlinMoveSourceSelection? { + val folder = KotlinFolderNodeSelection.resolve(lookup.lookupAll(Node::class.java).toTypedArray()) + ?: return null + return resolve(folder, roots) + } + + /** + * Resolves [folder] against explicitly supplied roots. + * + * @param folder physical package folder selected by the user + * @param roots conventional Kotlin roots belonging to the selected folder's project + * @return non-empty directory selection, or `null` when [folder] is outside [roots] + */ + fun resolve(folder: FileObject, roots: Collection): KotlinMoveSourceSelection? { + if (roots.none { root -> folder == root.folder || FileUtil.isParentOf(root.folder, folder) }) { + return null + } + return KotlinMoveSourceSelection.from(folder)?.takeIf(KotlinMoveSourceSelection::isDirectory) + } + + /** + * Chooses the initial directory name from an optional inline Explorer rename request. + * + * @param folder selected physical Kotlin package directory + * @param requestedName name entered through inline Explorer rename, if any + * @return nonblank requested name, otherwise the current simple folder name + */ + fun initialName(folder: FileObject, requestedName: String?): String = + requestedName?.takeIf(String::isNotBlank) ?: folder.name +} + diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt index 29c93ae1..25588240 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransaction.kt @@ -61,14 +61,26 @@ internal class KotlinRefactoringTransaction internal constructor( val originalName: String, val originalExtension: String, val targetParent: FileObject, + val targetName: String, + val targetExtension: String, var movedFile: FileObject? = null, ) + private data class FolderRenameEntry( + val originalFolder: FileObject, + val originalParent: FileObject, + val originalName: String, + val targetName: String, + var renamedFolder: FileObject? = null, + ) + private val entries = linkedMapOf() private val moves = mutableListOf() + private val folderRenames = mutableListOf() private val ownedFolders = mutableListOf() private var committed = false private var movesApplied = false + private var folderRenamesApplied = false private var restoring = false /** @@ -86,7 +98,47 @@ internal class KotlinRefactoringTransaction internal constructor( "Target file already exists: ${targetParent.path}/${file.nameExt}" } captureExisting(file) - moves += MoveEntry(file, originalParent, file.name, file.ext, targetParent) + moves += MoveEntry( + originalFile = file, + originalParent = originalParent, + originalName = file.name, + originalExtension = file.ext, + targetParent = targetParent, + targetName = file.name, + targetExtension = file.ext, + ) + } + + /** + * Records an in-place physical file rename to apply after staged documents are saved. + * + * @param file existing source file to rename + * @param name target basename without extension + * @param extension target extension without the leading dot + */ + fun renameFile(file: FileObject, name: String, extension: String = file.ext) { + check(file.isValid && !file.isFolder) { "Cannot rename invalid or folder file ${file.path}" } + check(name.isNotBlank()) { "Target file name must not be blank." } + val parent = file.parent ?: error("Cannot rename root file ${file.path}") + val existing = parent.getFileObject(name, extension) + check(existing == null || existing == file) { "Target file already exists: ${parent.path}/$name.$extension" } + captureExisting(file) + moves += MoveEntry(file, parent, file.name, file.ext, parent, name, extension) + } + + /** + * Records an in-place folder rename to apply after every staged document is saved. + * + * @param folder selected physical folder whose descendants must remain intact + * @param name target simple folder name + */ + fun renameFolder(folder: FileObject, name: String) { + check(folder.isValid && folder.isFolder) { "Cannot rename invalid or non-folder path ${folder.path}" } + check(name.isNotBlank()) { "Target folder name must not be blank." } + val parent = folder.parent ?: error("Cannot rename root folder ${folder.path}") + val existing = parent.getFileObject(name) + check(existing == null || existing == folder) { "Target folder already exists: ${parent.path}/$name" } + folderRenames += FolderRenameEntry(folder, parent, folder.name, name) } /** @@ -228,10 +280,12 @@ internal class KotlinRefactoringTransaction internal constructor( throw Failure(entry.file, error) } } - // NetBeans retains a FileLock for every modified editor document until it is saved. - // Persist every staged document before FileObject.move() attempts to acquire its lock. - stagedEntries.forEach { entry -> saveDocument(entry.file) } + // NetBeans retains a FileLock for every opened editor document until it is saved. + // Capture-only entries participate in a physical move too, so save every transaction + // document rather than only staged edits before FileObject.move() acquires its lock. + entries.values.forEach { entry -> saveDocument(entry.file) } applyMoves() + applyFolderRenames() committed = true } catch (error: Throwable) { rollback() @@ -248,17 +302,17 @@ internal class KotlinRefactoringTransaction internal constructor( /** Reverts a successfully committed refactoring using its original snapshots and paths. */ fun undo() = restoreOriginalState() - /** Applies all registered physical moves after every document has been validated. */ + /** Applies all registered physical file moves and renames after every document has been saved. */ private fun applyMoves() { moves.forEach { move -> val file = move.movedFile ?: move.originalFile check(file.isValid) { "Move source is invalid: ${move.originalFile.path}" } - check(move.targetParent.getFileObject(move.originalName, move.originalExtension) == null) { - "Move target already exists: ${move.targetParent.path}/${move.originalFile.nameExt}" + check(move.targetParent.getFileObject(move.targetName, move.targetExtension) == null) { + "Move target already exists: ${move.targetParent.path}/${move.targetName}.${move.targetExtension}" } try { file.lock().use { lock -> - move.movedFile = file.move(lock, move.targetParent, move.originalName, move.originalExtension) + move.movedFile = file.move(lock, move.targetParent, move.targetName, move.targetExtension) } // FileObject identity changes across a NetBeans move, but the editor document is // intentionally retained. Re-key the snapshot so staged writes and later undo use @@ -272,6 +326,35 @@ internal class KotlinRefactoringTransaction internal constructor( } } + /** + * Applies registered physical folder renames before K2 resolves final target PSI directories. + * + * Directory renames require the final physical target to exist before the ported IDEA K2 move + * handler can perform conflict discovery. The caller must still invoke [commit] afterwards so + * staged semantic document changes and Undo Last Refactoring remain part of this transaction. + */ + fun applyFolderRenamesNow() = applyFolderRenames() + + /** Renames selected folders only after staged descendant documents have been saved. */ + private fun applyFolderRenames() { + folderRenames.forEach { rename -> + if (rename.renamedFolder != null) return@forEach + val folder = rename.originalFolder + check(folder.isValid) { "Folder rename source is invalid: ${rename.originalFolder.path}" } + check(rename.originalParent.getFileObject(rename.targetName) == null) { + "Folder rename target already exists: ${rename.originalParent.path}/${rename.targetName}" + } + try { + folder.lock().use { lock -> folder.rename(lock, rename.targetName, "") } + rename.renamedFolder = rename.originalParent.getFileObject(rename.targetName) + ?: error("Folder rename did not produce ${rename.originalParent.path}/${rename.targetName}") + folderRenamesApplied = true + } catch (error: Throwable) { + throw Failure(folder, error) + } + } + } + /** Restores documents before paths, retaining the first cleanup failure. */ private fun restoreOriginalState() { if (restoring) return @@ -305,6 +388,10 @@ internal class KotlinRefactoringTransaction internal constructor( KotlinLogger.INSTANCE.logException("KotlinRefactoringTransaction.reverseMoves failed", error) if (failure == null) failure = error } + reverseFolderRenames().onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinRefactoringTransaction.reverseFolderRenames failed", error) + if (failure == null) failure = error + } // FileObject.move() may replace the on-disk content from its own filesystem state after the // pre-move editor save. Reapply each original snapshot through the FileObject at its final // restored path so Undo persists the package directive, not merely the retained document. @@ -368,6 +455,22 @@ internal class KotlinRefactoringTransaction internal constructor( movesApplied = false } + /** Restores renamed folders before source snapshots are written through their original paths. */ + private fun reverseFolderRenames(): Result = runCatching { + if (!folderRenamesApplied) return@runCatching + folderRenames.asReversed().forEach { rename -> + val folder = rename.renamedFolder ?: return@forEach + if (!folder.isValid) return@forEach + check(rename.originalParent.getFileObject(rename.originalName) == null) { + "Cannot restore renamed folder because original path is occupied: ${rename.originalParent.path}/${rename.originalName}" + } + folder.lock().use { lock -> folder.rename(lock, rename.originalName, "") } + rename.renamedFolder = rename.originalParent.getFileObject(rename.originalName) + ?: error("Folder restore did not produce ${rename.originalParent.path}/${rename.originalName}") + } + folderRenamesApplied = false + } + /** Produces bounded, one-line diagnostic text without logging full source contents. */ private fun describeText(text: String): String = "length=${text.length}, head=${text.take(160).replace("\n", "\\n")}" diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryAction.kt new file mode 100644 index 00000000..5ab6af61 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryAction.kt @@ -0,0 +1,353 @@ +/******************************************************************************* + * 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 java.awt.EventQueue +import javax.swing.Action +import javax.swing.JSeparator +import org.jetbrains.kotlin.log.KotlinLogger +import org.netbeans.modules.refactoring.spi.ui.ActionsImplementationProvider +import org.netbeans.modules.refactoring.spi.ui.UI +import org.netbeans.api.project.SourceGroup +import org.openide.awt.ActionID +import org.openide.awt.ActionRegistration +import org.openide.util.actions.Presenter +import org.openide.cookies.InstanceCookie +import org.openide.filesystems.FileObject +import org.openide.filesystems.FileUtil +import org.openide.loaders.DataFolder +import org.openide.loaders.DataObject +import org.openide.nodes.Node +import org.openide.util.ContextAwareAction +import org.openide.util.HelpCtx +import org.openide.util.Lookup +import org.openide.util.RequestProcessor +import org.openide.util.Utilities +import org.openide.util.actions.NodeAction +import org.openide.windows.TopComponent + +/** + * Folder-node action that renames one Kotlin package directory and its physical descendants. + * + * The refactoring transaction performs one physical folder rename while preserving every descendant. + * Use Change Kotlin Package for package directives and supported Kotlin-reference updates. + */ +@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameDirectoryAction") +@ActionRegistration(displayName = "Rename Kotlin Package Directory...") +open class KotlinRenameDirectoryAction : NodeAction() { + + init { + putValue(NAME, "Rename Kotlin Package Directory...") + putValue(SHORT_DESCRIPTION, "Rename Kotlin Package Directory") + } + + /** + * Opens the package-directory rename UI for one folder containing Kotlin descendants. + * + * @param nodes selected NetBeans nodes + */ + override fun performAction(nodes: Array) { + KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: invoked with ${nodes.size} selected node(s)") + logSelectedNodeActions(nodes) + logRenameEligibility(nodes) + logRefactoringMenuRegistration(nodes) + val selection = selectedSelection(nodes) + if (selection == null) { + KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: aborted because no Kotlin package directory was selected") + return + } + val folder = selection.selectedDirectory + if (folder == null) { + KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: aborted because the selected source is not a directory") + return + } + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: context resolved for ${folder.path} with ${selection.sourceFiles.size} Kotlin file(s)", + ) + KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: scheduling UI preparation") + REQUEST_PROCESSOR.post { + KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: scheduling refactoring UI on EDT") + EventQueue.invokeLater { + runCatching { + KotlinLogger.INSTANCE.logInfo("KotlinRenameDirectoryAction: opening refactoring UI on EDT") + UI.openRefactoringUI( + KotlinFilesystemRenameUI( + KotlinRenameDirectoryRefactoring(folder, folder.name, selection), + true, + folder.name, + ), + TopComponent.getRegistry().activated, + ) + }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryAction failed", error) } + } + } + } + + /** Enables the action for exactly one physical folder containing at least one Kotlin file. */ + override fun enable(nodes: Array): Boolean = selectedSelection(nodes) != null + + /** Declares the legacy action dispatcher contract; UI work is explicitly marshalled to EDT. */ + override fun asynchronous(): Boolean = false + + /** @return no dedicated help page. */ + override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP + + /** @return stable name used by the manual layer registration. */ + override fun getName(): String = "Rename Kotlin Package Directory" + + /** + * Records the selected Project Explorer node's lookup and effective popup actions. + * + * The record is intentionally emitted only while a known working directory action runs so it + * can compare Kotlin source/package nodes with Java source/package nodes without startup noise. + * + * @param nodes nodes selected when the directory action was invoked + */ + private fun logSelectedNodeActions(nodes: Array) { + nodes.forEachIndexed { index, node -> + val lookup = node.lookup + val parent = node.parentNode + val dataObject = lookup.lookup(DataObject::class.java) + val dataFolder = lookup.lookup(DataFolder::class.java) + val fileObject = lookup.lookup(FileObject::class.java) + val sourceGroup = lookup.lookup(SourceGroup::class.java) + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: selected node diagnostic index=$index " + + "class=${node.javaClass.name} name=${node.name} displayName=${node.displayName} " + + "parentClass=${parent?.javaClass?.name} parentName=${parent?.name} " + + "parentDisplayName=${parent?.displayName} " + + "dataObject=${dataObject?.javaClass?.name} " + + "dataFolder=${dataFolder?.primaryFile?.path} " + + "fileObject=${fileObject?.path} " + + "sourceGroup=${sourceGroup?.javaClass?.name} " + + "sourceRoot=${sourceGroup?.rootFolder?.path}", + ) + node.getActions(false).forEachIndexed { actionIndex, action -> + if (action == null) { + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: selected node action index=$index " + + "actionIndex=$actionIndex kind=separator", + ) + return@forEachIndexed + } + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: selected node action index=$index actionIndex=$actionIndex " + + "class=${action.javaClass.name} name=${action.getValue(Action.NAME)} " + + "enabled=${action.isEnabled} " + + "popupPresenter=${action is Presenter.Popup} " + + "contextAware=${action is ContextAwareAction} " + + "identity=${System.identityHashCode(action)}", + ) + if (action.javaClass.name == REFACTOR_SUBMENU_ACTION_CLASS) { + logContextAwarePopupAction(index, actionIndex, action, lookup) + } + } + } + } + + /** + * Records aggregate and per-provider rename eligibility for the two relevant action contexts. + * + * The Projects popup obtains a global context containing its selected node, while the earlier + * node diagnostic resolves actions from the node lookup directly. Both are recorded because a + * missing node in either context would make the provider result incomparable to the popup. + * + * @param nodes nodes selected when the directory action was invoked + */ + private fun logRenameEligibility(nodes: Array) { + logRenameEligibility("active-projects", Utilities.actionsGlobalContext()) + nodes.forEachIndexed { index, node -> + logRenameEligibility("selected-node-$index", node.lookup) + } + } + + /** + * Records aggregate and individual provider results for one refactoring action context. + * + * Each provider is isolated because diagnostic inspection must not interfere with the already + * validated directory action if an unrelated provider fails while examining its context. + * + * @param contextLabel stable label identifying the action context being inspected + * @param context lookup supplied to NetBeans refactoring actions + */ + private fun logRenameEligibility(contextLabel: String, context: Lookup) { + val contextNodes = context.lookupAll(Node::class.java) + val nodeDescription = contextNodes.joinToString { node -> + "${node.javaClass.name}(name=${node.name},displayName=${node.displayName})" + } + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: rename eligibility context=$contextLabel " + + "nodeCount=${contextNodes.size} nodes=[$nodeDescription]", + ) + runCatching { Lookup.getDefault().lookupAll(ActionsImplementationProvider::class.java) } + .onSuccess { providers -> + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: rename eligibility providers context=$contextLabel " + + "count=${providers.size}", + ) + providers.forEachIndexed { providerIndex, provider -> + runCatching { provider.canRename(context) } + .onSuccess { canRename -> + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: rename eligibility provider " + + "context=$contextLabel providerIndex=$providerIndex " + + "class=${provider.javaClass.name} canRename=$canRename", + ) + } + .onFailure { error -> + KotlinLogger.INSTANCE.logException( + "KotlinRenameDirectoryAction: rename eligibility provider failed " + + "context=$contextLabel providerIndex=$providerIndex " + + "class=${provider.javaClass.name}", + error, + ) + } + } + } + .onFailure { error -> + KotlinLogger.INSTANCE.logException( + "KotlinRenameDirectoryAction: rename eligibility provider lookup failed " + + "context=$contextLabel", + error, + ) + } + } + + /** + * Records the action that NetBeans uses after it applies the selected node lookup. + * + * Projects invokes the same context-aware conversion before it asks an action for a popup + * presenter. In particular, the standard Refactor action can be replaced by Rename for one + * package node but remain a submenu for another. + * + * @param nodeIndex index of the selected node being diagnosed + * @param actionIndex index of the original action in the node action array + * @param action original action supplied by the node + * @param context selected node lookup used to resolve the action + */ + private fun logContextAwarePopupAction( + nodeIndex: Int, + actionIndex: Int, + action: Action, + context: org.openide.util.Lookup, + ) { + if (action !is ContextAwareAction) { + return + } + runCatching { action.createContextAwareInstance(context) } + .onSuccess { resolvedAction -> + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: selected node contextual action index=$nodeIndex " + + "actionIndex=$actionIndex originalClass=${action.javaClass.name} " + + "originalName=${action.getValue(Action.NAME)} " + + "resolvedClass=${resolvedAction.javaClass.name} " + + "resolvedName=${resolvedAction.getValue(Action.NAME)} " + + "resolvedEnabled=${resolvedAction.isEnabled} " + + "resolvedPopupPresenter=${resolvedAction is Presenter.Popup} " + + "sameInstance=${resolvedAction === action} " + + "resolvedIdentity=${System.identityHashCode(resolvedAction)}", + ) + } + .onFailure { error -> + KotlinLogger.INSTANCE.logException( + "KotlinRenameDirectoryAction: selected node contextual action failed " + + "index=$nodeIndex actionIndex=$actionIndex class=${action.javaClass.name}", + error, + ) + } + } + + /** + * Logs the effective popup registry at the moment a known working Projects action runs. + * + * NetBeans' RefactoringSubMenuAction uses this same DataFolder/InstanceCookie walk to create + * the Refactor popup, so this reveals the actual action behind an ambiguous display label. + * + * @param nodes nodes selected when the Refactor popup action was invoked + */ + private fun logRefactoringMenuRegistration(nodes: Array) { + val selectedNodes = nodes.joinToString { node -> "${node.javaClass.name}(name=${node.name})" } + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: Refactor menu diagnostic selectedNodes=[$selectedNodes]", + ) + val menuFolder = FileUtil.getConfigFile(REFACTOR_MENU_PATH) + if (menuFolder == null) { + KotlinLogger.INSTANCE.logWarning( + "KotlinRenameDirectoryAction: Refactor menu diagnostic missing $REFACTOR_MENU_PATH", + ) + return + } + val folder = runCatching { DataFolder.findFolder(menuFolder) }.getOrElse { error -> + KotlinLogger.INSTANCE.logException( + "KotlinRenameDirectoryAction: Refactor menu diagnostic could not open ${menuFolder.path}", + error, + ) + return + } + KotlinLogger.INSTANCE.logInfo( + "KotlinRenameDirectoryAction: Refactor menu diagnostic inspecting ${menuFolder.path} " + + "with ${folder.children.size} registration(s)", + ) + folder.children.forEach { registration -> + val configFile = registration.primaryFile + val origin = configFile.getAttribute("originalFile") + val position = configFile.getAttribute("position") + val prefix = "KotlinRenameDirectoryAction: Refactor menu entry path=${configFile.path} " + + "originalFile=$origin position=$position" + val instanceCookie = registration.lookup.lookup(InstanceCookie::class.java) + if (instanceCookie == null) { + KotlinLogger.INSTANCE.logInfo("$prefix kind=no-instance-cookie") + return@forEach + } + runCatching { instanceCookie.instanceCreate() } + .onSuccess { instance -> + when (instance) { + is Action -> KotlinLogger.INSTANCE.logInfo( + "$prefix kind=action class=${instance.javaClass.name} " + + "name=${instance.getValue(Action.NAME)} " + + "shortDescription=${instance.getValue(Action.SHORT_DESCRIPTION)} " + + "enabled=${instance.isEnabled} identity=${System.identityHashCode(instance)}", + ) + is JSeparator -> KotlinLogger.INSTANCE.logInfo("$prefix kind=separator") + else -> KotlinLogger.INSTANCE.logInfo( + "$prefix kind=non-action class=${instance?.javaClass?.name ?: "null"}", + ) + } + } + .onFailure { error -> + KotlinLogger.INSTANCE.logException("$prefix kind=instance-creation-failed", error) + } + } + } + + /** Resolves the non-empty Kotlin selection validated before the refactoring UI is opened. */ + private fun selectedSelection(nodes: Array): KotlinMoveSourceSelection? = KotlinFolderNodeSelection.resolve(nodes) + ?.let(KotlinMoveSourceSelection::from) + ?.takeIf(KotlinMoveSourceSelection::isDirectory) + + private companion object { + /** Effective configuration folder read lazily by NetBeans' Refactor popup implementation. */ + const val REFACTOR_MENU_PATH = "Menu/Refactoring" + + /** NetBeans action whose context-aware conversion decides whether Refactor remains a submenu. */ + const val REFACTOR_SUBMENU_ACTION_CLASS = + "org.netbeans.modules.refactoring.spi.impl.RSMDataObjectAction" + + /** Worker used for filesystem discovery before the Swing refactoring UI is shown. */ + val REQUEST_PROCESSOR = RequestProcessor(KotlinRenameDirectoryAction::class.java.name, 1) + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt new file mode 100644 index 00000000..fb3b9323 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt @@ -0,0 +1,37 @@ +/******************************************************************************* + * 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.filesystems.FileObject +import org.openide.util.lookup.Lookups + +/** + * Carries one explicit Kotlin package-directory rename through the NetBeans refactoring lifecycle. + * + * The directory and every descendant are renamed physically; Kotlin package directives and code + * references are unchanged. Use Change Kotlin Package for semantic package/reference updates. + * + * @param directory selected physical directory containing Kotlin descendants + * @param newName target simple directory name + * @param selection source files verified while the package-tree action was enabled, when available + */ +class KotlinRenameDirectoryRefactoring( + val directory: FileObject, + var newName: String, + internal val selection: KotlinMoveSourceSelection? = null, +) : AbstractRefactoring(Lookups.fixed(directory)) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileAction.kt new file mode 100644 index 00000000..f9ec8970 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileAction.kt @@ -0,0 +1,64 @@ +/******************************************************************************* + * 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.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 + +/** + * Editor action that renames only the physical basename of the active Kotlin source file. + * + * Package directives and references intentionally remain unchanged because Kotlin declarations do + * not derive their fully qualified names from a source-file basename. + */ +class KotlinRenameFileAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) { + + init { + putValue(NAME, "Rename Kotlin File...") + putValue(SHORT_DESCRIPTION, "Rename Kotlin File") + putValue(POPUP_MENU_TEXT, "Rename Kotlin File...") + } + + /** + * Opens the explicit physical file-rename UI. + * + * @param evt NetBeans action event + * @param target active Kotlin editor component + */ + override fun actionPerformed(evt: ActionEvent, target: JTextComponent) { + val document = target.document as? StyledDocument ?: return + runCatching { + val file = ProjectUtils.getFileObjectForDocument(document)?.takeIf { it.isValid && it.ext == "kt" } + ?: return@runCatching + UI.openRefactoringUI( + KotlinFilesystemRenameUI(KotlinRenameFileRefactoring(file, file.name), false, file.nameExt), + TopComponent.getRegistry().activated, + ) + }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameFileAction failed", error) } + } + + companion object { + /** Action name used by the manual layer.xml registration. */ + const val ACTION_NAME = "kotlin-rename-file" + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeAction.kt new file mode 100644 index 00000000..1f32ed71 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeAction.kt @@ -0,0 +1,85 @@ +/******************************************************************************* + * 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 java.awt.EventQueue +import org.jetbrains.kotlin.log.KotlinLogger +import org.netbeans.modules.refactoring.spi.ui.UI +import org.openide.awt.ActionID +import org.openide.awt.ActionRegistration +import org.openide.filesystems.FileObject +import org.openide.loaders.DataObject +import org.openide.nodes.Node +import org.openide.util.HelpCtx +import org.openide.util.RequestProcessor +import org.openide.util.actions.NodeAction +import org.openide.windows.TopComponent + +/** Opens the explicit filesystem-rename UI for a Kotlin file selected in a project tree. */ +@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFileNodeAction") +@ActionRegistration(displayName = "Rename Kotlin File...") +open class KotlinRenameFileNodeAction : NodeAction() { + + init { + putValue(NAME, "Rename Kotlin File...") + putValue(SHORT_DESCRIPTION, "Rename Kotlin File") + } + + /** + * Opens the file-rename UI for the selected Kotlin file. + * + * @param nodes selected NetBeans nodes + */ + override fun performAction(nodes: Array) { + val file = selectedFile(nodes) ?: return + REQUEST_PROCESSOR.post { + EventQueue.invokeLater { + runCatching { + UI.openRefactoringUI( + KotlinFilesystemRenameUI(KotlinRenameFileRefactoring(file, file.name), false, file.nameExt), + TopComponent.getRegistry().activated, + ) + }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameFileNodeAction failed", error) } + } + } + } + + /** @return true only for one valid physical Kotlin source file. */ + override fun enable(nodes: Array): Boolean = selectedFile(nodes) != null + + /** Declares the legacy action dispatcher contract; UI work is explicitly marshalled to EDT. */ + override fun asynchronous(): Boolean = false + + /** @return no dedicated help page. */ + override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP + + /** @return stable name used by the manual layer registration. */ + override fun getName(): String = "Rename Kotlin File" + + /** Resolves one selected physical Kotlin file from NetBeans file-tree node lookups. */ + private fun selectedFile(nodes: Array): FileObject? { + if (nodes.size != 1) return null + val lookup = nodes.single().lookup + val file = lookup.lookup(FileObject::class.java) ?: lookup.lookup(DataObject::class.java)?.primaryFile + return file?.takeIf { it.isValid && !it.isFolder && it.ext == "kt" } + } + + private companion object { + /** Worker used to hand off the action without making Swing UI from the dispatcher thread. */ + val REQUEST_PROCESSOR = RequestProcessor(KotlinRenameFileNodeAction::class.java.name, 1) + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt new file mode 100644 index 00000000..922378b4 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt @@ -0,0 +1,35 @@ +/******************************************************************************* + * 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.filesystems.FileObject +import org.openide.util.lookup.Lookups + +/** + * Carries one explicit physical Kotlin-file rename through the NetBeans refactoring lifecycle. + * + * This carrier deliberately differs from [org.netbeans.modules.refactoring.api.RenameRefactoring]: + * the ordinary Rename refactoring remains responsible for caret-based declaration renames. + * + * @param file physical Kotlin source file to rename + * @param newName target basename without the `.kt` extension + */ +class KotlinRenameFileRefactoring( + val file: FileObject, + var newName: String, +) : AbstractRefactoring(Lookups.fixed(file)) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt new file mode 100644 index 00000000..6022e8d0 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -0,0 +1,270 @@ +/******************************************************************************* + * 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 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.text.CloneableEditorSupport +import org.openide.text.PositionBounds +import org.openide.util.Lookup +import org.openide.util.lookup.Lookups +import javax.swing.text.Position.Bias + +/** + * Creates the NetBeans refactoring lifecycle for an explicit Kotlin file rename. + * + * @param refactoring physical file rename requested from the editor action + */ +class KotlinRenameFilePlugin( + private val refactoring: KotlinRenameFileRefactoring, +) : ProgressProviderAdapter(), RefactoringPlugin { + + /** @return a fatal problem when the selected source is not a valid Kotlin file. */ + override fun preCheck(): Problem? = validateKotlinFile(refactoring.file) + + /** @return a fatal problem when the requested basename is invalid. */ + override fun fastCheckParameters(): Problem? = validateIdentifier(refactoring.newName) + + /** @return the same validation used before UI confirmation. */ + override fun checkParameters(): Problem? = fastCheckParameters() + + /** Cancels no external work because the operation runs within the lifecycle. */ + override fun cancelRequest() = Unit + + /** Adds one preview and atomic transaction apply element. */ + override fun prepare(bag: RefactoringElementsBag): Problem? { + preCheck()?.let { return it } + fastCheckParameters()?.let { return it } + bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange.NONE, refactoring.file)) + bag.add(refactoring, KotlinRenameFileApplyElement(refactoring)) + return null + } +} + +/** + * Performs and undoes one physical Kotlin-file basename rename. + * + * @param refactoring file and target basename selected by the user + */ +class KotlinRenameFileApplyElement( + private val refactoring: KotlinRenameFileRefactoring, +) : SimpleRefactoringElementImplementation() { + private var transaction: KotlinRefactoringTransaction? = null + + /** @return preview label. */ + override fun getText(): String = "Rename Kotlin file" + + /** @return preview label. */ + override fun getDisplayText(): String = text + + /** @return renamed source lookup. */ + override fun getLookup(): Lookup = Lookups.fixed(refactoring.file) + + /** @return renamed physical Kotlin file. */ + override fun getParentFile(): FileObject = refactoring.file + + /** @return source bounds when an editor support is available. */ + override fun getPosition(): PositionBounds? = positionFor(refactoring.file) + + /** Renames the basename atomically and retains the completed transaction for undo. */ + override fun performChange() { + var pending: KotlinRefactoringTransaction? = null + try { + val current = KotlinRefactoringTransaction() + pending = current + current.renameFile(refactoring.file, refactoring.newName, refactoring.file.ext) + current.commit() + transaction = current + pending = null + } catch (error: Throwable) { + KotlinLogger.INSTANCE.logException("KotlinRenameFileApplyElement.performChange failed", error) + rollbackAfterFailure(pending, error, "KotlinRenameFileApplyElement rollback failed") + throw error + } + } + + /** Restores the original basename. */ + override fun undoChange() { + runCatching { transaction?.undo(); transaction = null } + .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameFileApplyElement.undoChange failed", error) } + } +} + +/** + * Creates the NetBeans lifecycle for a Kotlin package-directory rename. + * + * @param refactoring folder rename and reference-update configuration from the folder action + */ +class KotlinRenameDirectoryPlugin( + private val refactoring: KotlinRenameDirectoryRefactoring, +) : ProgressProviderAdapter(), RefactoringPlugin { + + /** @return a fatal problem when the selected folder has no Kotlin sources. */ + override fun preCheck(): Problem? = if (directorySelection() != null) { + null + } else { + Problem(true, "Rename Kotlin Package Directory requires a folder containing Kotlin files.") + } + + /** @return a fatal problem when the target name is not a Kotlin identifier. */ + override fun fastCheckParameters(): Problem? = validateIdentifier(refactoring.newName) + + /** @return the same validation used before UI confirmation. */ + override fun checkParameters(): Problem? = fastCheckParameters() + + /** Cancels no external work because the operation runs within the lifecycle. */ + override fun cancelRequest() = Unit + + /** Adds source previews and one atomic physical folder-rename apply element. */ + override fun prepare(bag: RefactoringElementsBag): Problem? { + val selection = directorySelection() + ?: return Problem(true, "Rename Kotlin Package Directory requires a folder containing Kotlin files.") + fastCheckParameters()?.let { return it } + val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) + ?: ProjectUtils.getValidProject() + ?: return Problem(true, "Rename Kotlin Package Directory could not find the source project.") + val session = KotlinAnalysisAPISession.getSession(project) + selection.sourceFiles.forEach { source -> + val file = session.getKtFileForPath(source.path) + ?: return Problem(true, "Rename Kotlin Package Directory could not resolve ${source.nameExt}.") + bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source)) + } + bag.add(refactoring, KotlinRenameDirectoryApplyElement(selection, project, refactoring)) + return null + } + + /** @return action-validated selection, or one resolved for compatibility callers. */ + private fun directorySelection(): KotlinMoveSourceSelection? = refactoring.selection + ?.takeIf(KotlinMoveSourceSelection::isDirectory) + ?: KotlinMoveSourceSelection.from(refactoring.directory) + ?.takeIf(KotlinMoveSourceSelection::isDirectory) +} + +/** + * Renames a package directory physically and refreshes K2 after its path changes. + * + * @param selection recursive Kotlin descendants of the selected directory + * @param project owning NetBeans project whose session must discover the new path + * @param refactoring directory rename request + */ +class KotlinRenameDirectoryApplyElement( + private val selection: KotlinMoveSourceSelection, + private val project: org.netbeans.api.project.Project, + private val refactoring: KotlinRenameDirectoryRefactoring, +) : SimpleRefactoringElementImplementation() { + private var transaction: KotlinRefactoringTransaction? = null + + /** @return preview label. */ + override fun getText(): String = "Rename Kotlin package directory" + + /** @return preview label. */ + override fun getDisplayText(): String = text + + /** @return selected directory lookup. */ + override fun getLookup(): Lookup = Lookups.fixed(refactoring.directory) + + /** @return representative Kotlin file for preview placement. */ + override fun getParentFile(): FileObject = selection.representativeFile + + /** @return representative source bounds when editor support is available. */ + override fun getPosition(): PositionBounds? = positionFor(selection.representativeFile) + + /** + * Renames the physical folder, recreates K2's session after the filesystem path changes, and + * commits the retained transaction without changing Kotlin package directives or references. + */ + override fun performChange() { + var pending: KotlinRefactoringTransaction? = null + try { + val current = KotlinRefactoringTransaction() + pending = current + current.renameFolder(refactoring.directory, refactoring.newName) + current.applyFolderRenamesNow() + KotlinAnalysisAPISession.invalidate(project) + val refreshedSession = KotlinAnalysisAPISession.getSession(project) + selection.sourceFiles.forEach { source -> + check(refreshedSession.getKtFileForPath(source.path) != null) { + "Rename Kotlin Package Directory could not resolve ${source.nameExt} after folder rename." + } + } + current.commit() + transaction = current + pending = null + } catch (error: Throwable) { + KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryApplyElement.performChange failed", error) + rollbackAfterFailure(pending, error, "KotlinRenameDirectoryApplyElement rollback failed") + throw error + } finally { + KotlinAnalysisAPISession.invalidate(project) + } + } + + /** Restores the original physical directory name and refreshes K2's filesystem view. */ + override fun undoChange() { + runCatching { transaction?.undo(); transaction = null; KotlinAnalysisAPISession.invalidate(project) } + .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryApplyElement.undoChange failed", error) } + } +} + +/** + * Rolls back a transaction after an apply failure without concealing the originating failure. + * + * @param transaction transaction that may already have changed documents or paths + * @param originalError failure that NetBeans must receive + * @param logMessage diagnostic context for an additional rollback failure + */ +private fun rollbackAfterFailure( + transaction: KotlinRefactoringTransaction?, + originalError: Throwable, + logMessage: String, +) { + try { + transaction?.rollback() + } catch (rollbackError: Throwable) { + originalError.addSuppressed(rollbackError) + KotlinLogger.INSTANCE.logException(logMessage, rollbackError) + } +} + +/** Validates a physical Kotlin source before creating its filesystem rename lifecycle. */ +private fun validateKotlinFile(file: FileObject): Problem? = when { + !file.isValid || file.isFolder -> Problem(true, "Rename Kotlin File requires a valid Kotlin source file.") + file.ext != "kt" -> Problem(true, "Rename Kotlin File requires a .kt source file.") + else -> null +} + +/** Validates an explicit Kotlin file or package-directory basename. */ +private fun validateIdentifier(name: String): Problem? = if (name.matches(Regex("[A-Za-z_][A-Za-z0-9_]*"))) { + null +} else { + Problem(true, "The new Kotlin file or package-directory name must be a Kotlin identifier.") +} + +/** Creates position bounds for one preview source when a CloneableEditorSupport is available. */ +private fun positionFor(file: FileObject): PositionBounds? = runCatching { + val support = org.openide.loaders.DataObject.find(file).lookup.lookup(CloneableEditorSupport::class.java) ?: return null + PositionBounds(support.createPositionRef(0, Bias.Forward), support.createPositionRef(0, Bias.Backward)) +}.getOrNull() diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml index 0bbac40d..f558047f 100644 --- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml +++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml @@ -32,10 +32,12 @@ + - + @@ -124,6 +126,17 @@ + + + + + + + + + + + @@ -165,8 +178,18 @@ - - + + + + + + + + + + + @@ -238,19 +261,19 @@ - + - + - + - + @@ -362,17 +385,6 @@ - - - - - - - - - - - @@ -404,10 +416,13 @@ + + + + + - - - @@ -704,6 +719,10 @@ + + + + diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt index 0ed40da8..71874956 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/projectsextensions/KotlinProjectNodeFactoryTest.kt @@ -18,14 +18,17 @@ package io.github.nbplugins.kotlin.nbm.projectsextensions import java.beans.PropertyChangeListener +import javax.swing.Action import javax.swing.Icon import javax.swing.event.ChangeListener import org.netbeans.api.project.Project import org.netbeans.api.project.SourceGroup import org.netbeans.api.project.Sources import org.netbeans.junit.NbTestCase +import org.netbeans.spi.java.project.support.ui.PackageView import org.openide.filesystems.FileObject import org.openide.filesystems.FileUtil +import org.openide.loaders.DataFolder import org.openide.util.Lookup import org.openide.util.lookup.Lookups @@ -70,6 +73,30 @@ class KotlinProjectNodeFactoryTest : NbTestCase("KotlinProjectNodeFactoryTest") ) } + /** Verifies the bare PackageView source root retains the folder context used for diagnostics. */ + fun testPackageViewRoot_exposesDataFolderAndFixedRootActions() { + clearWorkDir() + val root = FileUtil.toFileObject(workDir) + val kotlinRoot = FileUtil.createFolder(root, "src/main/kotlin") + val sourceGroup = TestSourceGroup(kotlinRoot, KotlinProjectSourceGroups.KOTLIN_SOURCE_PACKAGES) + + val node = PackageView.createPackageView(sourceGroup) + val actions = node.getActions(false) + + assertNotNull( + "A package-view root must expose its backing folder through its lookup", + node.lookup.lookup(DataFolder::class.java), + ) + assertTrue( + "The baseline PackageView root must provide actions to log and compare", + actions.isNotEmpty(), + ) + assertFalse( + "The baseline PackageView root must not synthesize a Refactor submenu itself", + actions.any { action -> action?.getValue(Action.NAME) == "Refactor" }, + ) + } + /** Minimal source provider with preconfigured type-to-group mappings. */ private class TestSources(private val groups: Map>) : Sources { override fun getSourceGroups(type: String): Array = groups[type] ?: emptyArray() diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 4dbe5b0a..1ee7fd44 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -153,6 +153,56 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies Change Package retains a rebound qualified call when standalone FIR cannot shorten a + * range that also contains a local declaration. + */ + fun testApply_realSession_keepsQualifiedReferenceWhenLocalDeclarationPreventsShortening() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-local-shortener") + try { + val sourceDirectory = temporaryRoot.resolve("source") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(sourcePath, "package old.source\n\nfun moved() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.source.moved\n\nfun use(): Int {\n" + + " fun local() = moved()\n" + + " return local()\n" + + "}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-local-shortener-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("new.changed")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package new.changed") == true) + val usageText = success.changedFiles[usagePath.toString()] ?: error("Usage.kt was not changed") + assertTrue("Retargeted local call must retain a valid reference", usageText.contains("new.changed.moved()") || usageText.contains("moved()")) + assertFalse("Internal root-resolution prefix must never reach source text", usageText.contains("")) + assertTrue("Change Package must not move the declaration file", Files.isRegularFile(sourcePath)) + assertTrue("Change Package must not move the usage file", Files.isRegularFile(usagePath)) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** Verifies recursive-style per-file packages retarget imports for every selected source together. */ fun testApply_realSession_supportsDistinctDescendantPackages() { val stdlib = findStdlibJar() ?: run { diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt index c389242f..d1589eb8 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt @@ -23,22 +23,129 @@ import java.nio.file.Path /** Unit tests for [KotlinChangePackageDirectoryAction]. */ class KotlinChangePackageDirectoryActionTest : NbTestCase("KotlinChangePackageDirectoryActionTest") { - /** Verifies source discovery can run outside the Event Dispatch Thread before UI marshalling. */ + /** Verifies source discovery stays on the NodeAction worker before Swing UI is marshalled to EDT. */ fun testAsynchronous_allowsBackgroundSourceDiscovery() { assertTrue( - "K2 source discovery may run in NetBeans' RequestProcessor before UI is marshalled to EDT", + "Source discovery must run in NetBeans' worker before the action schedules its Swing UI", TestAction().isAsynchronous(), ) } - /** Verifies the directory action has exactly one folder-context layer registration. */ - fun testLayer_registersFolderActionWithoutGlobalMenuDuplicate() { + /** Verifies folder actions are discovered only through the shared Refactor submenu. */ + fun testLayer_keepsFolderActionsOutOfTheTopLevelContextMenu() { val layer = repositoryRoot().resolve("Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml") val contents = Files.readString(layer) - val actionName = "KotlinChangePackageDirectoryAction.shadow" - assertEquals("Folder context action must have exactly one dedicated layer shadow", 1, contents.countOccurrences(actionName)) - assertTrue("Folder context action must be registered in Loaders/folder/any/Actions", contents.contains("")) + assertFalse( + "Change Package must not have a direct folder-context shadow", + contents.contains(""), + ) + assertFalse( + "Rename Directory must not have a direct folder-context shadow", + contents.contains(""), + ) + assertTrue( + "Change Package directory action must remain globally available to RefactoringAll", + contents.contains("KotlinChangePackageDirectoryAction.instance"), + ) + assertTrue( + "Change Package editor action must remain registered for editor contexts", + contents.contains("KotlinChangePackageAction.instance"), + ) + assertPositions( + contents, + "Folder Refactor menu must route Change Package to its node action", + "io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangePackageDirectoryAction.shadow", + 267, + "io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangeSignatureAction.shadow", + 270, + ) + assertFalse( + "Folder Refactor menu must not route Change Package through the editor action", + contents.contains(""), + ) + assertTrue( + "Rename Directory must remain globally available to RefactoringAll", + contents.contains("KotlinRenameDirectoryAction.instance"), + ) + assertTrue( + "Kotlin file nodes must place the Refactor submenu inside their loader Actions folder", + contents.contains( + "\n" + + " ", + ), + ) + assertTrue( + "Refactor must register the node-aware F7 action", + contents.contains( + "\n" + + " " + ), + ) + assertFalse( + "The editor-only F7 action must not compete with the node action in RefactoringAll", + contents.contains(""), + ) + assertPositions( + contents, + "F7 file rename must precede the undo separator", + "io-github-nbplugins-kotlin-nbm-refactoring-KotlinRenameFileNodeAction.shadow", + 2665, + "separator-before-undo-redo.instance", + 3000, + ) + assertPositions( + contents, + "F7 directory rename must precede the undo separator", + "io-github-nbplugins-kotlin-nbm-refactoring-KotlinRenameDirectoryAction.shadow", + 2666, + "separator-before-undo-redo.instance", + 3000, + ) + assertPositions( + contents, + "Undo must remain after the final separator", + "separator-before-undo-redo.instance", + 3000, + "org-jetbrains-kotlin-refactorings-KotlinRefactoringUndoAction.shadow", + 3100, + ) + assertPositions( + contents, + "Redo must remain the final Refactor entry", + "org-jetbrains-kotlin-refactorings-KotlinRefactoringUndoAction.shadow", + 3100, + "org-jetbrains-kotlin-refactorings-KotlinRefactoringRedoAction.shadow", + 3200, + ) + } + + /** Verifies two layer entries retain the requested ordering and explicit positions. */ + private fun assertPositions( + contents: String, + message: String, + firstName: String, + firstPosition: Int, + secondName: String, + secondPosition: Int, + ) { + val firstMarker = "" + val secondMarker = "" + val firstStart = contents.indexOf(firstMarker) + val secondStart = contents.indexOf(secondMarker) + assertTrue("$message: missing $firstName", firstStart >= 0) + assertTrue("$message: missing $secondName", secondStart >= 0) + assertTrue("$message: layer entries are out of order", firstStart < secondStart) + assertTrue( + "$message: $firstName position mismatch", + contents.substring(firstStart, contents.indexOf("", firstStart)).contains("intvalue=\"$firstPosition\""), + ) + assertTrue( + "$message: $secondName position mismatch", + contents.substring(secondStart, contents.indexOf("", secondStart)).contains("intvalue=\"$secondPosition\""), + ) } /** Finds the checkout root from the NBM module's Surefire working directory. */ @@ -54,10 +161,6 @@ class KotlinChangePackageDirectoryActionTest : NbTestCase("KotlinChangePackageDi error("unreachable") } - /** Counts non-overlapping literal [needle] occurrences in this source text. */ - private fun String.countOccurrences(needle: String): Int = - split(needle).size - 1 - /** Exposes NodeAction's protected dispatcher contract for this focused test. */ private class TestAction : KotlinChangePackageDirectoryAction() { fun isAsynchronous(): Boolean = asynchronous() diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelectionTest.kt new file mode 100644 index 00000000..5a0d436b --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFolderNodeSelectionTest.kt @@ -0,0 +1,101 @@ +/******************************************************************************* + * 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 java.beans.PropertyChangeListener +import javax.swing.Icon +import org.netbeans.api.project.SourceGroup +import org.netbeans.spi.java.project.support.ui.PackageView +import org.openide.filesystems.FileObject +import org.openide.filesystems.FileSystem +import org.openide.filesystems.FileUtil +import org.openide.loaders.DataFolder +import org.openide.nodes.AbstractNode +import org.openide.nodes.Children +import org.openide.nodes.Node +import org.openide.util.lookup.Lookups + +/** Unit tests for [KotlinFolderNodeSelection]. */ +class KotlinFolderNodeSelectionTest : NbTestCase("KotlinFolderNodeSelectionTest") { + + /** In-memory filesystem used to model physical folder/file node selections. */ + private val fileSystem: FileSystem = FileUtil.createMemoryFileSystem() + + /** Verifies a physical folder exposed directly by a node is selected. */ + fun testResolve_usesDirectFolderFileObject() { + val folder = fileSystem.root.createFolder("direct") + + assertEquals(folder, KotlinFolderNodeSelection.resolve(arrayOf(node(folder)))) + } + + /** Verifies package-shaped nodes exposing a DataFolder resolve to the physical package folder. */ + fun testResolve_usesDataFolderFromPackageNodeLookup() { + val folder = fileSystem.root.createFolder("package") + val packageNode = node(DataFolder.findFolder(folder)) + + assertEquals(folder, KotlinFolderNodeSelection.resolve(arrayOf(packageNode))) + } + + /** Verifies files and multi-selection do not accidentally become folder operations. */ + fun testResolve_rejectsFilesAndMultipleNodes() { + val folder = fileSystem.root.createFolder("folder") + val file = fileSystem.root.createData("Source.kt") + + assertNull(KotlinFolderNodeSelection.resolve(arrayOf(node(file)))) + assertNull(KotlinFolderNodeSelection.resolve(arrayOf(node(folder), node(folder)))) + } + + /** Verifies the real PackageView package-node lookup retains Kotlin directory selection. */ + fun testResolve_usesRealPackageViewNodeAndDirectoryCarrierPrecheck() { + val sourceRoot = fileSystem.root.createFolder("src") + val packageFolder = sourceRoot.createFolder("example") + packageFolder.createData("Sample.kt") + val sourceGroup = TestSourceGroup(sourceRoot) + val packageRoot = PackageView.createPackageView(sourceGroup) + val packageNode = PackageView.findPath(packageRoot, packageFolder) + + assertNotNull("PackageView must locate the physical package folder", packageNode) + val folder = KotlinFolderNodeSelection.resolve(arrayOf(packageNode!!)) + assertEquals(packageFolder, folder) + val selection = KotlinMoveSourceSelection.from(folder!!) + assertNotNull("PackageView folder must discover its Kotlin source", selection) + assertTrue(selection!!.isDirectory) + + val refactoring = KotlinRenameDirectoryRefactoring(packageFolder, "renamed", selection) + val ui = KotlinFilesystemRenameUI(refactoring, true, packageFolder.name) + ui.getPanel { } + assertNull("Filesystem rename UI must accept the captured package selection", ui.setParameters()) + + val problem = KotlinRenameDirectoryPlugin(refactoring).preCheck() + assertNull("Captured package selection must remain usable through the rename lifecycle", problem) + } + + /** Creates a leaf node whose lookup exposes [value]. */ + private fun node(value: Any): Node = AbstractNode(Children.LEAF, Lookups.singleton(value)) + + /** Minimal source group used to obtain a genuine NetBeans package-tree node. */ + private class TestSourceGroup(private val root: FileObject) : SourceGroup { + override fun getRootFolder(): FileObject = root + override fun getName(): String = "test-source-root" + override fun getDisplayName(): String = "Test Source Root" + override fun getIcon(opened: Boolean): Icon? = null + override fun contains(file: FileObject): Boolean = file == root || file.path.startsWith("${root.path}/") + override fun addPropertyChangeListener(listener: PropertyChangeListener?) = Unit + override fun removePropertyChangeListener(listener: PropertyChangeListener?) = Unit + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt index 49335357..11d7922d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt @@ -21,10 +21,10 @@ import org.netbeans.junit.NbTestCase /** Unit tests for [KotlinMoveDirectoryAction]. */ class KotlinMoveDirectoryActionTest : NbTestCase("KotlinMoveDirectoryActionTest") { - /** Verifies source discovery remains asynchronous while the action explicitly marshals UI later. */ - fun testAsynchronous_allowsBackgroundSourceDiscovery() { - assertTrue( - "K2 source discovery may run in NetBeans' RequestProcessor before UI is marshalled to EDT", + /** Verifies the legacy NodeAction dispatcher contract is explicitly synchronous. */ + fun testAsynchronous_isExplicitlyFalse() { + assertFalse( + "The directory action must declare its dispatcher contract to NetBeans", TestAction().isAsynchronous(), ) } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt new file mode 100644 index 00000000..b89c6872 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt @@ -0,0 +1,120 @@ +/******************************************************************************* + * 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.projectsextensions.KotlinProjectSourceRoot +import org.netbeans.junit.NbTestCase +import org.openide.filesystems.FileObject +import org.openide.filesystems.FileUtil +import org.openide.loaders.DataFolder +import org.openide.nodes.AbstractNode +import org.openide.nodes.Children +import org.openide.nodes.Node +import org.openide.util.Lookup +import org.openide.util.lookup.Lookups + +/** Unit tests for [KotlinProjectFolderRenameSelection]. */ +class KotlinProjectFolderRenameSelectionTest : NbTestCase("KotlinProjectFolderRenameSelectionTest") { + + /** Verifies conventional main and test Kotlin roots and their package descendants are eligible. */ + fun testResolve_acceptsConventionalKotlinRootsAndDescendants() { + val project = FileUtil.createMemoryFileSystem().root + val mainRoot = FileUtil.createFolder(project, "src/main/kotlin") + val mainPackage = FileUtil.createFolder(mainRoot, "sample/main") + mainPackage.createData("Main.kt") + val testRoot = FileUtil.createFolder(project, "src/test/kotlin") + val testPackage = FileUtil.createFolder(testRoot, "sample/test") + testPackage.createData("Test.kt") + val roots = roots(mainRoot, testRoot) + + assertEquals(mainRoot, resolve(node(mainRoot), roots)?.selectedDirectory) + assertEquals(mainPackage, resolve(node(mainPackage), roots)?.selectedDirectory) + assertEquals(testRoot, resolve(node(testRoot), roots)?.selectedDirectory) + assertEquals(testPackage, resolve(node(testPackage), roots)?.selectedDirectory) + } + + /** Verifies folders outside conventional roots and path-prefix siblings cannot be Kotlin renames. */ + fun testResolve_rejectsNonKotlinRootsAndPrefixSiblings() { + val project = FileUtil.createMemoryFileSystem().root + val mainRoot = FileUtil.createFolder(project, "src/main/kotlin") + val kotlinSibling = FileUtil.createFolder(project, "src/main/kotlin2") + kotlinSibling.createData("Wrong.kt") + val javaPackage = FileUtil.createFolder(project, "src/main/java/sample") + javaPackage.createData("Java.kt") + val resources = FileUtil.createFolder(project, "src/main/resources/sample") + resources.createData("Resource.kt") + val build = FileUtil.createFolder(project, "build/generated/sample") + build.createData("Generated.kt") + val roots = roots(mainRoot) + + assertNull(resolve(node(kotlinSibling), roots)) + assertNull(resolve(node(javaPackage), roots)) + assertNull(resolve(node(resources), roots)) + assertNull(resolve(node(build), roots)) + } + + /** Verifies only folders with Kotlin descendants from exactly one supported node are eligible. */ + fun testResolve_rejectsEmptyNonKotlinAndMultipleSelections() { + val project = FileUtil.createMemoryFileSystem().root + val mainRoot = FileUtil.createFolder(project, "src/main/kotlin") + val empty = mainRoot.createFolder("empty") + val resources = mainRoot.createFolder("resources") + resources.createData("config.json") + val packageFolder = mainRoot.createFolder("sample") + packageFolder.createData("Sample.kt") + val roots = roots(mainRoot) + + assertNull(resolve(node(empty), roots)) + assertNull(resolve(node(resources), roots)) + assertNull(KotlinProjectFolderRenameSelection.resolve(Lookups.fixed(node(packageFolder), node(packageFolder)), roots)) + } + + /** Verifies inline Explorer names replace only the directory UI's initial name. */ + fun testInitialName_prefersNonBlankExplorerName() { + val folder = FileUtil.createMemoryFileSystem().root.createFolder("original") + + assertEquals("renamed", KotlinProjectFolderRenameSelection.initialName(folder, "renamed")) + assertEquals("original", KotlinProjectFolderRenameSelection.initialName(folder, "")) + assertEquals("original", KotlinProjectFolderRenameSelection.initialName(folder, " ")) + assertEquals("original", KotlinProjectFolderRenameSelection.initialName(folder, null)) + } + + /** Verifies package-tree DataFolder nodes use the same folder and root eligibility rules. */ + fun testResolve_acceptsDataFolderPackageNode() { + val project = FileUtil.createMemoryFileSystem().root + val mainRoot = FileUtil.createFolder(project, "src/main/kotlin") + val packageFolder = FileUtil.createFolder(mainRoot, "sample/package") + packageFolder.createData("Sample.kt") + + val selection = resolve(node(DataFolder.findFolder(packageFolder)), roots(mainRoot)) + + assertNotNull(selection) + assertEquals(packageFolder, selection!!.selectedDirectory) + assertEquals(listOf("Sample.kt"), selection.sourceFiles.map(FileObject::getNameExt)) + } + + /** Resolves a one-node context using explicitly supplied conventional roots. */ + private fun resolve(node: Node, roots: List): KotlinMoveSourceSelection? = + KotlinProjectFolderRenameSelection.resolve(Lookups.singleton(node), roots) + + /** Creates main/test root descriptors without requiring a loaded NetBeans project. */ + private fun roots(vararg folders: FileObject): List = + folders.mapIndexed { index, folder -> KotlinProjectSourceRoot(folder, index > 0) } + + /** Creates a physical or package-tree node carrying [value]. */ + private fun node(value: Any): Node = AbstractNode(Children.LEAF, Lookups.singleton(value)) +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt index 6294b6b1..53f601ac 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt @@ -490,6 +490,91 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio assertEquals("fun source() = 1\n", fixture.text(source)) } + /** Verifies a same-folder Kotlin file rename participates in commit and undo. */ + fun testCommitAndUndo_restoresRenamedFilePath() { + val fixture = fixture() + val source = fixture.existing("Before.kt", "fun renamed() = 1\n") + + fixture.transaction.renameFile(source, "After", "kt") + fixture.transaction.commit() + + assertNull("commit must remove the original file name", fixture.root.getFileObject("Before.kt")) + assertNotNull("commit must create the requested target name", fixture.root.getFileObject("After.kt")) + fixture.transaction.undo() + + assertNotNull("undo must restore the original file name", fixture.root.getFileObject("Before.kt")) + assertNull("undo must remove the target file name", fixture.root.getFileObject("After.kt")) + } + + /** Verifies file rename rejects an existing sibling before mutating the source. */ + fun testRenameFile_rejectsExistingTarget() { + val fixture = fixture() + val source = fixture.existing("Before.kt", "fun before() = 1\n") + fixture.existing("Taken.kt", "fun taken() = 1\n") + + try { + fixture.transaction.renameFile(source, "Taken", "kt") + fail("rename must reject an occupied sibling name") + } catch (_: IllegalStateException) { + // Expected: collision validation happens before commit. + } + assertTrue("source must remain valid after collision rejection", source.isValid) + assertNotNull("source must remain at its original path", fixture.root.getFileObject("Before.kt")) + } + + /** Verifies an early folder rename is restored when the later transaction commit fails. */ + fun testRollback_restoresFolderRenamedBeforeSemanticWrites() { + val fixture = fixture(failWriting = { it.nameExt == "Source.kt" }) + val folder = fixture.root.createFolder("before") + val source = fixture.existingIn(folder, "Source.kt", "fun source() = 1\n") + + fixture.transaction.renameFolder(folder, "after") + fixture.transaction.applyFolderRenamesNow() + val renamed = fixture.root.getFileObject("after/Source.kt") ?: error("Expected renamed source") + fixture.transaction.captureExisting(renamed) + fixture.transaction.stageText(renamed, "fun source() = 2\n") + + try { + fixture.transaction.commit() + fail("commit must fail when the staged semantic write fails") + } catch (_: KotlinRefactoringTransaction.Failure) { + // Expected: rollback restores the physical source directory. + } + assertNotNull("rollback must restore the original folder", fixture.root.getFileObject("before/Source.kt")) + assertNull("rollback must remove the early renamed folder", fixture.root.getFileObject("after")) + assertTrue("the original FileObject remains valid after restoration", source.isValid) + assertEquals("rollback must restore the original file path", "before/Source.kt", source.path) + } + + /** Verifies a folder rename preserves descendants and restores their physical paths on undo. */ + fun testCommitAndUndo_restoresRenamedFolderAndDescendantText() { + val fixture = fixture() + val sourceFolder = fixture.root.createFolder("before") + val nested = sourceFolder.createFolder("nested") + val originalText = "package before.nested\n\nfun source() = 1\n" + val source = fixture.existingIn(nested, "Source.kt", originalText) + val readme = sourceFolder.createData("Readme.txt") + + fixture.transaction.renameFolder(sourceFolder, "after") + fixture.transaction.captureExisting(source) + fixture.transaction.commit() + + val renamedFolder = fixture.root.getFileObject("after") + val renamedSource = renamedFolder?.getFileObject("nested/Source.kt") + assertNotNull("commit must rename the selected folder", renamedFolder) + assertNotNull("commit must retain Kotlin descendants", renamedSource) + assertNotNull("commit must retain non-Kotlin descendants", renamedFolder?.getFileObject("Readme.txt")) + assertEquals("physical directory rename must not rewrite the package directive", originalText, fixture.textByPath(renamedSource!!)) + + fixture.transaction.undo() + + val restored = fixture.root.getFileObject("before/nested/Source.kt") + assertNotNull("undo must restore the original folder path", restored) + assertNull("undo must remove the renamed folder", fixture.root.getFileObject("after")) + assertTrue("undo must preserve non-Kotlin descendants", readme.isValid) + assertEquals("undo must retain the original package directive", originalText, fixture.textByPath(restored!!)) + } + /** Applies the same descending minimal-hunk sequence used by multi-file refactorings. */ private fun applyHunks(document: StyledDocument, originalText: String, finalText: String) { TextRangeDiff.computeHunks(originalText, finalText) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryActionTest.kt new file mode 100644 index 00000000..9ca90b37 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryActionTest.kt @@ -0,0 +1,76 @@ +/******************************************************************************* + * 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 java.nio.file.Files +import java.nio.file.Path +import org.netbeans.junit.NbTestCase + +/** Unit tests for [KotlinRenameDirectoryAction]. */ +class KotlinRenameDirectoryActionTest : NbTestCase("KotlinRenameDirectoryActionTest") { + + /** Verifies the legacy NodeAction dispatcher contract is explicitly synchronous. */ + fun testAsynchronous_isExplicitlyFalse() { + assertFalse( + "The directory action must declare its dispatcher contract to NetBeans", + TestAction().isAsynchronous(), + ) + } + + /** Verifies the Refactor menu routes a package node through the node-aware package action. */ + fun testLayer_routesPackageNodeActionThroughRefactorMenu() { + val layer = repositoryRoot().resolve("Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml") + val contents = Files.readString(layer) + + assertTrue( + "The directory package action must be present in Menu/Refactoring for a package-node selection", + contents.contains("io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangePackageDirectoryAction.shadow"), + ) + assertFalse( + "The editor-only package action must not be a Refactor-menu shadow", + contents.contains(""), + ) + assertTrue( + "The directory package action must remain globally registered for the Refactor submenu", + contents.contains("io-github-nbplugins-kotlin-nbm-refactoring-KotlinChangePackageDirectoryAction.instance"), + ) + val provider = "org-jetbrains-kotlin-refactorings-rename-KotlinActionsImplementationProvider.instance" + val providerStart = contents.indexOf("") + val providerEnd = contents.indexOf("", providerStart) + assertTrue("The existing Kotlin rename provider must remain registered", providerStart >= 0) + assertTrue("The Kotlin rename provider must precede Java's generic position-100 provider", + contents.substring(providerStart, providerEnd).contains("intvalue=\"99\"")) + } + + /** Finds the checkout root from the NBM module's Surefire working directory. */ + private fun repositoryRoot(): Path { + var candidate: Path? = Path.of("").toAbsolutePath() + while (candidate != null) { + if (Files.isRegularFile(candidate.resolve("pom.xml")) && Files.isDirectory(candidate.resolve("Nbm"))) { + return candidate + } + candidate = candidate.parent + } + fail("Could not locate repository root from ${Path.of("").toAbsolutePath()}") + error("unreachable") + } + + /** Exposes NodeAction's protected dispatcher contract for this focused test. */ + private class TestAction : KotlinRenameDirectoryAction() { + fun isAsynchronous(): Boolean = asynchronous() + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeActionTest.kt new file mode 100644 index 00000000..b665dc22 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileNodeActionTest.kt @@ -0,0 +1,36 @@ +/******************************************************************************* + * 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 + +/** Unit tests for [KotlinRenameFileNodeAction]. */ +class KotlinRenameFileNodeActionTest : NbTestCase("KotlinRenameFileNodeActionTest") { + + /** Verifies the legacy NodeAction dispatcher contract is explicitly synchronous. */ + fun testAsynchronous_isExplicitlyFalse() { + assertFalse( + "The file-node action must declare its dispatcher contract to NetBeans", + TestAction().isAsynchronous(), + ) + } + + /** Exposes NodeAction's protected dispatcher contract for this focused test. */ + private class TestAction : KotlinRenameFileNodeAction() { + fun isAsynchronous(): Boolean = asynchronous() + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt new file mode 100644 index 00000000..bf0e9b15 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt @@ -0,0 +1,83 @@ +/******************************************************************************* + * 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.jetbrains.kotlin.refactorings.rename.KotlinRefactoringsFactory +import org.netbeans.junit.NbTestCase +import org.openide.filesystems.FileUtil +import org.openide.loaders.DataObject + +/** Unit tests for the typed Kotlin filesystem-rename refactoring carriers. */ +class KotlinRenameRefactoringTest : NbTestCase("KotlinRenameRefactoringTest") { + + /** Verifies a file carrier retains the selected Kotlin source and requested basename. */ + fun testFileCarrier_retainsSourceAndName() { + val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt") + + val refactoring = KotlinRenameFileRefactoring(file, "After") + + assertEquals(file, refactoring.file) + assertEquals("After", refactoring.newName) + } + + /** Verifies a directory carrier retains only the physical source and target folder name. */ + fun testDirectoryCarrier_retainsPhysicalSourceAndName() { + val directory = FileUtil.createMemoryFileSystem().root.createFolder("before") + + val refactoring = KotlinRenameDirectoryRefactoring(directory, "after") + + assertEquals(directory, refactoring.directory) + assertEquals("after", refactoring.newName) + } + + /** Verifies the file carrier bypasses the declaration-rename document applicability guard. */ + fun testFactory_createsFilePluginWithoutStyledDocument() { + val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt") + + val plugin = KotlinRefactoringsFactory().createInstance(KotlinRenameFileRefactoring(file, "After")) + + assertTrue("Filesystem file rename must use its typed plugin", plugin is KotlinRenameFilePlugin) + } + + /** Verifies the folder carrier bypasses the declaration-rename document applicability guard. */ + fun testFactory_createsDirectoryPluginWithoutStyledDocument() { + val directory = FileUtil.createMemoryFileSystem().root.createFolder("before") + directory.createData("Source.kt") + + val plugin = KotlinRefactoringsFactory().createInstance(KotlinRenameDirectoryRefactoring(directory, "after")) + + assertTrue("Filesystem directory rename must use its typed plugin", plugin is KotlinRenameDirectoryPlugin) + } + + /** Verifies the default DataObject editor path releases its lock before a physical file rename. */ + fun testFileApplyElement_renamesAndUndoesWithDefaultEditorTransaction() { + val root = FileUtil.createMemoryFileSystem().root + val before = root.createData("Before.kt") + before.getOutputStream().use { it.write("fun before() = Unit\n".toByteArray()) } + DataObject.find(before) + + val apply = KotlinRenameFileApplyElement(KotlinRenameFileRefactoring(before, "After")) + apply.performChange() + + assertNull("apply must remove the original physical filename", root.getFileObject("Before.kt")) + assertNotNull("apply must create the requested physical filename", root.getFileObject("After.kt")) + apply.undoChange() + + assertNotNull("undo must restore the original physical filename", root.getFileObject("Before.kt")) + assertNull("undo must remove the renamed physical filename", root.getFileObject("After.kt")) + } +} 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 b4ef56bf..5a25cf63 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, 23 partial, 1 absent, 24 total." + val totals = "**Baseline counts:** 0 complete, 24 partial, 0 absent, 24 total." assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals)) - assertEquals(23, records.count { it.status == "partial" }) - assertEquals(1, records.count { it.status == "absent" }) + assertEquals(24, records.count { it.status == "partial" }) + assertEquals(0, records.count { it.status == "absent" }) } /** Finds the checkout root from Surefire's module working directory. */ diff --git a/docs/development-plan.md b/docs/development-plan.md index ac0bf24e..e96c70dd 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -793,6 +793,23 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E initializers, alias imports, duplicate targets, Java/non-code/comment/index-only scenarios, and external qualified usages whose rewrite has not been validated. + - [x] **F7** — Rename Kotlin File and Package Directory (Refactor menu / folder context menu) + - Adds an explicit **Refactor → Rename Kotlin File...** command for the active `.kt` file. + It uses `KotlinRefactoringTransaction` to rename only the physical basename and supports + **Undo Last Refactoring**; package declarations and Java file-facade references are deliberately + unchanged. + - Adds folder-node **Rename Kotlin Package Directory...**. The transaction renames the folder + first so real final `PsiDirectory` targets exist, refreshes the standalone K2 session, then + invokes the already ported `K2MoveFilesHandler` through `KaMoveFileComputer`. The K2 adapter + performs eligible package-directive rewrites, portable conflict detection, and supported Kotlin + reference retargeting; the transaction stages its text results and rolls both text and folder + paths back if any later step fails. + - The generic IDEA rename UI/lifecycle remains intentionally unported because it depends on + writable IntelliJ VFS, indexed Java/file-facade search, automatic-renamer extensions, and + action/command infrastructure unavailable in standalone NetBeans. Unicode/quoted names, + Java/comment/text/index-only references, automatic renamers, and full project-model behavior + remain outside this portable adapter. + - [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 9f467193..7718348e 100644 --- a/docs/refactoring-coverage.md +++ b/docs/refactoring-coverage.md @@ -31,7 +31,7 @@ netbeans=; test=; milestone= - + ## Purpose and scope @@ -48,13 +48,13 @@ 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, 23 partial, 1 absent, 24 total. +**Baseline counts:** 0 complete, 24 partial, 0 absent, 24 total. ## Current NetBeans refactorings | ID | IDEA K2 family | NetBeans command | Status | Current coverage and material gap | Target | |---|---|---|---|---|---| -| `rename` | Rename | Rename (`Alt+Shift+R`) | Partial | K2 declaration/reference rename and override cascade are covered; file/package/directory renames and IDEA automatic renamers remain. | F7 | +| `rename` | Rename | Rename (`Alt+Shift+R`) | Partial | K2 declaration/reference rename and override cascade are covered. Explicit filesystem rename commands cover Kotlin file basenames and package directories; IDEA automatic renamers and Java-bridge scenarios remain. | F7 | | `safe-delete` | Safe Delete | Safe Delete (`Alt+Delete`) | Partial | Declaration usage check is available; IDEA supertype, type/value-argument, and Java-bridge cases remain. | F7 | | `change-signature` | Change Signature | `Ctrl+F6` | Partial | K2 signature engine handles calls, overrides, constructors, destructuring and operators; every touched document now commits or rolls back through one hunk-preserving transaction, while broader IDEA scenarios remain. | F1 | | `inline-variable-property` | Inline property | Inline (`Ctrl+Alt+N`) | Partial | IDEA code inliner supports local `val` and a local `var` with one declaration initializer and no later writes, invoked from a declaration or selected usage; a later assignment or increment/decrement is rejected before mutation with its expression identified. Receiver/member/accessor, comment, Java, and broader index-backed cases remain. | F3 | @@ -72,18 +72,13 @@ 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 | +| `rename-file-package-directory` | Rename file/package/directory | Refactor → Rename Kotlin File / folder → Rename Kotlin Package Directory | Partial | Explicit file rename changes one `.kt` basename and Undo Last Refactoring restores it. Directory rename preserves every physical descendant and restores paths on undo without changing package directives or Kotlin references; Change Kotlin Package remains the separate semantic operation for those changes. The K2 session is recreated after the path change so it discovers the renamed directory. Java file-facade, automatic-renamer, Java/comment/text/index-only, Unicode/quoted-name, and full IDEA project-model cases remain. | F7 | | `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 | | `push-members-down` | Push Members Down | `Ctrl+Alt+O` | Partial | Build-wide K2 direct-subclass discovery works; visibility, accidental-override, Java and full IDEA-index conflict checks remain. | F2 | -## Absent IDEA families - -| ID | IDEA family | Why absent | Target | -|---|---|---|---| -| `rename-file-package-directory` | Rename file/package/directory | Requires NetBeans filesystem refactoring integration and Kotlin-aware update rules. | F7 | - ## Cross-cutting milestones | Milestone | Outcome | diff --git a/pom.xml b/pom.xml index f1ed97fd..5954b533 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.11.260-SNAPSHOT + 0.11.280-SNAPSHOT pom Netbeans Plugin Kotlin