diff --git a/CHANGELOG.md b/CHANGELOG.md index ebc3823f..5f865b5a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Added Change Kotlin Package refactoring: use **Refactor → Change Kotlin Package...** for the active Kotlin file or a folder's recursive Kotlin descendants to update package directives in place; nested files retain their relative package hierarchy, supported Kotlin code references and direct imports update, and **Undo Last Refactoring** restores the original contents. - Added Move Kotlin File and Directory refactoring: move the active `.kt` file from **Refactor → Move Kotlin File...**, or right-click a folder and choose **Move Kotlin Directory...** to recursively move its Kotlin files while retaining the selected directory and nested structure below a selected source root/package; matching package directives and supported Kotlin code references are updated, and **Undo Last Refactoring** restores the original locations and contents. - Improved Kotlin Inline Variable: local `var` declarations with one initializer and no subsequent writes can now be inlined from either a declaration or selected usage; assignments and increment/decrement writes are rejected before changing the source with the conflicting expression identified. - Improved Kotlin Introduce Type Alias: selecting a complete generic type now creates a concrete alias, while selecting only its type constructor creates a parameterized alias that preserves nested and qualified type structures and each same-file usage's own type arguments; star projections and local type parameters that cannot form a valid top-level alias are rejected. diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index def966ab..4fafda65 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index e6ce9bbf..c5739481 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 99a782c5..8c9b4c9e 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 718cc9bd..a77898eb 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index f8d6137f..9b5f2c69 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index d611fb63..9ef79aaa 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 99876de5..1d555881 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index b313aaa6..8326f753 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring @@ -751,9 +751,10 @@ KaMoveFileComputer invokes the K2 handler semantics through a lifecycle-free adapter. Excluded: K2MoveHandler.kt (IDE action-system dispatch), ui/** (Swing dialog/model — NB provides its own UI), K2MoveDirectoryWithClassesHelper.kt (generic filesystem move), - K2ChangePackageDescriptor.kt/K2ChangePackageRefactoringProcessor.kt (F4.2), and - K2MoveDeclarationsRefactoringListener.kt (message-bus event topic, stripped from the - declaration processor because no standalone listeners are subscribed). --> + and K2MoveDeclarationsRefactoringListener.kt (message-bus event topic, stripped from + the declaration processor because no standalone listeners are subscribed). F4.2 ports + K2ChangePackageDescriptor/K2ChangePackageRefactoringProcessor and strips only their + IDEA lifecycle wrapper after copying. --> copy-kotlin-refactoring-move-k2-declarations initialize @@ -770,6 +771,8 @@ org/jetbrains/kotlin/idea/k2/refactoring/move/descriptor/K2MoveSourceDescriptor.kt 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/descriptor/K2ChangePackageDescriptor.kt + org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2ChangePackageRefactoringProcessor.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 @@ -1967,6 +1970,47 @@ if (k2MoveFilesProcessor.exists()) { log.info("Patched K2MoveFilesOrDirectoriesRefactoringProcessor.kt: retained lifecycle-free K2MoveFilesHandler only") } +// 16b. K2ChangePackageRefactoringProcessor.kt (F4.2): preserve the real IDEA K2 package-change +// algorithm while removing only BaseRefactoringProcessor's IDEA command, modal progress, +// conflict-dialog, usage-view, and event-listener lifecycle. KaChangePackageComputer supplies +// a per-file target package plan (needed for recursive NetBeans folder selection) and invokes +// this lifecycle-free port for its actual usage discovery, conflict check, package rewrite, +// declaration map, and retargeting sequence. +def k2ChangePackageProcessor = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2ChangePackageRefactoringProcessor.kt') +if (k2ChangePackageProcessor.exists()) { + def text = k2ChangePackageProcessor.text + text = text.replace('import com.intellij.openapi.actionSystem.ex.ActionUtil\n', '') + text = text.replace('import com.intellij.openapi.util.Ref\n', '') + text = text.replace('import com.intellij.refactoring.BaseRefactoringProcessor\n', '') + text = text.replace('import com.intellij.refactoring.RefactoringBundle\n', '') + text = text.replace('import com.intellij.refactoring.listeners.RefactoringEventData\n', '') + text = text.replace('import com.intellij.usageView.UsageViewDescriptor\n', '') + text = text.replace('import com.intellij.util.containers.MultiMap\n', '') + text = text.replace('import com.intellij.refactoring.util.MoveRenameUsageInfo\n', '') + text = text.replace('import org.jetbrains.kotlin.analysis.api.permissions.KaAllowAnalysisOnEdt\n', '') + text = text.replace('import org.jetbrains.kotlin.analysis.api.permissions.allowAnalysisOnEdt\n', '') + text = text.replace('import org.jetbrains.kotlin.idea.base.resources.KotlinBundle\n', '') + text = text.replace('class K2ChangePackageRefactoringProcessor(private val descriptor: K2ChangePackageDescriptor) : BaseRefactoringProcessor(descriptor.project) {', + 'class K2ChangePackageRefactoringProcessor(private val descriptor: K2ChangePackageDescriptor) {') + def commandStart = text.indexOf(' override fun getCommandName()') + def findUsagesStart = text.indexOf(' protected override fun findUsages()') + if (commandStart >= 0 && findUsagesStart > commandStart) { + text = text.substring(0, commandStart) + text.substring(findUsagesStart) + } + text = text.replace(' protected override fun findUsages()', ' fun findUsages()') + def preprocessStart = text.indexOf(' protected override fun preprocessUsages(') + def performStart = text.indexOf(' @OptIn(KaAllowAnalysisOnEdt::class)\n override fun performRefactoring(') + if (preprocessStart >= 0 && performStart > preprocessStart) { + text = text.substring(0, preprocessStart) + text.substring(performStart) + } + text = text.replace(' @OptIn(KaAllowAnalysisOnEdt::class)\n override fun performRefactoring(usages: Array) = allowAnalysisOnEdt {', + ' fun findConflicts(usages: List): com.intellij.util.containers.MultiMap =\n findAllMoveConflicts(descriptor.files, descriptor.target, usages)\n\n fun prepareRefactoring(oldToNewMap: MutableMap) {\n val files = descriptor.files\n files.forEach { it.updatePackageDirective(descriptor.target) }\n files.forEach { file ->\n oldToNewMap[file] = file\n file.declarations.filterIsInstance().forEach { declaration ->\n oldToNewMap[declaration] = declaration\n }\n }\n }\n\n fun retargetUsages(usages: Collection, oldToNewMap: Map) {\n retargetUsagesAfterMove(usages.toList(), oldToNewMap)\n }\n\n fun performRefactoring(usages: Collection) {\n val oldToNewMap = linkedMapOf()\n prepareRefactoring(oldToNewMap)\n retargetUsages(usages, oldToNewMap)\n }') + text = text.replace('\n\n override fun getBeforeData(): RefactoringEventData = RefactoringEventData().apply {\n addElements(descriptor.files)\n }', '') + text = text.replace(' val files = descriptor.files\n files.forEach { it.updatePackageDirective(descriptor.target) }\n val oldToNewMap: MutableMap = files.associateWith { it }.toMutableMap()\n files.forEach { file -> file.allDeclarationsToUpdate.forEach { decl -> oldToNewMap[decl] = decl } }\n retargetUsagesAfterMove(usages.toList(), oldToNewMap)\n }', '') + k2ChangePackageProcessor.text = text + log.info("Patched K2ChangePackageRefactoringProcessor.kt: retained lifecycle-free upstream K2 semantic sequence") +} + // 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 diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt new file mode 100644 index 00000000..337ad4db --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -0,0 +1,209 @@ +/******************************************************************************* + * 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.refactoring.util.MoveRenameUsageInfo +import com.intellij.usageView.UsageInfo +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.descriptor.K2ChangePackageDescriptor +import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2ChangePackageRefactoringProcessor +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtPsiFactory +import org.jetbrains.kotlin.resolve.ImportPath + +/** + * Applies IDEA K2 Change Package semantics without its IDE-specific processor lifecycle. + * + * NetBeans supplies writable session PSI and persists the returned text atomically. The computer + * retains the upstream algorithm's usage discovery, conflict pass, package rewrite, declaration + * identity map, and reference retargeting while deliberately leaving filesystem mutations to no one: + * changing a package never changes a physical path. + * + * @param ktFile representative writable K2-session source file + */ +class KaChangePackageComputer( + private val ktFile: KtFile, +) { + + /** Describes the result of applying package changes to one or more Kotlin source files. */ + sealed class ApplyOutcome { + /** K2 found blocking semantic conflicts and did not mutate any PSI. */ + data class Conflicts(val messages: List) : ApplyOutcome() + + /** K2 produced final texts and pre-mutation snapshots for every changed participant. */ + data class Success( + val changedFiles: Map, + val originalTexts: Map, + ) : ApplyOutcome() + + /** An unexpected K2 or PSI failure prevented the operation. */ + data class Error(val error: Throwable) : ApplyOutcome() + } + + /** + * Changes every source in [targets] to its requested package and retargets supported usages. + * + * @param targets writable source PSI mapped to their final declared packages + * @param updateReferences whether supported external Kotlin references and direct imports update + * @return conflicts without mutation, changed texts with original snapshots, or an error + */ + fun apply(targets: Map, updateReferences: Boolean): ApplyOutcome { + return try { + require(targets.isNotEmpty()) { "Change Package requires at least one Kotlin file." } + val files = targets.keys.toList() + require(files.all { it.virtualFile?.path != null }) { "Change Package requires physical Kotlin files." } + + val processors = targets.entries.groupBy({ it.value }, { it.key }).map { (targetPackage, groupedFiles) -> + K2ChangePackageRefactoringProcessor( + K2ChangePackageDescriptor( + project = ktFile.project, + files = groupedFiles.toSet(), + target = targetPackage, + searchForText = false, + searchInComments = false, + ), + ) + } + // Use the copied upstream Change Package processor rather than the F4.1 Move File helper: + // its findUsages() marks whole-file internal references. That is what forces a same-package, + // no-import usage in another source file to gain an import after its declaration changes package. + val usages = if (updateReferences) processors.flatMap { it.findUsages().asIterable() }.distinct() else emptyList() + val conflicts = processors.flatMap { processor -> + processor.findConflicts(usages.filterIsInstance()).values() + } + if (conflicts.isNotEmpty()) return ApplyOutcome.Conflicts(conflicts.distinct()) + + val movedImports = files.flatMap { file -> + file.declarations.mapNotNull { declaration -> + (declaration as? KtNamedDeclaration)?.name?.let { name -> + MoveImport(file.packageFqName, name) to targets.getValue(file) + } + } + }.toMap() + val samePackageImportUpdates = if (updateReferences) { + findSamePackageImportUpdates(files, targets) + } else { + emptyMap() + } + val importOnlyUsageFiles = if (updateReferences) { + KotlinMoveUsageSearchService.getInstance() + ?.findImportingFiles(ktFile.project, movedImports) + .orEmpty() + } else { + emptyList() + } + val usageFiles = (filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys).distinct() + val originalTexts = (files + usageFiles).distinct().mapNotNull { file -> + file.virtualFile?.path?.let { path -> path to file.text } + }.toMap() + val oldToNew = linkedMapOf() + processors.forEach { processor -> processor.prepareRefactoring(oldToNew) } + if (updateReferences) processors.first().retargetUsages(usages, oldToNew) + + val changedFiles = linkedMapOf() + files.forEach { file -> file.virtualFile?.path?.let { changedFiles[it] = file.text } } + usageFiles.filterNot { it in files }.forEach { file -> + rewriteMovedImports(file, movedImports) + val changedText = addSamePackageImports(file, samePackageImportUpdates[file].orEmpty()) + file.virtualFile?.path?.let { changedFiles[it] = changedText } + } + ApplyOutcome.Success(changedFiles, originalTexts) + } catch (error: Throwable) { + ApplyOutcome.Error(error) + } + } + + /** + * Finds old-package simple references that will lose their implicit package visibility. + * + * The standalone K2 retargeter excludes these whole-file internal usages, so identify them + * before changing directives and later add only the imports required to preserve binding. + */ + private fun findSamePackageImportUpdates( + movedFiles: Collection, + targets: Map, + ): Map> { + val movedDeclarations = movedFiles.flatMap { file -> + file.declarations.filterIsInstance().mapNotNull { declaration -> + declaration.name?.let { name -> declaration to targets.getValue(file).child(org.jetbrains.kotlin.name.Name.identifier(name)) } + } + }.toMap() + if (movedDeclarations.isEmpty()) return emptyMap() + val session = KotlinMoveUsageSearchService.getInstance() ?: return emptyMap() + val updates = linkedMapOf>() + movedDeclarations.forEach { (declaration, targetFqName) -> + session.findUsages(declaration).forEach { reference -> + val file = reference.element?.containingFile as? KtFile ?: return@forEach + if (file in movedFiles || file.packageFqName != declaration.containingKtFile.packageFqName) return@forEach + updates.getOrPut(file, ::linkedSetOf) += targetFqName + } + } + return updates + } + + /** + * Adds imports for former same-package declarations that no longer resolve implicitly. + * + * The standalone K2 retargeter creates the first import list without its package separator. + * Rebuild this narrow header text rather than inserting PSI whitespace, which the standalone + * parser drops while adding the import-list node. + * + * @return final source text with a canonical package/import boundary + */ + private fun addSamePackageImports(usageFile: KtFile, imports: Set): String { + if (imports.isEmpty()) return usageFile.text + val packageDirective = usageFile.packageDirective ?: return usageFile.text + val importList = usageFile.importList + val existingImports = importList?.imports.orEmpty().map { it.text } + val existingFqNames = usageFile.importDirectives.mapNotNull { it.importedFqName }.toSet() + val missingImports = imports.filterNot { it in existingFqNames }.map { imported -> + "import ${imported.asString()}" + } + val directives = existingImports + missingImports + if (directives.isEmpty()) return usageFile.text + val headerEnd = importList?.textRange?.endOffset ?: packageDirective.textRange.endOffset + return usageFile.text.substring(0, packageDirective.textRange.endOffset) + + "\n\n${directives.joinToString(separator = "\n")}" + + usageFile.text.substring(headerEnd) + } + + /** Captures usage files before K2 reference rebinding can invalidate individual usage elements. */ + private fun filesWithUsages(usages: Collection): List = + usages.mapNotNull { usage -> usage.element?.containingFile as? KtFile }.distinct() + + /** + * Replaces direct imports after K2 code retargeting. + * + * Standalone K2 cannot safely rebind a segment within an import directive because that can + * invalidate the complete import list before an IDEA document synchronizer updates it. + */ + 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))) + } + } +} diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 06a4d570..73999424 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageUI.java new file mode 100644 index 00000000..b06c3eca --- /dev/null +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageUI.java @@ -0,0 +1,232 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring; + +import java.awt.BorderLayout; +import java.awt.Component; +import java.awt.GridBagConstraints; +import java.awt.GridBagLayout; +import java.awt.Insets; +import javax.swing.BorderFactory; +import javax.swing.JCheckBox; +import javax.swing.JLabel; +import javax.swing.JPanel; +import javax.swing.JTextField; +import javax.swing.event.ChangeListener; +import javax.swing.event.DocumentEvent; +import javax.swing.event.DocumentListener; +import org.netbeans.modules.refactoring.api.AbstractRefactoring; +import org.netbeans.modules.refactoring.api.Problem; +import org.netbeans.modules.refactoring.spi.ui.CustomRefactoringPanel; +import org.netbeans.modules.refactoring.spi.ui.RefactoringUI; +import org.openide.util.HelpCtx; + +/** + * Configures the package and supported-reference update option for Change Kotlin Package. + * + * @param refactoring mutable carrier receiving user-selected settings + * @param target package validator and source-root context + * @param initialPackage initial package text shown to the user + */ +public final class KotlinChangePackageUI implements RefactoringUI { + + private final KotlinChangePackageRefactoring refactoring; + private final KotlinPackageTarget target; + private final String initialPackage; + private ChangePackagePanel panel; + + /** + * Creates the Change Kotlin Package UI. + * + * @param refactoring mutable carrier receiving user-selected settings + * @param target package validator and source-root context + * @param initialPackage initial package text shown to the user + */ + public KotlinChangePackageUI(KotlinChangePackageRefactoring refactoring, + KotlinPackageTarget target, + String initialPackage) { + this.refactoring = refactoring; + this.target = target; + this.initialPackage = initialPackage; + } + + /** @return the user-visible refactoring name. */ + @Override + public String getName() { + return "Change Kotlin Package"; + } + + /** @return concise description of the selected file or directory. */ + @Override + public String getDescription() { + if (refactoring.getSelection().isDirectory()) { + return "Change package of " + refactoring.getSelection().getSourceFiles().size() + + " Kotlin files in directory '" + refactoring.getSelection().getDisplayName() + "'"; + } + return "Change package of Kotlin file '" + refactoring.getSelection().getDisplayName() + "'"; + } + + /** @return false because the operation mutates Kotlin source documents. */ + @Override + public boolean isQuery() { + return false; + } + + /** + * Creates the package-change form on first use. + * + * @param parent listener notified when values change + * @return cached package-change form + */ + @Override + public CustomRefactoringPanel getPanel(ChangeListener parent) { + if (panel == null) { + panel = new ChangePackagePanel( + refactoring.getSelection().getDisplayName(), + refactoring.getSelection().getSourceFiles().size(), + refactoring.getSelection().isDirectory(), + initialPackage, + parent + ); + } + return panel; + } + + /** + * Copies selected settings to the carrier. + * + * @return a fatal problem for an invalid 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.setTargetPackage(packageName); + refactoring.setUpdateReferences(panel.isUpdateReferences()); + return null; + } + + /** + * Validates current form values before NetBeans schedules the operation. + * + * @return fatal validation problem, or {@code null} + */ + @Override + public Problem checkParameters() { + return setParameters(); + } + + /** @return true because Change Package has a configuration form. */ + @Override + public boolean hasParameters() { + return true; + } + + /** @return carrier supplied to NetBeans refactoring infrastructure. */ + @Override + public AbstractRefactoring getRefactoring() { + return refactoring; + } + + /** @return no dedicated help page. */ + @Override + public HelpCtx getHelpCtx() { + return null; + } + + /** Swing form for Change Kotlin Package parameters. */ + private static final class ChangePackagePanel implements CustomRefactoringPanel { + private final JPanel component; + private final JTextField packageField; + private final JCheckBox updateReferences; + + /** + * Builds the package-change form. + * + * @param name displayed source file or directory name + * @param sourceCount selected Kotlin file count + * @param directorySelection whether the selection originated from a directory + * @param initialPackage initially displayed package + * @param changeListener listener notified of UI changes + */ + ChangePackagePanel(String name, int sourceCount, boolean directorySelection, String initialPackage, + ChangeListener changeListener) { + packageField = new JTextField(initialPackage, 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(directorySelection ? name + " (" + sourceCount + " Kotlin files)" : name), field); + JLabel packageLabel = new JLabel("New package:"); + packageLabel.setLabelFor(packageField); + form.add(packageLabel, label); + form.add(packageField, field); + form.add(new JLabel(""), label); + form.add(updateReferences, field); + form.add(new JLabel(""), label); + String note = directorySelection + ? "Nested Kotlin files retain their relative subfolder hierarchy; physical locations remain unchanged." + : "The file's physical location remains unchanged."; + form.add(new JLabel(note), 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); + updateReferences.addActionListener(event -> changeListener.stateChanged(null)); + } + + /** @return current target-package text. */ + String getPackageValue() { + return packageField.getText(); + } + + /** @return whether supported external Kotlin references should be retargeted. */ + boolean isUpdateReferences() { + return updateReferences.isSelected(); + } + + /** Requests initial focus for the package field. */ + @Override + public void initialize() { + packageField.requestFocusInWindow(); + } + + /** @return component displayed by NetBeans. */ + @Override + public Component getComponent() { + return component; + } + } +} 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 dcdef66a..aa1580fb 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 @@ -40,6 +40,8 @@ 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.KotlinChangePackagePlugin; +import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackageRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignaturePlugin; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangeSignatureRefactoring; import io.github.nbplugins.kotlin.nbm.refactoring.KotlinIntroduceImportAliasPlugin; @@ -92,6 +94,7 @@ *
  • {@link KotlinCopyDeclarationRefactoring} — delegates to {@link KotlinCopyDeclarationPlugin}
  • *
  • {@link KotlinMoveDeclarationRefactoring} — delegates to {@link KotlinMoveDeclarationPlugin}
  • *
  • {@link KotlinMoveFileRefactoring} — delegates to {@link KotlinMoveFilePlugin}
  • + *
  • {@link KotlinChangePackageRefactoring} — delegates to {@link KotlinChangePackagePlugin}
  • *
  • {@link KotlinChangeSignatureRefactoring} — delegates to {@link KotlinChangeSignaturePlugin}
  • * */ @@ -106,6 +109,9 @@ public RefactoringPlugin createInstance(AbstractRefactoring refactoring) { if (refactoring instanceof KotlinMoveFileRefactoring) { return new KotlinMoveFilePlugin((KotlinMoveFileRefactoring) refactoring); } + if (refactoring instanceof KotlinChangePackageRefactoring) { + return new KotlinChangePackagePlugin((KotlinChangePackageRefactoring) 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/KotlinChangePackageAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt new file mode 100644 index 00000000..fca038dc --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageAction.kt @@ -0,0 +1,64 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import org.jetbrains.kotlin.log.KotlinLogger +import org.jetbrains.kotlin.utils.ProjectUtils +import org.netbeans.editor.BaseAction +import org.netbeans.modules.refactoring.spi.ui.UI +import org.openide.windows.TopComponent +import java.awt.event.ActionEvent +import javax.swing.text.JTextComponent +import javax.swing.text.StyledDocument + +/** + * Editor action that changes the active Kotlin file's package without moving its physical path. + */ +class KotlinChangePackageAction : BaseAction(ACTION_NAME, SAVE_POSITION or ABBREV_RESET) { + init { + putValue(NAME, "Change Kotlin Package...") + putValue(SHORT_DESCRIPTION, "Change Kotlin File Package Without Moving It") + putValue(POPUP_MENU_TEXT, "Change Kotlin Package...") + } + + /** + * Opens the package-change UI for the active Kotlin source file. + * + * @param evt action event raised by NetBeans + * @param target active editor component + */ + 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 = KotlinChangePackageSourceSelection.from(source) ?: return@runCatching + val packageTarget = KotlinPackageTarget(project, source) + UI.openRefactoringUI( + KotlinChangePackageUI(KotlinChangePackageRefactoring(selection), packageTarget, packageTarget.defaultPackage), + TopComponent.getRegistry().activated, + ) + }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageAction failed", error) } + } + + companion object { + /** Action name used by the manual layer.xml registration. */ + const val ACTION_NAME = "kotlin-change-package" + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt new file mode 100644 index 00000000..fd67a885 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryAction.kt @@ -0,0 +1,97 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import java.awt.EventQueue +import org.jetbrains.kotlin.log.KotlinLogger +import org.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 changes package directives for recursive Kotlin descendants in place. + * + * The selected folder itself is not included in the target package because physical directories do + * not move. Descendant folders become suffixes of the user-selected package. + */ +@ActionID(category = "Refactoring", id = "io.github.nbplugins.kotlin.nbm.refactoring.KotlinChangePackageDirectoryAction") +@ActionRegistration(displayName = "Change Kotlin Package...") +open class KotlinChangePackageDirectoryAction : NodeAction() { + init { + putValue(NAME, "Change Kotlin Package...") + putValue(SHORT_DESCRIPTION, "Change Package of Kotlin Files in Directory") + } + + /** + * Prepares and opens the shared UI for a selected Kotlin-containing folder. + * + * @param nodes folder node supplied by NetBeans + */ + override fun performAction(nodes: Array) { + val folder = selectedFolder(nodes) ?: return + val selection = KotlinChangePackageSourceSelection.from(folder) ?: return + runCatching { + val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) + ?: ProjectUtils.getValidProject() + ?: return@runCatching + val packageTarget = KotlinPackageTarget(project, selection.representativeFile) + openRefactoringUi( + KotlinChangePackageUI( + KotlinChangePackageRefactoring(selection), + packageTarget, + packageTarget.packageForFolder(folder), + ), + ) + }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction failed", error) } + } + + /** + * Opens the Swing refactoring UI on the Event Dispatch Thread. + * + * @param ui prepared UI whose display must run on the Event Dispatch Thread + */ + internal fun openRefactoringUi(ui: KotlinChangePackageUI) { + EventQueue.invokeLater { + runCatching { UI.openRefactoringUI(ui, TopComponent.getRegistry().activated) } + .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageDirectoryAction UI opening failed", error) } + } + } + + /** @return true only for one valid folder containing at least one Kotlin descendant. */ + override fun enable(nodes: Array): Boolean = selectedFolder(nodes) + ?.let(KotlinChangePackageSourceSelection::from) + ?.isDirectory == true + + /** @return NetBeans default help context. */ + override fun getHelpCtx(): HelpCtx = HelpCtx.DEFAULT_HELP + + /** @return stable action name used by the manual layer registration. */ + override fun getName(): String = "Change Kotlin Package" + + /** Resolves exactly one physical folder from NetBeans' selected node. */ + private fun selectedFolder(nodes: Array): org.openide.filesystems.FileObject? { + if (nodes.size != 1) return null + val dataObject = nodes.single().lookup.lookup(DataObject::class.java) ?: return null + return dataObject.primaryFile.takeIf { it.isValid && it.isFolder } + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt new file mode 100644 index 00000000..ed7b9cc1 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt @@ -0,0 +1,161 @@ +/******************************************************************************* + * 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.KaChangePackageComputer +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 Change Kotlin Package UI to the standalone K2 semantic adapter. + * + * @param refactoring selected sources and destination parameters from the NetBeans UI + */ +class KotlinChangePackagePlugin( + private val refactoring: KotlinChangePackageRefactoring, +) : ProgressProviderAdapter(), RefactoringPlugin { + /** @return no preflight problem; sources are checked during prepare. */ + override fun preCheck(): Problem? = null + /** @return no fast problem; UI validates the package text. */ + override fun fastCheckParameters(): Problem? = null + /** @return no extra parameter problem. */ + override fun checkParameters(): Problem? = null + /** Cancels no external asynchronous work. */ + override fun cancelRequest() = Unit + + /** Adds selected source previews and one atomic package-change element. */ + override fun prepare(bag: RefactoringElementsBag): Problem? { + val selection = refactoring.selection + val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) + ?: ProjectUtils.getValidProject() + ?: return Problem(true, "Change Kotlin Package could not find the source project.") + val session = KotlinAnalysisAPISession.getSession(project) + selection.sourceFiles.forEach { source -> + val file = session.getKtFileForPath(source.path) + ?: return Problem(true, "Change Kotlin Package could not resolve ${source.nameExt}.") + bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source)) + } + bag.add(refactoring, KotlinChangePackageApplyElement(selection, project, refactoring)) + return null + } +} + +/** + * Applies one atomic package change and retains its transaction for Undo Last Refactoring. + * + * @param selection Kotlin sources whose paths must remain unchanged + * @param project owning NetBeans project + * @param refactoring requested package settings + */ +class KotlinChangePackageApplyElement( + private val selection: KotlinChangePackageSourceSelection, + private val project: org.netbeans.api.project.Project, + private val refactoring: KotlinChangePackageRefactoring, +) : SimpleRefactoringElementImplementation() { + private var transaction: KotlinRefactoringTransaction? = null + + /** @return preview label for this all-or-nothing operation. */ + override fun getText(): String = "Change Kotlin package" + /** @return preview label for this all-or-nothing operation. */ + override fun getDisplayText(): String = text + /** @return representative source lookup. */ + override fun getLookup(): Lookup = Lookups.fixed(selection.representativeFile) + /** @return representative source for preview placement. */ + override fun getParentFile(): FileObject = selection.representativeFile + /** @return source bounds when the editor support is available. */ + override fun getPosition(): PositionBounds? = runCatching { + 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)) + }.getOrNull() + + /** Runs K2 semantics and persists every changed document through one transaction. */ + override fun performChange() { + var pending: KotlinRefactoringTransaction? = null + runCatching { + val basePackage = refactoring.targetPackage.trim() + check(KotlinPackageTarget(project, selection.representativeFile).isValidPackage(basePackage)) { + "Target package is invalid: $basePackage" + } + val session = KotlinAnalysisAPISession.getSession(project) + val sourceByPath = selection.sourceFiles.associateBy(FileObject::getPath) + val targets = selection.sourceFiles.associate { source -> + val file = session.getKtFileForPath(source.path) + ?: error("Change Kotlin Package could not resolve writable source PSI: ${source.nameExt}.") + val segments = basePackage.split('.').filter(String::isNotEmpty) + selection.targetRelativePackageSegments(source) + file to FqName(segments.joinToString(".")) + } + val current = KotlinRefactoringTransaction() + pending = current + when (val outcome = KaChangePackageComputer(targets.keys.first()).apply(targets, refactoring.updateReferences)) { + is KaChangePackageComputer.ApplyOutcome.Conflicts -> + throw IllegalStateException(outcome.messages.joinToString("\n")) + is KaChangePackageComputer.ApplyOutcome.Error -> throw outcome.error + is KaChangePackageComputer.ApplyOutcome.Success -> { + outcome.changedFiles.forEach { (path, text) -> + val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(java.io.File(path))) + ?: error("Change Kotlin Package could not resolve changed file $path.") + val originalText = outcome.originalTexts[path] + ?: error("Change Kotlin Package has no original text snapshot for $path.") + current.captureExisting(file, originalText) + current.stageText(file, text) + } + current.commit() + transaction = current + pending = null + } + } + }.onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageApplyElement.performChange failed", error) } + runCatching { pending?.rollback() } + .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinChangePackageApplyElement rollback failed", error) } + KotlinAnalysisAPISession.invalidate(project) + } + + /** Restores all original package directives and usage texts. */ + override fun undoChange() { + runCatching { + val current = transaction + if (current == null) { + KotlinLogger.INSTANCE.logWarning("KotlinChangePackageApplyElement.undoChange: no retained transaction") + } else { + KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: restoring retained transaction") + current.undo() + transaction = null + KotlinLogger.INSTANCE.logInfo("KotlinChangePackageApplyElement.undoChange: transaction restored") + } + KotlinAnalysisAPISession.invalidate(project) + }.onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinChangePackageApplyElement.undoChange failed", error) + } + } +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoring.kt new file mode 100644 index 00000000..bbf05ae6 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoring.kt @@ -0,0 +1,35 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import org.netbeans.modules.refactoring.api.AbstractRefactoring +import org.openide.util.lookup.Lookups + +/** + * Carries selected Kotlin sources and requested package-change parameters through NetBeans. + * + * @param selection physical Kotlin sources whose package directives will change without relocation + */ +class KotlinChangePackageRefactoring( + val selection: KotlinChangePackageSourceSelection, +) : AbstractRefactoring(Lookups.fixed(*selection.sourceFiles.toTypedArray())) { + /** User-selected base package, empty for Kotlin's root package. */ + var targetPackage: String = "" + + /** Whether supported external Kotlin references and direct imports should be retargeted. */ + var updateReferences: Boolean = true +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelection.kt new file mode 100644 index 00000000..3cc43f85 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelection.kt @@ -0,0 +1,95 @@ +/******************************************************************************* + * 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 sources selected for a Change Kotlin Package operation. + * + * A directory selection changes only package directives. Its own directory name is therefore not + * appended to the user-entered package; only descendant directory segments are retained. + * + * @param sourceFiles physical Kotlin sources in deterministic relative-path order + * @param selectedDirectory selected folder, or `null` for one editor-selected file + */ +class KotlinChangePackageSourceSelection private constructor( + val sourceFiles: List, + val selectedDirectory: FileObject?, +) { + /** @return whether this selection originated from a folder node. */ + val isDirectory: Boolean + get() = selectedDirectory != null + + /** @return source used for project discovery and concise UI details. */ + val representativeFile: FileObject + get() = sourceFiles.first() + + /** @return concise label for the selected file or folder. */ + val displayName: String + get() = selectedDirectory?.nameExt ?: representativeFile.nameExt + + /** + * Computes relative descendant folders to append below the user-selected target package. + * + * @param file a member of [sourceFiles] + * @return empty for one file or a file directly in the selected folder + */ + fun targetRelativePackageSegments(file: FileObject): List { + require(file in sourceFiles) { "File is not part of this Change Package selection: ${file.path}" } + val directory = selectedDirectory ?: return emptyList() + return relativeSegments(directory, file.parent) + } + + companion object { + /** + * Creates a selection from one Kotlin file or recursively from one folder. + * + * @param source physical file or folder selected by the user + * @return a non-empty Kotlin selection, or `null` for unsupported content + */ + fun from(source: FileObject): KotlinChangePackageSourceSelection? { + if (!source.isValid) return null + if (!source.isFolder) { + return if (source.ext == "kt") KotlinChangePackageSourceSelection(listOf(source), null) else null + } + val files = collectKotlinFiles(source).sortedBy(FileObject::getPath) + return files.takeIf(List::isNotEmpty)?.let { KotlinChangePackageSourceSelection(it, source) } + } + + /** Recursively finds valid Kotlin files while deliberately leaving all other content alone. */ + 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 segments 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/resources/org/jetbrains/kotlin/layer.xml b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml index 1a0718ab..091d756c 100644 --- a/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml +++ b/Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml @@ -120,6 +120,13 @@ + + + + + + + @@ -150,6 +157,10 @@ + + + + @@ -314,6 +325,17 @@ + + + + + + + + + + + @@ -645,6 +667,10 @@ + + + + diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt new file mode 100644 index 00000000..4dbe5b0a --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -0,0 +1,209 @@ +/******************************************************************************* + * 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.KaChangePackageComputer +import org.jetbrains.kotlin.name.FqName +import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** Integration tests for the standalone K2 [KaChangePackageComputer]. */ +class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { + + /** Verifies Change Package rewrites a file despite a package/directory mismatch without moving it. */ + fun testApply_realSession_changesMismatchedPackageWithoutMovingFile() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-mismatch") + try { + val sourceDirectory = temporaryRoot.resolve("layout/source") + Files.createDirectories(sourceDirectory) + val sourcePath = sourceDirectory.resolve("Mismatch.kt") + Files.writeString(sourcePath, "package declared.somewhereElse\n\nfun mismatch() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-mismatch-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("target.changed")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package target.changed") == true) + assertTrue("Change Package must not move physical files", Files.isRegularFile(sourcePath)) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** Verifies one package change atomically retargets external Kotlin imports and code usages. */ + fun testApply_realSession_retargetsExternalImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-usage") + try { + val sourceDirectory = temporaryRoot.resolve("source") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(sourcePath, "package old.source\n\nfun moved() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.source.moved\n\nfun use() = moved()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-usage-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("new.changed")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals("package old.source\n\nfun moved() = 1\n", success.originalTexts[sourcePath.toString()]) + assertEquals( + "package usage\n\nimport old.source.moved\n\nfun use() = moved()\n", + success.originalTexts[usagePath.toString()], + ) + assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package new.changed") == true) + assertTrue(success.changedFiles[usagePath.toString()]?.contains("import new.changed.moved") == true) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a caller in the old package receives an explicit reference after its declaration moves. + * + * Before Change Package's upstream whole-file usage discovery was used, this no-import call was + * invisible to the adapter: it remained a stale simple name in the old package. + */ + fun testApply_realSession_retargetsSamePackageReferenceWithoutImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-same-package") + try { + val oldPackageDirectory = temporaryRoot.resolve("old/source") + Files.createDirectories(oldPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val callerPath = oldPackageDirectory.resolve("Caller.kt") + Files.writeString(sourcePath, "package old.source\n\nfun moved() = 1\n") + Files.writeString(callerPath, "package old.source\n\nfun use() = moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-same-package-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("new.changed")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertTrue(success.changedFiles[sourcePath.toString()]?.contains("package new.changed") == true) + assertEquals( + "A same-package caller without existing imports needs a separated import section", + "package old.source\n\nimport new.changed.moved\n\nfun use() = moved()\n", + success.changedFiles[callerPath.toString()], + ) + assertEquals( + "Every generated import change needs an exact pre-refactoring snapshot for Undo Last Refactoring", + "package old.source\n\nfun use() = moved()\n", + success.originalTexts[callerPath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** Verifies recursive-style per-file packages retarget imports for every selected source together. */ + fun testApply_realSession_supportsDistinctDescendantPackages() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-directory") + try { + val folder = temporaryRoot.resolve("physical/feature") + val internal = folder.resolve("internal") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(internal) + Files.createDirectories(usageDirectory) + val publicPath = folder.resolve("Public.kt") + val helperPath = internal.resolve("Helper.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(publicPath, "package old.feature\n\nfun publicApi() = helper()\n") + Files.writeString(helperPath, "package old.feature.internal\n\nfun helper() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.feature.publicApi\nimport old.feature.internal.helper\n\nfun use() = publicApi() + helper()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-directory-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + 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 outcome = KaChangePackageComputer(publicFile).apply( + mapOf(publicFile to FqName("new.target"), helperFile to FqName("new.target.internal")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertTrue(success.changedFiles[publicPath.toString()]?.contains("package new.target") == true) + assertTrue(success.changedFiles[helperPath.toString()]?.contains("package new.target.internal") == true) + assertTrue(success.changedFiles[usagePath.toString()]?.contains("import new.target.publicApi") == true) + assertTrue(success.changedFiles[usagePath.toString()]?.contains("import new.target.internal.helper") == true) + assertTrue("Change Package must retain the first physical path", Files.isRegularFile(publicPath)) + assertTrue("Change Package must retain the nested physical path", Files.isRegularFile(helperPath)) + } finally { + temporaryRoot.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) + .firstOrNull { it.fileName?.toString()?.startsWith("kotlin-stdlib") == true && it.toFile().exists() } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt new file mode 100644 index 00000000..c389242f --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageDirectoryActionTest.kt @@ -0,0 +1,65 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import org.netbeans.junit.NbTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** Unit tests for [KotlinChangePackageDirectoryAction]. */ +class KotlinChangePackageDirectoryActionTest : NbTestCase("KotlinChangePackageDirectoryActionTest") { + + /** Verifies source discovery can run outside the Event Dispatch Thread before UI marshalling. */ + fun testAsynchronous_allowsBackgroundSourceDiscovery() { + assertTrue( + "K2 source discovery may run in NetBeans' RequestProcessor before UI is marshalled to EDT", + TestAction().isAsynchronous(), + ) + } + + /** Verifies the directory action has exactly one folder-context layer registration. */ + fun testLayer_registersFolderActionWithoutGlobalMenuDuplicate() { + val layer = repositoryRoot().resolve("Nbm/src/main/resources/org/jetbrains/kotlin/layer.xml") + val contents = Files.readString(layer) + val actionName = "KotlinChangePackageDirectoryAction.shadow" + + assertEquals("Folder context action must have exactly one dedicated layer shadow", 1, contents.countOccurrences(actionName)) + assertTrue("Folder context action must be registered in Loaders/folder/any/Actions", contents.contains("")) + } + + /** Finds the checkout root from the NBM module's Surefire working directory. */ + private fun repositoryRoot(): Path { + var candidate: Path? = Path.of("").toAbsolutePath() + while (candidate != null) { + if (Files.isRegularFile(candidate.resolve("pom.xml")) && Files.isDirectory(candidate.resolve("Nbm"))) { + return candidate + } + candidate = candidate.parent + } + fail("Could not locate repository root from ${Path.of("").toAbsolutePath()}") + error("unreachable") + } + + /** Counts non-overlapping literal [needle] occurrences in this source text. */ + private fun String.countOccurrences(needle: String): Int = + split(needle).size - 1 + + /** Exposes NodeAction's protected dispatcher contract for this focused test. */ + private class TestAction : KotlinChangePackageDirectoryAction() { + fun isAsynchronous(): Boolean = asynchronous() + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoringTest.kt new file mode 100644 index 00000000..48497892 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageRefactoringTest.kt @@ -0,0 +1,41 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import org.jetbrains.kotlin.refactorings.rename.KotlinRefactoringsFactory +import org.netbeans.junit.NbTestCase +import org.openide.filesystems.FileUtil + +/** Unit tests for [KotlinChangePackageRefactoring]'s plugin-factory applicability. */ +class KotlinChangePackageRefactoringTest : NbTestCase("KotlinChangePackageRefactoringTest") { + + /** Verifies a folder carrier creates the plugin without requiring an editor document. */ + fun testFactory_createsPluginForDirectorySelectionWithoutStyledDocument() { + val root = FileUtil.createMemoryFileSystem().root + val folder = root.createFolder("feature") + folder.createData("Public.kt") + val selection = KotlinChangePackageSourceSelection.from(folder) ?: error("Expected Kotlin selection") + val refactoring = KotlinChangePackageRefactoring(selection) + + val plugin = KotlinRefactoringsFactory().createInstance(refactoring) + + assertTrue( + "Change Package folder selection must not be rejected by the editor-document applicability guard", + plugin is KotlinChangePackagePlugin, + ) + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelectionTest.kt new file mode 100644 index 00000000..657badb6 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackageSourceSelectionTest.kt @@ -0,0 +1,78 @@ +/******************************************************************************* + * 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 [KotlinChangePackageSourceSelection]. */ +class KotlinChangePackageSourceSelectionTest : NbTestCase("KotlinChangePackageSourceSelectionTest") { + + /** Verifies a folder selection includes only Kotlin descendants in deterministic 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") + + val selection = KotlinChangePackageSourceSelection.from(feature) ?: error("Expected Kotlin selection") + + assertTrue(selection.isDirectory) + assertEquals("feature", selection.displayName) + assertEquals(listOf(apiFile.path, helper.path), selection.sourceFiles.map(FileObject::getPath)) + } + + /** Verifies nested descendants retain only their relative folder hierarchy below the target package. */ + fun testTargetRelativePackageSegments_directoryExcludesSelectedFolderName() { + 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 = KotlinChangePackageSourceSelection.from(feature) ?: error("Expected Kotlin selection") + + assertEquals(listOf("internal"), selection.targetRelativePackageSegments(helper)) + assertEquals(emptyList(), selection.targetRelativePackageSegments(rootFile)) + } + + /** Verifies an editor file selection has no extra target package hierarchy. */ + fun testFrom_fileCreatesSingleFileSelectionWithoutRelativeHierarchy() { + val root = FileUtil.createMemoryFileSystem().root + val file = root.createData("Single.kt") + + val selection = KotlinChangePackageSourceSelection.from(file) ?: error("Expected Kotlin selection") + + assertFalse(selection.isDirectory) + assertEquals(listOf(file), selection.sourceFiles) + assertEquals(emptyList(), selection.targetRelativePackageSegments(file)) + } + + /** Verifies non-Kotlin files and folders without Kotlin descendants are rejected. */ + fun testFrom_nonKotlinSourceReturnsNull() { + val root = FileUtil.createMemoryFileSystem().root + val resources = root.createFolder("resources") + resources.createData("config.json") + val text = root.createData("Notes.txt") + + assertNull(KotlinChangePackageSourceSelection.from(resources)) + assertNull(KotlinChangePackageSourceSelection.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 463f943b..6294b6b1 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRefactoringTransactionTest.kt @@ -112,6 +112,25 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio assertEquals("undo must restore the snapshot from before K2 PSI mutation", original, fixture.text(source)) } + /** Verifies undo removes an import that Change Package added to a same-package caller. */ + fun testCommitAndUndo_removesGeneratedSamePackageImport() { + val fixture = fixture() + val moved = fixture.existing("Moved.kt", "package new.target\n\nfun moved() = 1\n") + val caller = fixture.existing("Caller.kt", "package old.source\n\nimport new.target.moved\n\nfun use() = moved()\n") + val originalMoved = "package old.source\n\nfun moved() = 1\n" + val originalCaller = "package old.source\n\nfun use() = moved()\n" + + fixture.transaction.captureExisting(moved, originalMoved) + fixture.transaction.captureExisting(caller, originalCaller) + fixture.transaction.stageText(moved, "package new.target\n\nfun moved() = 1\n") + fixture.transaction.stageText(caller, "package old.source\n\nimport new.target.moved\n\nfun use() = moved()\n") + fixture.transaction.commit() + fixture.transaction.undo() + + assertEquals("package source must be restored", originalMoved, fixture.text(moved)) + assertEquals("undo must remove the generated import exactly", originalCaller, fixture.text(caller)) + } + /** Verifies several directory descendants move and undo together in reverse physical-move order. */ fun testCommitAndUndo_restoresEveryMovedDirectoryDescendant() { 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 78aed07f..e356e738 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, 19 partial, 5 absent, 24 total." + val totals = "**Baseline counts:** 0 complete, 20 partial, 4 absent, 24 total." assertTrue("Visible baseline totals must match the records", Files.readString(matrix).contains(totals)) - assertEquals(19, records.count { it.status == "partial" }) - assertEquals(5, records.count { it.status == "absent" }) + assertEquals(20, records.count { it.status == "partial" }) + assertEquals(4, records.count { it.status == "absent" }) } /** Finds the checkout root from Surefire's module working directory. */ diff --git a/docs/development-plan.md b/docs/development-plan.md index fea05930..b6c0e88d 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -764,6 +764,21 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E remaining F4.1 scope is generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA project-model conflict behavior. + - [x] **F4.2** — Change Kotlin Package (Refactor menu / folder context menu) + - Ports real upstream `K2ChangePackageDescriptor` and + `K2ChangePackageRefactoringProcessor` sources from `kotlin.refactorings.move.k2`. The copied + processor is patched only to remove IDEA's `BaseRefactoringProcessor` command/progress/dialog/ + listener lifecycle; its K2 usage discovery, conflict collection, package-directive rewrite, + declaration mapping, and retargeting sequence remain the semantic engine. + - **Refactor → Change Kotlin Package...** changes the active Kotlin file's package without + moving it. A folder-node invocation recursively changes only Kotlin descendants; direct + children use the entered package while nested descendants append their relative folder paths. + The selected folder's name is not added because its physical path remains unchanged. + - Supported Kotlin code references and direct non-aliased imports update atomically through + `KotlinRefactoringTransaction`; **Undo Last Refactoring** restores every source and usage + document. Java/comment/text/index-only references, full IDEA project-model conflicts, and + arbitrary multi-selection remain outside the standalone scope. + - [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-change-kotlin-package.md b/docs/manual-test-change-kotlin-package.md new file mode 100644 index 00000000..7a20106b --- /dev/null +++ b/docs/manual-test-change-kotlin-package.md @@ -0,0 +1,68 @@ +# Manual Test — Change Kotlin Package + +Use the NBM built from the `feature/f4-2-change-kotlin-package` branch. Ensure NetBeans starts +with the required JVM `--add-opens` flags from `CLAUDE.md`. + +## 1. Change the active Kotlin file package + +1. Create or open a Kotlin project with: + ```kotlin + // src/main/kotlin/old/source/Moved.kt + package old.source + + fun moved() = 1 + ``` +2. Create a caller: + ```kotlin + // src/main/kotlin/usage/Usage.kt + package usage + + import old.source.moved + + fun use() = moved() + ``` +3. Open `Moved.kt`, select **Refactor → Change Kotlin Package...**, enter `new.changed`, leave + **Update Kotlin references** selected, and apply. +4. Verify: + - `Moved.kt` remains physically at `old/source/Moved.kt`. + - Its directive is `package new.changed`. + - `Usage.kt` imports `new.changed.moved` and its call remains valid. +5. Choose **Refactor → Undo Last Refactoring**. Verify both files' exact original text and the + original physical path are restored. + +## 2. Change a recursive folder package in place + +1. Create a directory `src/main/kotlin/physical/feature` containing: + ```kotlin + // physical/feature/Public.kt + package old.feature + + fun publicApi() = helper() + ``` + ```kotlin + // physical/feature/internal/Helper.kt + package old.feature.internal + + fun helper() = 1 + ``` +2. Add a separate caller importing both declarations. +3. Right-click the **feature** folder and select **Refactor → Change Kotlin Package...**. +4. Enter `new.target`, retain **Update Kotlin references**, and apply. +5. Verify: + - `Public.kt` is still in `physical/feature` and declares `package new.target`. + - `Helper.kt` is still in `physical/feature/internal` and declares + `package new.target.internal`. + - The caller imports `new.target.publicApi` and `new.target.internal.helper`. + - No directory was created, deleted, renamed, or moved. +6. Use **Undo Last Refactoring** and verify every package directive and import returns exactly to + its original state. + +## 3. Boundaries and validation + +1. Put a non-Kotlin file such as `README.txt` below the selected folder. Repeat the folder test and + verify it remains unchanged. +2. Try an invalid package such as `not-valid` or `123.start`; verify the dialog blocks refactoring. +3. Add a Java usage, a comment mention, and a string mention of the old FQ name. Verify the dialog + describes the limitation and those non-Kotlin/non-code occurrences are not claimed as updated. +4. Trigger a known Kotlin name/visibility conflict where feasible. Verify the refactoring does not + leave partially changed source or import documents. diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md index 5de7b62b..91fbf33f 100644 --- a/docs/refactoring-coverage.md +++ b/docs/refactoring-coverage.md @@ -29,7 +29,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, 19 partial, 5 absent, 24 total. +**Baseline counts:** 0 complete, 20 partial, 4 absent, 24 total. ## Current NetBeans refactorings @@ -69,6 +69,7 @@ The corresponding record comments above are deliberately machine-readable. `Refa | `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 | +| `change-package` | Change Package | Refactor → Change Kotlin Package... / folder → Change Kotlin Package... | Partial | Changes the active Kotlin file or recursively selected folder's Kotlin descendants without moving physical paths; nested descendants retain relative package segments below the entered package, supported Kotlin code/direct imports update atomically, and Undo Last Refactoring restores every original text. Java/comment/text/index-only references, full IDEA project-model conflicts, and arbitrary multi-selection remain unavailable. | F4 | | `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 | @@ -80,7 +81,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 | -| `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 0007f98e..6cdb1b89 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.11.245-SNAPSHOT + 0.11.249-SNAPSHOT pom Netbeans Plugin Kotlin