From d85fdadd0aecf8a254d974207cd65707e77f015b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 3 Sep 2026 21:27:55 +0300 Subject: [PATCH 1/2] Added Move Kotlin File refactoring Co-Authored-By: Claude --- CHANGELOG.md | 1 + CoreImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinRefactoring/pom.xml | 148 ++++++---- .../kotlin/refactoring/KaMoveFileComputer.kt | 248 ++++++++++++++++ .../move/processor/findMoveFileUsages.kt | 47 +++ .../processor/prepareMovedFileForPackage.kt | 48 +++ Nbm/pom.xml | 2 +- .../nbm/refactoring/KotlinMoveFileUI.java | 277 ++++++++++++++++++ .../rename/KotlinRefactoringsFactory.java | 6 + .../nbm/refactoring/KotlinMoveFileAction.kt | 74 +++++ .../nbm/refactoring/KotlinMoveFilePlugin.kt | 236 +++++++++++++++ .../refactoring/KotlinMoveFileRefactoring.kt | 43 +++ .../KotlinRefactoringTransaction.kt | 213 +++++++++++++- .../resources/org/jetbrains/kotlin/layer.xml | 12 + .../nbm/refactoring/KaMoveFileComputerTest.kt | 238 +++++++++++++++ .../KotlinRefactoringTransactionTest.kt | 186 ++++++++++++ .../RefactoringCoverageBaselineTest.kt | 6 +- .../projForTest/src/moveFile/matching.kt | 3 + .../projForTest/src/moveFile/mismatch.kt | 3 + docs/development-plan.md | 21 +- docs/manual-test-move-kotlin-file.md | 120 ++++++++ docs/refactoring-coverage.md | 6 +- pom.xml | 2 +- 29 files changed, 1871 insertions(+), 83 deletions(-) create mode 100644 KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt create mode 100644 KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/findMoveFileUsages.kt create mode 100644 KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/prepareMovedFileForPackage.kt create mode 100644 Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt create mode 100644 Nbm/src/test/resources/projForTest/src/moveFile/matching.kt create mode 100644 Nbm/src/test/resources/projForTest/src/moveFile/mismatch.kt create mode 100644 docs/manual-test-move-kotlin-file.md diff --git a/CHANGELOG.md b/CHANGELOG.md index 41dc35a0..7765ab0a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added Move Kotlin File refactoring: move the active `.kt` file from **Refactor → Move Kotlin File...** to a selected source root and package; its matching package directive and supported Kotlin code references are updated, and **Undo Last Refactoring** restores the original location 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. - Improved Kotlin hierarchy-aware refactorings: Pull Members Up, Push Members Down, and compatible IDEA refactoring paths now discover Kotlin override chains and direct supertypes across all source files in the active Maven or Gradle build; Java/library and full IDEA-index conflict scenarios remain unavailable. diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 0377183e..994d4d28 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index e17d2cc2..64df6cce 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index fee74bee..add86a22 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 645015f7..c452728f 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index e2fa4b61..b6675bcd 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index d5c9a59e..eb2cc2e4 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 37cb1163..f2778571 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 12190e6d..5d36799d 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring @@ -741,16 +741,19 @@ - + declaration processor because no standalone listeners are subscribed). --> copy-kotlin-refactoring-move-k2-declarations initialize @@ -768,6 +771,7 @@ org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveTargetDescriptor.kt org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveOperationDescriptor.kt org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2MoveDeclarationsRefactoringProcessor.kt + org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2MoveFilesOrDirectoriesRefactoringProcessor.kt org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUtil.kt org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUsageUtil.kt org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveConflictUtil.kt @@ -1928,46 +1932,60 @@ if (k2MoveProcessor.exists()) { log.info("Patched K2MoveDeclarationsRefactoringProcessor.kt: decoupled from BaseRefactoringProcessor.run() pipeline and IDE-only listener/UI paths") } -// 17. K2MoveOperationDescriptor.kt (E9.7): drop the Files-move variant (out of scope — see the -// pom.xml comment on copy-kotlin-refactoring-move-k2-declarations) and Kotlin Multiplatform -// expect/actual fan-out (this plugin does not support Kotlin Multiplatform, consistent with -// ExpectActualUtils being dropped everywhere else it was pulled in, e.g. CodeInliner.kt). +// 16a. K2MoveFilesOrDirectoriesRefactoringProcessor.kt (F4.1): retain the real K2MoveFilesHandler +// semantics but remove the outer processor. The outer class is inseparable from IDEA's generic +// MoveFilesOrDirectoriesProcessor (indexed ReferencesSearch, writable IntelliJ VFS, command/ +// listener lifecycle, and modal UI). In NetBeans, KaMoveFileComputer calls this handler-shaped +// adapter while KotlinRefactoringTransaction performs physical moves and undo. This preserves +// IDEA's Kotlin-specific package eligibility, conflict collection, package-directive rewrite, +// declaration mapping, and K2 usage retargeting without pretending the unavailable platform +// lifecycle is portable. +def k2MoveFilesProcessor = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2MoveFilesOrDirectoriesRefactoringProcessor.kt') +if (k2MoveFilesProcessor.exists()) { + def text = k2MoveFilesProcessor.text + text = text.replace('import com.intellij.openapi.application.runWriteAction\n', '') + text = text.replace('import com.intellij.openapi.util.Ref\n', '') + text = text.replace('import com.intellij.refactoring.move.moveFilesOrDirectories.MoveFileHandler\n', '') + text = text.replace('import com.intellij.refactoring.move.moveFilesOrDirectories.MoveFilesOrDirectoriesProcessor\n', '') + text = text.replace('import org.jetbrains.kotlin.idea.core.getImplicitPackagePrefix\n', '') + text = text.replace('import org.jetbrains.kotlin.idea.k2.refactoring.move.descriptor.K2MoveOperationDescriptor\n', '') + + def processorStart = text.indexOf('internal class K2MoveFilesOrDirectoriesRefactoringProcessor(') + def handlerStart = text.indexOf('class K2MoveFilesHandler') + if (processorStart >= 0 && handlerStart > processorStart) { + text = text.substring(0, processorStart) + text.substring(handlerStart) + } + text = text.replace('class K2MoveFilesHandler : MoveFileHandler() {', + 'class K2MoveFilesHandler {') + text = text.replace(' override fun canProcessElement(', ' fun canProcessElement(') + text = text.replace(' override fun findUsages(', ' fun findUsages(') + text = text.replace(' override fun detectConflicts(', ' fun detectConflicts(') + text = text.replace(' override fun prepareMovedFile(', ' fun prepareMovedFile(') + text = text.replace(' override fun updateMovedFile(', ' fun updateMovedFile(') + text = text.replace(' override fun retargetUsages(', ' fun retargetUsages(') + k2MoveFilesProcessor.text = text + log.info("Patched K2MoveFilesOrDirectoriesRefactoringProcessor.kt: retained lifecycle-free K2MoveFilesHandler only") +} + +// 17. K2MoveOperationDescriptor.kt (E9.7/F4.1): preserve the Files descriptor so its upstream +// source/target data model stays available to the lifecycle-free F4.1 adapter. The descriptor's +// refactoringProcessor() would construct the full IDEA MoveFilesOrDirectoriesProcessor, which +// is intentionally not compiled standalone: NetBeans owns physical VFS moves, command/undo and +// UI. Keep the real file-move descriptor but fail explicitly if an accidental caller invokes the +// unavailable IDEA lifecycle. Also remove Kotlin Multiplatform expect/actual fan-out, which this +// plugin does not support (consistent with the rest of the refactoring ports). def k2MoveOpDescriptor = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveOperationDescriptor.kt') if (k2MoveOpDescriptor.exists()) { def text = k2MoveOpDescriptor.text text = text.replace('import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2MoveFilesOrDirectoriesRefactoringProcessor\n', '') text = text.replace('import org.jetbrains.kotlin.idea.search.ExpectActualUtils\n', '') - text = text.replace( -''' class Files( - project: Project, - moveDescriptors: List, - searchForText: Boolean, - searchInComments: Boolean, - searchReferences: Boolean, - dirStructureMatchesPkg: Boolean, - isMoveToExplicitPackage: Boolean, - moveCallBack: MoveCallback? = null, - ) : K2MoveOperationDescriptor( - project, - moveDescriptors, - searchForText, - searchInComments, - searchReferences, - dirStructureMatchesPkg, - isMoveToExplicitPackage, - moveCallBack - ) { - override val sourceElements: List get() = moveDescriptors.flatMap { it.source.elements } - - override fun refactoringProcessor(): BaseRefactoringProcessor { +''' override fun refactoringProcessor(): BaseRefactoringProcessor { return K2MoveFilesOrDirectoriesRefactoringProcessor(this) - } - } - - class Declarations(''', -''' class Declarations(''' + }''', +''' override fun refactoringProcessor(): BaseRefactoringProcessor = + error("The standalone F4.1 adapter drives K2MoveFilesHandler directly; NetBeans owns the file-move lifecycle.")''' ) text = text.replace( @@ -1998,7 +2016,7 @@ if (k2MoveOpDescriptor.exists()) { ) k2MoveOpDescriptor.text = text - log.info("Patched K2MoveOperationDescriptor.kt: dropped Files-move variant and multiplatform expect/actual fan-out") + log.info("Patched K2MoveOperationDescriptor.kt: retained Files data descriptor, blocked IDEA lifecycle, dropped MPP fan-out") } // 18. K2MoveTargetDescriptor.kt (E9.7): DirectoryUtil/VfsUtilCore (com.intellij.ide.util / @@ -2105,14 +2123,13 @@ if (k2MoveProcessorOrder.exists()) { // org.jetbrains.kotlin.idea.base.projectStructure.kaModuleBridge.kt (KotlinRefactoring local // sources). No text changes needed beyond what that bridge already resolves; this entry exists // to document why moveConflictUtil.kt compiles without further patching. -// 20. moveUsageUtil.kt (E9.7): TextOccurrencesUtil (searchInCommentsAndStrings/searchForText — +// 20. moveUsageUtil.kt (E9.7/F4.1): TextOccurrencesUtil (searchInCommentsAndStrings/searchForText — // opt-in, default-off "also update text mentions" scan) is not on our classpath and would // otherwise need a whole-project text-search engine to implement for real; simplify // findNonCodeUsages to always report no non-code usages (matches behaviour when the user -// leaves those checkboxes off, which IDEA itself defaults to). KtFile.findUsages (the -// whole-file-move overload, calling the now-removed K2MoveRenameUsageInfo.findExternalUsages) -// is dead code for Move Declaration — only K2MoveDeclarationsRefactoringProcessor.getUsages() -// is ever called, which uses the KtNamedDeclaration.findUsages overload below it. +// leaves those checkboxes off, which IDEA itself defaults to). F4.1 restores the whole-file +// overload and backs it with the real KotlinMoveUsageSearchService scan, instead of IDEA's +// unavailable Java/light-class ReferencesSearch path. def moveUsageUtilFile = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUsageUtil.kt') if (moveUsageUtilFile.exists()) { def text = moveUsageUtilFile.text @@ -2133,9 +2150,17 @@ if (moveUsageUtilFile.exists()) { K2MoveRenameUsageInfo.findExternalUsages(decl) + decl.findNonCodeUsages(searchInCommentsAndStrings, searchForText, newPkgName) } }''', -'''// KtFile.findUsages (whole-file-move overload) intentionally omitted: file/directory moves are -// out of scope for Move Declaration (see pom.xml), and it depended on the now-removed -// K2MoveRenameUsageInfo.findExternalUsages (Java-interop-only, dropped in patch #14).''' +'''internal fun KtFile.findUsages( + searchInCommentsAndStrings: Boolean, + searchForText: Boolean, + newPkgName: FqName +): List { + markInternalUsages(this, this) + return topLevelDeclarationsToUpdate.flatMap { declaration -> + org.jetbrains.kotlin.idea.k2.refactoring.move.processor.usages.findMoveDeclarationUsages(declaration) + + declaration.findNonCodeUsages(searchInCommentsAndStrings, searchForText, newPkgName) + } +}''' ) text = text.replace( @@ -2234,18 +2259,19 @@ private fun KtNamedDeclaration.findNonCodeUsages( text = text.replace('import com.intellij.refactoring.rename.RenameUtil\n', '') moveUsageUtilFile.text = text - log.info("Patched moveUsageUtil.kt: dropped whole-file-move overload, wired findMoveDeclarationUsages, dropped TextOccurrencesUtil-based non-code-usage search") + log.info("Patched moveUsageUtil.kt: restored standalone whole-file Kotlin usage search and dropped TextOccurrencesUtil-based non-code search") } -// 21. moveUtil.kt (E9.7): K2ChangePackageDescriptor (Change Package is a separate, out-of-scope -// feature — see pom.xml) and forcedTargetPackage/getPossiblyForcedPackageFqName (dead code: -// only used by K2MoveFilesOrDirectoriesRefactoringProcessor, also out of scope) are removed. +// 21. moveUtil.kt (E9.7/F4.1): K2ChangePackageDescriptor remains out of scope until F4.2. F4.1 +// restores forcedTargetPackage/getPossiblyForcedPackageFqName, but stores it directly on the +// session/disk PsiDirectory rather than IDEA's sourceRoot project-model abstraction. NetBeans +// has no implicit package prefixes, so the field safely scopes the chosen destination package +// through conflict detection and the K2 handler's package rewrite. def moveUtilFile = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/moveUtil.kt') if (moveUtilFile.exists()) { def text = moveUtilFile.text text = text.replace('import org.jetbrains.kotlin.idea.k2.refactoring.move.descriptor.K2ChangePackageDescriptor\n', '') text = text.replace('import org.jetbrains.kotlin.idea.util.sourceRoot\n', '') - text = text.replace('import com.intellij.openapi.util.Key\n', '') text = text.replace('import com.intellij.openapi.vfs.VirtualFile\n', '') text = text.replace( @@ -2268,12 +2294,18 @@ internal fun PsiDirectory.getPossiblyForcedPackageFqName(): FqName { val forcedPkg = parentsWithSelf.filterIsInstance().map { it.forcedTargetPackage }.firstNotNullOfOrNull { it } return forcedPkg ?: getFqNameWithImplicitPrefixOrRoot() }''', -'''// forcedTargetPackage/getPossiblyForcedPackageFqName intentionally omitted: only used by -// K2MoveFilesOrDirectoriesRefactoringProcessor (file/directory moves), which is out of scope.''' +'''private var PsiDirectory.forcedTargetPackage: FqName? by UserDataProperty(Key.create("FORCED_TARGET_PACKAGE")) + +internal fun PsiDirectory.getPossiblyForcedPackageFqName(): FqName { + val forcedPkg = parentsWithSelf.filterIsInstance() + .map { it.forcedTargetPackage } + .firstNotNullOfOrNull { it } + return forcedPkg ?: getFqNameWithImplicitPrefixOrRoot() +}''' ) moveUtilFile.text = text - log.info("Patched moveUtil.kt: dropped Change Package support and dead forced-target-package caching") + log.info("Patched moveUtil.kt: kept standalone forced-target package support; Change Package remains deferred to F4.2") } // 22. implicitPackagePrefixConflict.kt (E9.7): getImplicitPackagePrefix() always returns null now diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt new file mode 100644 index 00000000..7ab37f56 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt @@ -0,0 +1,248 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.refactoring + +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiElement +import com.intellij.usageView.UsageInfo +import com.intellij.util.containers.MultiMap +import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2MoveFilesHandler +import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.findMoveFileUsages +import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.prepareMovedFileForPackage +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.resolve.ImportPath +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtPsiFactory + +/** + * Discovers whether a Kotlin file can participate in the Move File refactoring. + * + * The computer deliberately separates discovery from physical filesystem mutation: NetBeans owns + * the latter through its refactoring transaction, while the later apply phase drives the ported K2 + * usage/conflict logic against writable session PSI. + * + * @param ktFile writable K2-session PSI for the file selected for movement + */ +class KaMoveFileComputer( + private val ktFile: KtFile, +) { + + /** Represents the result of validating a selected Kotlin source file. */ + sealed class Outcome { + /** The selected PSI has no physical path and cannot be moved by NetBeans. */ + data object NotApplicable : Outcome() + + /** Discovery failed unexpectedly. */ + data class Error(val error: Throwable) : Outcome() + + /** Discovery succeeded and [result] describes the movable source file. */ + data class Ready(val result: KaMoveFileResult) : Outcome() + } + + /** + * Validates the selected file and describes its package-update eligibility. + * + * @return [Outcome.Ready] for a physical Kotlin source file, [Outcome.NotApplicable] when its + * session PSI has no physical path, or [Outcome.Error] if discovery fails + */ + fun compute(): Outcome = try { + val path = ktFile.virtualFile?.path ?: return Outcome.NotApplicable + Outcome.Ready( + KaMoveFileResult( + sourceFilePath = path, + fileName = ktFile.name, + packageName = ktFile.packageFqName.asString(), + packageMayBeUpdated = packageMatchesDirectory(path, ktFile.packageFqName.asString()), + ) + ) + } catch (error: Throwable) { + Outcome.Error(error) + } + + /** + * Applies the ported IDEA K2 file-move semantics to [files] before NetBeans persists their + * physical move. + * + * The caller supplies session-owned writable PSI for the files and a real disk-backed + * [targetDirectory]. The adapter performs the same K2 sequence as IDEA's + * `MoveFilesOrDirectoriesProcessor`: find Kotlin usages, collect Kotlin conflicts, rewrite + * eligible package directives, create the old-to-new declaration map, and retarget Kotlin + * references. It deliberately does not move VFS entries; NetBeans must perform that irreversible + * operation through its transaction only after this method reports [ApplyOutcome.Success]. + * + * @param files writable K2 PSI files selected for the move + * @param targetDirectory real target directory used for conflict resolution + * @param targetPackage package selected by NetBeans for the destination + * @param updateReferences whether Kotlin external references should be retargeted + * @return conflicts without mutation, a success containing every changed PSI file, or an error + */ + fun apply( + files: Collection, + targetDirectory: PsiDirectory, + targetPackage: FqName, + updateReferences: Boolean, + ): ApplyOutcome { + return try { + require(files.isNotEmpty()) { "Move File requires at least one Kotlin file." } + val handler = K2MoveFilesHandler() + // ProjectFileIndex and JavaDirectoryService cannot reconstruct the source-root-relative + // directory of this session's LightVirtualFiles. Calling K2MoveFilesHandler.findUsages() + // or needsUpdate() would therefore suppress valid work before its actual K2 semantic + // branch. Use the same package/directory criterion against the real path, then retain + // the upstream handler for package rewrite, conflict detection, declaration mapping, + // and reference retargeting. + val eligibleFiles = files.filter { file -> + file.virtualFile?.path?.let { path -> packageMatchesDirectory(path, file.packageFqName.asString()) } == true + } + eligibleFiles.forEach(handler::markRequiresUpdate) + val usages = if (updateReferences) { + eligibleFiles.flatMap { file -> + file.findMoveFileUsages( + searchInCommentsAndStrings = false, + searchForText = false, + targetPackage = targetPackage, + ) + } + } else { + emptyList() + } + val conflicts = MultiMap() + handler.detectConflicts(conflicts, files.toTypedArray(), usages.toTypedArray(), targetDirectory) + if (!conflicts.isEmpty) { + ApplyOutcome.Conflicts(conflicts.values().toList()) + } else { + val usageFiles = filesWithUsages(usages) + val originalTexts = (files + usageFiles).distinct().mapNotNull { file -> + file.virtualFile?.path?.let { path -> path to file.text } + }.toMap() + val movedImportTargets = files.flatMap { file -> + file.declarations.mapNotNull { declaration -> + (declaration as? KtNamedDeclaration)?.name?.let { name -> + MovedImport(file.packageFqName, name) + } + } + } + val oldToNew = linkedMapOf() + val changedFiles = linkedMapOf() + files.forEach { file -> + handler.prepareMovedFileForPackage( + file = file, + targetPackage = targetPackage, + rewritePackage = file in eligibleFiles, + oldToNewMap = oldToNew, + ) + file.virtualFile?.path?.let { path -> changedFiles[path] = file.text } + } + if (updateReferences) handler.retargetUsages(usages, oldToNew) + usageFiles.filterNot { it in files }.forEach { file -> + rewriteMovedImports(file, movedImportTargets, targetPackage) + file.virtualFile?.path?.let { path -> changedFiles[path] = file.text } + } + ApplyOutcome.Success(changedFiles, originalTexts) + } + } catch (error: Throwable) { + ApplyOutcome.Error(error) + } + } + + /** Represents the result of applying K2-only Move File semantics. */ + sealed class ApplyOutcome { + /** K2 found blocking semantic conflicts and left PSI unchanged. */ + data class Conflicts(val messages: List) : ApplyOutcome() + + /** + * K2 changed package directives and/or Kotlin references. + * + * @param changedFiles final texts NetBeans must persist + * @param originalTexts pre-K2 snapshots for the same participants, used by transaction Undo + */ + data class Success( + val changedFiles: Map, + val originalTexts: Map, + ) : ApplyOutcome() + + /** The K2 semantic operation failed. */ + data class Error(val error: Throwable) : ApplyOutcome() + } + + /** + * Retains Kotlin files whose text may change during external-usage retargeting. + * + * K2 replaces the usage PSI while rebinding it, so [UsageInfo.element] can be invalid or absent + * after `retargetUsages`. Capture containing files before that mutation so NetBeans can persist + * updated imports and qualified references afterward. + */ + private fun filesWithUsages(usages: Collection): List = + usages.mapNotNull { usage -> usage.element?.containingFile as? KtFile }.distinct() + + /** + * Rewrites imports of top-level declarations moved from their former package to [targetPackage]. + * + * Standalone K2's reference-rebinding service intentionally skips imports because replacing an + * import-path segment invalidates the whole import list without an IDEA document synchronizer. + * The old-to-new map for a File Move preserves declaration identity, so update the direct import + * path explicitly after K2 retargeting; code usages remain handled by the upstream K2 engine. + */ + private fun rewriteMovedImports(usageFile: KtFile, movedImports: Collection, targetPackage: FqName) { + if (movedImports.isEmpty()) return + val factory = KtPsiFactory(usageFile.project) + usageFile.importDirectives.forEach { directive -> + if (directive.aliasName != null || directive.isAllUnder) return@forEach + val importedFqName = directive.importedFqName ?: return@forEach + val importedName = directive.importedName ?: return@forEach + if (MovedImport(importedFqName.parent(), importedName.asString()) in movedImports) { + directive.replace(factory.createImportDirective(ImportPath(targetPackage.child(importedName), false))) + } + } + } + + /** Identifies a directly imported top-level declaration before its package PSI is rewritten. */ + private data class MovedImport(val packageName: FqName, val declarationName: String) + + /** + * Determines whether [packageName]'s segments are the terminal directory segments of [path]. + * + * A standalone K2 session does not expose IDEA's source-root model to a `LightVirtualFile`. + * Comparing only the terminal package suffix preserves the relevant K2MoveFilesHandler rule: + * package rewriting is safe only when the source-root-relative directory agrees with the + * declared package. The root package is safe because every directory layout represents it. + */ + private fun packageMatchesDirectory(path: String, packageName: String): Boolean { + if (packageName.isEmpty()) return true + val parentSegments = path.substringBeforeLast('/', missingDelimiterValue = "") + .split('/') + .filter(String::isNotEmpty) + val packageSegments = packageName.split('.') + return parentSegments.takeLast(packageSegments.size) == packageSegments + } +} + +/** + * Immutable data required by the NetBeans Move File UI and apply phase. + * + * @param sourceFilePath absolute physical path of the selected Kotlin file + * @param fileName source file name, including extension + * @param packageName declared Kotlin package, or an empty string for the root package + * @param packageMayBeUpdated whether the declared package agrees with the file's directory layout + */ +data class KaMoveFileResult( + val sourceFilePath: String, + val fileName: String, + val packageName: String, + val packageMayBeUpdated: Boolean, +) diff --git a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/findMoveFileUsages.kt b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/findMoveFileUsages.kt new file mode 100644 index 00000000..e514e982 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/findMoveFileUsages.kt @@ -0,0 +1,47 @@ +/******************************************************************************* + * 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 org.jetbrains.kotlin.idea.k2.refactoring.move.processor + +import com.intellij.usageView.UsageInfo +import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.usages.findMoveDeclarationUsages +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile + +/** + * Finds Kotlin usages affected by moving a complete Kotlin file in a standalone K2 session. + * + * This is the portable semantic half of upstream `KtFile.findUsages`. IDEA obtains external + * references through an indexed Java/light-class path; NetBeans instead uses the registered + * [org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService]. Non-code searches + * remain disabled for F4.1, consistent with the unchecked IDEA defaults and existing move ports. + * + * @param searchInCommentsAndStrings whether comments and string usages were requested + * @param searchForText whether non-code text usages were requested + * @param targetPackage package that declarations will belong to after the physical move + * @return Kotlin usages that require package/reference retargeting + */ +fun KtFile.findMoveFileUsages( + searchInCommentsAndStrings: Boolean, + searchForText: Boolean, + targetPackage: FqName, +): List { + // Parameters are deliberately retained to mirror IDEA's API. F4.1 does not provide the + // platform text-occurrence index, so only semantic Kotlin references are searched. + @Suppress("UNUSED_VARIABLE") + val ignoredNonCodeSearch = searchInCommentsAndStrings || searchForText || targetPackage.isRoot + return topLevelDeclarationsToUpdate.flatMap(::findMoveDeclarationUsages) +} diff --git a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/prepareMovedFileForPackage.kt b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/prepareMovedFileForPackage.kt new file mode 100644 index 00000000..ab9a64a0 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/processor/prepareMovedFileForPackage.kt @@ -0,0 +1,48 @@ +/******************************************************************************* + * 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 org.jetbrains.kotlin.idea.k2.refactoring.move.processor + +import com.intellij.psi.PsiElement +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile + +/** + * Applies the K2 Move File handler's mutation with a NetBeans-selected package. + * + * The upstream handler derives the destination package from IntelliJ's source-root-aware + * `PsiDirectory`. Standalone K2 PSI is backed by `LightVirtualFile`s and cannot expose that model, + * so F4.1 supplies the package chosen through NetBeans' [KotlinPackageTarget] equivalent directly. + * The actual rewrite, declaration mapping, and eligibility marker remain in [K2MoveFilesHandler]. + * + * @param file writable session file whose package may be rewritten + * @param targetPackage selected destination package + * @param rewritePackage whether the source package matched its source-root-relative directory + * @param oldToNewMap mutable moved declaration map used for later K2 retargeting + */ +fun K2MoveFilesHandler.prepareMovedFileForPackage( + file: KtFile, + targetPackage: FqName, + rewritePackage: Boolean, + oldToNewMap: MutableMap, +) { + // This is the mapping half of K2MoveFilesHandler.prepareMovedFile with the one IDEA-only + // dependency replaced: the handler normally derives the package from JavaDirectoryService. + // A mismatch must preserve its declaration exactly, matching K2MoveFilesHandler.needsUpdate. + if (rewritePackage && file.packageFqName != targetPackage) file.updatePackageDirective(targetPackage) + oldToNewMap[file] = file + file.allDeclarationsToUpdate.forEach { declaration -> oldToNewMap[declaration] = declaration } +} diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 7f7ac40d..6ff9f3d9 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java new file mode 100644 index 00000000..ad096715 --- /dev/null +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java @@ -0,0 +1,277 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring; + +import io.github.nbplugins.kotlin.refactoring.KaMoveFileResult; +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import java.util.List; +import javax.swing.BorderFactory; +import javax.swing.JCheckBox; +import javax.swing.JComboBox; +import javax.swing.JLabel; +import javax.swing.JList; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.ListCellRenderer; +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 Kotlin Move File's source-root/package destination and supported reference updates. + * + * @param initialResult discovered source-file data + * @param refactoring mutable carrier that receives selected parameters + * @param target available NetBeans source roots and package validator + */ +public class KotlinMoveFileUI implements RefactoringUI { + + private final KaMoveFileResult initialResult; + private final KotlinMoveFileRefactoring refactoring; + private final KotlinPackageTarget target; + private MoveFilePanel panel; + + /** + * Creates the Move File UI adapter. + * + * @param initialResult discovered source-file data + * @param refactoring mutable carrier that receives selected parameters + * @param target available NetBeans source roots and package validator + */ + public KotlinMoveFileUI(KaMoveFileResult initialResult, + KotlinMoveFileRefactoring refactoring, + KotlinPackageTarget target) { + this.initialResult = initialResult; + this.refactoring = refactoring; + this.target = target; + } + + /** @return the user-visible refactoring name. */ + @Override + public String getName() { + return "Move Kotlin File"; + } + + /** @return concise user-visible description of the file being moved. */ + @Override + public String getDescription() { + return "Move Kotlin file '" + initialResult.getFileName() + "' to another package"; + } + + /** @return false because Move File mutates project sources. */ + @Override + public boolean isQuery() { + return false; + } + + /** + * Creates the destination form on first use. + * + * @param parent listener notified when form values change + * @return the cached Move File form + */ + @Override + public CustomRefactoringPanel getPanel(ChangeListener parent) { + if (panel == null) { + panel = new MoveFilePanel( + initialResult.getFileName(), + initialResult.getPackageName(), + initialResult.getPackageMayBeUpdated(), + target.getRoots(), + target.getDefaultRootPath(), + parent + ); + } + return panel; + } + + /** + * Copies selected UI values to the refactoring carrier. + * + * @return a fatal problem for an invalid target package, otherwise {@code null} + */ + @Override + public Problem setParameters() { + if (panel == null) { + return null; + } + String packageName = panel.getPackageValue().trim(); + if (!target.isValidPackage(packageName)) { + return new Problem(true, "Target package is not a valid Kotlin package name."); + } + refactoring.setTargetRootPath(panel.getRootPath()); + refactoring.setTargetPackage(packageName); + refactoring.setUpdateReferences(panel.isUpdateReferences()); + return null; + } + + /** + * Validates current form values before NetBeans schedules the refactoring. + * + * @return a fatal problem when the destination is invalid, otherwise {@code null} + */ + @Override + public Problem checkParameters() { + return setParameters(); + } + + /** @return true because Move File has a destination configuration form. */ + @Override + public boolean hasParameters() { + return true; + } + + /** @return the refactoring carrier used by NetBeans. */ + @Override + public AbstractRefactoring getRefactoring() { + return refactoring; + } + + /** @return no dedicated help page. */ + @Override + public HelpCtx getHelpCtx() { + return null; + } + + /** Swing form for Move File parameters. */ + private static final class MoveFilePanel implements CustomRefactoringPanel { + private final JPanel component; + private final JComboBox rootField; + private final JTextField packageField; + private final JCheckBox updateReferences; + + /** + * Builds the destination form. + * + * @param fileName displayed source file name + * @param sourcePackage initially selected package + * @param mayUpdatePackage whether automatic package rewriting is available + * @param roots selectable destination source roots + * @param defaultRoot source root containing the current file, if available + * @param changeListener listener notified when values change + */ + MoveFilePanel(String fileName, + String sourcePackage, + boolean mayUpdatePackage, + List roots, + String defaultRoot, + ChangeListener changeListener) { + rootField = new JComboBox<>(roots.toArray(new KotlinPackageTargetRoot[0])); + rootField.setRenderer(new RootRenderer()); + for (int index = 0; index < rootField.getItemCount(); index++) { + KotlinPackageTargetRoot root = rootField.getItemAt(index); + if (root.getPath().equals(defaultRoot)) { + rootField.setSelectedIndex(index); + break; + } + } + packageField = new JTextField(sourcePackage, 30); + updateReferences = new JCheckBox("Update Kotlin references", true); + updateReferences.setToolTipText("Updates supported Kotlin code references; Java, comments, and text are not searched."); + + 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("Kotlin file:"), label); + form.add(new JLabel(fileName), field); + JLabel rootLabel = new JLabel("Target source root:"); + rootLabel.setLabelFor(rootField); + form.add(rootLabel, label); + form.add(rootField, field); + JLabel packageLabel = new JLabel("Target package:"); + packageLabel.setLabelFor(packageField); + form.add(packageLabel, label); + form.add(packageField, field); + form.add(new JLabel(""), label); + form.add(updateReferences, field); + if (!mayUpdatePackage) { + form.add(new JLabel(""), label); + form.add(new JLabel("Package differs from its current directory and will be preserved."), field); + } + + component = new JPanel(new BorderLayout()); + component.add(form, BorderLayout.NORTH); + DocumentListener listener = new DocumentListener() { + @Override public void insertUpdate(DocumentEvent event) { changeListener.stateChanged(null); } + @Override public void removeUpdate(DocumentEvent event) { changeListener.stateChanged(null); } + @Override public void changedUpdate(DocumentEvent event) { changeListener.stateChanged(null); } + }; + packageField.getDocument().addDocumentListener(listener); + rootField.addActionListener(event -> changeListener.stateChanged(null)); + updateReferences.addActionListener(event -> changeListener.stateChanged(null)); + } + + /** @return selected source-root path or an empty string. */ + String getRootPath() { + KotlinPackageTargetRoot root = (KotlinPackageTargetRoot) rootField.getSelectedItem(); + return root == null ? "" : root.getPath(); + } + + /** @return current target package text. */ + String getPackageValue() { + return packageField.getText(); + } + + /** @return whether supported Kotlin references should be retargeted. */ + boolean isUpdateReferences() { + return updateReferences.isSelected(); + } + + /** Requests initial focus for the target package field. */ + @Override + public void initialize() { + packageField.requestFocusInWindow(); + } + + /** @return Swing component displayed by NetBeans. */ + @Override + public Component getComponent() { + return component; + } + } + + /** Renders source roots by their project-visible display names. */ + private static final class RootRenderer extends JLabel implements ListCellRenderer { + @Override + public Component getListCellRendererComponent(JList list, + KotlinPackageTargetRoot value, int index, + boolean selected, boolean focus) { + setText(value == null ? "" : value.getDisplayName()); + setOpaque(true); + setBackground(selected ? list.getSelectionBackground() : list.getBackground()); + setForeground(selected ? list.getSelectionForeground() : list.getForeground()); + return this; + } + } +} diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinRefactoringsFactory.java index 39061565..f438053e 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 @@ -38,6 +38,8 @@ import io.github.nbplugins.kotlin.nbm.refactoring.KotlinCopyDeclarationRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDeclarationPlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDeclarationRefactoring; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFilePlugin; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveFileRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignaturePlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignatureRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinIntroduceImportAliasPlugin; @@ -89,6 +91,7 @@ *
  • {@link KotlinIntroduceFunctionalParameterRefactoring} — delegates to {@link KotlinIntroduceFunctionalParameterPlugin}
  • *
  • {@link KotlinCopyDeclarationRefactoring} — delegates to {@link KotlinCopyDeclarationPlugin}
  • *
  • {@link KotlinMoveDeclarationRefactoring} — delegates to {@link KotlinMoveDeclarationPlugin}
  • + *
  • {@link KotlinMoveFileRefactoring} — delegates to {@link KotlinMoveFilePlugin}
  • *
  • {@link KotlinChangeSignatureRefactoring} — delegates to {@link KotlinChangeSignaturePlugin}
  • * */ @@ -153,6 +156,9 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) { if (refactoring instanceof KotlinMoveDeclarationRefactoring) { return new KotlinMoveDeclarationPlugin((KotlinMoveDeclarationRefactoring) refactoring); } + if (refactoring instanceof KotlinMoveFileRefactoring) { + return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring); + } if (refactoring instanceof KotlinChangeSignatureRefactoring) { return new KotlinChangeSignaturePlugin((KotlinChangeSignatureRefactoring) refactoring); } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt new file mode 100644 index 00000000..6f0eace2 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt @@ -0,0 +1,74 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer +import io.github.nbplugins.kotlin.refactoring.KaMoveFileResult +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 for moving the currently open Kotlin file to another source-root/package directory. + * + * The physical move, package update, and supported Kotlin reference retargeting are executed later + * by [KotlinMoveFilePlugin] through the NetBeans refactoring lifecycle. + */ +class KotlinMoveFileAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) { + + init { + putValue(NAME, "Move Kotlin File...") + putValue(SHORT_DESCRIPTION, "Move Kotlin File to Another Package") + putValue(POPUP_MENU_TEXT, "Move Kotlin File...") + } + + override fun actionPerformed(evt: ActionEvent, target: JTextComponent) { + val document = target.document as? StyledDocument ?: return + runCatching { + val source = ProjectUtils.getFileObjectForDocument(document) ?: return@runCatching + val project = ProjectUtils.getKotlinProjectForFileObject(source) + ?: ProjectUtils.getValidProject() + ?: return@runCatching + val result = resolveOutcome(project, source.path) ?: return@runCatching + val refactoring = KotlinMoveFileRefactoring(document) + UI.openRefactoringUI( + KotlinMoveFileUI(result, refactoring, KotlinPackageTarget(project, source)), + TopComponent.getRegistry().activated, + ) + }.onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinMoveFileAction failed", error) + } + } + + /** Resolves a discovery result for the current physical Kotlin source file. */ + private fun resolveOutcome(project: org.netbeans.api.project.Project, sourcePath: String): KaMoveFileResult? { + val session = KotlinAnalysisAPISession.getSession(project) + val file = session.getKtFileForPath(sourcePath) ?: return null + return (KaMoveFileComputer(file).compute() as? KaMoveFileComputer.Outcome.Ready)?.result + } + + companion object { + /** Action name used by the manual layer.xml registration. */ + const val ACTION_NAME = "kotlin-move-file" + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt new file mode 100644 index 00000000..1ed68946 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt @@ -0,0 +1,236 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import io.github.nbplugins.kotlin.nbm.navigation.KotlinFindUsagesResultElement +import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaMoveDeclarationComputer +import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer +import org.jetbrains.kotlin.log.KotlinLogger +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.utils.ProjectUtils +import org.netbeans.modules.csl.api.OffsetRange +import org.netbeans.modules.refactoring.api.Problem +import org.netbeans.modules.refactoring.spi.ProgressProviderAdapter +import org.netbeans.modules.refactoring.spi.RefactoringElementsBag +import org.netbeans.modules.refactoring.spi.RefactoringPlugin +import org.netbeans.modules.refactoring.spi.SimpleRefactoringElementImplementation +import org.openide.filesystems.FileObject +import org.openide.filesystems.FileUtil +import org.openide.text.CloneableEditorSupport +import org.openide.text.PositionBounds +import org.openide.util.Lookup +import org.openide.util.lookup.Lookups +import javax.swing.text.Position.Bias + +/** + * Bridges NetBeans Move Kotlin File UI to the standalone K2 Move File semantic adapter. + * + * NetBeans owns destination creation, physical movement, document persistence, rollback, and undo. + * The ported K2 handler supplies eligible package rewriting, Kotlin conflict detection, and + * retargeting of supported external Kotlin references. + * + * @param refactoring carrier containing the destination selected in the Move File UI + */ +class KotlinMoveFilePlugin( + private val refactoring: KotlinMoveFileRefactoring, +) : ProgressProviderAdapter(), RefactoringPlugin { + + /** @return no preliminary problem; validation happens while preparing the physical destination. */ + override fun preCheck(): Problem? = null + + /** @return no fast problem; the UI validates the package before this point. */ + override fun fastCheckParameters(): Problem? = null + + /** @return no extra parameter problem. */ + override fun checkParameters(): Problem? = null + + /** Cancels no external work because all Move File work runs in the refactoring lifecycle. */ + override fun cancelRequest() = Unit + + /** + * Adds one source preview element and one atomic apply element for the current Kotlin file. + * + * @param bag NetBeans bag populated with preview and mutation elements + * @return a fatal problem when the selected source cannot be analyzed, otherwise `null` + */ + override fun prepare(bag: RefactoringElementsBag): Problem? { + val source = ProjectUtils.getFileObjectForDocument(refactoring.document) ?: return null + val project = ProjectUtils.getKotlinProjectForFileObject(source) ?: return null + val file = KotlinAnalysisAPISession.getSession(project).getKtFileForPath(source.path) ?: return null + return when (val outcome = KaMoveFileComputer(file).compute()) { + is KaMoveFileComputer.Outcome.NotApplicable -> Problem(true, "The selected Kotlin file has no physical path.") + is KaMoveFileComputer.Outcome.Error -> Problem(true, outcome.error.message ?: "Move Kotlin File analysis failed.") + is KaMoveFileComputer.Outcome.Ready -> { + bag.add( + refactoring, + KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source), + ) + bag.add(refactoring, KotlinMoveFileApplyElement(source, project, refactoring)) + null + } + } + } +} + +/** + * Performs one atomic Kotlin file move and retains the transaction for Undo Last Refactoring. + * + * @param sourceFile source file selected by the editor action + * @param project NetBeans project owning the source file + * @param refactoring destination and search parameters + */ +class KotlinMoveFileApplyElement( + private val sourceFile: FileObject, + private val project: org.netbeans.api.project.Project, + private val refactoring: KotlinMoveFileRefactoring, +) : SimpleRefactoringElementImplementation() { + + /** Successful transaction retained so [undoChange] can restore original paths and text. */ + private var transaction: KotlinRefactoringTransaction? = null + + /** @return user-visible preview text. */ + override fun getText(): String = "Move Kotlin file" + + /** @return user-visible preview text. */ + override fun getDisplayText(): String = getText() + + /** @return source file lookup. */ + override fun getLookup(): Lookup = Lookups.fixed(sourceFile) + + /** @return original source file for NetBeans preview placement. */ + override fun getParentFile(): FileObject = sourceFile + + /** @return source position bounds, when the editor support is available. */ + override fun getPosition(): PositionBounds? = try { + val support = org.openide.loaders.DataObject.find(sourceFile) + .lookup.lookup(CloneableEditorSupport::class.java) ?: return null + PositionBounds( + support.createPositionRef(0, Bias.Forward), + support.createPositionRef(0, Bias.Backward), + ) + } catch (_: Exception) { + null + } + + /** + * Runs K2 semantics first, then atomically moves the physical file and persists every changed + * Kotlin document. Any failure rolls paths, documents, owned destination folders, and created + * files back to their original state. + * + * @return no value; failures are logged and the transaction is rolled back + */ + override fun performChange() { + var pending: KotlinRefactoringTransaction? = null + runCatching { + val activeSource = ProjectUtils.getFileObjectForDocument(refactoring.document) ?: sourceFile + val sourceProject = ProjectUtils.getKotlinProjectForFileObject(activeSource) ?: project + val packageTarget = KotlinPackageTarget(sourceProject, activeSource) + val targetRoot = packageTarget.roots.firstOrNull { it.path == refactoring.targetRootPath } + ?: packageTarget.roots.firstOrNull { it.path == packageTarget.defaultRootPath } + ?: error("Move Kotlin File could not find a destination source root.") + val targetPackage = refactoring.targetPackage.trim() + check(packageTarget.isValidPackage(targetPackage)) { "Target package is invalid: $targetPackage" } + + val current = KotlinRefactoringTransaction() + pending = current + val targetFolder = createTargetFolder(current, targetRoot.folder, targetPackage) + check(targetFolder != activeSource.parent) { "Kotlin file is already in the selected destination folder." } + check(targetFolder.getFileObject(activeSource.name, activeSource.ext) == null) { + "Target already contains ${activeSource.nameExt}." + } + + val session = KotlinAnalysisAPISession.getSession(sourceProject) + val sourceKtFile = session.getKtFileForPath(activeSource.path) + ?: error("Move Kotlin File could not resolve writable source PSI.") + // The session's disk-backed PSI is required only for target metadata/conflicts. Its + // VFS cannot create or move files; NetBeans performs that separately in the transaction. + val targetDirectory = KaMoveDeclarationComputer.resolveDirectory(sourceKtFile.project, targetFolder.path) + ?: error("Move Kotlin File could not resolve destination PSI directory.") + val outcome = KaMoveFileComputer(sourceKtFile).apply( + files = listOf(sourceKtFile), + targetDirectory = targetDirectory, + targetPackage = FqName(targetPackage), + updateReferences = refactoring.updateReferences, + ) + when (outcome) { + is KaMoveFileComputer.ApplyOutcome.Conflicts -> { + throw IllegalStateException(outcome.messages.joinToString("\n")) + } + is KaMoveFileComputer.ApplyOutcome.Error -> throw outcome.error + is KaMoveFileComputer.ApplyOutcome.Success -> { + current.moveFile(activeSource, targetFolder) + outcome.changedFiles.forEach { (path, text) -> + val file = if (path == activeSource.path) { + activeSource + } else { + FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path))) + ?: error("Move Kotlin File could not resolve changed file $path.") + } + val originalText = outcome.originalTexts[path] + KotlinLogger.INSTANCE.logInfo( + "KotlinMoveFileApplyElement.performChange: staging path=$path, " + + "original=${describeText(originalText)}, final=${describeText(text)}" + ) + current.captureExisting(file, originalText) + current.stageText(file, text) + } + current.commit() + transaction = current + KotlinLogger.INSTANCE.logInfo("KotlinMoveFileApplyElement.performChange: transaction committed") + pending = null + } + } + }.onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinMoveFileApplyElement.performChange failed", error) + } + runCatching { pending?.rollback() } + .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinMoveFileApplyElement rollback failed", error) } + KotlinAnalysisAPISession.invalidate(project) + } + + /** Restores the original source path and text through the transaction retained after commit. */ + override fun undoChange() { + runCatching { + val current = transaction + if (current == null) { + KotlinLogger.INSTANCE.logWarning("KotlinMoveFileApplyElement.undoChange: no retained transaction") + } else { + KotlinLogger.INSTANCE.logInfo("KotlinMoveFileApplyElement.undoChange: restoring retained transaction") + current.undo() + KotlinLogger.INSTANCE.logInfo("KotlinMoveFileApplyElement.undoChange: transaction restored") + transaction = null + } + KotlinAnalysisAPISession.invalidate(project) + }.onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinMoveFileApplyElement.undoChange failed", error) + } + } + + /** Produces bounded, one-line diagnostic text without logging full source contents. */ + private fun describeText(text: String?): String = + if (text == null) "missing" else "length=${text.length}, head=${text.take(160).replace("\n", "\\n")}" + + /** Creates only destination package folders that this transaction can safely delete on undo. */ + private fun createTargetFolder( + transaction: KotlinRefactoringTransaction, + root: FileObject, + packageName: String, + ): FileObject = packageName.split('.').filter(String::isNotEmpty).fold(root) { parent, segment -> + transaction.createFolder(parent, segment) + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt new file mode 100644 index 00000000..912085b6 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt @@ -0,0 +1,43 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import org.netbeans.modules.refactoring.api.AbstractRefactoring +import org.openide.util.lookup.Lookups +import javax.swing.text.StyledDocument + +/** + * Carries parameters for Kotlin Move File and Directory refactoring. + * + * The editor action seeds this carrier with the selected Kotlin source file. A directory invocation + * expands its Kotlin descendants into [sourceFiles] before the refactoring framework runs. + * + * @param document document associated with the action invocation + */ +class KotlinMoveFileRefactoring( + val document: StyledDocument, +) : AbstractRefactoring(Lookups.fixed(document)) { + + /** Source-root path selected in the destination UI. */ + var targetRootPath: String = "" + + /** Kotlin package selected for the destination folder. */ + var targetPackage: String = "" + + /** Whether supported external Kotlin references should be retargeted. */ + var updateReferences: Boolean = true +} 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 e48fbd51..29c93ae1 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 @@ -16,6 +16,7 @@ *******************************************************************************/ package io.github.nbplugins.kotlin.nbm.refactoring +import org.jetbrains.kotlin.log.KotlinLogger import org.openide.cookies.EditorCookie import org.openide.filesystems.FileObject import org.openide.loaders.DataObject @@ -31,10 +32,12 @@ import javax.swing.text.StyledDocument * * @param openDocument resolves a writable editor document for a file * @param writeDocument replaces a document's text; injectable to test rollback deterministically + * @param saveDocument persists an editor document and releases its NetBeans file lock before a move */ internal class KotlinRefactoringTransaction internal constructor( private val openDocument: (FileObject) -> StyledDocument? = ::openEditorDocument, private val writeDocument: (FileObject, StyledDocument, String) -> Unit = ::replaceDocumentText, + private val saveDocument: (FileObject) -> Unit = ::saveEditorDocument, ) { /** Identifies a file whose transaction operation could not be completed. */ @@ -52,24 +55,79 @@ internal class KotlinRefactoringTransaction internal constructor( var stagedWriter: ((StyledDocument, String, String) -> Unit)? = null, ) + private data class MoveEntry( + val originalFile: FileObject, + val originalParent: FileObject, + val originalName: String, + val originalExtension: String, + val targetParent: FileObject, + var movedFile: FileObject? = null, + ) + private val entries = linkedMapOf() + private val moves = mutableListOf() + private val ownedFolders = mutableListOf() + private var committed = false + private var movesApplied = false + private var restoring = false + + /** + * Records a physical file move that will be applied atomically with staged document changes. + * + * @param file existing Kotlin source file to move + * @param targetParent destination folder, which must not already contain the same file name + */ + fun moveFile(file: FileObject, targetParent: FileObject) { + check(file.isValid) { "Cannot move invalid file ${file.path}" } + check(!file.isFolder) { "Move transaction supports files; enumerate Kotlin directory descendants first." } + val originalParent = file.parent ?: error("Cannot move root file ${file.path}") + check(targetParent.isValid && targetParent.isFolder) { "Invalid target folder ${targetParent.path}" } + check(targetParent.getFileObject(file.name, file.ext) == null) { + "Target file already exists: ${targetParent.path}/${file.nameExt}" + } + captureExisting(file) + moves += MoveEntry(file, originalParent, file.name, file.ext, targetParent) + } + + /** + * Creates a destination folder owned by this transaction when it does not already exist. + * + * @param parent existing folder in which to create or find the child + * @param name simple child folder name + * @return the existing or newly created folder; only a newly created folder is deleted on undo + */ + fun createFolder(parent: FileObject, name: String): FileObject { + parent.getFileObject(name)?.let { existing -> + check(existing.isFolder) { "Target path is not a folder: ${existing.path}" } + return existing + } + val folder = parent.createFolder(name) + ownedFolders += folder + return folder + } /** * Captures [file]'s original text and opens its NetBeans editor document exactly once. * * @param file an existing source file that this transaction may modify + * @param originalText optional snapshot taken before an external semantic engine mutated PSI * @return the opened document associated with [file] */ - fun captureExisting(file: FileObject): StyledDocument { + fun captureExisting(file: FileObject, originalText: String? = null): StyledDocument { entries[file]?.let { return it.document } check(file.isValid) { "Cannot capture invalid file ${file.path}" } val document = requireDocument(file) + val snapshot = originalText ?: document.getText(0, document.length) entries[file] = Entry( file, existedBefore = true, - originalText = document.getText(0, document.length), + originalText = snapshot, document = document, ) + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.captureExisting: path=${file.path}, " + + "snapshot=${describeText(snapshot)}, document=${describeText(document.getText(0, document.length))}" + ) return document } @@ -141,6 +199,7 @@ internal class KotlinRefactoringTransaction internal constructor( * have been restored and transaction-created files have been deleted */ fun commit() { + check(!committed) { "Refactoring transaction was already committed." } val stagedEntries = entries.values.filter { it.stagedText != null } try { stagedEntries.forEach { entry -> @@ -149,43 +208,174 @@ internal class KotlinRefactoringTransaction internal constructor( "Target document changed while preparing transaction: ${entry.file.path}" } } + // A NetBeans editor document remains attached to its current FileObject. Write all + // staged content while every source path is still valid; moving first invalidates the + // source FileObject and makes CloneableEditorSupport reject a later document edit. stagedEntries.forEach { entry -> try { + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.commit: writing path=${entry.file.path}, " + + "snapshot=${describeText(entry.originalText ?: "")}, " + + "before=${describeDocument(entry.document)}, target=${describeText(entry.stagedText!!)}" + ) entry.stagedWriter?.invoke(entry.document, entry.originalText ?: "", entry.stagedText!!) ?: writeDocument(entry.file, entry.document, entry.stagedText!!) + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.commit: wrote path=${entry.file.path}, " + + "after=${describeDocument(entry.document)}" + ) } catch (error: Throwable) { 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) } + applyMoves() + committed = true } catch (error: Throwable) { rollback() if (error is Failure) throw error - val failed = stagedEntries.lastOrNull()?.file ?: entries.values.lastOrNull()?.file + val failed = stagedEntries.lastOrNull()?.file ?: moves.lastOrNull()?.originalFile ?: entries.values.lastOrNull()?.file if (failed != null) throw Failure(failed, error) throw error } } - /** Restores original documents and removes only files this transaction created. */ + /** Restores original documents, reverses owned physical moves, and removes owned files/folders. */ fun rollback() = restoreOriginalState() - /** Reverts a successfully committed refactoring using its original snapshots. */ + /** Reverts a successfully committed refactoring using its original snapshots and paths. */ fun undo() = restoreOriginalState() - /** Restores existing documents before removing owned files, retaining the first failure. */ + /** Applies all registered physical moves after every document has been validated. */ + 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}" + } + try { + file.lock().use { lock -> + move.movedFile = file.move(lock, move.targetParent, move.originalName, move.originalExtension) + } + // 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 + // the physical object now located at the destination. + val entry = entries.remove(file) + if (entry != null) entries[move.movedFile!!] = entry.copy(file = move.movedFile!!) + movesApplied = true + } catch (error: Throwable) { + throw Failure(file, error) + } + } + } + + /** Restores documents before paths, retaining the first cleanup failure. */ private fun restoreOriginalState() { + if (restoring) return + restoring = true var failure: Throwable? = null entries.values.filter { it.existedBefore }.forEach { entry -> - runCatching { writeDocument(entry.file, entry.document, entry.originalText!!) } - .onFailure { if (failure == null) failure = it } + runCatching { + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.restore: writing path=${entry.file.path}, " + + "snapshot=${describeText(entry.originalText ?: "")}, before=${describeDocument(entry.document)}" + ) + writeDocument(entry.file, entry.document, entry.originalText!!) + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.restore: wrote path=${entry.file.path}, " + + "after=${describeDocument(entry.document)}" + ) + // Persist every restored document before any path reversal. This both makes rollback + // durable if a later move fails and releases the DataEditorSupport file lock. + saveDocument(entry.file) + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.restore: saved path=${entry.file.path}" + ) + }.onFailure { error -> + KotlinLogger.INSTANCE.logException( + "KotlinRefactoringTransaction.restore failed for ${entry.file.path}", error + ) + if (failure == null) failure = error + } + } + reverseMoves().onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinRefactoringTransaction.reverseMoves 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. + entries.values.filter { it.existedBefore }.forEach { entry -> + runCatching { + val finalDocument = requireDocument(entry.file) + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.restoreAfterMoves: writing path=${entry.file.path}, " + + "snapshot=${describeText(entry.originalText ?: "")}, before=${describeDocument(finalDocument)}" + ) + writeDocument(entry.file, finalDocument, entry.originalText!!) + saveDocument(entry.file) + entries[entry.file] = entry.copy(document = finalDocument) + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.restoreAfterMoves: saved path=${entry.file.path}, " + + "after=${describeDocument(finalDocument)}" + ) + }.onFailure { error -> + KotlinLogger.INSTANCE.logException( + "KotlinRefactoringTransaction.restoreAfterMoves failed for ${entry.file.path}", error + ) + if (failure == null) failure = error + } } entries.values.filterNot { it.existedBefore }.forEach { entry -> runCatching { if (entry.file.isValid) entry.file.delete() } .onFailure { if (failure == null) failure = it } } - failure?.let { throw Failure(entries.values.first().file, it) } + ownedFolders.asReversed().forEach { folder -> + runCatching { if (folder.isValid && folder.children.isEmpty()) folder.delete() } + .onFailure { if (failure == null) failure = it } + } + committed = false + restoring = false + failure?.let { throw Failure(entries.values.firstOrNull()?.file ?: moves.first().originalFile, it) } } + /** Moves each successfully moved file back to its original parent and name. */ + private fun reverseMoves(): Result = runCatching { + if (!movesApplied) return@runCatching + moves.asReversed().forEach { move -> + val moved = move.movedFile ?: return@forEach + if (!moved.isValid) return@forEach + check(move.originalParent.getFileObject(move.originalName, move.originalExtension) == null) { + "Cannot restore moved file because original path is occupied: ${move.originalParent.path}/${move.originalName}.${move.originalExtension}" + } + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.reverseMoves: moving ${moved.path} to ${move.originalParent.path}, " + + "document=${entries[moved]?.let { describeDocument(it.document) } ?: "missing"}" + ) + moved.lock().use { lock -> + move.movedFile = moved.move(lock, move.originalParent, move.originalName, move.originalExtension) + } + val entry = entries.remove(moved) + if (entry != null) entries[move.movedFile!!] = entry.copy(file = move.movedFile!!) + KotlinLogger.INSTANCE.logInfo( + "KotlinRefactoringTransaction.reverseMoves: moved to ${move.movedFile!!.path}, " + + "document=${entries[move.movedFile!!]?.let { describeDocument(it.document) } ?: "missing"}" + ) + } + movesApplied = 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")}" + + /** Produces a bounded description of the live document, including the package-header evidence. */ + private fun describeDocument(document: StyledDocument): String = + describeText(document.getText(0, document.length)) + /** Obtains a document or fails with a clear transaction error. */ private fun requireDocument(file: FileObject): StyledDocument = openDocument(file) ?: error("Could not open editor document for ${file.path}") @@ -202,5 +392,10 @@ internal class KotlinRefactoringTransaction internal constructor( document.insertString(0, text, null) } } + + /** Persists a modified editor document so its DataEditorSupport releases the source file lock. */ + fun saveEditorDocument(file: FileObject) { + DataObject.find(file).lookup.lookup(EditorCookie::class.java)?.saveDocument() + } } } diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml index 5759e8ec..376c0109 100644 --- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml +++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml @@ -111,6 +111,10 @@ + + + + @@ -133,6 +137,10 @@ + + + + @@ -624,6 +632,10 @@ + + + + diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt new file mode 100644 index 00000000..907e68e6 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt @@ -0,0 +1,238 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaMoveDeclarationComputer +import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer +import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** + * Unit tests for [KaMoveFileComputer]. + * + * Fixtures are in `projForTest/src/moveFile/`. Each subdirectory contains a Kotlin file whose + * source-root-relative package layout is used to verify Move File discovery. + */ +class KaMoveFileComputerTest : KotlinTestCase("KaMoveFileComputerTest", "moveFile") { + + /** + * Verifies that a Kotlin source file whose package matches its source-root-relative directory + * is accepted and declares that its package may be updated after a physical move. + */ + fun testCompute_matchingPackageDirectory_returnsReadyWithPackageUpdate() { + val session = KotlinAnalysisAPISession.getSession(project) + if (!session.hasDependencies) { + println("KaMoveFileComputerTest: skipping — no K2 dependencies available") + return + } + val file = dir.getFileObject("matching.kt") ?: error("Missing matching.kt fixture") + val ktFile = session.getKtFileForPath(file.path) ?: error("Could not obtain matching.kt PSI") + + val outcome = KaMoveFileComputer(ktFile).compute() + + assertTrue("Expected Ready for a Kotlin source file, got $outcome", outcome is KaMoveFileComputer.Outcome.Ready) + val result = (outcome as KaMoveFileComputer.Outcome.Ready).result + assertEquals(file.path, result.sourceFilePath) + assertEquals("moveFile", result.packageName) + assertTrue("Matching source-root/package layout must permit a package update", result.packageMayBeUpdated) + } + + /** + * Verifies that a Kotlin source file with a mismatched package and directory remains movable, + * but Move File explicitly preserves its package rather than applying a guessed rewrite. + */ + fun testCompute_mismatchedPackageDirectory_preservesPackage() { + val session = KotlinAnalysisAPISession.getSession(project) + if (!session.hasDependencies) { + println("KaMoveFileComputerTest: skipping — no K2 dependencies available") + return + } + val file = dir.getFileObject("mismatch.kt") ?: error("Missing mismatch.kt fixture") + val ktFile = session.getKtFileForPath(file.path) ?: error("Could not obtain mismatch.kt PSI") + + val outcome = KaMoveFileComputer(ktFile).compute() + + assertTrue("Expected Ready for a Kotlin source file, got $outcome", outcome is KaMoveFileComputer.Outcome.Ready) + val result = (outcome as KaMoveFileComputer.Outcome.Ready).result + assertEquals("different.package", result.packageName) + assertFalse("Mismatched source-root/package layout must preserve the declared package", result.packageMayBeUpdated) + } + + /** + * Verifies the lifecycle-free port invokes IDEA's K2MoveFilesHandler semantics: a matching + * package is rewritten for the destination and no full IDEA file-move processor is required. + */ + fun testApply_realSession_updatesEligiblePackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Move File integration test") + return + } + val temp = Files.createTempDirectory("nbkotlin-move-file") + try { + val sourceDirectory = temp.resolve("move/source") + val targetDirectory = temp.resolve("move/target") + Files.createDirectories(sourceDirectory) + Files.createDirectories(targetDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + Files.writeString(sourcePath, "package move.source\\n\\nfun moved() = 1\\n") + + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "move-file-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temp), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val targetPsi = KaMoveDeclarationComputer.resolveDirectory(source.project, targetDirectory.toString()) + ?: error("Could not resolve target directory PSI") + + val outcome = KaMoveFileComputer(source).apply( + files = listOf(source), + targetDirectory = targetPsi, + targetPackage = org.jetbrains.kotlin.name.FqName("move.target"), + updateReferences = true, + ) + + assertTrue("Expected K2 Move File success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success) + val success = outcome as KaMoveFileComputer.ApplyOutcome.Success + assertTrue( + "Expected package directive rewritten by K2MoveFilesHandler, got ${success.changedFiles[sourcePath.toString()]}", + success.changedFiles[sourcePath.toString()]?.contains("package move.target") == true, + ) + } finally { + temp.toFile().deleteRecursively() + } + } + + /** + * Verifies an external Kotlin import is returned for persistence after Move File retargeting. + */ + fun testApply_realSession_returnsRetargetedUsageImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Move File integration test") + return + } + val temp = Files.createTempDirectory("nbkotlin-move-file-usage") + try { + val sourceDirectory = temp.resolve("move/source") + val targetDirectory = temp.resolve("move/target") + val usageDirectory = temp.resolve("move/usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(targetDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(sourcePath, "package move.source\n\nfun moved() = 1\n") + Files.writeString( + usagePath, + "package move.usage\n\nimport move.source.moved\n\nfun useMoved() = moved()\n", + ) + + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "move-file-usage-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temp), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val targetPsi = KaMoveDeclarationComputer.resolveDirectory(source.project, targetDirectory.toString()) + ?: error("Could not resolve target directory PSI") + + val outcome = KaMoveFileComputer(source).apply( + files = listOf(source), + targetDirectory = targetPsi, + targetPackage = org.jetbrains.kotlin.name.FqName("move.target"), + updateReferences = true, + ) + + assertTrue("Expected K2 Move File success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success) + val success = outcome as KaMoveFileComputer.ApplyOutcome.Success + assertTrue( + "Expected changed files to include the retargeted usage, got ${success.changedFiles.keys}", + usagePath.toString() in success.changedFiles, + ) + assertEquals( + "The source snapshot must precede K2's package mutation", + "package move.source\n\nfun moved() = 1\n", + success.originalTexts[sourcePath.toString()], + ) + assertEquals( + "The usage snapshot must precede K2's import retargeting", + "package move.usage\n\nimport move.source.moved\n\nfun useMoved() = moved()\n", + success.originalTexts[usagePath.toString()], + ) + assertTrue( + "Expected usage import rewritten to the target package, got ${success.changedFiles[usagePath.toString()]}", + success.changedFiles[usagePath.toString()]?.contains("import move.target.moved") == true, + ) + } finally { + temp.toFile().deleteRecursively() + } + } + + /** + * Verifies a package/directory mismatch is physically movable but retains its declared package + * in the K2 result, matching the upstream `K2MoveFilesHandler.needsUpdate` contract. + */ + fun testApply_realSession_preservesMismatchedPackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Move File integration test") + return + } + val temp = Files.createTempDirectory("nbkotlin-move-file-mismatch") + try { + val sourceDirectory = temp.resolve("layout/source") + val targetDirectory = temp.resolve("layout/target") + Files.createDirectories(sourceDirectory) + Files.createDirectories(targetDirectory) + val sourcePath = sourceDirectory.resolve("Mismatch.kt") + Files.writeString(sourcePath, "package declared.somewhereElse\\n\\nfun mismatch() = 1\\n") + + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "move-file-mismatch-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temp), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val targetPsi = KaMoveDeclarationComputer.resolveDirectory(source.project, targetDirectory.toString()) + ?: error("Could not resolve target directory PSI") + + val outcome = KaMoveFileComputer(source).apply( + files = listOf(source), + targetDirectory = targetPsi, + targetPackage = org.jetbrains.kotlin.name.FqName("layout.target"), + updateReferences = true, + ) + + assertTrue("Expected K2 Move File success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success) + val success = outcome as KaMoveFileComputer.ApplyOutcome.Success + assertTrue( + "Mismatched package must be preserved, got ${success.changedFiles[sourcePath.toString()]}", + success.changedFiles[sourcePath.toString()]?.contains("package declared.somewhereElse") == true, + ) + } finally { + temp.toFile().deleteRecursively() + } + } + + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ + private fun findStdlibJar(): Path? = System.getProperty("java.class.path") + .split(System.getProperty("path.separator")) + .map { Path.of(it) } + .firstOrNull { it.fileName?.toString()?.startsWith("kotlin-stdlib") == true && it.toFile().exists() } +} + 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 7f2abfd3..5f7871c1 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 @@ -54,6 +54,13 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio return file } + /** Creates an existing file and document beneath [parent]. */ + private fun Fixture.existingIn(parent: FileObject, name: String, text: String): FileObject { + val file = parent.createData(name) + documents[file] = DefaultStyledDocument().also { it.insertString(0, text, null) } + return file + } + /** Reads the in-memory document associated with [file]. */ private fun Fixture.text(file: FileObject): String { val document = documents[file] @@ -61,6 +68,15 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio return document!!.getText(0, document.length) } + /** Finds the retained editor document after a FileObject changed identity through a move. */ + private fun Fixture.textByPath(file: FileObject): String { + val document = documents.entries.firstOrNull { (knownFile, _) -> + knownFile.path == file.path || !knownFile.isValid + }?.value + assertNotNull("Expected retained document for ${file.path}", document) + return document!!.getText(0, document.length) + } + /** Verifies commit changes every staged existing document and undo restores every snapshot. */ fun testCommitAndUndo_restoresEveryExistingDocument() { val fixture = fixture() @@ -82,6 +98,176 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio assertEquals("fun target() = 2\n", fixture.text(target)) } + /** Verifies an externally captured pre-mutation snapshot is restored by undo. */ + fun testCommitAndUndo_restoresProvidedPreMutationSnapshot() { + val fixture = fixture() + val source = fixture.existing("Source.kt", "package target\n\nfun source() = 1\n") + val original = "package source\n\nfun source() = 1\n" + + fixture.transaction.captureExisting(source, original) + fixture.transaction.stageText(source, "package target\n\nfun source() = 1\n") + fixture.transaction.commit() + fixture.transaction.undo() + + assertEquals("undo must restore the snapshot from before K2 PSI mutation", original, fixture.text(source)) + } + + /** Verifies a physical move is committed and Undo Last Refactoring restores its path and text. */ + fun testCommitAndUndo_restoresMovedFilePathAndText() { + val fixture = fixture() + val sourceFolder = fixture.root.createFolder("source") + val targetFolder = fixture.root.createFolder("target") + val source = fixture.existingIn(sourceFolder, "Moved.kt", "package source\n\nfun moved() = 1\n") + + fixture.transaction.moveFile(source, targetFolder) + fixture.transaction.stageText(source, "package target\n\nfun moved() = 1\n") + fixture.transaction.commit() + + val moved = targetFolder.getFileObject("Moved.kt") + assertNotNull("commit must physically move the file", moved) + assertNull("source path must no longer exist", sourceFolder.getFileObject("Moved.kt")) + assertEquals("package target\n\nfun moved() = 1\n", fixture.textByPath(moved!!)) + + fixture.transaction.undo() + + val restored = sourceFolder.getFileObject("Moved.kt") + assertNotNull("undo must restore the original source path", restored) + assertNull("undo must remove the moved target path", targetFolder.getFileObject("Moved.kt")) + assertEquals("package source\n\nfun moved() = 1\n", fixture.textByPath(restored!!)) + } + + /** Verifies undo writes the original snapshot through the FileObject returned by the reverse move. */ + fun testUndo_rewritesSnapshotAtRestoredFilePath() { + val root = FileUtil.createMemoryFileSystem().root + val sourceFolder = root.createFolder("source") + val targetFolder = root.createFolder("target") + val source = sourceFolder.createData("Moved.kt") + val original = "package source\n\nfun moved() = 1\n" + val target = "package target\n\nfun moved() = 1\n" + val documents = mutableMapOf() + val persistedTexts = mutableMapOf(source.path to original) + val transaction = KotlinRefactoringTransaction( + openDocument = { file -> documents.getOrPut(file) { DefaultStyledDocument().also { it.insertString(0, persistedTexts[file.path] ?: "", null) } } }, + writeDocument = { file, document, text -> + if (document.length > 0) document.remove(0, document.length) + document.insertString(0, text, null) + persistedTexts[file.path] = text + }, + saveDocument = { }, + ) + + transaction.moveFile(source, targetFolder) + transaction.captureExisting(source, original) + transaction.stageText(source, target) + transaction.commit() + val moved = targetFolder.getFileObject("Moved.kt") ?: error("Expected moved file") + persistedTexts[moved.path] = target + transaction.undo() + + val restored = sourceFolder.getFileObject("Moved.kt") ?: error("Expected restored file") + assertEquals("undo must persist the snapshot at the restored physical path", original, persistedTexts[restored.path]) + } + + /** Verifies a later write failure prevents the physical move and restores source text. */ + fun testCommitFailure_doesNotMoveFileBeforeEveryDocumentIsWritten() { + val fixture = fixture(failWriting = { it.nameExt == "Failing.kt" }) + val sourceFolder = fixture.root.createFolder("source") + val targetFolder = fixture.root.createFolder("target") + val source = fixture.existingIn(sourceFolder, "Moved.kt", "fun moved() = 1\n") + val failing = fixture.existing("Failing.kt", "fun failing() = 1\n") + + fixture.transaction.moveFile(source, targetFolder) + fixture.transaction.stageText(source, "fun moved() = 2\n") + fixture.transaction.captureExisting(failing) + fixture.transaction.stageText(failing, "fun failing() = 2\n") + + try { + fixture.transaction.commit() + fail("commit must fail when a staged write fails") + } catch (_: KotlinRefactoringTransaction.Failure) { + // Expected: no physical move occurs until every staged document is writable. + } + + val restored = sourceFolder.getFileObject("Moved.kt") + assertNotNull("failed document writes must leave the source path intact", restored) + assertNull("failed document writes must not create the target path", targetFolder.getFileObject("Moved.kt")) + assertEquals("fun moved() = 1\n", fixture.textByPath(restored!!)) + assertEquals("fun failing() = 1\n", fixture.text(failing)) + } + + /** Verifies every staged document is written and saved before the source file is moved. */ + fun testCommit_writesAndSavesEveryDocumentBeforePhysicalMove() { + val root = FileUtil.createMemoryFileSystem().root + val sourceFolder = root.createFolder("source") + val targetFolder = root.createFolder("target") + val source = sourceFolder.createData("Moved.kt") + val usage = root.createData("Usage.kt") + val sourceDocument = DefaultStyledDocument().also { it.insertString(0, "fun moved() = 1\n", null) } + val usageDocument = DefaultStyledDocument().also { it.insertString(0, "fun usage() = moved()\n", null) } + val documents = mapOf(source to sourceDocument, usage to usageDocument) + val savedFiles = mutableListOf() + val transaction = KotlinRefactoringTransaction( + openDocument = { file -> documents[file] }, + writeDocument = { _, editableDocument, text -> + if (editableDocument.length > 0) editableDocument.remove(0, editableDocument.length) + editableDocument.insertString(0, text, null) + }, + saveDocument = { file -> + assertNotNull("a moved file must remain at its original path until saves complete", sourceFolder.getFileObject("Moved.kt")) + savedFiles += file + }, + ) + + transaction.moveFile(source, targetFolder) + transaction.stageText(source, "fun moved() = 2\n") + transaction.captureExisting(usage) + transaction.stageText(usage, "fun usage() = moved()\n") + transaction.commit() + + assertEquals("all staged documents must be saved before the move", listOf(source, usage), savedFiles) + assertNotNull("commit must still move the file after saving", targetFolder.getFileObject("Moved.kt")) + } + + /** Verifies undo saves the moved source before restoring its original path. */ + fun testUndo_savesMovedSourceBeforePhysicalRestore() { + val root = FileUtil.createMemoryFileSystem().root + val sourceFolder = root.createFolder("source") + val targetFolder = root.createFolder("target") + val source = sourceFolder.createData("Moved.kt") + val document = DefaultStyledDocument().also { it.insertString(0, "fun moved() = 1\n", null) } + var savedMovedSourceBeforeRestore = false + val transaction = KotlinRefactoringTransaction( + openDocument = { document }, + writeDocument = { _, editableDocument, text -> + if (editableDocument.length > 0) editableDocument.remove(0, editableDocument.length) + editableDocument.insertString(0, text, null) + }, + saveDocument = { file -> + if (file.parent == targetFolder) { + savedMovedSourceBeforeRestore = sourceFolder.getFileObject("Moved.kt") == null + } + }, + ) + + transaction.moveFile(source, targetFolder) + transaction.stageText(source, "fun moved() = 2\n") + transaction.commit() + transaction.undo() + + assertTrue("undo must save the moved source before restoring its original path", savedMovedSourceBeforeRestore) + assertNotNull("undo must restore the original path", sourceFolder.getFileObject("Moved.kt")) + } + + /** Verifies empty destination folders created by the transaction are deleted on rollback. */ + fun testRollback_deletesTransactionOwnedEmptyFolder() { + val fixture = fixture() + val destination = fixture.transaction.createFolder(fixture.root, "generated") + + fixture.transaction.rollback() + + assertFalse("rollback must delete an unused transaction-owned folder", destination.isValid()) + } + /** Verifies an owned target file is retained at commit and deleted by undo. */ fun testUndo_deletesTransactionCreatedFile() { val fixture = fixture() 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 b10a831f..78aed07f 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, 18 partial, 6 absent, 24 total." + val totals = "**Baseline counts:** 0 complete, 19 partial, 5 absent, 24 total." assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals)) - assertEquals(18, records.count { it.status == "partial" }) - assertEquals(6, records.count { it.status == "absent" }) + assertEquals(19, records.count { it.status == "partial" }) + assertEquals(5, records.count { it.status == "absent" }) } /** Finds the checkout root from Surefire's module working directory. */ diff --git a/Nbm/src/test/resources/projForTest/src/moveFile/matching.kt b/Nbm/src/test/resources/projForTest/src/moveFile/matching.kt new file mode 100644 index 00000000..e31cf917 --- /dev/null +++ b/Nbm/src/test/resources/projForTest/src/moveFile/matching.kt @@ -0,0 +1,3 @@ +package moveFile + +fun matching() = Unit diff --git a/Nbm/src/test/resources/projForTest/src/moveFile/mismatch.kt b/Nbm/src/test/resources/projForTest/src/moveFile/mismatch.kt new file mode 100644 index 00000000..f07f8c6c --- /dev/null +++ b/Nbm/src/test/resources/projForTest/src/moveFile/mismatch.kt @@ -0,0 +1,3 @@ +package different.package + +fun mismatch() = Unit diff --git a/docs/development-plan.md b/docs/development-plan.md index 65501125..0466f9ae 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -658,7 +658,8 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E implicit-package-prefix, module-dependency, internal-member-usage, actual/expect, plus helpers). Excluded (out of scope): `K2MoveHandler.kt` (IDE action dispatch), `ui/**` (Swing — NB has its own text-field UI), `K2MoveFilesOrDirectoriesRefactoringProcessor.kt` (file/dir - moves), `K2ChangePackageDescriptor.kt`/processor (separate feature). + moves; F4.1 now ports its Kotlin handler through a lifecycle-free adapter), + `K2ChangePackageDescriptor.kt`/processor (separate F4.2 feature). - Single-module architecture: this plugin has no multi-module/multiplatform project model anywhere (confirmed via dedicated research — every NetBeans project gets one isolated K2 analysis session). `SingletonModule`/`ModuleUtilCore`/`ModuleType` stubs @@ -742,6 +743,24 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E is removed on any conflict/error, an existing target is restored exactly, and **Refactor → Undo Last Refactoring** restores both documents (or removes the target file if the refactoring created it). + - [x] **F4.1** — Move Kotlin File (Refactor menu; directory action remains the next F4.1 increment) + - Ports the Kotlin-specific `K2MoveFilesHandler` portion of + `K2MoveFilesOrDirectoriesRefactoringProcessor.kt` through `KaMoveFileComputer`. The generic + IDEA processor is intentionally not invoked: it depends on writable IntelliJ VFS, indexed + generic-file search, command/listener lifecycle, and modal UI unavailable in standalone K2. + - **Refactor → Move Kotlin File...** selects a destination source root and package. NetBeans owns + physical movement through `KotlinRefactoringTransaction`; the K2 adapter updates a package only + where source-root-relative package/directory alignment proves it safe, checks portable Kotlin + conflicts, and retargets supported Kotlin references. + - Transactions now record reversible physical file moves and transaction-owned destination folders; + failed writes roll back the path and document snapshots, while **Undo Last Refactoring** restores + the original path and text. + - Portable coverage includes matching-package update, package/directory mismatch preservation, + K2 semantic adapter execution, physical-move rollback, owned-folder cleanup, and undo. The + remaining F4.1 scope is directory/file-node invocation, recursive Kotlin-directory moves, + generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA project-model + conflict behavior. + - [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/manual-test-move-kotlin-file.md b/docs/manual-test-move-kotlin-file.md new file mode 100644 index 00000000..e2576473 --- /dev/null +++ b/docs/manual-test-move-kotlin-file.md @@ -0,0 +1,120 @@ +# Ручное тестирование: Move Kotlin File + +Этот план проверяет refactoring **Move Kotlin File** (F4.1) и исправление отображения source root. + +## Подготовка + +1. Соберите плагин и установите созданный `.nbm` в тестовый NetBeans. +2. Перезапустите NetBeans. +3. Откройте или создайте Kotlin-проект с исходным корнем `src/main/kotlin` либо `src/main/java`. +4. Для проверок обновления ссылок используйте не менее двух Kotlin-файлов, один из которых импортирует или использует объявление из перемещаемого файла. + +## 1. Доступность команды + +1. Откройте Kotlin-файл в редакторе. +2. Поместите курсор в файл без выделения текста. +3. Выберите **Refactor → Move Kotlin File**. + +**Ожидаемый результат:** открывается диалог Move Kotlin File; команда доступна для Kotlin-файла в редакторе. + +## 2. Отображение Source Root + +1. В открытом диалоге раскройте список **Source Root**. +2. Если в проекте несколько source root, проверьте каждый элемент. +3. Закройте список и проверьте выбранное значение. + +**Ожидаемый результат:** список и выбранное значение показывают краткие проектные имена, например `Source Packages`. Не должны отображаться технические строки вида `KotlinPackageTargetRoot(path=..., displayName=..., folder=...)`. + +## 3. Перемещение в корректный пакет с обновлением ссылок + +1. Создайте файл `sample/source/Greeting.kt`: + ```kotlin + package sample.source + + class Greeting + ``` +2. Создайте использующий его файл, например `sample/client/UseGreeting.kt`: + ```kotlin + package sample.client + + import sample.source.Greeting + + val greeting = Greeting() + ``` +3. Откройте `Greeting.kt`, выберите **Refactor → Move Kotlin File**. +4. Выберите нужный Source Root, укажите пакет `sample.target`. +5. Оставьте включённым **Update Kotlin references** и выполните рефакторинг. + +**Ожидаемый результат:** + +- файл перемещён в каталог `sample/target` выбранного source root; +- в `Greeting.kt` пакет изменён на `package sample.target`; +- импорт в `UseGreeting.kt` изменён на `import sample.target.Greeting`; +- проект остаётся без ошибок компиляции, использование `Greeting()` разрешается. + +## 4. Undo Last Refactoring + +1. После успешного перемещения выберите **Refactor → Undo Last Refactoring**. +2. Проверьте оба файла и их расположение. + +**Ожидаемый результат:** + +- `Greeting.kt` возвращён в исходный каталог; +- исходная директива `package sample.source` восстановлена; +- импорт в использующем файле снова указывает на `sample.source.Greeting`; +- состояние редакторов и проекта согласовано. + +## 5. Перемещение в другой source root + +1. Добавьте в проект второй source root, например `src/test/kotlin`, если его ещё нет. +2. Откройте `Greeting.kt` и запустите Move Kotlin File. +3. В поле **Source Root** выберите второй корень. +4. Укажите пакет `sample.target` и выполните рефакторинг. + +**Ожидаемый результат:** файл появляется в `sample/target` именно выбранного source root; значение Source Root остаётся читаемым; package declaration соответствует заданному пакету. + +## 6. Несоответствие package и каталога + +1. Создайте Kotlin-файл в каталоге, который не соответствует его `package`, например: + - путь: `sample/wrong/Unexpected.kt`; + - содержимое: `package sample.declared`. +2. Переместите файл в пакет `sample.target`. + +**Ожидаемый результат:** операция задаёт итоговое расположение по выбранному target package и обновляет package declaration на `sample.target`; она не зависит от исходного несовпадения package и каталога. + +## 7. Конфликт имени файла + +1. В целевом каталоге заранее создайте файл с тем же именем, например `sample/target/Greeting.kt`. +2. Попробуйте переместить туда исходный `Greeting.kt`. + +**Ожидаемый результат:** рефакторинг сообщает о конфликте и не повреждает ни исходный, ни уже существующий целевой файл. + +## 8. Отключение обновления Kotlin-ссылок + +1. Подготовьте файл с Kotlin-импортом перемещаемого класса, как в разделе 3. +2. В диалоге Move Kotlin File снимите флажок **Update Kotlin references**. +3. Переместите файл в другой пакет. + +**Ожидаемый результат:** файл и его package declaration перемещены/обновлены, но импорт или ссылки в других Kotlin-файлах автоматически не меняются. Неразрешённая ссылка допустима как ожидаемое следствие отключённой опции. + +## 9. Откат при ошибке записи + +1. Подготовьте операцию, для которой целевой каталог недоступен для записи, либо воспроизведите другую контролируемую ошибку файловой системы без риска потери данных. +2. Запустите перемещение. + +**Ожидаемый результат:** при ошибке операция прекращается корректно; исходный файл, его package declaration и ранее существующие целевые файлы не остаются в частично изменённом состоянии. + +## Неподдерживаемые сценарии + +Следующие сценарии не входят в F4.1 и не являются критериями приёмки: + +- запуск Move Kotlin File из контекстного меню узла файла или каталога; +- рекурсивное перемещение каталогов; +- перемещение не-Kotlin файлов; +- обновление Java usages; +- обновление упоминаний в комментариях, строках и произвольном тексте; +- сценарии, требующие IDEA index-backed filesystem или IDEA project model. + +## Результат проверки + +Зафиксируйте для каждого раздела: **пройдено**, **не пройдено** либо **не применимо**, а для непрохождения приложите минимальный пример проекта и шаги воспроизведения. diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md index c7d46e60..a16508c4 100644 --- a/docs/refactoring-coverage.md +++ b/docs/refactoring-coverage.md @@ -28,7 +28,7 @@ netbeans=; test=; milestone= - + @@ -48,7 +48,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | **Absent** | IDEA exposes the Kotlin refactoring family but the NetBeans plugin has no corresponding command. | | **Not applicable** | IDEA-only integration which is not a Kotlin refactoring target for this plugin. No such rows are currently tracked. | -**Baseline counts:** 0 complete, 18 partial, 6 absent, 24 total. +**Baseline counts:** 0 complete, 19 partial, 5 absent, 24 total. ## Current NetBeans refactorings @@ -68,6 +68,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | `introduce-parameter` | Introduce Parameter | `Ctrl+Alt+P` | Partial | Change Signature pipeline updates callers through a transaction that rolls every touched file back on failure; context parameters and adding an absent primary constructor remain. | F6 | | `introduce-functional-parameter` | Introduce Functional Parameter | `Ctrl+Alt+Shift+P` | Partial | K2 extraction/Change Signature path covers single expressions with transactional multi-file persistence; multi-statement and idiomatic lambda cases remain. | F6 | | `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 | Partial | Moves one Kotlin file to a selectable source root/package, updates a matching package directive and supported Kotlin code references, and restores the original path/text through Undo Last Refactoring. Directory invocation, generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA filesystem behavior remain. | 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 | @@ -79,7 +80,6 @@ The corresponding record comments above are deliberately machine-readable. `Refa |---|---|---|---| | `inline-type-alias` | Inline Type Alias | The K2 processor is not compiled or adapted to NetBeans. | F5 | | `inline-anonymous-function` | Inline Anonymous Function/Lambda | The K2 processor is not compiled or adapted to NetBeans. | F5 | -| `move-file` | Move Kotlin file/directory | Requires filesystem transaction, source-root and project search services. | F4 | | `change-package` | Change Package | Requires package/filesystem move support and usage retargeting. | F4 | | `move-nested-member-method` | Move nested class/member/method | Requires receiver/visibility/hierarchy conflict support. | F4 | | `rename-file-package-directory` | Rename file/package/directory | Requires NetBeans filesystem refactoring integration and Kotlin-aware update rules. | F7 | diff --git a/pom.xml b/pom.xml index 043b1751..f0cee6e1 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.11.231-SNAPSHOT + 0.11.238-SNAPSHOT pom Netbeans Plugin Kotlin From 6c08b4347e4cd1c00fcbbfbbd278481f3ca05259 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 15 Sep 2026 22:39:37 +0300 Subject: [PATCH 2/2] Added Move Kotlin Directory refactoring Co-Authored-By: Claude --- CHANGELOG.md | 2 +- CoreImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../kotlin/refactoring/KaMoveFileComputer.kt | 67 +++++-- .../move/KotlinMoveUsageSearchService.kt | 14 ++ Nbm/pom.xml | 2 +- .../nbm/refactoring/KotlinMoveFileUI.java | 42 ++++- .../rename/KotlinRefactoringsFactory.java | 9 +- .../refactoring/KotlinMoveDirectoryAction.kt | 110 +++++++++++ .../nbm/refactoring/KotlinMoveFileAction.kt | 5 +- .../nbm/refactoring/KotlinMoveFilePlugin.kt | 172 ++++++++++-------- .../refactoring/KotlinMoveFileRefactoring.kt | 13 +- .../refactoring/KotlinMoveSourceSelection.kt | 104 +++++++++++ .../KotlinMoveUsageSearchServiceImpl.kt | 38 ++++ .../nbm/refactoring/KotlinPackageTarget.kt | 22 +++ .../resources/org/jetbrains/kotlin/layer.xml | 9 + .../nbm/refactoring/KaMoveFileComputerTest.kt | 65 +++++++ .../KotlinMoveDirectoryActionTest.kt | 36 ++++ .../KotlinMoveFileRefactoringTest.kt | 38 ++++ .../KotlinMoveSourceSelectionTest.kt | 79 ++++++++ .../KotlinRefactoringTransactionTest.kt | 29 +++ docs/development-plan.md | 11 +- docs/manual-test-move-kotlin-file.md | 33 +++- docs/refactoring-coverage.md | 2 +- pom.xml | 2 +- 31 files changed, 787 insertions(+), 133 deletions(-) create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelection.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoringTest.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelectionTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7765ab0a..ebc3823f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Added Move Kotlin File refactoring: move the active `.kt` file from **Refactor → Move Kotlin File...** to a selected source root and package; its matching package directive and supported Kotlin code references are updated, and **Undo Last Refactoring** restores the original location and 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. - Improved Kotlin hierarchy-aware refactorings: Pull Members Up, Push Members Down, and compatible IDEA refactoring paths now discover Kotlin override chains and direct supertypes across all source files in the active Maven or Gradle build; Java/library and full IDEA-index conflict scenarios remain unavailable. diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 994d4d28..def966ab 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 64df6cce..e6ce9bbf 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index add86a22..99a782c5 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index c452728f..718cc9bd 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index b6675bcd..f8d6137f 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index eb2cc2e4..d611fb63 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index f2778571..99876de5 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 5d36799d..b313aaa6 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt index 7ab37f56..0202ac56 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt @@ -20,6 +20,8 @@ import com.intellij.psi.PsiDirectory import com.intellij.psi.PsiElement import com.intellij.usageView.UsageInfo import com.intellij.util.containers.MultiMap +import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService +import org.jetbrains.kotlin.idea.k2.refactoring.move.MoveImport import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2MoveFilesHandler import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.findMoveFileUsages import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.prepareMovedFileForPackage @@ -96,9 +98,26 @@ class KaMoveFileComputer( targetDirectory: PsiDirectory, targetPackage: FqName, updateReferences: Boolean, - ): ApplyOutcome { + ): ApplyOutcome = apply( + files.associateWith { MoveTarget(targetDirectory, targetPackage) }, + updateReferences, + ) + + /** + * Applies K2 Move File semantics for sources whose final directories and packages differ. + * + * The directory Move command supplies one [MoveTarget] per source. K2 usage discovery occurs + * before any source is mutated and retargeting receives one aggregate old-to-new map, which is + * essential when two moved descendants refer to one another across relative directories. + * + * @param targets physical source PSI mapped to its final directory and declared package + * @param updateReferences whether supported external Kotlin references should be retargeted + * @return conflicts without mutation, changed texts with original snapshots, or an error + */ + fun apply(targets: Map, updateReferences: Boolean): ApplyOutcome { return try { - require(files.isNotEmpty()) { "Move File requires at least one Kotlin file." } + require(targets.isNotEmpty()) { "Move File requires at least one Kotlin file." } + val files = targets.keys.toList() val handler = K2MoveFilesHandler() // ProjectFileIndex and JavaDirectoryService cannot reconstruct the source-root-relative // directory of this session's LightVirtualFiles. Calling K2MoveFilesHandler.findUsages() @@ -115,34 +134,43 @@ class KaMoveFileComputer( file.findMoveFileUsages( searchInCommentsAndStrings = false, searchForText = false, - targetPackage = targetPackage, + targetPackage = targets.getValue(file).targetPackage, ) - } + }.distinct() } else { emptyList() } val conflicts = MultiMap() - handler.detectConflicts(conflicts, files.toTypedArray(), usages.toTypedArray(), targetDirectory) + files.groupBy { targets.getValue(it).targetDirectory }.forEach { (directory, groupedFiles) -> + handler.detectConflicts(conflicts, groupedFiles.toTypedArray(), usages.toTypedArray(), directory) + } if (!conflicts.isEmpty) { ApplyOutcome.Conflicts(conflicts.values().toList()) } else { - val usageFiles = filesWithUsages(usages) - val originalTexts = (files + usageFiles).distinct().mapNotNull { file -> - file.virtualFile?.path?.let { path -> path to file.text } - }.toMap() val movedImportTargets = files.flatMap { file -> file.declarations.mapNotNull { declaration -> (declaration as? KtNamedDeclaration)?.name?.let { name -> - MovedImport(file.packageFqName, name) + MoveImport(file.packageFqName, name) to targets.getValue(file).targetPackage } } - } + }.toMap() + // K2 retargeting intentionally excludes import directives because mutating an import + // path through bindToElement() destabilizes standalone PSI. Discover direct imports + // separately, then rewrite their full directives below with KtPsiFactory. + val importOnlyUsageFiles = KotlinMoveUsageSearchService.getInstance() + ?.findImportingFiles(files.first().project, movedImportTargets) + .orEmpty() + val usageFiles = (filesWithUsages(usages) + importOnlyUsageFiles).distinct() + val originalTexts = (files + usageFiles).distinct().mapNotNull { file -> + file.virtualFile?.path?.let { path -> path to file.text } + }.toMap() val oldToNew = linkedMapOf() val changedFiles = linkedMapOf() files.forEach { file -> + val target = targets.getValue(file) handler.prepareMovedFileForPackage( file = file, - targetPackage = targetPackage, + targetPackage = target.targetPackage, rewritePackage = file in eligibleFiles, oldToNewMap = oldToNew, ) @@ -150,7 +178,7 @@ class KaMoveFileComputer( } if (updateReferences) handler.retargetUsages(usages, oldToNew) usageFiles.filterNot { it in files }.forEach { file -> - rewriteMovedImports(file, movedImportTargets, targetPackage) + rewriteMovedImports(file, movedImportTargets) file.virtualFile?.path?.let { path -> changedFiles[path] = file.text } } ApplyOutcome.Success(changedFiles, originalTexts) @@ -160,6 +188,9 @@ class KaMoveFileComputer( } } + /** A source's physical K2 target directory and final declared package. */ + data class MoveTarget(val targetDirectory: PsiDirectory, val targetPackage: FqName) + /** Represents the result of applying K2-only Move File semantics. */ sealed class ApplyOutcome { /** K2 found blocking semantic conflicts and left PSI unchanged. */ @@ -198,21 +229,19 @@ class KaMoveFileComputer( * The old-to-new map for a File Move preserves declaration identity, so update the direct import * path explicitly after K2 retargeting; code usages remain handled by the upstream K2 engine. */ - private fun rewriteMovedImports(usageFile: KtFile, movedImports: Collection, targetPackage: FqName) { + private fun rewriteMovedImports(usageFile: KtFile, movedImports: Map) { if (movedImports.isEmpty()) return val factory = KtPsiFactory(usageFile.project) usageFile.importDirectives.forEach { directive -> if (directive.aliasName != null || directive.isAllUnder) return@forEach val importedFqName = directive.importedFqName ?: return@forEach val importedName = directive.importedName ?: return@forEach - if (MovedImport(importedFqName.parent(), importedName.asString()) in movedImports) { - directive.replace(factory.createImportDirective(ImportPath(targetPackage.child(importedName), false))) - } + val targetPackage = movedImports[MoveImport(importedFqName.parent(), importedName.asString())] + ?: return@forEach + directive.replace(factory.createImportDirective(ImportPath(targetPackage.child(importedName), false))) } } - /** Identifies a directly imported top-level declaration before its package PSI is rewritten. */ - private data class MovedImport(val packageName: FqName, val declarationName: String) /** * Determines whether [packageName]'s segments are the terminal directory segments of [path]. diff --git a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt index ec331e25..c8ad5ac8 100644 --- a/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt +++ b/KotlinRefactoring/src/main/kotlin/org/jetbrains/kotlin/idea/k2/refactoring/move/KotlinMoveUsageSearchService.kt @@ -18,8 +18,13 @@ package org.jetbrains.kotlin.idea.k2.refactoring.move import com.intellij.openapi.application.ApplicationManager import com.intellij.psi.PsiReference +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNamedDeclaration +/** Identifies a top-level declaration's original direct-import path. */ +data class MoveImport(val packageName: FqName, val declarationName: String) + /** * Application service: finds every reference to [declaration] across the whole NetBeans project. * @@ -34,6 +39,15 @@ import org.jetbrains.kotlin.psi.KtNamedDeclaration interface KotlinMoveUsageSearchService { fun findUsages(declaration: KtNamedDeclaration): List + /** + * Finds session files that directly import one of [movedImports]' old declaration paths. + * + * @param project IntelliJ project whose standalone K2 session supplies candidate files + * @param movedImports old package/name pairs mapped to their final package + * @return Kotlin files whose import directives require explicit non-semantic replacement + */ + fun findImportingFiles(project: com.intellij.openapi.project.Project, movedImports: Map): List = emptyList() + companion object { fun getInstance(): KotlinMoveUsageSearchService? = ApplicationManager.getApplication()?.getService(KotlinMoveUsageSearchService::class.java) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 6ff9f3d9..06a4d570 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java index ad096715..b6e7efaa 100644 --- a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java @@ -52,6 +52,7 @@ public class KotlinMoveFileUI implements RefactoringUI { private final KaMoveFileResult initialResult; private final KotlinMoveFileRefactoring refactoring; private final KotlinPackageTarget target; + private final String initialPackage; private MoveFilePanel panel; /** @@ -64,20 +65,40 @@ public class KotlinMoveFileUI implements RefactoringUI { public KotlinMoveFileUI(KaMoveFileResult initialResult, KotlinMoveFileRefactoring refactoring, KotlinPackageTarget target) { + this(initialResult, refactoring, target, initialResult.getPackageName()); + } + + /** + * Creates a Move File or Move Directory UI adapter with an explicit initial package. + * + * @param initialResult discovered representative Kotlin-file data + * @param refactoring mutable carrier that receives selected parameters + * @param target available NetBeans source roots and package validator + * @param initialPackage package initially shown in the target field + */ + public KotlinMoveFileUI(KaMoveFileResult initialResult, + KotlinMoveFileRefactoring refactoring, + KotlinPackageTarget target, + String initialPackage) { this.initialResult = initialResult; this.refactoring = refactoring; this.target = target; + this.initialPackage = initialPackage; } /** @return the user-visible refactoring name. */ @Override public String getName() { - return "Move Kotlin File"; + return refactoring.getSelection().isDirectory() ? "Move Kotlin Directory" : "Move Kotlin File"; } - /** @return concise user-visible description of the file being moved. */ + /** @return concise user-visible description of the selected file or directory. */ @Override public String getDescription() { + if (refactoring.getSelection().isDirectory()) { + return "Move " + refactoring.getSelection().getSourceFiles().size() + " Kotlin files from directory '" + + refactoring.getSelection().getDisplayName() + "' to another package"; + } return "Move Kotlin file '" + initialResult.getFileName() + "' to another package"; } @@ -97,9 +118,13 @@ public boolean isQuery() { public CustomRefactoringPanel getPanel(ChangeListener parent) { if (panel == null) { panel = new MoveFilePanel( - initialResult.getFileName(), - initialResult.getPackageName(), + refactoring.getSelection().isDirectory() + ? refactoring.getSelection().getDisplayName() + " (" + + refactoring.getSelection().getSourceFiles().size() + " Kotlin files)" + : initialResult.getFileName(), + initialPackage, initialResult.getPackageMayBeUpdated(), + refactoring.getSelection().isDirectory(), target.getRoots(), target.getDefaultRootPath(), parent @@ -169,6 +194,8 @@ private static final class MoveFilePanel implements CustomRefactoringPanel { * @param fileName displayed source file name * @param sourcePackage initially selected package * @param mayUpdatePackage whether automatic package rewriting is available + * @param directorySelection whether the selected directory name is retained below the target + * package * @param roots selectable destination source roots * @param defaultRoot source root containing the current file, if available * @param changeListener listener notified when values change @@ -176,6 +203,7 @@ private static final class MoveFilePanel implements CustomRefactoringPanel { MoveFilePanel(String fileName, String sourcePackage, boolean mayUpdatePackage, + boolean directorySelection, List roots, String defaultRoot, ChangeListener changeListener) { @@ -203,7 +231,7 @@ private static final class MoveFilePanel implements CustomRefactoringPanel { field.gridwidth = GridBagConstraints.REMAINDER; field.insets = new Insets(2, 0, 2, 0); - form.add(new JLabel("Kotlin file:"), label); + form.add(new JLabel(directorySelection ? "Kotlin directory:" : "Kotlin file:"), label); form.add(new JLabel(fileName), field); JLabel rootLabel = new JLabel("Target source root:"); rootLabel.setLabelFor(rootField); @@ -215,6 +243,10 @@ private static final class MoveFilePanel implements CustomRefactoringPanel { form.add(packageField, field); form.add(new JLabel(""), label); form.add(updateReferences, field); + if (directorySelection) { + form.add(new JLabel(""), label); + form.add(new JLabel("The selected directory name and nested folders are retained below the target package."), field); + } if (!mayUpdatePackage) { form.add(new JLabel(""), label); form.add(new JLabel("Package differs from its current directory and will be preserved."), field); 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 f438053e..dcdef66a 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 @@ -100,6 +100,12 @@ public class KotlinRefactoringsFactory implements RefactoringPluginFactory { @Override public RefactoringPlugin createInstance(AbstractRefactoring refactoring) { + // Move Kotlin Directory is invoked from a folder node, so its refactoring source deliberately + // contains FileObjects rather than an editor StyledDocument. Recognize its typed carrier + // before the legacy editor-document applicability guard below. + if (refactoring instanceof KotlinMoveFileRefactoring) { + return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring); + } FileObject fo = ProjectUtils.getFileObjectForDocument( refactoring.getRefactoringSource().lookup(StyledDocument.class)); if (fo == null || !fo.hasExt("kt")) { @@ -156,9 +162,6 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) { if (refactoring instanceof KotlinMoveDeclarationRefactoring) { return new KotlinMoveDeclarationPlugin((KotlinMoveDeclarationRefactoring) refactoring); } - if (refactoring instanceof KotlinMoveFileRefactoring) { - return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring); - } if (refactoring instanceof KotlinChangeSignatureRefactoring) { return new KotlinChangeSignaturePlugin((KotlinChangeSignatureRefactoring) refactoring); } 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 new file mode 100644 index 00000000..1a2160fc --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryAction.kt @@ -0,0 +1,110 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaMoveFileComputer +import java.awt.EventQueue +import org.jetbrains.kotlin.log.KotlinLogger +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 +import org.openide.windows.TopComponent + +/** + * Folder-node action that moves all Kotlin descendants while retaining the selected folder below + * the target package. + * + * The action deliberately accepts one folder only. It leaves non-Kotlin descendants in place and + * delegates the Kotlin-only multi-file operation to [KotlinMoveFilePlugin]. + */ +@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinMoveDirectoryAction") +@ActionRegistration(displayName = "Move Kotlin Directory...") +open class KotlinMoveDirectoryAction : NodeAction() { + + init { + putValue(NAME, "Move Kotlin Directory...") + putValue(SHORT_DESCRIPTION, "Move Kotlin Files in Directory to Another Package") + } + + /** + * Opens the shared destination UI for the selected Kotlin-containing directory. + * + * Source discovery and K2 session preparation may take time, so NetBeans can run this action in + * a worker. [openRefactoringUi] moves the Swing-only UI step to the Event Dispatch Thread. + */ + 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) + } + } + + /** + * Opens a refactoring panel on the Event Dispatch Thread regardless of NodeAction's dispatcher. + * + * @param ui prepared destination UI; its construction is thread-neutral but its display is not + */ + internal fun openRefactoringUi(ui: KotlinMoveFileUI) { + KotlinLogger.INSTANCE.logInfo("KotlinMoveDirectoryAction: scheduling refactoring UI on EDT") + EventQueue.invokeLater { + runCatching { + KotlinLogger.INSTANCE.logInfo("KotlinMoveDirectoryAction: opening refactoring UI on EDT") + UI.openRefactoringUI(ui, TopComponent.getRegistry().activated) + }.onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinMoveDirectoryAction UI opening failed", error) + } + } + } + + /** Enables the action exclusively for one physical folder containing a Kotlin descendant. */ + override fun enable(nodes: Array): Boolean = selectedFolder(nodes) + ?.let(KotlinMoveSourceSelection::from) + ?.isDirectory == true + + /** @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 } + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt index 6f0eace2..059617aa 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt @@ -49,10 +49,11 @@ class KotlinMoveFileAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RES val project = ProjectUtils.getKotlinProjectForFileObject(source) ?: ProjectUtils.getValidProject() ?: return@runCatching + val selection = KotlinMoveSourceSelection.from(source) ?: return@runCatching val result = resolveOutcome(project, source.path) ?: return@runCatching - val refactoring = KotlinMoveFileRefactoring(document) + val refactoring = KotlinMoveFileRefactoring(selection) UI.openRefactoringUI( - KotlinMoveFileUI(result, refactoring, KotlinPackageTarget(project, source)), + KotlinMoveFileUI(result, refactoring, KotlinPackageTarget(project, selection.representativeFile)), TopComponent.getRegistry().activated, ) }.onFailure { error -> diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt index 1ed68946..a53db099 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt @@ -38,19 +38,20 @@ import org.openide.util.lookup.Lookups import javax.swing.text.Position.Bias /** - * Bridges NetBeans Move Kotlin File UI to the standalone K2 Move File semantic adapter. + * Bridges NetBeans Move Kotlin File and Move Kotlin Directory UI to the standalone K2 semantic + * adapter. * * NetBeans owns destination creation, physical movement, document persistence, rollback, and undo. * The ported K2 handler supplies eligible package rewriting, Kotlin conflict detection, and * retargeting of supported external Kotlin references. * - * @param refactoring carrier containing the destination selected in the Move File UI + * @param refactoring carrier containing selected Kotlin sources and the destination selected in UI */ class KotlinMoveFilePlugin( private val refactoring: KotlinMoveFileRefactoring, ) : ProgressProviderAdapter(), RefactoringPlugin { - /** @return no preliminary problem; validation happens while preparing the physical destination. */ + /** @return no preliminary problem; source validation happens while preparing the operation. */ override fun preCheck(): Problem? = null /** @return no fast problem; the UI validates the package before this point. */ @@ -63,39 +64,52 @@ class KotlinMoveFilePlugin( override fun cancelRequest() = Unit /** - * Adds one source preview element and one atomic apply element for the current Kotlin file. + * Adds a preview element for every selected Kotlin source and one atomic apply element. * * @param bag NetBeans bag populated with preview and mutation elements - * @return a fatal problem when the selected source cannot be analyzed, otherwise `null` + * @return a fatal problem when a source cannot be analyzed, otherwise `null` */ override fun prepare(bag: RefactoringElementsBag): Problem? { - val source = ProjectUtils.getFileObjectForDocument(refactoring.document) ?: return null - val project = ProjectUtils.getKotlinProjectForFileObject(source) ?: return null - val file = KotlinAnalysisAPISession.getSession(project).getKtFileForPath(source.path) ?: return null - return when (val outcome = KaMoveFileComputer(file).compute()) { - is KaMoveFileComputer.Outcome.NotApplicable -> Problem(true, "The selected Kotlin file has no physical path.") - is KaMoveFileComputer.Outcome.Error -> Problem(true, outcome.error.message ?: "Move Kotlin File analysis failed.") - is KaMoveFileComputer.Outcome.Ready -> { - bag.add( - refactoring, - KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source), - ) - bag.add(refactoring, KotlinMoveFileApplyElement(source, project, refactoring)) - null + KotlinLogger.INSTANCE.logInfo("KotlinMoveFilePlugin.prepare: plugin created") + val selection = refactoring.selection + val sourceProject = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) + ?: ProjectUtils.getValidProject() + ?: return Problem(true, "Move Kotlin File could not find the source project.") + KotlinLogger.INSTANCE.logInfo( + "KotlinMoveFilePlugin.prepare: ${selection.sourceFiles.size} Kotlin source(s), " + + "project=${sourceProject.projectDirectory.path}", + ) + val session = KotlinAnalysisAPISession.getSession(sourceProject) + val sources = selection.sourceFiles.map { source -> + session.getKtFileForPath(source.path) + ?: return Problem(true, "Move Kotlin File could not resolve ${source.nameExt}.") + } + val problem = sources.firstNotNullOfOrNull { file -> + when (val outcome = KaMoveFileComputer(file).compute()) { + is KaMoveFileComputer.Outcome.NotApplicable -> Problem(true, "The selected Kotlin file has no physical path.") + is KaMoveFileComputer.Outcome.Error -> Problem(true, outcome.error.message ?: "Move Kotlin File analysis failed.") + is KaMoveFileComputer.Outcome.Ready -> null } } + if (problem != null) return problem + sources.zip(selection.sourceFiles).forEach { (file, source) -> + bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source)) + } + bag.add(refactoring, KotlinMoveFileApplyElement(selection, sourceProject, refactoring)) + return null } } /** - * Performs one atomic Kotlin file move and retains the transaction for Undo Last Refactoring. + * Performs one atomic Kotlin file or directory move and retains the transaction for Undo Last + * Refactoring. * - * @param sourceFile source file selected by the editor action - * @param project NetBeans project owning the source file + * @param selection Kotlin sources selected by the editor or directory-node action + * @param project NetBeans project owning the selected sources * @param refactoring destination and search parameters */ class KotlinMoveFileApplyElement( - private val sourceFile: FileObject, + private val selection: KotlinMoveSourceSelection, private val project: org.netbeans.api.project.Project, private val refactoring: KotlinMoveFileRefactoring, ) : SimpleRefactoringElementImplementation() { @@ -104,20 +118,20 @@ class KotlinMoveFileApplyElement( private var transaction: KotlinRefactoringTransaction? = null /** @return user-visible preview text. */ - override fun getText(): String = "Move Kotlin file" + override fun getText(): String = if (selection.isDirectory) "Move Kotlin directory" else "Move Kotlin file" /** @return user-visible preview text. */ override fun getDisplayText(): String = getText() - /** @return source file lookup. */ - override fun getLookup(): Lookup = Lookups.fixed(sourceFile) + /** @return selected representative file lookup. */ + override fun getLookup(): Lookup = Lookups.fixed(selection.representativeFile) - /** @return original source file for NetBeans preview placement. */ - override fun getParentFile(): FileObject = sourceFile + /** @return representative source file for NetBeans preview placement. */ + override fun getParentFile(): FileObject = selection.representativeFile /** @return source position bounds, when the editor support is available. */ override fun getPosition(): PositionBounds? = try { - val support = org.openide.loaders.DataObject.find(sourceFile) + val support = org.openide.loaders.DataObject.find(selection.representativeFile) .lookup.lookup(CloneableEditorSupport::class.java) ?: return null PositionBounds( support.createPositionRef(0, Bias.Forward), @@ -128,18 +142,14 @@ class KotlinMoveFileApplyElement( } /** - * Runs K2 semantics first, then atomically moves the physical file and persists every changed - * Kotlin document. Any failure rolls paths, documents, owned destination folders, and created - * files back to their original state. - * - * @return no value; failures are logged and the transaction is rolled back + * Runs K2 semantics first, then atomically moves every physical Kotlin file and persists every + * changed Kotlin document. Any failure rolls paths, documents, owned destination folders, and + * created files back to their original state. */ override fun performChange() { var pending: KotlinRefactoringTransaction? = null runCatching { - val activeSource = ProjectUtils.getFileObjectForDocument(refactoring.document) ?: sourceFile - val sourceProject = ProjectUtils.getKotlinProjectForFileObject(activeSource) ?: project - val packageTarget = KotlinPackageTarget(sourceProject, activeSource) + val packageTarget = KotlinPackageTarget(project, selection.representativeFile) val targetRoot = packageTarget.roots.firstOrNull { it.path == refactoring.targetRootPath } ?: packageTarget.roots.firstOrNull { it.path == packageTarget.defaultRootPath } ?: error("Move Kotlin File could not find a destination source root.") @@ -148,45 +158,33 @@ class KotlinMoveFileApplyElement( val current = KotlinRefactoringTransaction() pending = current - val targetFolder = createTargetFolder(current, targetRoot.folder, targetPackage) - check(targetFolder != activeSource.parent) { "Kotlin file is already in the selected destination folder." } - check(targetFolder.getFileObject(activeSource.name, activeSource.ext) == null) { - "Target already contains ${activeSource.nameExt}." + val sourceByPath = selection.sourceFiles.associateBy(FileObject::getPath) + val targetFolders = selection.sourceFiles.associateWith { source -> + val packageSegments = targetPackage.split('.').filter(String::isNotEmpty) + + selection.targetRelativeFolderSegments(source) + createTargetFolder(current, targetRoot.folder, packageSegments) } - - val session = KotlinAnalysisAPISession.getSession(sourceProject) - val sourceKtFile = session.getKtFileForPath(activeSource.path) - ?: error("Move Kotlin File could not resolve writable source PSI.") - // The session's disk-backed PSI is required only for target metadata/conflicts. Its - // VFS cannot create or move files; NetBeans performs that separately in the transaction. - val targetDirectory = KaMoveDeclarationComputer.resolveDirectory(sourceKtFile.project, targetFolder.path) - ?: error("Move Kotlin File could not resolve destination PSI directory.") - val outcome = KaMoveFileComputer(sourceKtFile).apply( - files = listOf(sourceKtFile), - targetDirectory = targetDirectory, - targetPackage = FqName(targetPackage), - updateReferences = refactoring.updateReferences, - ) - when (outcome) { - is KaMoveFileComputer.ApplyOutcome.Conflicts -> { - throw IllegalStateException(outcome.messages.joinToString("\n")) - } + validatePhysicalTargets(selection.sourceFiles, targetFolders) + + val session = KotlinAnalysisAPISession.getSession(project) + val ktTargets = selection.sourceFiles.associate { source -> + val sourceKtFile = session.getKtFileForPath(source.path) + ?: error("Move Kotlin File could not resolve writable source PSI: ${source.nameExt}.") + val targetFolder = targetFolders.getValue(source) + val targetDirectory = KaMoveDeclarationComputer.resolveDirectory(sourceKtFile.project, targetFolder.path) + ?: error("Move Kotlin File could not resolve destination PSI directory: ${targetFolder.path}.") + val finalPackage = targetPackageFor(source, targetPackage) + sourceKtFile to KaMoveFileComputer.MoveTarget(targetDirectory, FqName(finalPackage)) + } + when (val outcome = KaMoveFileComputer(ktTargets.keys.first()).apply(ktTargets, refactoring.updateReferences)) { + is KaMoveFileComputer.ApplyOutcome.Conflicts -> throw IllegalStateException(outcome.messages.joinToString("\n")) is KaMoveFileComputer.ApplyOutcome.Error -> throw outcome.error is KaMoveFileComputer.ApplyOutcome.Success -> { - current.moveFile(activeSource, targetFolder) + selection.sourceFiles.forEach { source -> current.moveFile(source, targetFolders.getValue(source)) } outcome.changedFiles.forEach { (path, text) -> - val file = if (path == activeSource.path) { - activeSource - } else { - FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path))) - ?: error("Move Kotlin File could not resolve changed file $path.") - } - val originalText = outcome.originalTexts[path] - KotlinLogger.INSTANCE.logInfo( - "KotlinMoveFileApplyElement.performChange: staging path=$path, " + - "original=${describeText(originalText)}, final=${describeText(text)}" - ) - current.captureExisting(file, originalText) + val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path))) + ?: error("Move Kotlin File could not resolve changed file $path.") + current.captureExisting(file, outcome.originalTexts[path]) current.stageText(file, text) } current.commit() @@ -203,7 +201,7 @@ class KotlinMoveFileApplyElement( KotlinAnalysisAPISession.invalidate(project) } - /** Restores the original source path and text through the transaction retained after commit. */ + /** Restores original source paths and texts through the transaction retained after commit. */ override fun undoChange() { runCatching { val current = transaction @@ -221,16 +219,32 @@ class KotlinMoveFileApplyElement( } } - /** Produces bounded, one-line diagnostic text without logging full source contents. */ - private fun describeText(text: String?): String = - if (text == null) "missing" else "length=${text.length}, head=${text.take(160).replace("\n", "\\n")}" + /** Rejects same-directory, duplicate-path, and pre-existing-target moves before K2 mutates PSI. */ + private fun validatePhysicalTargets( + sources: List, + targets: Map, + ) { + val targetPaths = mutableSetOf() + sources.forEach { source -> + val target = targets.getValue(source) + check(target != source.parent) { "${source.nameExt} is already in the selected destination folder." } + val path = "${target.path}/${source.nameExt}" + check(targetPaths.add(path)) { "Several selected Kotlin files would move to $path." } + val existing = target.getFileObject(source.name, source.ext) + check(existing == null || existing == source) { "Target already contains ${source.nameExt}." } + } + } + + /** Computes one source's final package, retaining a selected directory's nested hierarchy. */ + private fun targetPackageFor(source: FileObject, basePackage: String): String { + val segments = basePackage.split('.').filter(String::isNotEmpty) + selection.targetRelativeFolderSegments(source) + return segments.joinToString(".") + } /** Creates only destination package folders that this transaction can safely delete on undo. */ private fun createTargetFolder( transaction: KotlinRefactoringTransaction, root: FileObject, - packageName: String, - ): FileObject = packageName.split('.').filter(String::isNotEmpty).fold(root) { parent, segment -> - transaction.createFolder(parent, segment) - } + segments: List, + ): FileObject = segments.fold(root) { parent, segment -> transaction.createFolder(parent, segment) } } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt index 912085b6..92e27741 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt @@ -18,19 +18,18 @@ package io.github.nbplugins.kotlin.nbm.refactoring import org.netbeans.modules.refactoring.api.AbstractRefactoring import org.openide.util.lookup.Lookups -import javax.swing.text.StyledDocument /** - * Carries parameters for Kotlin Move File and Directory refactoring. + * Carries selected Kotlin sources and destination parameters for Move File and Move Directory. * - * The editor action seeds this carrier with the selected Kotlin source file. A directory invocation - * expands its Kotlin descendants into [sourceFiles] before the refactoring framework runs. + * The editor action creates a single-file [selection]. The folder-node action creates a recursive + * Kotlin-only selection; both variants run through one refactoring lifecycle and transaction. * - * @param document document associated with the action invocation + * @param selection physical Kotlin sources selected for movement */ class KotlinMoveFileRefactoring( - val document: StyledDocument, -) : AbstractRefactoring(Lookups.fixed(document)) { + val selection: KotlinMoveSourceSelection, +) : AbstractRefactoring(Lookups.fixed(*selection.sourceFiles.toTypedArray())) { /** Source-root path selected in the destination UI. */ var targetRootPath: String = "" diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelection.kt new file mode 100644 index 00000000..29cb2bf0 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelection.kt @@ -0,0 +1,104 @@ +/******************************************************************************* + * 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 + +/** + * Immutable Kotlin files selected for a Move Kotlin File or Move Kotlin Directory operation. + * + * A directory selection retains its leaf directory below the package chosen in the destination UI. + * For example, moving `feature/internal/Helper.kt` with selected directory `feature` to package + * `sample.target` places the file below `sample/target/feature/internal`. + * + * @param sourceFiles physical Kotlin source files in deterministic relative-path order + * @param selectedDirectory directory selected by the user, or `null` for a file selection + */ +class KotlinMoveSourceSelection private constructor( + val sourceFiles: List, + val selectedDirectory: FileObject?, +) { + /** @return whether this selection was created from a directory node. */ + val isDirectory: Boolean + get() = selectedDirectory != null + + /** @return source used for project/source-root discovery and single-file UI details. */ + val representativeFile: FileObject + get() = sourceFiles.first() + + /** @return concise UI label for the selected source. */ + val displayName: String + get() = selectedDirectory?.nameExt ?: representativeFile.nameExt + + /** + * Computes the final relative folder segments beneath the user-selected target package. + * + * @param file a member of [sourceFiles] + * @return empty for a directly selected file, otherwise selected-directory name plus the file's + * relative parent path + */ + fun targetRelativeFolderSegments(file: FileObject): List { + require(file in sourceFiles) { "File is not part of this Move Kotlin selection: ${file.path}" } + val directory = selectedDirectory ?: return emptyList() + val relativeParent = relativeSegments(directory, file.parent) + return listOf(directory.name) + relativeParent + } + + /** + * Creates a selection from one Kotlin file or recursively from one directory. + * + * @param source selected physical Kotlin file or folder + * @return a non-empty selection, or `null` if [source] contains no valid Kotlin files + */ + companion object { + /** + * Creates a Kotlin move selection from one physical file or recursively from one folder. + * + * @param source selected physical Kotlin file or source-tree folder + * @return a non-empty selection, or `null` when [source] has no valid Kotlin descendants + */ + fun from(source: FileObject): KotlinMoveSourceSelection? { + if (!source.isValid) return null + if (!source.isFolder) { + return if (source.ext == "kt") KotlinMoveSourceSelection(listOf(source), null) else null + } + val files = collectKotlinFiles(source).sortedBy { it.path } + return files.takeIf(List::isNotEmpty)?.let { KotlinMoveSourceSelection(it, source) } + } + + /** Recursively finds valid Kotlin source files while deliberately ignoring other content. */ + private fun collectKotlinFiles(folder: FileObject): List = folder.children.flatMap { child -> + when { + child.isFolder -> collectKotlinFiles(child) + child.isValid && child.ext == "kt" -> listOf(child) + else -> emptyList() + } + } + + /** Returns [descendant]'s relative folder path below [ancestor]. */ + private fun relativeSegments(ancestor: FileObject, descendant: FileObject?): List { + val segments = mutableListOf() + var current = descendant + while (current != null && current != ancestor) { + segments += current.name + current = current.parent + } + require(current == ancestor) { "${descendant?.path} is not below ${ancestor.path}" } + return segments.asReversed() + } + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt index 8c46d192..e73f2cf4 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt @@ -21,7 +21,10 @@ import com.intellij.psi.util.PsiTreeUtil import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService +import org.jetbrains.kotlin.idea.k2.refactoring.move.MoveImport import org.jetbrains.kotlin.idea.references.KtReference +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.log.KotlinLogger import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration @@ -78,4 +81,39 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { } return references } + + /** + * Finds Kotlin files that directly import a moved top-level declaration. + * + * Import directives are intentionally absent from [findUsages] because K2 semantic rebinding + * cannot safely replace an import-path segment in standalone PSI. The caller replaces whole + * directives after semantic retargeting instead. + * + * @param project IntelliJ project owning the standalone K2 session + * @param movedImports original package/name keys mapped to final packages + * @return session files with a direct non-aliased import that needs rewriting + */ + override fun findImportingFiles( + project: com.intellij.openapi.project.Project, + movedImports: Map, + ): List { + if (movedImports.isEmpty()) return emptyList() + val session = KotlinAnalysisAPISession.forProject(project) ?: return emptyList() + val candidates = session.fileMap.values.mapNotNull { lightFile -> + val file = session.getKtFileForPath(lightFile.path) ?: return@mapNotNull null + file.takeIf { candidate -> + candidate.importDirectives.any { directive -> + directive.aliasName == null && !directive.isAllUnder && directive.importedFqName?.let { imported -> + MoveImport(imported.parent(), imported.shortName().asString()) in movedImports + } == true + } + } + } + if (candidates.isNotEmpty()) { + KotlinLogger.INSTANCE.logInfo( + "KotlinMoveUsageSearchServiceImpl: found ${candidates.size} import-only Move File usage(s)", + ) + } + return candidates + } } diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt index 41692470..361b0973 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageTarget.kt @@ -89,6 +89,17 @@ class KotlinPackageTarget(private val project: Project, private val sourceFile: fun isValidPackage(packageName: String): Boolean = packageName.isBlank() || packageName.split('.').all { segment -> IDENTIFIER.matches(segment) } + /** + * Returns [folder]'s dotted path relative to its containing source root. + * + * @param folder source-root descendant for which to calculate an initial target package + * @return the source-root-relative package, or an empty string when no root contains [folder] + */ + fun packageForFolder(folder: FileObject): String = roots.firstNotNullOfOrNull { root -> + val relative = relativePath(root.folder, folder) ?: return@firstNotNullOfOrNull null + relative.split('/').filter(String::isNotEmpty).joinToString(".") + }.orEmpty() + /** @return whether [file] is located at or beneath this target's source root. */ private fun contains(root: FileObject): Boolean = sourceFile.toURI().let { sourceUri -> root.toURI().let { rootUri -> sourceUri.toString().startsWith(rootUri.toString()) } @@ -112,6 +123,17 @@ class KotlinPackageTarget(private val project: Project, private val sourceFile: .filter(FileObject::isFolder) .map(::KotlinSourceGroup) + /** Returns the source-root-relative path to [folder], or `null` when it is outside [root]. */ + private fun relativePath(root: FileObject, folder: FileObject): String? { + val rootPath = root.path.trimEnd('/') + val folderPath = folder.path + return when { + folderPath == rootPath -> "" + folderPath.startsWith("$rootPath/") -> folderPath.removePrefix("$rootPath/") + else -> null + } + } + /** Returns the selected root object, retaining selection by path rather than UI object identity. */ private fun rootFolder(path: String?): FileObject? = path?.let { selected -> roots.firstOrNull { it.path == selected }?.folder diff --git a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml index 376c0109..1a0718ab 100644 --- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml +++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml @@ -115,6 +115,11 @@ + + + + @@ -141,6 +146,10 @@ + + + + diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt index 907e68e6..5a7915d3 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt @@ -229,6 +229,71 @@ class KaMoveFileComputerTest : KotlinTestCase("KaMoveFileComputerTest", "moveFil } } + /** + * Verifies one K2 pass supports distinct target packages for directory descendants and retargets + * imports of declarations from both source files. + */ + fun testApply_realSession_updatesNestedDirectoryTargetsTogether() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Move Directory integration test") + return + } + val temp = Files.createTempDirectory("nbkotlin-move-directory") + try { + val feature = temp.resolve("source/feature") + val internal = feature.resolve("internal") + val usageDirectory = temp.resolve("usage") + val targetFeature = temp.resolve("target/sample/destination/feature") + val targetInternal = targetFeature.resolve("internal") + Files.createDirectories(internal) + Files.createDirectories(usageDirectory) + Files.createDirectories(targetInternal) + val publicPath = feature.resolve("Public.kt") + val helperPath = internal.resolve("Helper.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(publicPath, "package source.feature\n\nfun publicApi() = helper()\n") + Files.writeString(helperPath, "package source.feature.internal\n\nfun helper() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nimport source.feature.publicApi\nimport source.feature.internal.helper\n\nfun use() = publicApi() + helper()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "move-directory-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temp), + ) + val publicFile = session.getKtFileForPath(publicPath.toString()) ?: error("Could not obtain Public.kt PSI") + val helperFile = session.getKtFileForPath(helperPath.toString()) ?: error("Could not obtain Helper.kt PSI") + val targetFeaturePsi = KaMoveDeclarationComputer.resolveDirectory(publicFile.project, targetFeature.toString()) + ?: error("Could not resolve feature target PSI") + val targetInternalPsi = KaMoveDeclarationComputer.resolveDirectory(publicFile.project, targetInternal.toString()) + ?: error("Could not resolve internal target PSI") + + val outcome = KaMoveFileComputer(publicFile).apply( + mapOf( + publicFile to KaMoveFileComputer.MoveTarget( + targetFeaturePsi, + org.jetbrains.kotlin.name.FqName("sample.destination.feature"), + ), + helperFile to KaMoveFileComputer.MoveTarget( + targetInternalPsi, + org.jetbrains.kotlin.name.FqName("sample.destination.feature.internal"), + ), + ), + updateReferences = true, + ) + + assertTrue("Expected K2 Move Directory success, got $outcome", outcome is KaMoveFileComputer.ApplyOutcome.Success) + val success = outcome as KaMoveFileComputer.ApplyOutcome.Success + assertTrue(success.changedFiles[publicPath.toString()]?.contains("package sample.destination.feature") == true) + assertTrue(success.changedFiles[helperPath.toString()]?.contains("package sample.destination.feature.internal") == true) + assertTrue(success.changedFiles[usagePath.toString()]?.contains("import sample.destination.feature.publicApi") == true) + assertTrue(success.changedFiles[usagePath.toString()]?.contains("import sample.destination.feature.internal.helper") == true) + } finally { + temp.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) 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 new file mode 100644 index 00000000..49335357 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveDirectoryActionTest.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 [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", + TestAction().isAsynchronous(), + ) + } + + /** Exposes NodeAction's protected dispatcher contract for this focused test. */ + private class TestAction : KotlinMoveDirectoryAction() { + fun isAsynchronous(): Boolean = asynchronous() + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoringTest.kt new file mode 100644 index 00000000..dc444e9d --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoringTest.kt @@ -0,0 +1,38 @@ +/******************************************************************************* + * 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 org.openide.filesystems.FileUtil +import org.jetbrains.kotlin.refactorings.rename.KotlinRefactoringsFactory + +/** Unit tests for [KotlinMoveFileRefactoring]'s plugin-factory applicability. */ +class KotlinMoveFileRefactoringTest : NbTestCase("KotlinMoveFileRefactoringTest") { + + /** Verifies folder-context sources create the Kotlin Move File plugin without an editor document. */ + fun testFactory_createsPluginForDirectorySelectionWithoutStyledDocument() { + val root = FileUtil.createMemoryFileSystem().root + val folder = root.createFolder("feature") + folder.createData("Public.kt") + val selection = KotlinMoveSourceSelection.from(folder) ?: error("Expected Kotlin selection") + val refactoring = KotlinMoveFileRefactoring(selection) + + val plugin = KotlinRefactoringsFactory().createInstance(refactoring) + + assertTrue("Move Directory must not be rejected by the editor-document applicability guard", plugin is KotlinMoveFilePlugin) + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelectionTest.kt new file mode 100644 index 00000000..d5a3b735 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveSourceSelectionTest.kt @@ -0,0 +1,79 @@ +/******************************************************************************* + * 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 org.openide.filesystems.FileObject +import org.openide.filesystems.FileUtil + +/** Unit tests for [KotlinMoveSourceSelection]. */ +class KotlinMoveSourceSelectionTest : NbTestCase("KotlinMoveSourceSelectionTest") { + + /** Verifies a selected directory recursively includes only Kotlin source files in path order. */ + fun testFrom_directoryCollectsOnlyKotlinFilesInDeterministicOrder() { + val root = FileUtil.createMemoryFileSystem().root + val feature = root.createFolder("feature") + feature.createData("Readme.txt") + val api = feature.createFolder("api") + val apiFile = api.createData("Api.kt") + val internal = feature.createFolder("internal") + val helper = internal.createData("Helper.kt") + feature.createFolder("empty") + + val selection = KotlinMoveSourceSelection.from(feature) ?: error("Expected Kotlin directory selection") + + assertTrue(selection.isDirectory) + assertEquals("feature", selection.displayName) + assertEquals(listOf(apiFile.path, helper.path), selection.sourceFiles.map(FileObject::getPath)) + } + + /** Verifies a directory target retains its own name and each nested relative parent path. */ + fun testTargetRelativeFolderSegments_directoryRetainsDirectoryAndNestedParent() { + val root = FileUtil.createMemoryFileSystem().root + val feature = root.createFolder("feature") + val internal = feature.createFolder("internal") + val helper = internal.createData("Helper.kt") + val rootFile = feature.createData("Public.kt") + val selection = KotlinMoveSourceSelection.from(feature) ?: error("Expected selection") + + assertEquals(listOf("feature", "internal"), selection.targetRelativeFolderSegments(helper)) + assertEquals(listOf("feature"), selection.targetRelativeFolderSegments(rootFile)) + } + + /** Verifies a direct Kotlin-file selection has no retained directory hierarchy. */ + fun testFrom_fileCreatesSingleFileSelectionWithoutRelativeFolder() { + val root = FileUtil.createMemoryFileSystem().root + val file = root.createData("Single.kt") + + val selection = KotlinMoveSourceSelection.from(file) ?: error("Expected Kotlin file selection") + + assertFalse(selection.isDirectory) + assertEquals(listOf(file), selection.sourceFiles) + assertEquals(emptyList(), selection.targetRelativeFolderSegments(file)) + } + + /** Verifies folders without Kotlin descendants and non-Kotlin files cannot start the refactoring. */ + fun testFrom_nonKotlinSourceReturnsNull() { + val root = FileUtil.createMemoryFileSystem().root + val folder = root.createFolder("resources") + folder.createData("config.json") + val text = root.createData("Notes.txt") + + assertNull(KotlinMoveSourceSelection.from(folder)) + assertNull(KotlinMoveSourceSelection.from(text)) + } +} 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 5f7871c1..463f943b 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 @@ -112,6 +112,35 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio assertEquals("undo must restore the snapshot from before K2 PSI mutation", original, fixture.text(source)) } + /** Verifies several directory descendants move and undo together in reverse physical-move order. */ + fun testCommitAndUndo_restoresEveryMovedDirectoryDescendant() { + val fixture = fixture() + val sourceFolder = fixture.root.createFolder("feature") + val nestedSource = sourceFolder.createFolder("internal") + val targetFolder = fixture.root.createFolder("target") + val nestedTarget = targetFolder.createFolder("internal") + val publicFile = fixture.existingIn(sourceFolder, "Public.kt", "package feature\n\nfun publicApi() = 1\n") + val helperFile = fixture.existingIn(nestedSource, "Helper.kt", "package feature.internal\n\nfun helper() = 1\n") + val retainedText = sourceFolder.createData("Readme.txt") + + fixture.transaction.moveFile(publicFile, targetFolder) + fixture.transaction.moveFile(helperFile, nestedTarget) + fixture.transaction.stageText(publicFile, "package target\n\nfun publicApi() = 1\n") + fixture.transaction.stageText(helperFile, "package target.internal\n\nfun helper() = 1\n") + fixture.transaction.commit() + + assertNotNull(targetFolder.getFileObject("Public.kt")) + assertNotNull(nestedTarget.getFileObject("Helper.kt")) + assertTrue("non-Kotlin source content must remain untouched", retainedText.isValid) + fixture.transaction.undo() + + assertNotNull(sourceFolder.getFileObject("Public.kt")) + assertNotNull(nestedSource.getFileObject("Helper.kt")) + assertNull(targetFolder.getFileObject("Public.kt")) + assertNull(nestedTarget.getFileObject("Helper.kt")) + assertTrue("non-Kotlin source content must remain after undo", retainedText.isValid) + } + /** Verifies a physical move is committed and Undo Last Refactoring restores its path and text. */ fun testCommitAndUndo_restoresMovedFilePathAndText() { val fixture = fixture() diff --git a/docs/development-plan.md b/docs/development-plan.md index 0466f9ae..fea05930 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -743,7 +743,7 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E is removed on any conflict/error, an existing target is restored exactly, and **Refactor → Undo Last Refactoring** restores both documents (or removes the target file if the refactoring created it). - - [x] **F4.1** — Move Kotlin File (Refactor menu; directory action remains the next F4.1 increment) + - [x] **F4.1** — Move Kotlin File and Directory (Refactor menu / folder context menu) - Ports the Kotlin-specific `K2MoveFilesHandler` portion of `K2MoveFilesOrDirectoriesRefactoringProcessor.kt` through `KaMoveFileComputer`. The generic IDEA processor is intentionally not invoked: it depends on writable IntelliJ VFS, indexed @@ -755,11 +755,14 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E - Transactions now record reversible physical file moves and transaction-owned destination folders; failed writes roll back the path and document snapshots, while **Undo Last Refactoring** restores the original path and text. + - Folder-node **Move Kotlin Directory...** recursively moves only Kotlin descendants through one + atomic transaction, retaining the selected directory and every nested Kotlin folder below the + target package; a single K2 pass uses per-file target packages so inter-file references remain + coherent. Non-Kotlin descendants remain at their source paths. - Portable coverage includes matching-package update, package/directory mismatch preservation, K2 semantic adapter execution, physical-move rollback, owned-folder cleanup, and undo. The - remaining F4.1 scope is directory/file-node invocation, recursive Kotlin-directory moves, - generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA project-model - conflict behavior. + remaining F4.1 scope is generic non-Kotlin content, Java/comment/text/index-only references, + and full IDEA project-model conflict behavior. - [x] **E9.8** — Change Signature (Ctrl+F6) — PRs #114, #115 - IDEA sources: `changeSignature/KotlinChangeSignatureUsageSearcher.kt`, diff --git a/docs/manual-test-move-kotlin-file.md b/docs/manual-test-move-kotlin-file.md index e2576473..c2ec1d8a 100644 --- a/docs/manual-test-move-kotlin-file.md +++ b/docs/manual-test-move-kotlin-file.md @@ -104,12 +104,41 @@ **Ожидаемый результат:** при ошибке операция прекращается корректно; исходный файл, его package declaration и ранее существующие целевые файлы не остаются в частично изменённом состоянии. +## 10. Перемещение каталога Kotlin + +1. Создайте каталог `sample/source/feature` с файлами `Public.kt` и `internal/Helper.kt`: + ```kotlin + // Public.kt + package sample.source.feature + + fun publicApi() = helper() + + // internal/Helper.kt + package sample.source.feature.internal + + fun helper() = 1 + ``` +2. Добавьте рядом не-Kotlin файл `sample/source/feature/README.txt`. +3. В дереве Projects или Files щёлкните правой кнопкой по каталогу `feature` и выберите + **Move Kotlin Directory...**. +4. Выберите source root, укажите пакет `sample.target`, оставьте обновление Kotlin-ссылок включённым + и выполните рефакторинг. + +**Ожидаемый результат:** + +- `Public.kt` перемещён в `sample/target/feature` и имеет `package sample.target.feature`; +- `Helper.kt` перемещён в `sample/target/feature/internal` и имеет + `package sample.target.feature.internal`; +- ссылки и импорты Kotlin на оба объявления обновлены; +- `README.txt` остаётся по исходному пути `sample/source/feature/README.txt`; +- **Undo Last Refactoring** возвращает оба Kotlin-файла, их package directives и ссылки, не удаляя + исходный каталог с `README.txt`. + ## Неподдерживаемые сценарии Следующие сценарии не входят в F4.1 и не являются критериями приёмки: -- запуск Move Kotlin File из контекстного меню узла файла или каталога; -- рекурсивное перемещение каталогов; +- запуск Move Kotlin File из контекстного меню узла файла (для одного файла используйте команду в редакторе); - перемещение не-Kotlin файлов; - обновление Java usages; - обновление упоминаний в комментариях, строках и произвольном тексте; diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md index a16508c4..5de7b62b 100644 --- a/docs/refactoring-coverage.md +++ b/docs/refactoring-coverage.md @@ -68,7 +68,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | `introduce-parameter` | Introduce Parameter | `Ctrl+Alt+P` | Partial | Change Signature pipeline updates callers through a transaction that rolls every touched file back on failure; context parameters and adding an absent primary constructor remain. | F6 | | `introduce-functional-parameter` | Introduce Functional Parameter | `Ctrl+Alt+Shift+P` | Partial | K2 extraction/Change Signature path covers single expressions with transactional multi-file persistence; multi-statement and idiomatic lambda cases remain. | F6 | | `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 | Partial | Moves one Kotlin file to a selectable source root/package, updates a matching package directive and supported Kotlin code references, and restores the original path/text through Undo Last Refactoring. Directory invocation, generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA filesystem behavior remain. | F4 | +| `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 | | `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 | diff --git a/pom.xml b/pom.xml index f0cee6e1..0007f98e 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.11.238-SNAPSHOT + 0.11.245-SNAPSHOT pom Netbeans Plugin Kotlin