diff --git a/CHANGELOG.md b/CHANGELOG.md index 41dc35a0..ebc3823f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- 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 0377183e..def966ab 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.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index e17d2cc2..e6ce9bbf 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.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index fee74bee..99a782c5 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.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 645015f7..718cc9bd 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.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index e2fa4b61..f8d6137f 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.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index d5c9a59e..d611fb63 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.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 37cb1163..99876de5 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.245-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 12190e6d..b313aaa6 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.245-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..0202ac56 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaMoveFileComputer.kt @@ -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.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.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 +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 = 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(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() + // 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 = targets.getValue(file).targetPackage, + ) + }.distinct() + } else { + emptyList() + } + val conflicts = MultiMap() + 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 movedImportTargets = files.flatMap { file -> + file.declarations.mapNotNull { declaration -> + (declaration as? KtNamedDeclaration)?.name?.let { 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 = target.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) + file.virtualFile?.path?.let { path -> changedFiles[path] = file.text } + } + ApplyOutcome.Success(changedFiles, originalTexts) + } + } catch (error: Throwable) { + ApplyOutcome.Error(error) + } + } + + /** 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. */ + 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: 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 + val targetPackage = movedImports[MoveImport(importedFqName.parent(), importedName.asString())] + ?: return@forEach + directive.replace(factory.createImportDirective(ImportPath(targetPackage.child(importedName), false))) + } + } + + + /** + * 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/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/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..06a4d570 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.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 new file mode 100644 index 00000000..b6e7efaa --- /dev/null +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileUI.java @@ -0,0 +1,309 @@ +/******************************************************************************* + * 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 final String initialPackage; + 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, 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 refactoring.getSelection().isDirectory() ? "Move Kotlin Directory" : "Move Kotlin File"; + } + + /** @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"; + } + + /** @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( + 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 + ); + } + 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 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 + */ + MoveFilePanel(String fileName, + String sourcePackage, + boolean mayUpdatePackage, + boolean directorySelection, + 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(directorySelection ? "Kotlin directory:" : "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 (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); + } + + 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..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 @@ -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}
  • * */ @@ -97,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")) { 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 new file mode 100644 index 00000000..059617aa --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileAction.kt @@ -0,0 +1,75 @@ +/******************************************************************************* + * 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 selection = KotlinMoveSourceSelection.from(source) ?: return@runCatching + val result = resolveOutcome(project, source.path) ?: return@runCatching + val refactoring = KotlinMoveFileRefactoring(selection) + UI.openRefactoringUI( + KotlinMoveFileUI(result, refactoring, KotlinPackageTarget(project, selection.representativeFile)), + 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..a53db099 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFilePlugin.kt @@ -0,0 +1,250 @@ +/******************************************************************************* + * 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 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 selected Kotlin sources and the destination selected in UI + */ +class KotlinMoveFilePlugin( + private val refactoring: KotlinMoveFileRefactoring, +) : ProgressProviderAdapter(), RefactoringPlugin { + + /** @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. */ + 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 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 a source cannot be analyzed, otherwise `null` + */ + override fun prepare(bag: RefactoringElementsBag): Problem? { + 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 or directory move and retains the transaction for Undo Last + * Refactoring. + * + * @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 selection: KotlinMoveSourceSelection, + 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 = if (selection.isDirectory) "Move Kotlin directory" else "Move Kotlin file" + + /** @return user-visible preview text. */ + override fun getDisplayText(): String = getText() + + /** @return selected representative file lookup. */ + override fun getLookup(): Lookup = Lookups.fixed(selection.representativeFile) + + /** @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(selection.representativeFile) + .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 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 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.") + val targetPackage = refactoring.targetPackage.trim() + check(packageTarget.isValidPackage(targetPackage)) { "Target package is invalid: $targetPackage" } + + val current = KotlinRefactoringTransaction() + pending = current + 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) + } + 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 -> { + selection.sourceFiles.forEach { source -> current.moveFile(source, targetFolders.getValue(source)) } + outcome.changedFiles.forEach { (path, text) -> + 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() + 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 original source paths and texts 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) + } + } + + /** 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, + 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 new file mode 100644 index 00000000..92e27741 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveFileRefactoring.kt @@ -0,0 +1,42 @@ +/******************************************************************************* + * 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 + +/** + * Carries selected Kotlin sources and destination parameters for Move File and Move Directory. + * + * 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 selection physical Kotlin sources selected for movement + */ +class KotlinMoveFileRefactoring( + val selection: KotlinMoveSourceSelection, +) : AbstractRefactoring(Lookups.fixed(*selection.sourceFiles.toTypedArray())) { + + /** 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/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/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..1a0718ab 100644 --- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml +++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml @@ -111,6 +111,15 @@ + + + + + + + + @@ -133,6 +142,14 @@ + + + + + + + + @@ -624,6 +641,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..5a7915d3 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaMoveFileComputerTest.kt @@ -0,0 +1,303 @@ +/******************************************************************************* + * 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() + } + } + + /** + * 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")) + .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/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 7f2abfd3..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 @@ -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,205 @@ 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 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() + 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..fea05930 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,27 @@ 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 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 + 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. + - 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 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..c2ec1d8a --- /dev/null +++ b/docs/manual-test-move-kotlin-file.md @@ -0,0 +1,149 @@ +# Ручное тестирование: 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 и ранее существующие целевые файлы не остаются в частично изменённом состоянии. + +## 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 из контекстного меню узла файла (для одного файла используйте команду в редакторе); +- перемещение не-Kotlin файлов; +- обновление Java usages; +- обновление упоминаний в комментариях, строках и произвольном тексте; +- сценарии, требующие IDEA index-backed filesystem или IDEA project model. + +## Результат проверки + +Зафиксируйте для каждого раздела: **пройдено**, **не пройдено** либо **не применимо**, а для непрохождения приложите минимальный пример проекта и шаги воспроизведения. diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md index c7d46e60..5de7b62b 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 / 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 | @@ -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..0007f98e 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.245-SNAPSHOT pom Netbeans Plugin Kotlin