From 234b15ade519cedb2f6f5c7db248cf2e122b59db Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Sun, 1 Nov 2026 22:48:07 +0300 Subject: [PATCH 01/55] Changed Rename on a Kotlin package folder to rename the directory together with its packages MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Refactor → Rename on a package folder now renames the physical directory, derives every descendant's package from its resulting path (including files whose package did not match the directory), and updates supported Kotlin imports and references. Rename Kotlin Package Directory... stays directory-only, Change Kotlin Package... stays package-only, and Rename is no longer offered for the src/main/kotlin and src/test/kotlin source roots. On a .kt file, Rename also renames a matching top-level Kotlin class and its supported Kotlin references. KaChangePackageComputer keeps the copied IDEA processor's usage discovery, conflict checking, and package rewriting, but retargets references from the captured source text: in standalone NetBeans, rewriting a package directive invalidates every PSI smart pointer held by a K2 UsageInfo, which made retargetUsagesAfterMove crash with PsiInvalidElementAccessException. Imports and fully qualified references are now rewritten by FQN, and callers that lose an implicit same-package binding receive an explicit import. Also added: - KotlinPackageRenameMapping for path-derived target package calculation. - RegistryValue.addListener(RegistryValueListener, Disposable) so the bundled Kotlin code links against the local registry stub. Co-Authored-By: Claude Code --- CHANGELOG.md | 1 + CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 10 +- .../refactoring/KaChangePackageComputer.kt | 226 +++++++++++++---- Nbm/pom.xml | 2 +- .../openapi/util/registry/RegistryValue.java | 8 + .../refactoring/KotlinFilesystemRenameUI.java | 44 +++- .../KotlinActionsImplementationProvider.java | 4 +- .../nbm/refactoring/CaretRestoreOnUndo.kt | 15 ++ .../refactoring/KotlinChangePackagePlugin.kt | 15 -- .../refactoring/KotlinPackageRenameMapping.kt | 66 +++++ .../KotlinProjectFolderRenameSelection.kt | 8 +- .../KotlinRenameDirectoryRefactoring.kt | 10 +- .../KotlinRenameFileRefactoring.kt | 9 +- .../nbm/refactoring/KotlinRenamePlugin.kt | 238 ++++++++++++++++-- .../nbm/refactoring/KaChangePackageTest.kt | 54 ++++ .../KotlinFilesystemRenameUITest.kt | 69 +++++ .../KotlinPackageRenameMappingTest.kt | 112 +++++++++ .../KotlinProjectFolderRenameSelectionTest.kt | 8 +- .../KotlinRefactoringTransactionTest.kt | 24 ++ .../KotlinRenameRefactoringTest.kt | 32 ++- docs/development-plan.md | 23 +- docs/refactoring-coverage.md | 4 +- pom.xml | 2 +- 32 files changed, 867 insertions(+), 135 deletions(-) create mode 100644 Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMapping.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUITest.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMappingTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index c06474f6a..7ca2adceb 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,3 +1,4 @@ +- Changed **Refactor → Rename** for a Kotlin package folder to rename both the directory and the package: every Kotlin file inside receives the package derived from its new path (including files whose package did not match the directory), imports and supported Kotlin references in other files are updated, and **Undo Last Refactoring** restores the original paths, texts, and open-editor carets. On a Kotlin file, it also renames a matching top-level class and its supported Kotlin references. **Rename Kotlin Package Directory...** still renames only the directory, **Change Kotlin Package...** still changes only the package, and Rename is no longer offered for the `src/main/kotlin` and `src/test/kotlin` source roots. - Added Java to Kotlin conversion: **Refactor → Convert to Kotlin** (in the main menu, the Java editor's right-click menu, and the context menu of a Java file in the Projects/Files windows) opens the standard Refactoring window with the file to convert, and pressing **Refactor** runs the conversion under NetBeans' progress dialog, which closes when the conversion finishes; the Java file is then replaced with a sibling Kotlin file produced by the IntelliJ IDEA K2 converter (getters/setters become properties, static-only classes become `object`s, inner classes become `inner class`es); nullability annotations on the Java declarations are carried over into Kotlin `?` types, the Kotlin file appears only after the conversion succeeds, a failed conversion leaves the Java file untouched, and **Undo Last Refactoring** restores the Java file and removes the Kotlin one in a single step. # 0.11.59 (2026-10-26) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 32c2fb11d..339887878 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 588d93e69..f966c43de 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 7dbc1684b..a63a58dbd 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 0e8cf7ac9..cac8bc8cb 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 90f9d87d7..62a13c6b6 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 2940d8217..2fcd4ad31 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 74365e0a7..e07680858 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 21367336d..a9af97786 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index fd0210b7d..420b8c437 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 181f05392..84cc54a13 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring @@ -2018,8 +2018,10 @@ if (k2MoveFilesProcessor.exists()) { // 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. +// this lifecycle-free port for its actual usage discovery, conflict check, and package rewrite. +// Reference retargeting is NOT done here: in standalone mode updatePackageDirective invalidates +// every PSI smart pointer held by UsageInfo, so the computer rewrites imports and qualified +// references in the captured source text instead. def k2ChangePackageProcessor = new File(srcDir, 'org/jetbrains/kotlin/idea/k2/refactoring/move/processor/K2ChangePackageRefactoringProcessor.kt') if (k2ChangePackageProcessor.exists()) { def text = k2ChangePackageProcessor.text @@ -2048,7 +2050,7 @@ if (k2ChangePackageProcessor.exists()) { 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 }') + ' fun findConflicts(usages: List): com.intellij.util.containers.MultiMap =\n findAllMoveConflicts(descriptor.files, descriptor.target, usages)\n\n fun updatePackages() {\n descriptor.files.forEach { it.updatePackageDirective(descriptor.target) }\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 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 index 337ad4db3..2a93c07a8 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -23,24 +23,34 @@ 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.name.Name 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. + * retains the upstream algorithm's usage discovery, conflict pass, and package rewrite, then + * retargets supported Kotlin references by rewriting captured source text. It deliberately leaves + * filesystem mutations to no one: changing a package never changes a physical path. + * + * Source text is used for reference retargeting because mutating a package directive invalidates + * every standalone PSI smart pointer, including those held by K2 [UsageInfo] objects. The IDEA + * implementation survives this only because its documents are synchronised between the phases. * * @param ktFile representative writable K2-session source file */ class KaChangePackageComputer( private val ktFile: KtFile, ) { + private companion object { + /** Matches a file-level Kotlin package directive in source text. */ + val PACKAGE_DIRECTIVE = Regex("(?m)^package\\s+[^\\s;]+") + + /** Matches a non-aliased Kotlin import directive in source text. */ + val IMPORT_DIRECTIVE = Regex("(?m)^import\\s+([A-Za-z_][A-Za-z0-9_.]*)\\s*$") + } /** Describes the result of applying package changes to one or more Kotlin source files. */ sealed class ApplyOutcome { @@ -82,18 +92,19 @@ class KaChangePackageComputer( ) } // 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. + // its findUsages() also reports whole-file internal references, so a same-package caller + // without any import is still known and gets an explicit import once the package changes. 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 -> + val movedDeclarations = files.flatMap { file -> file.declarations.mapNotNull { declaration -> (declaration as? KtNamedDeclaration)?.name?.let { name -> - MoveImport(file.packageFqName, name) to targets.getValue(file) + file.packageFqName.child(Name.identifier(name)) to + targets.getValue(file).child(Name.identifier(name)) } } }.toMap() @@ -104,24 +115,35 @@ class KaChangePackageComputer( } val importOnlyUsageFiles = if (updateReferences) { KotlinMoveUsageSearchService.getInstance() - ?.findImportingFiles(ktFile.project, movedImports) + ?.findImportingFiles( + ktFile.project, + movedDeclarations.mapKeys { (oldFqName, _) -> + MoveImport(oldFqName.parent(), oldFqName.shortName().asString()) + }.mapValues { (_, newFqName) -> newFqName.parent() }, + ) .orEmpty() } else { emptyList() } - val usageFiles = (filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys).distinct() - val originalTexts = (files + usageFiles).distinct().mapNotNull { file -> + val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys) + .distinct() + // Snapshot before any mutation: updatePackageDirective invalidates standalone PSI. + val originalTexts = participants.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) + + // Everything needed from PSI is captured above. From here on only text is touched, + // because a package-directive replacement invalidates standalone PSI stubs and every + // K2 UsageInfo smart pointer the upstream retargeter would dereference. + processors.forEach { processor -> processor.updatePackages() } 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()) + participants.forEach { file -> + val changedText = rewriteMovedReferences( + file.text, + movedDeclarations, + samePackageImportUpdates[file].orEmpty(), + ) file.virtualFile?.path?.let { changedFiles[it] = changedText } } ApplyOutcome.Success(changedFiles, originalTexts) @@ -133,8 +155,8 @@ class KaChangePackageComputer( /** * 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. + * Text-based retargeting can rewrite an existing import but cannot invent one, so discover the + * affected callers before directives change and later add only the imports they need. */ private fun findSamePackageImportUpdates( movedFiles: Collection, @@ -142,7 +164,7 @@ class KaChangePackageComputer( ): 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)) } + declaration.name?.let { name -> declaration to targets.getValue(file).child(Name.identifier(name)) } } }.toMap() if (movedDeclarations.isEmpty()) return emptyMap() @@ -151,7 +173,9 @@ class KaChangePackageComputer( 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 + val originalPackage = declaration.containingKtFile.packageFqName + val finalPackage = targets[file] + if (file.packageFqName != originalPackage || finalPackage == targetFqName.parent()) return@forEach updates.getOrPut(file, ::linkedSetOf) += targetFqName } } @@ -161,27 +185,30 @@ class KaChangePackageComputer( /** * 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. + * The header is rebuilt as text rather than through PSI: the import list is invalid after the + * package directive changed, and the standalone parser drops inserted whitespace nodes. * * @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()}" + private fun addSamePackageImports(usageText: String, imports: Set): String { + if (imports.isEmpty()) return usageText + val packageMatch = PACKAGE_DIRECTIVE.find(usageText) ?: return usageText + val existingFqNames = IMPORT_DIRECTIVE.findAll(usageText) + .map { match -> FqName(match.groupValues[1]) } + .toSet() + val missingImports = imports.filterNot { it in existingFqNames } + .map { imported -> "import ${imported.asString()}" } + if (missingImports.isEmpty()) return usageText + val firstImport = IMPORT_DIRECTIVE.find(usageText) + return if (firstImport == null) { + usageText.substring(0, packageMatch.range.last + 1) + + "\n\n${missingImports.joinToString(separator = "\n")}" + + usageText.substring(packageMatch.range.last + 1) + } else { + usageText.substring(0, firstImport.range.first) + + "${missingImports.joinToString(separator = "\n")}\n" + + usageText.substring(firstImport.range.first) } - 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. */ @@ -189,21 +216,114 @@ class KaChangePackageComputer( usages.mapNotNull { usage -> usage.element?.containingFile as? KtFile }.distinct() /** - * Replaces direct imports after K2 code retargeting. + * Rewrites imports and fully qualified Kotlin references without touching invalid standalone PSI. + * + * [K2ChangePackageRefactoringProcessor.updatePackages] invalidates every standalone PSI smart + * pointer. Source text has already been captured in [originalTexts] at that point, so update + * only FQN token sequences and import directives here; ordinary simple references continue to + * resolve through their rewritten imports. * - * 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. + * @param usageText source text read after the package directive changed + * @param movedDeclarations old declaration FQNs mapped to their new FQNs + * @param samePackageImports explicit imports needed by callers that formerly shared a package + * @return source text that preserves supported Kotlin bindings after the package change */ - 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))) + private fun rewriteMovedReferences( + usageText: String, + movedDeclarations: Map, + samePackageImports: Set, + ): String { + val retargetedText = if (movedDeclarations.isEmpty()) { + usageText + } else { + usageText.replaceFqNames(movedDeclarations) } + return addSamePackageImports(retargetedText, samePackageImports) } + + /** + * Replaces the longest matching declaration FQN at each position, outside comments and strings. + * + * Longest match wins so that a qualified access such as `a.b.C.member` keeps its suffix even + * when both `a.b.C` and a shorter prefix are moved declarations. + */ + private fun String.replaceFqNames(mappings: Map): String { + val candidates = mappings.entries + .map { (oldFqName, newFqName) -> oldFqName.asString() to newFqName.asString() } + .sortedByDescending { (oldFqName, _) -> oldFqName.length } + val result = StringBuilder(length) + var index = 0 + var inLineComment = false + var inBlockComment = false + var quote: Char? = null + while (index < length) { + val current = this[index] + val next = getOrNull(index + 1) + when { + inLineComment -> { + result.append(current) + if (current == '\n') inLineComment = false + index++ + } + inBlockComment -> { + result.append(current) + if (current == '*' && next == '/') { + result.append(next) + index += 2 + inBlockComment = false + } else { + index++ + } + } + quote != null -> { + result.append(current) + if (current == '\\' && next != null) { + result.append(next) + index += 2 + } else { + if (current == quote) quote = null + index++ + } + } + current == '/' && next == '/' -> { + result.append(current).append(next) + index += 2 + inLineComment = true + } + current == '/' && next == '*' -> { + result.append(current).append(next) + index += 2 + inBlockComment = true + } + current == '\'' || current == '\"' -> { + result.append(current) + quote = current + index++ + } + else -> { + val match = candidates.firstOrNull { (oldFqName, _) -> + startsWith(oldFqName, index) && canStartFqNameAt(index) && canEndFqNameAt(index + oldFqName.length) + } + if (match == null) { + result.append(current) + index++ + } else { + result.append(match.second) + index += match.first.length + } + } + } + } + return result.toString() + } + + /** Rejects a match that continues a longer identifier or qualified name on its left. */ + private fun String.canStartFqNameAt(index: Int): Boolean { + val previous = index - 1 + return previous !in indices || (!this[previous].isLetterOrDigit() && this[previous] != '_' && this[previous] != '.') + } + + /** Rejects a match that continues a longer identifier on its right; `.member` suffixes are kept. */ + private fun String.canEndFqNameAt(index: Int): Boolean = + index !in indices || (!this[index].isLetterOrDigit() && this[index] != '_') } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 3ba44a064..ae0898409 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/java/com/intellij/openapi/util/registry/RegistryValue.java b/Nbm/src/main/java/com/intellij/openapi/util/registry/RegistryValue.java index 4f1efc2d2..cd8b6e514 100644 --- a/Nbm/src/main/java/com/intellij/openapi/util/registry/RegistryValue.java +++ b/Nbm/src/main/java/com/intellij/openapi/util/registry/RegistryValue.java @@ -18,6 +18,14 @@ public RegistryValue(String key, String value) { public void setValue(String v) { this.value = v; } public void setValue(boolean v, com.intellij.openapi.Disposable d) { setValue(v); } + /** + * Registers a listener for compatibility with the IntelliJ registry API. + * + * @param listener registry value listener + * @param parentDisposable lifecycle owner for the listener + */ + public void addListener(RegistryValueListener listener, com.intellij.openapi.Disposable parentDisposable) {} + // Used by RegistryManager implementations public int get(int defaultValue) { return asInteger(); } public boolean get(boolean defaultValue) { return asBoolean(); } diff --git a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java index e611d9d84..fb94416d1 100644 --- a/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java +++ b/Nbm/src/main/java/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUI.java @@ -45,6 +45,8 @@ public final class KotlinFilesystemRenameUI implements RefactoringUI { private final AbstractRefactoring refactoring; private final boolean directory; + private final boolean updatePackage; + private final boolean updatePrimaryClass; private final String displayName; private RenamePanel panel; @@ -59,18 +61,34 @@ public KotlinFilesystemRenameUI(AbstractRefactoring refactoring, boolean directo this.refactoring = refactoring; this.directory = directory; this.displayName = displayName; + this.updatePackage = directory + && refactoring instanceof KotlinRenameDirectoryRefactoring + && ((KotlinRenameDirectoryRefactoring) refactoring).getUpdatePackage(); + this.updatePrimaryClass = !directory + && refactoring instanceof KotlinRenameFileRefactoring + && ((KotlinRenameFileRefactoring) refactoring).getUpdatePrimaryClass(); } /** @return user-visible operation title. */ @Override public String getName() { - return directory ? "Rename Kotlin Package Directory" : "Rename Kotlin File"; + if (!directory) { + return updatePrimaryClass ? "Rename Kotlin File and Class" : "Rename Kotlin File"; + } + return updatePackage ? "Rename Kotlin Package" : "Rename Kotlin Package Directory"; } /** @return concise description of the physical source to rename. */ @Override public String getDescription() { - return (directory ? "Rename Kotlin package directory '" : "Rename Kotlin file '") + displayName + "'"; + if (!directory) { + return updatePrimaryClass + ? "Rename Kotlin file '" + displayName + "' and a matching top-level class" + : "Rename Kotlin file '" + displayName + "'"; + } + return updatePackage + ? "Rename Kotlin package directory '" + displayName + "' and its package declarations" + : "Rename Kotlin package directory '" + displayName + "'"; } /** @return false because the operation changes project files. */ @@ -91,7 +109,7 @@ public CustomRefactoringPanel getPanel(ChangeListener parent) { String currentName = directory ? ((KotlinRenameDirectoryRefactoring) refactoring).getNewName() : ((KotlinRenameFileRefactoring) refactoring).getNewName(); - panel = new RenamePanel(directory, displayName, currentName, parent); + panel = new RenamePanel(directory, updatePackage, updatePrimaryClass, displayName, currentName, parent); } return panel; } @@ -151,11 +169,20 @@ private static final class RenamePanel implements CustomRefactoringPanel { * Builds the form. * * @param directory whether the form represents a package directory + * @param updatePackage whether packages and supported references follow the directory rename + * @param updatePrimaryClass whether a matching primary class and references follow the file rename * @param displayName source name shown to the user * @param currentName initial basename or folder name * @param listener recipient of state-change notifications */ - RenamePanel(boolean directory, String displayName, String currentName, ChangeListener listener) { + RenamePanel( + boolean directory, + boolean updatePackage, + boolean updatePrimaryClass, + String displayName, + String currentName, + ChangeListener listener + ) { nameField = new JTextField(currentName, 30); JPanel form = new JPanel(new GridBagLayout()); form.setBorder(BorderFactory.createEmptyBorder(8, 12, 8, 12)); @@ -176,8 +203,13 @@ private static final class RenamePanel implements CustomRefactoringPanel { form.add(new JLabel(""), label); form.add( new JLabel(directory - ? "The directory and its descendants are renamed; Kotlin packages and references are unchanged." - : "The .kt basename changes; packages and Java file-facade references are unchanged."), + ? (updatePackage + ? "The directory and its descendants are renamed; each Kotlin file receives the package " + + "derived from its new path and supported Kotlin references are updated." + : "The directory and its descendants are renamed; Kotlin packages and references are unchanged.") + : (updatePrimaryClass + ? "The .kt basename changes; a matching top-level class and supported Kotlin references are renamed." + : "The .kt basename changes; packages and Java file-facade references are unchanged.")), field ); component = new JPanel(new BorderLayout()); diff --git a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java index 07fd861e9..37f59b465 100644 --- a/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java +++ b/Nbm/src/main/java/org/jetbrains/kotlin/refactorings/rename/KotlinActionsImplementationProvider.java @@ -121,7 +121,7 @@ public void doRename(Lookup lookup) { private void openFileRename(FileObject file) { EventQueue.invokeLater(() -> UI.openRefactoringUI( new KotlinFilesystemRenameUI( - new io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFileRefactoring(file, file.getName()), + new io.github.nbplugins.kotlin.nbm.refactoring.KotlinRenameFileRefactoring(file, file.getName(), true), false, file.getNameExt()), TopComponent.getRegistry().getActivated())); @@ -195,7 +195,7 @@ private void openDirectoryRename(Lookup lookup, KotlinMoveSourceSelection select String initialName = KotlinProjectFolderRenameSelection.INSTANCE.initialName(directory, requestedName); EventQueue.invokeLater(() -> UI.openRefactoringUI( new KotlinFilesystemRenameUI( - new KotlinRenameDirectoryRefactoring(directory, initialName, selection), + new KotlinRenameDirectoryRefactoring(directory, initialName, selection, true), true, directory.getName()), TopComponent.getRegistry().getActivated())); diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/CaretRestoreOnUndo.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/CaretRestoreOnUndo.kt index c2fb5ea5c..2ee99cc28 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/CaretRestoreOnUndo.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/CaretRestoreOnUndo.kt @@ -76,6 +76,21 @@ internal fun restoreAfterEditorUpdate(restore: () -> Unit) { SwingUtilities.invokeLater { SwingUtilities.invokeLater(restore) } } +/** + * Reads the caret offset from an already-open editor pane for [file] without opening a new editor. + * + * Refactorings capture this before editing so native undo and Undo Last Refactoring can put each + * modified open editor back where the user triggered the operation. Files without an open pane have + * no user-visible caret and deliberately return `null`. + * + * @param file source file whose open editor pane should be inspected + * @return the current caret offset, or `null` when no editor pane is open + */ +internal fun openCaretOffset(file: FileObject): Int? = runCatching { + val cookie = DataObject.find(file).lookup.lookup(EditorCookie::class.java) ?: return null + cookie.openedPanes?.firstOrNull()?.caretPosition +}.getOrNull() + /** Sets the caret of [fo]'s first opened editor pane to [offset], clamped to the document. */ internal fun restoreCaret(fo: FileObject, offset: Int) { runCatching { diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt index d0c4a5f77..f69043c57 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinChangePackagePlugin.kt @@ -28,10 +28,8 @@ 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.cookies.EditorCookie import org.openide.filesystems.FileObject import org.openide.filesystems.FileUtil -import org.openide.loaders.DataObject import org.openide.text.CloneableEditorSupport import org.openide.text.PositionBounds import org.openide.util.Lookup @@ -187,19 +185,6 @@ class KotlinChangePackageApplyElement( KotlinAnalysisAPISession.invalidate(project) } - /** - * Returns the caret offset from an already-open editor for [file] without opening a new editor. - * - * Native undo must restore each modified open editor independently; unopened files do not have a - * user-visible caret and therefore deliberately receive no restoration edit. - */ - private fun openCaretOffset(file: FileObject): Int? = runCatching { - val cookie = DataObject.find(file).lookup.lookup(EditorCookie::class.java) ?: return null - cookie.openedPanes?.firstOrNull()?.caretPosition - }.onFailure { error -> - CHANGE_PACKAGE_LOGGER.log(Level.FINE, "Could not read an open caret for ${file.path}", error) - }.getOrNull() - /** Restores all original package directives, usage texts, and open-editor carets. */ override fun undoChange() { runCatching { diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMapping.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMapping.kt new file mode 100644 index 000000000..7eb8b1a86 --- /dev/null +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMapping.kt @@ -0,0 +1,66 @@ +/******************************************************************************* + * 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 + +/** + * Derives the Kotlin package that a source file must declare after its package directory is renamed. + * + * The mapping follows the physical package layout: every directory between the source root and the + * file becomes one package segment, with the renamed directory contributing its new simple name. + * This rewrites all descendants by path, including files whose previous directive did not match + * their directory. + */ +internal object KotlinPackageRenameMapping { + + /** + * Computes the post-rename package of [file]. + * + * @param root conventional Kotlin source root that contains [directory] + * @param directory directory whose simple name changes to [newName] + * @param newName target simple directory name, already validated by the rename form + * @param file Kotlin source located directly in [directory] or in one of its descendants + * @return dotted package derived from the file's path after the rename + * @throws IllegalArgumentException when the path is not below [root] or a segment is not a + * plain Kotlin identifier + */ + fun renamedPackage(root: FileObject, directory: FileObject, newName: String, file: FileObject): String { + val segments = relativeSegments(root, directory.parent) + newName + relativeSegments(directory, file.parent) + val invalid = segments.firstOrNull { !IDENTIFIER.matches(it) } + require(invalid == null) { + "Cannot derive a Kotlin package from the renamed path " + + "'${segments.joinToString(".")}': '$invalid' is not a Kotlin identifier." + } + return segments.joinToString(".") + } + + /** Returns [descendant]'s folder names below [ancestor], outermost first. */ + 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() + } + + /** Matches one plain Kotlin package segment accepted by the package-target validation. */ + private val IDENTIFIER = Regex("[A-Za-z_][A-Za-z0-9_]*") +} diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt index fdee9c155..c066b9f43 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelection.kt @@ -66,12 +66,16 @@ object KotlinProjectFolderRenameSelection { /** * Resolves [folder] against explicitly supplied roots. * + * A source root itself is rejected because generic Rename also renames the package derived from + * the path, and a root has no package segment. The explicit "Rename Kotlin Package + * Directory..." action uses a separate selection path and remains available for roots. + * * @param folder physical package folder selected by the user * @param roots conventional Kotlin roots belonging to the selected folder's project - * @return non-empty directory selection, or `null` when [folder] is outside [roots] + * @return non-empty directory selection, or `null` when [folder] is a root or is outside [roots] */ fun resolve(folder: FileObject, roots: Collection): KotlinMoveSourceSelection? { - if (roots.none { root -> folder == root.folder || FileUtil.isParentOf(root.folder, folder) }) { + if (roots.none { root -> FileUtil.isParentOf(root.folder, folder) }) { return null } return KotlinMoveSourceSelection.from(folder)?.takeIf(KotlinMoveSourceSelection::isDirectory) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt index fb3b9323f..12135ba60 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameDirectoryRefactoring.kt @@ -21,17 +21,21 @@ import org.openide.filesystems.FileObject import org.openide.util.lookup.Lookups /** - * Carries one explicit Kotlin package-directory rename through the NetBeans refactoring lifecycle. + * Carries one Kotlin package-directory rename through the NetBeans refactoring lifecycle. * - * The directory and every descendant are renamed physically; Kotlin package directives and code - * references are unchanged. Use Change Kotlin Package for semantic package/reference updates. + * Every descendant is always renamed physically. When [updatePackage] is `true` (generic Rename), + * every Kotlin descendant also receives the package derived from its post-rename path and supported + * Kotlin references are updated. When it is `false` (explicit "Rename Kotlin Package Directory..."), + * package directives and code references stay unchanged; use Change Kotlin Package for those. * * @param directory selected physical directory containing Kotlin descendants * @param newName target simple directory name * @param selection source files verified while the package-tree action was enabled, when available + * @param updatePackage whether Kotlin package directives and supported references follow the rename */ class KotlinRenameDirectoryRefactoring( val directory: FileObject, var newName: String, internal val selection: KotlinMoveSourceSelection? = null, + val updatePackage: Boolean = false, ) : AbstractRefactoring(Lookups.fixed(directory)) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt index 922378b4f..95ea3e93c 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameFileRefactoring.kt @@ -21,15 +21,20 @@ import org.openide.filesystems.FileObject import org.openide.util.lookup.Lookups /** - * Carries one explicit physical Kotlin-file rename through the NetBeans refactoring lifecycle. + * Carries one Kotlin-file rename through the NetBeans refactoring lifecycle. * * This carrier deliberately differs from [org.netbeans.modules.refactoring.api.RenameRefactoring]: - * the ordinary Rename refactoring remains responsible for caret-based declaration renames. + * the ordinary Rename refactoring remains responsible for caret-based declaration renames. When + * [updatePrimaryClass] is `true`, generic Rename also renames a top-level Kotlin class whose name + * matches the original file basename, together with its supported Kotlin references. * * @param file physical Kotlin source file to rename * @param newName target basename without the `.kt` extension + * @param updatePrimaryClass whether a matching top-level Kotlin class and its references follow + * this physical file rename */ class KotlinRenameFileRefactoring( val file: FileObject, var newName: String, + val updatePrimaryClass: Boolean = false, ) : AbstractRefactoring(Lookups.fixed(file)) diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index 6022e8d0c..7feb8644d 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -17,9 +17,17 @@ package io.github.nbplugins.kotlin.nbm.refactoring import io.github.nbplugins.kotlin.nbm.navigation.KotlinFindUsagesResultElement +import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceGroups import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession +import io.github.nbplugins.kotlin.refactoring.KaChangePackageComputer +import io.github.nbplugins.kotlin.refactoring.KaRenameComputer +import io.github.nbplugins.kotlin.refactoring.TextChange import org.jetbrains.kotlin.log.KotlinLogger +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.utils.ProjectUtils +import org.netbeans.api.project.Project import org.netbeans.modules.csl.api.OffsetRange import org.netbeans.modules.refactoring.api.Problem import org.netbeans.modules.refactoring.spi.ProgressProviderAdapter @@ -27,11 +35,15 @@ 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 import javax.swing.text.Position.Bias +import java.io.File +import java.util.LinkedHashMap /** * Creates the NetBeans refactoring lifecycle for an explicit Kotlin file rename. @@ -93,8 +105,12 @@ class KotlinRenameFileApplyElement( override fun performChange() { var pending: KotlinRefactoringTransaction? = null try { + if (refactoring.file.name == refactoring.newName) return val current = KotlinRefactoringTransaction() pending = current + if (refactoring.updatePrimaryClass) { + stagePrimaryClassRename(current) + } current.renameFile(refactoring.file, refactoring.newName, refactoring.file.ext) current.commit() transaction = current @@ -103,13 +119,53 @@ class KotlinRenameFileApplyElement( KotlinLogger.INSTANCE.logException("KotlinRenameFileApplyElement.performChange failed", error) rollbackAfterFailure(pending, error, "KotlinRenameFileApplyElement rollback failed") throw error + } finally { + if (refactoring.updatePrimaryClass) { + ProjectUtils.getKotlinProjectForFileObject(refactoring.file)?.let(KotlinAnalysisAPISession::invalidate) + } + } + } + + /** + * Stages the semantic rename of the single top-level class matching the old file basename. + * + * @param current transaction that writes the K2-generated texts before renaming the file + */ + private fun stagePrimaryClassRename(current: KotlinRefactoringTransaction) { + val project = ProjectUtils.getKotlinProjectForFileObject(refactoring.file) ?: return + KotlinAnalysisAPISession.invalidate(project) + val session = KotlinAnalysisAPISession.getSession(project) + session.synchronizeOpenDocuments() + val source = session.getKtFileForPath(refactoring.file.path) ?: return + val primaryClass = source.declarations.filterIsInstance() + .firstOrNull { it.name == refactoring.file.name } + ?: return + val allFiles = org.jetbrains.kotlin.builder.KotlinPsiManager.getFilesByProject(project) + val allKtFiles = allFiles.mapNotNull { session.getKtFileForPath(it.path) }.toSet() + val changes = KaRenameComputer( + source, + primaryClass.nameIdentifier?.textRange?.startOffset ?: return, + allKtFiles, + refactoring.newName, + ).compute() ?: return + val sourceByPath = allFiles.associateBy(FileObject::getPath) + changes.forEach { (path, textChanges) -> + val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(File(path))) + ?: error("Rename Kotlin File could not resolve changed file $path.") + val document = current.captureExisting(file) + val originalText = document.getText(0, document.length) + val finalText = applyTextChanges(originalText, textChanges) + current.stageHunkText(file, finalText, project, openCaretOffset(file)) } } - /** Restores the original basename. */ + /** Restores the original basename and any matching primary-class rename. */ override fun undoChange() { runCatching { transaction?.undo(); transaction = null } .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameFileApplyElement.undoChange failed", error) } + if (refactoring.updatePrimaryClass) { + ProjectUtils.getKotlinProjectForFileObject(refactoring.file)?.let(KotlinAnalysisAPISession::invalidate) + } } } @@ -122,11 +178,15 @@ class KotlinRenameDirectoryPlugin( private val refactoring: KotlinRenameDirectoryRefactoring, ) : ProgressProviderAdapter(), RefactoringPlugin { + /** @return operation name that matches whether package directives also change. */ + private val operationName: String + get() = if (refactoring.updatePackage) "Rename Kotlin Package" else "Rename Kotlin Package Directory" + /** @return a fatal problem when the selected folder has no Kotlin sources. */ override fun preCheck(): Problem? = if (directorySelection() != null) { null } else { - Problem(true, "Rename Kotlin Package Directory requires a folder containing Kotlin files.") + Problem(true, "$operationName requires a folder containing Kotlin files.") } /** @return a fatal problem when the target name is not a Kotlin identifier. */ @@ -138,18 +198,33 @@ class KotlinRenameDirectoryPlugin( /** Cancels no external work because the operation runs within the lifecycle. */ override fun cancelRequest() = Unit - /** Adds source previews and one atomic physical folder-rename apply element. */ + /** + * Validates the derived packages and adds source previews plus one atomic apply element. + * + * An invalid path-derived package is reported here, before any refactoring work starts. + */ override fun prepare(bag: RefactoringElementsBag): Problem? { val selection = directorySelection() - ?: return Problem(true, "Rename Kotlin Package Directory requires a folder containing Kotlin files.") + ?: return Problem(true, "$operationName requires a folder containing Kotlin files.") fastCheckParameters()?.let { return it } val project = ProjectUtils.getKotlinProjectForFileObject(selection.representativeFile) ?: ProjectUtils.getValidProject() - ?: return Problem(true, "Rename Kotlin Package Directory could not find the source project.") + ?: return Problem(true, "$operationName could not find the source project.") + if (refactoring.updatePackage && refactoring.directory.name != refactoring.newName) { + val root = kotlinSourceRootFor(project, refactoring.directory) + ?: return Problem(true, "$operationName could not find a Kotlin source root for ${refactoring.directory.path}.") + try { + selection.sourceFiles.forEach { source -> + KotlinPackageRenameMapping.renamedPackage(root, refactoring.directory, refactoring.newName, source) + } + } catch (error: IllegalArgumentException) { + return Problem(true, error.message ?: "$operationName could not derive the new package names.") + } + } val session = KotlinAnalysisAPISession.getSession(project) selection.sourceFiles.forEach { source -> val file = session.getKtFileForPath(source.path) - ?: return Problem(true, "Rename Kotlin Package Directory could not resolve ${source.nameExt}.") + ?: return Problem(true, "$operationName could not resolve ${source.nameExt}.") bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange(0, file.textLength), source)) } bag.add(refactoring, KotlinRenameDirectoryApplyElement(selection, project, refactoring)) @@ -164,7 +239,11 @@ class KotlinRenameDirectoryPlugin( } /** - * Renames a package directory physically and refreshes K2 after its path changes. + * Renames a package directory physically and, when requested, its Kotlin packages and references. + * + * The semantic mode runs K2 on the original paths, stages every returned text change, and lets one + * transaction write those documents before it renames the folder. This mirrors Move Kotlin File: + * conflicts are reported before any physical change, and undo restores paths and texts together. * * @param selection recursive Kotlin descendants of the selected directory * @param project owning NetBeans project whose session must discover the new path @@ -172,13 +251,15 @@ class KotlinRenameDirectoryPlugin( */ class KotlinRenameDirectoryApplyElement( private val selection: KotlinMoveSourceSelection, - private val project: org.netbeans.api.project.Project, + private val project: Project, private val refactoring: KotlinRenameDirectoryRefactoring, ) : SimpleRefactoringElementImplementation() { private var transaction: KotlinRefactoringTransaction? = null + private var undoCaretOffsets: Map = emptyMap() - /** @return preview label. */ - override fun getText(): String = "Rename Kotlin package directory" + /** @return preview label that reflects whether package directives also change. */ + override fun getText(): String = + if (refactoring.updatePackage) "Rename Kotlin package" else "Rename Kotlin package directory" /** @return preview label. */ override fun getDisplayText(): String = text @@ -193,24 +274,39 @@ class KotlinRenameDirectoryApplyElement( override fun getPosition(): PositionBounds? = positionFor(selection.representativeFile) /** - * Renames the physical folder, recreates K2's session after the filesystem path changes, and - * commits the retained transaction without changing Kotlin package directives or references. + * Renames the selected folder and commits the retained transaction. + * + * Generic Rename also derives every descendant's new package from its post-rename path, updates + * supported Kotlin references, and preserves open-editor carets. The explicit package-directory + * action keeps the previous physical-only behavior and refreshes K2 after the path change. */ override fun performChange() { var pending: KotlinRefactoringTransaction? = null try { + if (refactoring.updatePackage && refactoring.directory.name == refactoring.newName) { + return + } val current = KotlinRefactoringTransaction() pending = current - current.renameFolder(refactoring.directory, refactoring.newName) - current.applyFolderRenamesNow() - KotlinAnalysisAPISession.invalidate(project) - val refreshedSession = KotlinAnalysisAPISession.getSession(project) - selection.sourceFiles.forEach { source -> - check(refreshedSession.getKtFileForPath(source.path) != null) { - "Rename Kotlin Package Directory could not resolve ${source.nameExt} after folder rename." + if (refactoring.updatePackage) { + val carets = stageSemanticPackageRename(current) + current.commit() + carets.livePositions.forEach { (file, position) -> + restoreAfterEditorUpdate { restoreCaret(file, position.offset) } + } + undoCaretOffsets = carets.undoOffsets + } else { + current.renameFolder(refactoring.directory, refactoring.newName) + current.applyFolderRenamesNow() + KotlinAnalysisAPISession.invalidate(project) + val refreshedSession = KotlinAnalysisAPISession.getSession(project) + selection.sourceFiles.forEach { source -> + check(refreshedSession.getKtFileForPath(source.path) != null) { + "Rename Kotlin Package Directory could not resolve ${source.nameExt} after folder rename." + } } + current.commit() } - current.commit() transaction = current pending = null } catch (error: Throwable) { @@ -222,13 +318,97 @@ class KotlinRenameDirectoryApplyElement( } } - /** Restores the original physical directory name and refreshes K2's filesystem view. */ + /** + * Runs K2 Change Package semantics for the post-rename layout and stages every changed document. + * + * @param current transaction that will write the staged texts and then rename the folder + * @return caret snapshots captured before staging, used after commit and on undo + */ + private fun stageSemanticPackageRename(current: KotlinRefactoringTransaction): SemanticRenameCarets { + val directory = refactoring.directory + val root = kotlinSourceRootFor(project, directory) + ?: error("Rename Kotlin Package could not find a Kotlin source root for ${directory.path}.") + val packages = selection.sourceFiles.associateWith { source -> + KotlinPackageRenameMapping.renamedPackage(root, directory, refactoring.newName, source) + } + // Register the physical rename first so an occupied target is rejected before K2 analysis. + current.renameFolder(directory, refactoring.newName) + KotlinAnalysisAPISession.invalidate(project) + val session = KotlinAnalysisAPISession.getSession(project) + session.synchronizeOpenDocuments() + val sourceByPath = selection.sourceFiles.associateBy(FileObject::getPath) + val targets = selection.sourceFiles.associate { source -> + val file = session.getKtFileForPath(source.path) + ?: error("Rename Kotlin Package could not resolve writable source PSI: ${source.nameExt}.") + file to FqName(packages.getValue(source)) + } + return when (val outcome = KaChangePackageComputer(targets.keys.first()).apply(targets, updateReferences = true)) { + is KaChangePackageComputer.ApplyOutcome.Conflicts -> + throw IllegalStateException(outcome.messages.joinToString("\n")) + is KaChangePackageComputer.ApplyOutcome.Error -> throw outcome.error + is KaChangePackageComputer.ApplyOutcome.Success -> { + val undoOffsets = LinkedHashMap() + val livePositions = LinkedHashMap() + outcome.changedFiles.forEach { (path, text) -> + val file = sourceByPath[path] ?: FileUtil.toFileObject(FileUtil.normalizeFile(File(path))) + ?: error("Rename Kotlin Package could not resolve changed file $path.") + val document = current.captureExisting(file) + val originalOffset = openCaretOffset(file)?.coerceIn(0, document.length) + originalOffset?.let { + undoOffsets[file] = it + livePositions[file] = document.createPosition(it) + } + current.stageHunkText(file, text, project, originalOffset) + } + SemanticRenameCarets(undoOffsets, livePositions) + } + } + } + + /** Restores the original physical directory name, document texts, and open-editor carets. */ override fun undoChange() { - runCatching { transaction?.undo(); transaction = null; KotlinAnalysisAPISession.invalidate(project) } - .onFailure { error -> KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryApplyElement.undoChange failed", error) } + runCatching { + val current = transaction ?: return@runCatching + val caretOffsets = undoCaretOffsets + current.undo() + transaction = null + undoCaretOffsets = emptyMap() + caretOffsets.forEach { (file, offset) -> restoreAfterEditorUpdate { restoreCaret(file, offset) } } + }.onFailure { error -> + KotlinLogger.INSTANCE.logException("KotlinRenameDirectoryApplyElement.undoChange failed", error) + } + KotlinAnalysisAPISession.invalidate(project) } } +/** + * Caret snapshots for one semantic directory rename. + * + * @param undoOffsets pre-refactoring offsets restored by Undo Last Refactoring after texts are back + * @param livePositions document-tracked positions used right after commit, once hunks have shifted + */ +private class SemanticRenameCarets( + val undoOffsets: Map, + val livePositions: Map, +) + +/** + * Produces the final source text after applying non-overlapping [changes] in descending order. + * + * @param originalText source text that produced the K2 offsets + * @param changes K2 rename replacements for the source + * @return source text with every replacement applied + */ +internal fun applyTextChanges(originalText: String, changes: List): String = + StringBuilder(originalText).also { text -> + changes.sortedByDescending(TextChange::start).forEach { change -> + require(change.start in 0..text.length && change.end in change.start..text.length) { + "Invalid rename range [${change.start}, ${change.end}) for ${text.length}-character source." + } + text.replace(change.start, change.end, change.newText) + } + }.toString() + /** * Rolls back a transaction after an apply failure without concealing the originating failure. * @@ -249,6 +429,18 @@ private fun rollbackAfterFailure( } } +/** + * Finds the conventional Kotlin source root of [project] that contains [directory]. + * + * The most specific (deepest) root wins, which keeps the derived package independent of any future + * nested conventional root. + */ +private fun kotlinSourceRootFor(project: Project, directory: FileObject): FileObject? = + KotlinProjectSourceGroups.findRoots(project.projectDirectory) + .map { it.folder } + .filter { FileUtil.isParentOf(it, directory) } + .maxByOrNull { it.path.length } + /** Validates a physical Kotlin source before creating its filesystem rename lifecycle. */ private fun validateKotlinFile(file: FileObject): Problem? = when { !file.isValid || file.isFolder -> Problem(true, "Rename Kotlin File requires a valid Kotlin source file.") diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 1ee7fd44f..bc28f8192 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -251,6 +251,60 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** Verifies a directory rename's path-derived package rewrites every moved descendant together. */ + fun testApply_realSession_appliesOnePathDerivedPackageToAllDescendants() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-rename-package-directory") + try { + val folder = temporaryRoot.resolve("physical/before") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(folder) + Files.createDirectories(usageDirectory) + val firstPath = folder.resolve("First.kt") + val secondPath = folder.resolve("Second.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(firstPath, "package declared.pkg\n\nfun first() = 1\n") + Files.writeString(secondPath, "package declared.pkg\n\nfun second() = first()\n") + Files.writeString( + usagePath, + "package usage\n\nimport declared.pkg.first\n\nfun use() = first()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-package-directory-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val firstFile = session.getKtFileForPath(firstPath.toString()) ?: error("Could not obtain First.kt PSI") + val secondFile = session.getKtFileForPath(secondPath.toString()) ?: error("Could not obtain Second.kt PSI") + + val outcome = KaChangePackageComputer(firstFile).apply( + mapOf(firstFile to FqName("renamed"), secondFile to FqName("renamed")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val firstText = success.changedFiles[firstPath.toString()].orEmpty() + val secondText = success.changedFiles[secondPath.toString()].orEmpty() + assertTrue(firstText, firstText.contains("package renamed")) + assertTrue(secondText, secondText.contains("package renamed")) + assertFalse( + "a co-moved same-package caller must not gain an import: $secondText", + secondText.contains("import renamed.first"), + ) + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue( + "External import must follow the declaration; changed=${success.changedFiles.keys}, usage=$usageText", + usageText.contains("import renamed.first"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUITest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUITest.kt new file mode 100644 index 000000000..ac94326da --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUITest.kt @@ -0,0 +1,69 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.nbm.refactoring + +import org.netbeans.junit.NbTestCase +import org.openide.filesystems.FileUtil + +/** Unit tests for [KotlinFilesystemRenameUI] labels. */ +class KotlinFilesystemRenameUITest : NbTestCase("KotlinFilesystemRenameUITest") { + + /** Verifies the explicit action keeps its physical-only title and description. */ + fun testPhysicalDirectoryRenameLabels() { + val directory = FileUtil.createMemoryFileSystem().root.createFolder("before") + val ui = KotlinFilesystemRenameUI(KotlinRenameDirectoryRefactoring(directory, "after"), true, "before") + + assertEquals("Rename Kotlin Package Directory", ui.name) + assertTrue(ui.description, ui.description.contains("Rename Kotlin package directory 'before'")) + assertFalse("the physical-only description must not promise package updates", ui.description.contains("package declarations")) + } + + /** Verifies generic Rename announces that package declarations follow the directory rename. */ + fun testSemanticDirectoryRenameLabels() { + val directory = FileUtil.createMemoryFileSystem().root.createFolder("before") + val ui = KotlinFilesystemRenameUI( + KotlinRenameDirectoryRefactoring(directory, "after", updatePackage = true), + true, + "before", + ) + + assertEquals("Rename Kotlin Package", ui.name) + assertTrue(ui.description, ui.description.contains("package declarations")) + } + + /** Verifies the explicit file action keeps its physical-only labels. */ + fun testPhysicalFileRenameLabels() { + val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt") + val ui = KotlinFilesystemRenameUI(KotlinRenameFileRefactoring(file, "After"), false, "Before.kt") + + assertEquals("Rename Kotlin File", ui.name) + assertEquals("Rename Kotlin file 'Before.kt'", ui.description) + } + + /** Verifies generic file Rename announces the matching-class update. */ + fun testSemanticFileRenameLabels() { + val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt") + val ui = KotlinFilesystemRenameUI( + KotlinRenameFileRefactoring(file, "After", updatePrimaryClass = true), + false, + "Before.kt", + ) + + assertEquals("Rename Kotlin File and Class", ui.name) + assertTrue(ui.description, ui.description.contains("matching top-level class")) + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMappingTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMappingTest.kt new file mode 100644 index 000000000..db16e08db --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMappingTest.kt @@ -0,0 +1,112 @@ +/******************************************************************************* + * 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 [KotlinPackageRenameMapping]. */ +class KotlinPackageRenameMappingTest : NbTestCase("KotlinPackageRenameMappingTest") { + + /** Verifies a directory directly below the root yields a one-segment package. */ + fun testRenamedPackage_flatPackage() { + val root = root("src/main/kotlin") + val directory = FileUtil.createFolder(root, "sample") + val file = directory.createData("Sample.kt") + + assertEquals("renamed", rename(root, directory, "renamed", file)) + } + + /** Verifies a file in a nested directory keeps every descendant segment below the new name. */ + fun testRenamedPackage_keepsNestedDescendants() { + val root = root("src/main/kotlin") + val directory = FileUtil.createFolder(root, "sample") + val file = FileUtil.createFolder(directory, "sub").createData("Nested.kt") + + assertEquals("renamed.sub", rename(root, directory, "renamed", file)) + } + + /** Verifies renaming a nested directory retains the package segments above it. */ + fun testRenamedPackage_keepsAncestorSegments() { + val root = root("src/main/kotlin") + val directory = FileUtil.createFolder(root, "com/sample") + val file = FileUtil.createFolder(directory, "sub").createData("Nested.kt") + + assertEquals("com.renamed.sub", rename(root, directory, "renamed", file)) + } + + /** Verifies a file outside the renamed directory is rejected instead of silently misassigned. */ + fun testRenamedPackage_rejectsFileOutsideDirectory() { + val root = root("src/main/kotlin") + val directory = FileUtil.createFolder(root, "sample") + val outside = FileUtil.createFolder(root, "other").createData("Other.kt") + + assertFailsWithMessage("not below") { rename(root, directory, "renamed", outside) } + } + + /** Verifies a directory outside the supplied source root is rejected. */ + fun testRenamedPackage_rejectsDirectoryOutsideRoot() { + val root = root("src/main/kotlin") + val directory = root.parent.createFolder("outside") + val file = directory.createData("Outside.kt") + + assertFailsWithMessage("not below") { rename(root, directory, "renamed", file) } + } + + /** Verifies a path segment that cannot be a Kotlin package identifier is reported clearly. */ + fun testRenamedPackage_rejectsInvalidPathSegment() { + val root = root("src/main/kotlin") + val directory = FileUtil.createFolder(root, "sample") + val file = FileUtil.createFolder(directory, "my-dir").createData("Invalid.kt") + + assertFailsWithMessage("'my-dir' is not a Kotlin identifier") { + rename(root, directory, "renamed", file) + } + } + + /** Verifies an invalid target directory name is rejected before any filesystem change. */ + fun testRenamedPackage_rejectsInvalidNewName() { + val root = root("src/main/kotlin") + val directory = FileUtil.createFolder(root, "sample") + val file = directory.createData("Sample.kt") + + assertFailsWithMessage("'1bad' is not a Kotlin identifier") { rename(root, directory, "1bad", file) } + } + + /** Computes the post-rename package of [file] below [root]. */ + private fun rename(root: FileObject, directory: FileObject, newName: String, file: FileObject): String = + KotlinPackageRenameMapping.renamedPackage(root, directory, newName, file) + + /** Creates a memory filesystem root at [path]. */ + private fun root(path: String): FileObject = FileUtil.createMemoryFileSystem().root.let { + FileUtil.createFolder(it, path) + } + + /** Asserts that [action] throws an [IllegalArgumentException] containing [expectedMessage]. */ + private fun assertFailsWithMessage(expectedMessage: String, action: () -> Unit) { + try { + action() + fail("Expected IllegalArgumentException containing '$expectedMessage'") + } catch (error: IllegalArgumentException) { + assertTrue( + "Expected '$expectedMessage' in '${error.message}'", + error.message.orEmpty().contains(expectedMessage), + ) + } + } +} diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt index b89c6872f..91ff85d67 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinProjectFolderRenameSelectionTest.kt @@ -30,8 +30,8 @@ import org.openide.util.lookup.Lookups /** Unit tests for [KotlinProjectFolderRenameSelection]. */ class KotlinProjectFolderRenameSelectionTest : NbTestCase("KotlinProjectFolderRenameSelectionTest") { - /** Verifies conventional main and test Kotlin roots and their package descendants are eligible. */ - fun testResolve_acceptsConventionalKotlinRootsAndDescendants() { + /** Verifies package descendants of conventional Kotlin roots are eligible but the roots are not. */ + fun testResolve_acceptsKotlinPackageDescendantsAndRejectsRoots() { val project = FileUtil.createMemoryFileSystem().root val mainRoot = FileUtil.createFolder(project, "src/main/kotlin") val mainPackage = FileUtil.createFolder(mainRoot, "sample/main") @@ -41,9 +41,9 @@ class KotlinProjectFolderRenameSelectionTest : NbTestCase("KotlinProjectFolderRe testPackage.createData("Test.kt") val roots = roots(mainRoot, testRoot) - assertEquals(mainRoot, resolve(node(mainRoot), roots)?.selectedDirectory) + assertNull("generic Rename must not rename a source root", resolve(node(mainRoot), roots)) + assertNull("generic Rename must not rename a test source root", resolve(node(testRoot), roots)) assertEquals(mainPackage, resolve(node(mainPackage), roots)?.selectedDirectory) - assertEquals(testRoot, resolve(node(testRoot), roots)?.selectedDirectory) assertEquals(testPackage, resolve(node(testPackage), roots)?.selectedDirectory) } 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 e0e60a214..79fe0971e 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 @@ -724,6 +724,30 @@ class KotlinRefactoringTransactionTest : NbTestCase("KotlinRefactoringTransactio assertEquals("undo must retain the original package directive", originalText, fixture.textByPath(restored!!)) } + /** Verifies semantic text written on the original path survives the pending folder rename and undo. */ + fun testCommitAndUndo_writesStagedTextBeforeFolderRenameAndRestoresBoth() { + val fixture = fixture() + val folder = fixture.root.createFolder("before") + val originalText = "package before\n\nfun source() = 1\n" + val changedText = "package after\n\nfun source() = 1\n" + val source = fixture.existingIn(folder, "Source.kt", originalText) + + fixture.transaction.renameFolder(folder, "after") + fixture.transaction.captureExisting(source) + fixture.transaction.stageText(source, changedText, ::applyHunks) + fixture.transaction.commit() + + assertNotNull("commit must rename the selected folder", fixture.root.getFileObject("after/Source.kt")) + assertEquals("commit must write the semantic text before renaming", changedText, fixture.text(source)) + + fixture.transaction.undo() + + assertNotNull("undo must restore the original folder path", fixture.root.getFileObject("before/Source.kt")) + assertNull("undo must remove the renamed folder", fixture.root.getFileObject("after")) + assertTrue("the original FileObject remains valid after undo", source.isValid) + assertEquals("undo must restore the original package directive", originalText, fixture.text(source)) + } + /** Verifies commit deletes a scheduled file and undo recreates it with its original text. */ fun testCommitAndUndo_deletesFileAndRestoresItsText() { val fixture = fixture() diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt index bf0e9b15e..cb3accce7 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt @@ -32,9 +32,19 @@ class KotlinRenameRefactoringTest : NbTestCase("KotlinRenameRefactoringTest") { assertEquals(file, refactoring.file) assertEquals("After", refactoring.newName) + assertFalse("the explicit action must stay physical-only", refactoring.updatePrimaryClass) } - /** Verifies a directory carrier retains only the physical source and target folder name. */ + /** Verifies generic file Rename carries the matching-primary-class mode. */ + fun testFileCarrier_retainsPrimaryClassRenameMode() { + val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt") + + val refactoring = KotlinRenameFileRefactoring(file, "After", updatePrimaryClass = true) + + assertTrue("generic Rename must update a matching top-level class", refactoring.updatePrimaryClass) + } + + /** Verifies a directory carrier defaults to a physical-only rename. */ fun testDirectoryCarrier_retainsPhysicalSourceAndName() { val directory = FileUtil.createMemoryFileSystem().root.createFolder("before") @@ -42,6 +52,16 @@ class KotlinRenameRefactoringTest : NbTestCase("KotlinRenameRefactoringTest") { assertEquals(directory, refactoring.directory) assertEquals("after", refactoring.newName) + assertFalse("the explicit action must stay physical-only", refactoring.updatePackage) + } + + /** Verifies generic Rename carries the semantic package-update mode. */ + fun testDirectoryCarrier_retainsSemanticMode() { + val directory = FileUtil.createMemoryFileSystem().root.createFolder("before") + + val refactoring = KotlinRenameDirectoryRefactoring(directory, "after", updatePackage = true) + + assertTrue("generic Rename must update packages and references", refactoring.updatePackage) } /** Verifies the file carrier bypasses the declaration-rename document applicability guard. */ @@ -80,4 +100,14 @@ class KotlinRenameRefactoringTest : NbTestCase("KotlinRenameRefactoringTest") { assertNotNull("undo must restore the original physical filename", root.getFileObject("Before.kt")) assertNull("undo must remove the renamed physical filename", root.getFileObject("After.kt")) } + + /** Verifies K2 rename replacements are applied without offset drift. */ + fun testApplyTextChanges_appliesDescendingRanges() { + val changes = listOf( + io.github.nbplugins.kotlin.refactoring.TextChange(0, 3, "Baz", "Foo"), + io.github.nbplugins.kotlin.refactoring.TextChange(9, 12, "Baz", "Foo"), + ) + + assertEquals("Baz uses Baz", applyTextChanges("Foo uses Foo", changes)) + } } diff --git a/docs/development-plan.md b/docs/development-plan.md index 802d75933..541911ac8 100644 --- a/docs/development-plan.md +++ b/docs/development-plan.md @@ -797,13 +797,22 @@ Priority order: E1 → E2 → E3 → E4 → E5 → E6 → E7 → E8 → E9 → E - Adds an explicit **Refactor → Rename Kotlin File...** command for the active `.kt` file. It uses `KotlinRefactoringTransaction` to rename only the physical basename and supports **Undo Last Refactoring**; package declarations and Java file-facade references are deliberately - unchanged. - - Adds folder-node **Rename Kotlin Package Directory...**. The transaction renames the folder - first so real final `PsiDirectory` targets exist, refreshes the standalone K2 session, then - invokes the already ported `K2MoveFilesHandler` through `KaMoveFileComputer`. The K2 adapter - performs eligible package-directive rewrites, portable conflict detection, and supported Kotlin - reference retargeting; the transaction stages its text results and rolls both text and folder - paths back if any later step fails. + unchanged. The generic file **Rename** additionally renames a matching top-level Kotlin class + and its supported Kotlin references through K2 before renaming the file. + - Adds folder-node **Rename Kotlin Package Directory...**, which renames only the physical + directory and its descendants while leaving package directives and references unchanged. + - The generic folder **Rename** (available for package folders below a conventional source root, + not for `src/main/kotlin`/`src/test/kotlin` themselves) renames the directory together with its + Kotlin packages. Every descendant receives the package derived from its post-rename path + (including files whose directive did not match the directory), and `KaChangePackageComputer` + retargets supported Kotlin imports and references. Reference retargeting works on the captured + source text: in standalone mode rewriting a package directive invalidates every PSI smart + pointer held by K2 `UsageInfo`, so imports and fully qualified references are rewritten by FQN + token and callers that lose an implicit same-package binding receive an explicit import. The semantic pass runs on the original + paths first, stages minimal hunk texts through `KotlinRefactoringTransaction`, and lets the + single commit write those documents before renaming the folder, so conflicts are reported + before any physical change and Undo Last Refactoring restores paths, texts, and open-editor + carets together. **Change Kotlin Package...** remains the package-only operation. - The generic IDEA rename UI/lifecycle remains intentionally unported because it depends on writable IntelliJ VFS, indexed Java/file-facade search, automatic-renamer extensions, and action/command infrastructure unavailable in standalone NetBeans. Unicode/quoted names, diff --git a/docs/refactoring-coverage.md b/docs/refactoring-coverage.md index b27ea0373..75a581f1c 100644 --- a/docs/refactoring-coverage.md +++ b/docs/refactoring-coverage.md @@ -62,7 +62,7 @@ Remaining work to close each row's gap is tracked in [refactoring-backlog.md](re | ID | IDEA K2 family | NetBeans command | Status | Current coverage and material gap | Target | |---|---|---|---|---|---| -| `rename` | Rename | Rename (`Alt+Shift+R`) | Partial | K2 declaration/reference rename and override cascade are covered. Explicit filesystem rename commands cover Kotlin file basenames and package directories; IDEA automatic renamers and Java-bridge scenarios remain. | F7 | +| `rename` | Rename | Rename (`Alt+Shift+R`) | Partial | K2 declaration/reference rename and override cascade are covered. On a Kotlin package folder, Rename renames the directory together with every descendant's path-derived package and supported Kotlin references; on a Kotlin file, it also renames a matching top-level class and supported Kotlin references. **Rename Kotlin Package Directory...** stays physical-only and **Change Kotlin Package...** stays package-only; source roots are excluded from generic Rename. Explicit file rename covers only Kotlin file basenames. IDEA automatic renamers and Java-bridge scenarios remain. | F7 | | `safe-delete` | Safe Delete | Safe Delete (`Alt+Delete`) | Partial | Declaration usage check is available; IDEA supertype, type/value-argument, and Java-bridge cases remain. | F7 | | `change-signature` | Change Signature | `Ctrl+F6` | Partial | K2 signature engine handles calls, overrides, constructors, destructuring and operators; every touched document now commits or rolls back through one hunk-preserving transaction, while broader IDEA scenarios remain. | F1 | | `inline-variable-property` | Inline property | Inline (`Ctrl+Alt+N`) | Partial | IDEA code inliner supports local `val` and a local `var` with one declaration initializer and no later writes, invoked from a declaration or selected usage; a later assignment or increment/decrement is rejected before mutation with its expression identified. Receiver/member/accessor, comment, Java, and broader index-backed cases remain. | F3 | @@ -80,7 +80,7 @@ Remaining work to close each row's gap is tracked in [refactoring-backlog.md](re | `move-declaration` | Move top-level declaration | Refactor → Move Declaration | Partial | Real K2 move/retargeting engine is used; source/target mutations now roll back atomically and support Undo Last Refactoring, while only top-level declarations are exposed. | F1 | | `move-file` | Move Kotlin file/directory | Refactor → Move Kotlin File / folder → Move Kotlin Directory | Partial | Moves one Kotlin file or recursively moves Kotlin descendants of a selected folder to a selectable source root/package, retaining the selected folder and nested hierarchy; matching package directives and supported Kotlin code references are updated, and Undo Last Refactoring restores every original path/text. Generic non-Kotlin content, Java/comment/text/index-only references, and full IDEA filesystem behavior remain. | F4 | | `change-package` | Change Package | Refactor → Change Kotlin Package... / folder → Change Kotlin Package... | Partial | Changes the active Kotlin file or recursively selected folder's Kotlin descendants without moving physical paths; nested descendants retain relative package segments below the entered package, supported Kotlin code/direct imports update atomically, and Undo Last Refactoring restores every original text. Java/comment/text/index-only references, full IDEA project-model conflicts, and arbitrary multi-selection remain unavailable. | F4 | -| `rename-file-package-directory` | Rename file/package/directory | Refactor → Rename Kotlin File / folder → Rename Kotlin Package Directory | Partial | Explicit file rename changes one `.kt` basename and Undo Last Refactoring restores it. Directory rename preserves every physical descendant and restores paths on undo without changing package directives or Kotlin references; Change Kotlin Package remains the separate semantic operation for those changes. The K2 session is recreated after the path change so it discovers the renamed directory. Java file-facade, automatic-renamer, Java/comment/text/index-only, Unicode/quoted-name, and full IDEA project-model cases remain. | F7 | +| `rename-file-package-directory` | Rename file/package/directory | Refactor → Rename Kotlin File / folder → Rename Kotlin Package Directory | Partial | Explicit file rename changes one `.kt` basename and Undo Last Refactoring restores it; generic Rename additionally renames a matching top-level class and supported Kotlin references. The explicit directory command renames every physical descendant and restores paths on undo without changing package directives or Kotlin references; the generic folder Rename additionally derives each descendant's package from its new path and updates supported Kotlin references, staging K2 texts through the same transaction so undo restores paths and texts together. Even unchanged names are no-ops, and the K2 session is refreshed after the path change. Generic Rename is intentionally unavailable for source roots. Java file-facade, automatic-renamer, Java/comment/text/index-only, Unicode/quoted-name, non-identifier path segments, and full IDEA project-model cases remain. | F7 | | `move-nested-member-method` | Move nested class/member/method | Refactor → Move Nested Member... | Partial | Moves a nested named Kotlin class/object to a selected named class/object, or a companion function/property to a selected target with exactly one companion object. Source/target changes commit atomically and support Undo Last Refactoring. Instance members, constructors, enum entries, aliases, Java/non-code/comment/index-only cases, and unvalidated external qualified-use rewrites remain unavailable. | F4 | | `copy-declaration` | Copy declaration | Refactor → Copy / F5 | Partial | Top-level copy supports selectable source-root/package targets, internal retargeting, atomic rollback, and Undo Last Refactoring; nested declarations and broader IDEA target workflows remain. | F1 | | `extract-interface-superclass` | Extract Interface / Superclass | Refactor menu | Partial | K2 Extract Super engine atomically creates or restores its target together with the source, including Undo Last Refactoring; advanced constructors, generics and full conflicts remain. | F6 | diff --git a/pom.xml b/pom.xml index 8438a8cbe..69a1f544c 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.12-SNAPSHOT + 0.12.17-SNAPSHOT pom Netbeans Plugin Kotlin From e60b490dae4dd3dff97bd686cdf85fc0d7e51bb8 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 08:25:01 +0300 Subject: [PATCH 02/55] Fixed reference retargeting gaps in Kotlin package changes Text-based retargeting now adds imports for declarations that stay in a moved file's former package, rewrites fully qualified references inside ${...} string templates, leaves triple-quoted raw strings untouched, and never rewrites declarations moved out of the root package. A package directive inserted into a file that had none is separated from the following declaration. Co-Authored-By: Claude Code --- .../refactoring/KaChangePackageComputer.kt | 296 +++++++++++++++--- .../nbm/refactoring/KaChangePackageTest.kt | 176 +++++++++++ 2 files changed, 426 insertions(+), 46 deletions(-) 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 index 2a93c07a8..3bfc9f1a7 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -16,16 +16,23 @@ *******************************************************************************/ package io.github.nbplugins.kotlin.refactoring +import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo +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.k2.refactoring.move.descriptor.K2ChangePackageDescriptor import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2ChangePackageRefactoringProcessor +import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid /** * Applies IDEA K2 Change Package semantics without its IDE-specific processor lifecycle. @@ -109,7 +116,14 @@ class KaChangePackageComputer( } }.toMap() val samePackageImportUpdates = if (updateReferences) { - findSamePackageImportUpdates(files, targets) + // Incoming imports fix callers that lose visibility of a moved declaration; + // outgoing imports fix the moved file itself losing visibility of the declarations + // that stay behind in its former package. + val incomingImports = findSamePackageImportUpdates(files, targets) + val outgoingImports = findOutgoingSamePackageImports(files, targets) + (incomingImports.keys + outgoingImports.keys).associateWith { file -> + incomingImports[file].orEmpty() + outgoingImports[file].orEmpty() + } } else { emptyMap() } @@ -127,6 +141,10 @@ class KaChangePackageComputer( } val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys) .distinct() + // A root-package declaration's FQN is its bare name: replacing it textually would also + // rewrite the declaration itself (`fun moved` -> `fun dest.moved`). Root-package + // references are never package-qualified, so the imports above keep them bound. + val retargetableDeclarations = movedDeclarations.filterKeys { oldFqName -> !oldFqName.parent().isRoot } // Snapshot before any mutation: updatePackageDirective invalidates standalone PSI. val originalTexts = participants.mapNotNull { file -> file.virtualFile?.path?.let { path -> path to file.text } @@ -139,9 +157,10 @@ class KaChangePackageComputer( val changedFiles = linkedMapOf() participants.forEach { file -> + val text = targets[file]?.let { target -> ensurePackageDirectiveSeparator(file.text, target) } ?: file.text val changedText = rewriteMovedReferences( - file.text, - movedDeclarations, + text, + retargetableDeclarations, samePackageImportUpdates[file].orEmpty(), ) file.virtualFile?.path?.let { changedFiles[it] = changedText } @@ -182,6 +201,91 @@ class KaChangePackageComputer( return updates } + /** + * Finds declarations that stay in a moved file's former package and need an explicit import. + * + * Changing a package directive removes the implicit same-package visibility that such + * references rely on, and those references have no import directive to rewrite. Resolve the + * moved file's references before any directive is mutated and record the imports they need. + * + * Members reached through a receiver are skipped: only top-level declarations participate in + * same-package visibility, and a receiver whose text already spells out the package makes the + * reference self-sufficient. + * + * @param movedFiles Kotlin sources whose declared package changes + * @param targets mapped files with their final declared packages + * @return affected files mapped to the imports their references now require + */ + private fun findOutgoingSamePackageImports( + movedFiles: Collection, + targets: Map, + ): Map> { + val movedPaths = movedFiles.mapNotNull { file -> file.virtualFile?.path }.toSet() + val updates = linkedMapOf>() + movedFiles.forEach { file -> + val originalPackage = file.packageFqName + if (originalPackage.isRoot || targets[file] == originalPackage) return@forEach + val importedFqNames = file.importDirectives.mapNotNull { it.importedFqName }.toSet() + val starPackages = file.importDirectives + .filter { it.isAllUnder } + .mapNotNull { it.importedFqName } + .toSet() + val missingImports = linkedSetOf() + runCatching { + analyze(file) { + file.accept(object : KtTreeVisitorVoid() { + override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { + super.visitSimpleNameExpression(expression) + if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return + val reference = expression.references.filterIsInstance().firstOrNull() ?: return + val declaration = reference.resolveToSymbol()?.psi as? KtNamedDeclaration ?: return + val containingFile = declaration.containingKtFile + if (containingFile.virtualFile?.path in movedPaths) return + if (containingFile.packageFqName != originalPackage) return + if (declaration.parent !is KtFile) return + val name = declaration.name ?: return + val importFqName = originalPackage.child(Name.identifier(name)) + if (importFqName in importedFqNames || originalPackage in starPackages) return + if (expression.isPackageQualified(originalPackage)) return + missingImports += importFqName + } + }) + } + } + if (missingImports.isNotEmpty()) updates[file] = missingImports + } + return updates + } + + /** + * Tells whether [this] is the selector of a qualified access whose receiver already spells out + * [packageFqName], in which case the reference resolves without an import. + */ + private fun KtSimpleNameExpression.isPackageQualified(packageFqName: FqName): Boolean { + val qualified = parent as? KtDotQualifiedExpression ?: return false + if (qualified.selectorExpression != this) return false + val receiverText = qualified.receiverExpression.text + val packageName = packageFqName.asString() + return packageName == receiverText || packageName.startsWith("$receiverText.") + } + + /** + * Separates a package directive from the first declaration of a source that had none. + * + * [K2ChangePackageRefactoringProcessor.updatePackages] replaces the empty directive of a + * root-package file in place, which leaves `package dest` glued to the next declaration in + * standalone PSI. Restore the mandatory line break before references are rewritten. + * + * @return text whose package directive is followed by whitespace + */ + private fun ensurePackageDirectiveSeparator(text: String, targetPackage: FqName): String { + if (targetPackage.isRoot) return text + val directive = "package ${targetPackage.asString()}" + if (!text.startsWith(directive)) return text + val next = text.getOrNull(directive.length) ?: return text + return if (next.isWhitespace()) text else "$directive\n\n${text.substring(directive.length)}" + } + /** * Adds imports for former same-package declarations that no longer resolve implicitly. * @@ -241,75 +345,175 @@ class KaChangePackageComputer( return addSamePackageImports(retargetedText, samePackageImports) } + /** Lexical context of the source scanner; only code contexts allow FQN replacement. */ + private sealed class ScanMode { + /** Ordinary Kotlin code, including package and import directives. */ + object Code : ScanMode() + + /** Executable `${...}` expression inside a string literal, where code resumes. */ + class Template(var braceDepth: Int) : ScanMode() + + /** Double-quoted string literal, where only a `${...}` template re-enters code. */ + object StringLiteral : ScanMode() + + /** Triple-quoted raw string, where only a `${...}` template re-enters code. */ + object RawString : ScanMode() + + /** Character literal, whose content is never a reference. */ + object CharacterLiteral : ScanMode() + + /** Block comment; Kotlin block comments nest. */ + class BlockComment(var depth: Int) : ScanMode() + } + /** - * Replaces the longest matching declaration FQN at each position, outside comments and strings. + * Replaces the longest matching declaration FQN at each code position. * + * Comments, character literals, string literals, and raw strings are copied verbatim, except + * that executable `${...}` template expressions re-enter code mode: a reference such as + * `"${old.pkg.moved()}"` is code, not the string-text occurrence excluded by the refactoring. * Longest match wins so that a qualified access such as `a.b.C.member` keeps its suffix even - * when both `a.b.C` and a shorter prefix are moved declarations. + * when both `a.b.C` and a shorter prefix are moved declarations. The package directive is + * never rewritten: it can share its text with a declaration's FQN. */ private fun String.replaceFqNames(mappings: Map): String { val candidates = mappings.entries .map { (oldFqName, newFqName) -> oldFqName.asString() to newFqName.asString() } .sortedByDescending { (oldFqName, _) -> oldFqName.length } + val packageRange = PACKAGE_DIRECTIVE.find(this)?.range val result = StringBuilder(length) + val modes = ArrayDeque() + modes.addLast(ScanMode.Code) var index = 0 - var inLineComment = false - var inBlockComment = false - var quote: Char? = null while (index < length) { val current = this[index] val next = getOrNull(index + 1) - when { - inLineComment -> { - result.append(current) - if (current == '\n') inLineComment = false - index++ + when (val mode = modes.last()) { + ScanMode.Code, is ScanMode.Template -> { + val template = mode as? ScanMode.Template + when { + current == '/' && next == '/' -> { + val lineEnd = indexOf('\n', index).let { if (it < 0) length else it } + result.append(this, index, lineEnd) + index = lineEnd + } + current == '/' && next == '*' -> { + result.append(current).append(next) + index += 2 + modes.addLast(ScanMode.BlockComment(1)) + } + current == '"' && getOrNull(index + 1) == '"' && getOrNull(index + 2) == '"' -> { + result.append("\"\"\"") + index += 3 + modes.addLast(ScanMode.RawString) + } + current == '"' -> { + result.append(current) + index++ + modes.addLast(ScanMode.StringLiteral) + } + current == '\'' -> { + result.append(current) + index++ + modes.addLast(ScanMode.CharacterLiteral) + } + template != null && current == '{' -> { + result.append(current) + index++ + template.braceDepth++ + } + template != null && current == '}' -> { + result.append(current) + index++ + if (--template.braceDepth <= 0) modes.removeLast() + } + else -> { + val match = if (packageRange != null && index in packageRange) { + null + } else { + candidates.firstOrNull { (oldFqName, _) -> + startsWith(oldFqName, index) && + canStartFqNameAt(index) && + canEndFqNameAt(index + oldFqName.length) + } + } + if (match == null) { + result.append(current) + index++ + } else { + result.append(match.second) + index += match.first.length + } + } + } } - inBlockComment -> { - result.append(current) - if (current == '*' && next == '/') { - result.append(next) + ScanMode.StringLiteral -> when { + current == '\\' -> { + result.append(current) + if (next != null) { + result.append(next) + index += 2 + } else { + index++ + } + } + current == '$' && next == '{' -> { + result.append(current).append(next) index += 2 - inBlockComment = false - } else { + modes.addLast(ScanMode.Template(1)) + } + else -> { + result.append(current) index++ + if (current == '"') modes.removeLast() } } - quote != null -> { - result.append(current) - if (current == '\\' && next != null) { - result.append(next) + ScanMode.RawString -> when { + current == '$' && next == '{' -> { + result.append(current).append(next) index += 2 - } else { - if (current == quote) quote = null + modes.addLast(ScanMode.Template(1)) + } + current == '"' && getOrNull(index + 1) == '"' && getOrNull(index + 2) == '"' -> { + result.append("\"\"\"") + index += 3 + modes.removeLast() + } + else -> { + result.append(current) index++ } } - current == '/' && next == '/' -> { - result.append(current).append(next) - index += 2 - inLineComment = true - } - current == '/' && next == '*' -> { - result.append(current).append(next) - index += 2 - inBlockComment = true - } - current == '\'' || current == '\"' -> { - result.append(current) - quote = current - index++ + ScanMode.CharacterLiteral -> when { + current == '\\' -> { + result.append(current) + if (next != null) { + result.append(next) + index += 2 + } else { + index++ + } + } + else -> { + result.append(current) + index++ + if (current == '\'') modes.removeLast() + } } - else -> { - val match = candidates.firstOrNull { (oldFqName, _) -> - startsWith(oldFqName, index) && canStartFqNameAt(index) && canEndFqNameAt(index + oldFqName.length) + is ScanMode.BlockComment -> when { + current == '/' && next == '*' -> { + result.append(current).append(next) + index += 2 + mode.depth++ + } + current == '*' && next == '/' -> { + result.append(current).append(next) + index += 2 + if (--mode.depth <= 0) modes.removeLast() } - if (match == null) { + else -> { result.append(current) index++ - } else { - result.append(match.second) - index += match.first.length } } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index bc28f8192..77121536c 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -305,6 +305,182 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a moved file explicitly imports declarations that remain in its former package. + * + * Same-package visibility disappears with the package directive and leaves no import directive + * to rewrite, so without a captured import the reference would silently become unresolved. + */ + fun testApply_realSession_addsImportForOutgoingSamePackageReference() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing") + try { + val oldPackageDirectory = temporaryRoot.resolve("old/source") + Files.createDirectories(oldPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val staysPath = oldPackageDirectory.resolve("Stays.kt") + Files.writeString(sourcePath, "package old.source\n\nfun moved() = stays()\n") + Files.writeString(staysPath, "package old.source\n\nfun stays() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-outgoing-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( + "A moved file must import declarations that stay in its former package", + "package new.changed\n\nimport old.source.stays\n\nfun moved() = stays()\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies fully qualified references inside `${...}` templates follow a moved declaration. + * + * A template expression is executable Kotlin code, not the string-text occurrence excluded from + * the refactoring, so string tracking must re-enter code mode inside the braces. + */ + fun testApply_realSession_rewritesQualifiedReferenceInsideStringTemplate() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-template") + 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() = \"\${old.source.moved()}\"\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-template-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue("A template expression must be retargeted: $usageText", usageText.contains("\${new.changed.moved()}")) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies raw strings with interior quotes keep their literal text after reference retargeting. + * + * Treating the first interior quote as the end of the raw string made later literal text look + * like code, silently changing runtime string values. + */ + fun testApply_realSession_keepsRawStringLiteralWithInteriorQuotes() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-raw-string") + 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\n" + + "val text = \"\"\"say \"old.source.moved\" now\"\"\"\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-raw-string-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue("The import must still follow the declaration: $usageText", usageText.contains("import new.changed.moved")) + assertTrue( + "Raw string content must stay verbatim: $usageText", + usageText.contains("say \"old.source.moved\" now"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies moving a file out of the root package never rewrites its own declaration names. + * + * Root-package declarations have single-segment FQNs, so token replacement of the old name + * turned `fun moved()` into `fun dest.moved()`. Bindings survive through imports instead. + */ + fun testApply_realSession_keepsDeclarationNamesWhenMovingFromDefaultPackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-default-package") + try { + val sourcePath = temporaryRoot.resolve("Moved.kt") + Files.writeString(sourcePath, "fun moved() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-default-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val changedText = success.changedFiles[sourcePath.toString()].orEmpty() + assertTrue("The new package directive is required: $changedText", changedText.contains("package dest")) + assertTrue("Declaration names must survive the package change: $changedText", changedText.contains("fun moved() = 1")) + assertFalse("A declaration must never be rewritten to a qualified name: $changedText", changedText.contains("dest.moved")) + } 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")) From 9dbfc0f3bcb13275173a6e13b0cc4d2dcf1f09cc Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 08:50:06 +0300 Subject: [PATCH 03/55] Fixed reference retargeting for wildcard imports, default-package imports, and unbound chains Callers that imported the whole old package with a wildcard now receive an explicit import for the moved declaration, while the wildcard itself is kept for declarations that stay behind. Explicit imports of default-package declarations are rewritten to the new package even though bare-name code references remain untouched to protect declaration names. Replaced the lexical fully qualified name scanner with replacement ranges captured from PSI before the package directive changes: direct import directives plus qualified chains that K2 resolved to a moved declaration. Comments, string literals, default-package declaration names, and receiver chains bound to unrelated members are therefore never rewritten, while template expressions still follow moved declarations. Added integration tests for wildcard importers, default-package imports, and receiver chains that only spell a moved FQN. Co-Authored-By: Claude Code --- .../refactoring/KaChangePackageComputer.kt | 397 ++++++++---------- .../nbm/refactoring/KaChangePackageTest.kt | 144 +++++++ 2 files changed, 326 insertions(+), 215 deletions(-) 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 index 3bfc9f1a7..a7a4d4490 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -27,12 +27,15 @@ import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2ChangePackageRe import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtUserType /** * Applies IDEA K2 Change Package semantics without its IDE-specific processor lifecycle. @@ -98,9 +101,8 @@ class KaChangePackageComputer( ), ) } - // Use the copied upstream Change Package processor rather than the F4.1 Move File helper: - // its findUsages() also reports whole-file internal references, so a same-package caller - // without any import is still known and gets an explicit import once the package changes. + // Reuse the copied upstream Change Package processor for usage discovery, conflict + // checking, and package rewriting; only its IDE-bound retargeting pass is replaced. val usages = if (updateReferences) processors.flatMap { it.findUsages().asIterable() }.distinct() else emptyList() val conflicts = processors.flatMap { processor -> processor.findConflicts(usages.filterIsInstance()).values() @@ -116,10 +118,11 @@ class KaChangePackageComputer( } }.toMap() val samePackageImportUpdates = if (updateReferences) { - // Incoming imports fix callers that lose visibility of a moved declaration; - // outgoing imports fix the moved file itself losing visibility of the declarations + // Incoming imports fix callers that lose visibility of a moved declaration, either + // because they shared its package or because they imported the whole old package. + // Outgoing imports fix the moved file itself losing visibility of the declarations // that stay behind in its former package. - val incomingImports = findSamePackageImportUpdates(files, targets) + val incomingImports = findIncomingImportUpdates(files, targets) val outgoingImports = findOutgoingSamePackageImports(files, targets) (incomingImports.keys + outgoingImports.keys).associateWith { file -> incomingImports[file].orEmpty() + outgoingImports[file].orEmpty() @@ -141,10 +144,14 @@ class KaChangePackageComputer( } val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys) .distinct() - // A root-package declaration's FQN is its bare name: replacing it textually would also - // rewrite the declaration itself (`fun moved` -> `fun dest.moved`). Root-package - // references are never package-qualified, so the imports above keep them bound. - val retargetableDeclarations = movedDeclarations.filterKeys { oldFqName -> !oldFqName.parent().isRoot } + // Capture replacement ranges while PSI is still valid. Only usages that K2 resolved to + // a moved declaration and direct import directives are retargeted, so text that merely + // spells a moved FQN (comments, strings, unrelated receiver chains) is left alone. + val textEdits = if (updateReferences) { + captureTextEdits(participants, movedDeclarations, usages) + } else { + emptyMap() + } // Snapshot before any mutation: updatePackageDirective invalidates standalone PSI. val originalTexts = participants.mapNotNull { file -> file.virtualFile?.path?.let { path -> path to file.text } @@ -157,13 +164,13 @@ class KaChangePackageComputer( val changedFiles = linkedMapOf() participants.forEach { file -> + val path = file.virtualFile?.path ?: return@forEach + val originalText = originalTexts[path] ?: return@forEach val text = targets[file]?.let { target -> ensurePackageDirectiveSeparator(file.text, target) } ?: file.text - val changedText = rewriteMovedReferences( - text, - retargetableDeclarations, - samePackageImportUpdates[file].orEmpty(), - ) - file.virtualFile?.path?.let { changedFiles[it] = changedText } + // Captured offsets refer to the original text; the package directive is the only + // text before them that changed, so a single length delta realigns all edits. + val retargetedText = applyTextEdits(text, textEdits[file].orEmpty(), text.length - originalText.length) + changedFiles[path] = addSamePackageImports(retargetedText, samePackageImportUpdates[file].orEmpty()) } ApplyOutcome.Success(changedFiles, originalTexts) } catch (error: Throwable) { @@ -172,12 +179,23 @@ class KaChangePackageComputer( } /** - * Finds old-package simple references that will lose their implicit package visibility. + * Finds callers that will lose visibility of a moved declaration and need an explicit import. * - * Text-based retargeting can rewrite an existing import but cannot invent one, so discover the - * affected callers before directives change and later add only the imports they need. + * A caller loses that visibility in two ways: it formerly shared the declaration's package, or + * it imported the whole old package with a wildcard. A direct import is rewritten elsewhere and + * keeps the binding, and a caller that ends in the declaration's final package keeps the + * implicit binding. Text-based retargeting cannot invent an import, so these callers are + * discovered from resolved references before any directive changes. + * + * Every moved declaration is searched directly rather than through the processor's usages: + * the processor only reports functions, properties, and classes, while a moved type alias also + * loses its implicit same-package binding. + * + * @param movedFiles Kotlin sources whose declared package changes + * @param targets mapped files with their final declared packages + * @return affected files mapped to the imports their references now require */ - private fun findSamePackageImportUpdates( + private fun findIncomingImportUpdates( movedFiles: Collection, targets: Map, ): Map> { @@ -190,11 +208,24 @@ class KaChangePackageComputer( val session = KotlinMoveUsageSearchService.getInstance() ?: return emptyMap() val updates = linkedMapOf>() movedDeclarations.forEach { (declaration, targetFqName) -> + val name = declaration.name ?: return@forEach + val declarationFile = declaration.containingKtFile + val originalPackage = declarationFile.packageFqName + val oldFqName = originalPackage.child(Name.identifier(name)) session.findUsages(declaration).forEach { reference -> val file = reference.element?.containingFile as? KtFile ?: return@forEach - val originalPackage = declaration.containingKtFile.packageFqName - val finalPackage = targets[file] - if (file.packageFqName != originalPackage || finalPackage == targetFqName.parent()) return@forEach + if (file.virtualFile?.path == declarationFile.virtualFile?.path) return@forEach + val finalPackage = targets[file] ?: file.packageFqName + if (finalPackage == targetFqName.parent()) return@forEach + val importsDirectly = file.importDirectives.any { directive -> + !directive.isAllUnder && directive.importedFqName == oldFqName + } + if (importsDirectly) return@forEach + val sharesOriginalPackage = file.packageFqName == originalPackage + val importsOriginalPackage = file.importDirectives.any { directive -> + directive.isAllUnder && directive.importedFqName == originalPackage + } + if (!sharesOriginalPackage && !importsOriginalPackage) return@forEach updates.getOrPut(file, ::linkedSetOf) += targetFqName } } @@ -287,7 +318,7 @@ class KaChangePackageComputer( } /** - * Adds imports for former same-package declarations that no longer resolve implicitly. + * Adds imports that no longer arrive implicitly from the package or a wildcard import. * * The header is rebuilt as text rather than through PSI: the import list is invalid after the * package directive changed, and the standalone parser drops inserted whitespace nodes. @@ -319,215 +350,151 @@ class KaChangePackageComputer( private fun filesWithUsages(usages: Collection): List = usages.mapNotNull { usage -> usage.element?.containingFile as? KtFile }.distinct() + /** One replacement of `[start, end)` in the source text captured before package mutation. */ + private data class TextEdit(val start: Int, val end: Int, val replacement: String) + /** - * Rewrites imports and fully qualified Kotlin references without touching invalid standalone PSI. + * Captures the exact text ranges that must follow moved declarations. * - * [K2ChangePackageRefactoringProcessor.updatePackages] invalidates every standalone PSI smart - * pointer. Source text has already been captured in [originalTexts] at that point, so update - * only FQN token sequences and import directives here; ordinary simple references continue to - * resolve through their rewritten imports. + * Only two kinds of text are captured: direct import directives and reference chains that K2 + * resolved to a moved declaration before mutation. Everything else — comments, string literals, + * declaration names, and receiver chains that merely spell the same text — is left alone. * - * @param usageText source text read after the package directive changed - * @param movedDeclarations old declaration FQNs mapped to their new FQNs - * @param samePackageImports explicit imports needed by callers that formerly shared a package - * @return source text that preserves supported Kotlin bindings after the package change + * @param participants files whose text may change + * @param movedDeclarations old top-level declaration FQNs mapped to their new FQNs + * @param usages external usages resolved before package mutation + * @return per-file replacements keyed by captured source offsets */ - private fun rewriteMovedReferences( - usageText: String, + private fun captureTextEdits( + participants: Collection, movedDeclarations: Map, - samePackageImports: Set, - ): String { - val retargetedText = if (movedDeclarations.isEmpty()) { - usageText - } else { - usageText.replaceFqNames(movedDeclarations) + usages: Collection, + ): Map> { + if (movedDeclarations.isEmpty()) return emptyMap() + val mappings = movedDeclarations.entries + .sortedByDescending { (oldFqName, _) -> oldFqName.pathSegments().size } + val edits = linkedMapOf>() + participants.forEach { file -> + file.importDirectives.forEach { directive -> + if (directive.isAllUnder) return@forEach + val importedText = directive.importPath?.pathStr ?: return@forEach + val replacement = rewriteImportedPath(importedText, mappings) ?: return@forEach + val offset = directive.textRange.startOffset + directive.text.indexOf(importedText) + edits.getOrPut(file, ::linkedSetOf) += + TextEdit(offset, offset + importedText.length, replacement) + } + } + usages.filterIsInstance().forEach { usage -> + val element = usage.element as? KtElement ?: return@forEach + val file = element.containingFile as? KtFile ?: return@forEach + val declaration = usage.referencedElement as? KtNamedDeclaration ?: return@forEach + val name = declaration.name ?: return@forEach + val oldFqName = declaration.containingKtFile.packageFqName.child(Name.identifier(name)) + val newFqName = movedDeclarations[oldFqName] ?: return@forEach + // A root-package FQN is a bare name. Qualifying such a reference would rewrite + // declaration-like positions that cannot take a package prefix (`::moved`); imports + // alone keep root-package bindings intact. + if (oldFqName.parent().isRoot) return@forEach + val chain = element.qualifiedChain() + if (chain.text != oldFqName.asString()) return@forEach + edits.getOrPut(file, ::linkedSetOf) += TextEdit( + chain.startOffset, + chain.endOffset, + newFqName.asString(), + ) } - return addSamePackageImports(retargetedText, samePackageImports) + return edits.mapValues { (_, fileEdits) -> fileEdits.toList() } } - /** Lexical context of the source scanner; only code contexts allow FQN replacement. */ - private sealed class ScanMode { - /** Ordinary Kotlin code, including package and import directives. */ - object Code : ScanMode() - - /** Executable `${...}` expression inside a string literal, where code resumes. */ - class Template(var braceDepth: Int) : ScanMode() - - /** Double-quoted string literal, where only a `${...}` template re-enters code. */ - object StringLiteral : ScanMode() - - /** Triple-quoted raw string, where only a `${...}` template re-enters code. */ - object RawString : ScanMode() - - /** Character literal, whose content is never a reference. */ - object CharacterLiteral : ScanMode() - - /** Block comment; Kotlin block comments nest. */ - class BlockComment(var depth: Int) : ScanMode() + /** + * Rewrites the package prefix of an imported path, keeping alias and nested-class suffixes. + * + * Import paths are compared segment by segment rather than as token sequences so a backticked + * segment still matches its plain FQN. + * + * @param importedText path text as written in the directive + * @param mappings moved declarations ordered from the longest FQN to the shortest + * @return retargeted path text, or `null` when the import does not target a moved declaration + */ + private fun rewriteImportedPath( + importedText: String, + mappings: List>, + ): String? { + val segments = importedText.split('.') + mappings.forEach { (oldFqName, newFqName) -> + val oldSegments = oldFqName.pathSegments().map { segment -> segment.asString() } + if (segments.size < oldSegments.size) return@forEach + val prefix = segments.take(oldSegments.size).map { segment -> segment.removeSurrounding("`") } + if (prefix != oldSegments) return@forEach + val suffix = segments.drop(oldSegments.size) + val separator = if (suffix.isEmpty()) "" else "." + return newFqName.asString() + suffix.joinToString(separator = ".", prefix = separator) + } + return null } + /** Source range and text of the qualified chain that ends at a reference expression. */ + private data class QualifiedChain(val startOffset: Int, val endOffset: Int, val text: String) + /** - * Replaces the longest matching declaration FQN at each code position. + * Extends a reference expression through the qualified receiver chain that contains it. * - * Comments, character literals, string literals, and raw strings are copied verbatim, except - * that executable `${...}` template expressions re-enter code mode: a reference such as - * `"${old.pkg.moved()}"` is code, not the string-text occurrence excluded by the refactoring. - * Longest match wins so that a qualified access such as `a.b.C.member` keeps its suffix even - * when both `a.b.C` and a shorter prefix are moved declarations. The package directive is - * never rewritten: it can share its text with a declaration's FQN. + * `old.pkg.moved()` visits the `moved` reference; climbing through qualified expressions, their + * call nodes, and user types yields the `old.pkg.moved` text while dropping any argument list + * or type argument suffix. The chain stops at any other construct. */ - private fun String.replaceFqNames(mappings: Map): String { - val candidates = mappings.entries - .map { (oldFqName, newFqName) -> oldFqName.asString() to newFqName.asString() } - .sortedByDescending { (oldFqName, _) -> oldFqName.length } - val packageRange = PACKAGE_DIRECTIVE.find(this)?.range - val result = StringBuilder(length) - val modes = ArrayDeque() - modes.addLast(ScanMode.Code) - var index = 0 - while (index < length) { - val current = this[index] - val next = getOrNull(index + 1) - when (val mode = modes.last()) { - ScanMode.Code, is ScanMode.Template -> { - val template = mode as? ScanMode.Template - when { - current == '/' && next == '/' -> { - val lineEnd = indexOf('\n', index).let { if (it < 0) length else it } - result.append(this, index, lineEnd) - index = lineEnd - } - current == '/' && next == '*' -> { - result.append(current).append(next) - index += 2 - modes.addLast(ScanMode.BlockComment(1)) - } - current == '"' && getOrNull(index + 1) == '"' && getOrNull(index + 2) == '"' -> { - result.append("\"\"\"") - index += 3 - modes.addLast(ScanMode.RawString) - } - current == '"' -> { - result.append(current) - index++ - modes.addLast(ScanMode.StringLiteral) - } - current == '\'' -> { - result.append(current) - index++ - modes.addLast(ScanMode.CharacterLiteral) - } - template != null && current == '{' -> { - result.append(current) - index++ - template.braceDepth++ - } - template != null && current == '}' -> { - result.append(current) - index++ - if (--template.braceDepth <= 0) modes.removeLast() - } - else -> { - val match = if (packageRange != null && index in packageRange) { - null - } else { - candidates.firstOrNull { (oldFqName, _) -> - startsWith(oldFqName, index) && - canStartFqNameAt(index) && - canEndFqNameAt(index + oldFqName.length) - } - } - if (match == null) { - result.append(current) - index++ - } else { - result.append(match.second) - index += match.first.length - } - } - } - } - ScanMode.StringLiteral -> when { - current == '\\' -> { - result.append(current) - if (next != null) { - result.append(next) - index += 2 - } else { - index++ - } - } - current == '$' && next == '{' -> { - result.append(current).append(next) - index += 2 - modes.addLast(ScanMode.Template(1)) - } - else -> { - result.append(current) - index++ - if (current == '"') modes.removeLast() - } + private fun KtElement.qualifiedChain(): QualifiedChain { + var current: KtElement = this + var start = textRange.startOffset + val end = textRange.endOffset + while (true) { + val parent = current.parent + when { + parent is KtDotQualifiedExpression && parent.selectorExpression == current -> { + start = parent.receiverExpression.textRange.startOffset + current = parent } - ScanMode.RawString -> when { - current == '$' && next == '{' -> { - result.append(current).append(next) - index += 2 - modes.addLast(ScanMode.Template(1)) - } - current == '"' && getOrNull(index + 1) == '"' && getOrNull(index + 2) == '"' -> { - result.append("\"\"\"") - index += 3 - modes.removeLast() - } - else -> { - result.append(current) - index++ - } - } - ScanMode.CharacterLiteral -> when { - current == '\\' -> { - result.append(current) - if (next != null) { - result.append(next) - index += 2 - } else { - index++ - } - } - else -> { - result.append(current) - index++ - if (current == '\'') modes.removeLast() - } - } - is ScanMode.BlockComment -> when { - current == '/' && next == '*' -> { - result.append(current).append(next) - index += 2 - mode.depth++ - } - current == '*' && next == '/' -> { - result.append(current).append(next) - index += 2 - if (--mode.depth <= 0) modes.removeLast() - } - else -> { - result.append(current) - index++ - } + parent is KtCallExpression && parent.calleeExpression == current -> current = parent + parent is KtUserType && parent.referenceExpression == current -> { + parent.qualifier?.let { qualifier -> start = qualifier.textRange.startOffset } + current = parent } + else -> return QualifiedChain(start, end, containingFile.text.substring(start, end)) } } - return result.toString() } - /** Rejects a match that continues a longer identifier or qualified name on its left. */ - private fun String.canStartFqNameAt(index: Int): Boolean { - val previous = index - 1 - return previous !in indices || (!this[previous].isLetterOrDigit() && this[previous] != '_' && this[previous] != '.') + /** + * Applies replacements captured on the pre-mutation text to the post-mutation text. + * + * The package directive is the only text before the captured ranges that changes, so one length + * delta realigns every offset. Nested ranges are dropped in favour of the widest captured + * replacement, and edits are applied from the end so earlier offsets stay valid. + * + * @param text source text after package mutation + * @param edits replacements captured on the original text + * @param shift length difference between the mutated and original text + * @return text with all non-overlapping replacements applied + */ + private fun applyTextEdits(text: String, edits: Collection, shift: Int): String { + if (edits.isEmpty()) return text + val shifted = edits.asSequence() + .map { edit -> TextEdit(edit.start + shift, edit.end + shift, edit.replacement) } + .filter { edit -> edit.start >= 0 && edit.end <= text.length && edit.start < edit.end } + .distinct() + .sortedWith(compareBy { it.start }.thenByDescending { it.end }) + .toList() + val accepted = mutableListOf() + shifted.forEach { edit -> + val contained = accepted.any { acceptedEdit -> + acceptedEdit.start <= edit.start && edit.end <= acceptedEdit.end + } + if (!contained) accepted += edit + } + val result = StringBuilder(text) + accepted.sortedByDescending { edit -> edit.start }.forEach { edit -> + result.replace(edit.start, edit.end, edit.replacement) + } + return result.toString() } - - /** Rejects a match that continues a longer identifier on its right; `.member` suffixes are kept. */ - private fun String.canEndFqNameAt(index: Int): Boolean = - index !in indices || (!this[index].isLetterOrDigit() && this[index] != '_') } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 77121536c..e6642e1dd 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -481,6 +481,150 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a caller importing the whole old package receives an explicit import for the move. + * + * The wildcard import still covers declarations that stay behind, so it must be preserved + * while the moved declaration gets its own explicit import. + */ + fun testApply_realSession_addsExplicitImportForWildcardImportCaller() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard") + 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.*\n\nfun use() = moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue( + "A wildcard importer needs an explicit import for the moved declaration: $usageText", + usageText.contains("import new.changed.moved"), + ) + assertTrue( + "The wildcard import must stay for declarations that remain: $usageText", + usageText.contains("import old.source.*"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an explicit import of a default-package declaration follows it into a named package. + * + * Bare names are excluded from reference replacement to protect declaration names, but their + * import directives still need the new package prefix. + */ + fun testApply_realSession_rewritesExplicitImportOfDefaultPackageDeclaration() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-default-import") + try { + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(usageDirectory) + val sourcePath = temporaryRoot.resolve("Foo.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(sourcePath, "class Foo\n") + Files.writeString(usagePath, "package usage\n\nimport Foo\n\nfun use() = Foo()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-default-import-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue( + "An explicit default-package import must gain the new package: $usageText", + usageText.contains("import dest.Foo"), + ) + assertTrue("The resolved call itself needs no rewrite: $usageText", usageText.contains("fun use() = Foo()")) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies only references bound to the moved declaration are rewritten. + * + * A receiver chain that merely spells the same text but resolves to unrelated members must keep + * its original text; identifier boundaries cannot tell the two apart. + */ + fun testApply_realSession_keepsUnboundReceiverChainWhenSpellingMatchesMovedFqName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-unbound-chain") + 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\n" + + "class Holder {\n val source: Holder get() = this\n val moved: Int get() = 1\n}\n\n" + + "fun read(old: Holder) = old.source.moved\n\nfun use() = moved()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-unbound-chain-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue("The import itself must follow the declaration: $usageText", usageText.contains("import new.changed.moved")) + assertTrue( + "A receiver chain bound to unrelated members must stay untouched: $usageText", + usageText.contains("fun read(old: Holder) = old.source.moved"), + ) + } 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")) From 41eac23ede12d21b347cbcc8e99a2d91d1c13000 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 09:03:23 +0300 Subject: [PATCH 04/55] Fixed imports for root-package callers and PSI-based import ranges A caller that stays in the default package now receives the explicit import it needs even though it has no package directive and no imports: the import is inserted before its first declaration, after file annotations and header comments, using an insertion point captured from PSI before mutation. Import replacement ranges now come from the imported reference PSI instead of searching the rendered import path in the directive text. A backticked segment made that search fail with a negative offset and corrupted text before the directive. Reference retargeting is now captured from the declaration-level usage scan as well, so qualified references to moved type aliases follow the declaration and same-package callers of an alias gain an explicit import. Added integration tests for root-package callers, backticked import paths, and type alias references. Co-Authored-By: Claude Code --- .../refactoring/KaChangePackageComputer.kt | 231 ++++++++++++------ .../nbm/refactoring/KaChangePackageTest.kt | 139 +++++++++++ 2 files changed, 289 insertions(+), 81 deletions(-) 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 index a7a4d4490..3f1a95cbc 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -117,18 +117,14 @@ class KaChangePackageComputer( } } }.toMap() - val samePackageImportUpdates = if (updateReferences) { - // Incoming imports fix callers that lose visibility of a moved declaration, either - // because they shared its package or because they imported the whole old package. - // Outgoing imports fix the moved file itself losing visibility of the declarations - // that stay behind in its former package. - val incomingImports = findIncomingImportUpdates(files, targets) - val outgoingImports = findOutgoingSamePackageImports(files, targets) - (incomingImports.keys + outgoingImports.keys).associateWith { file -> - incomingImports[file].orEmpty() + outgoingImports[file].orEmpty() - } - } else { - emptyMap() + // Incoming changes fix callers that lose visibility of a moved declaration, either + // because they shared its package or because they imported the whole old package. + // Outgoing imports fix the moved file itself losing visibility of the declarations + // that stay behind in its former package. + val incoming = if (updateReferences) findIncomingRetargeting(files, targets) else IncomingRetargeting() + val outgoingImports = if (updateReferences) findOutgoingSamePackageImports(files, targets) else emptyMap() + val samePackageImportUpdates = (incoming.imports.keys + outgoingImports.keys).associateWith { file -> + incoming.imports[file].orEmpty() + outgoingImports[file].orEmpty() } val importOnlyUsageFiles = if (updateReferences) { KotlinMoveUsageSearchService.getInstance() @@ -142,16 +138,29 @@ class KaChangePackageComputer( } else { emptyList() } - val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys) + val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys + incoming.edits.keys) .distinct() - // Capture replacement ranges while PSI is still valid. Only usages that K2 resolved to - // a moved declaration and direct import directives are retargeted, so text that merely - // spells a moved FQN (comments, strings, unrelated receiver chains) is left alone. + // Capture replacement ranges while PSI is still valid. Only import directives that + // target a moved declaration and references that K2 resolved to one are retargeted, so + // text that merely spells a moved FQN (comments, strings, unrelated chains) is untouched. val textEdits = if (updateReferences) { - captureTextEdits(participants, movedDeclarations, usages) + val importEdits = captureImportEdits(participants, movedDeclarations) + (importEdits.keys + incoming.edits.keys).associateWith { file -> + importEdits[file].orEmpty() + incoming.edits[file].orEmpty() + } } else { emptyMap() } + // A caller without a package directive and without imports still needs a place to + // receive one: its first declaration, after file annotations and header comments. + val importInsertionOffsets = participants.mapNotNull { file -> + val path = file.virtualFile?.path ?: return@mapNotNull null + if (targets[file] != null || !file.packageFqName.isRoot || file.importDirectives.isNotEmpty()) { + return@mapNotNull null + } + val offset = file.declarations.firstOrNull()?.textRange?.startOffset ?: return@mapNotNull null + path to offset + }.toMap() // Snapshot before any mutation: updatePackageDirective invalidates standalone PSI. val originalTexts = participants.mapNotNull { file -> file.virtualFile?.path?.let { path -> path to file.text } @@ -170,7 +179,11 @@ class KaChangePackageComputer( // Captured offsets refer to the original text; the package directive is the only // text before them that changed, so a single length delta realigns all edits. val retargetedText = applyTextEdits(text, textEdits[file].orEmpty(), text.length - originalText.length) - changedFiles[path] = addSamePackageImports(retargetedText, samePackageImportUpdates[file].orEmpty()) + changedFiles[path] = addSamePackageImports( + retargetedText, + samePackageImportUpdates[file].orEmpty(), + importInsertionOffsets[path], + ) } ApplyOutcome.Success(changedFiles, originalTexts) } catch (error: Throwable) { @@ -178,45 +191,56 @@ class KaChangePackageComputer( } } + /** Caller-side text changes captured before the package directives mutate. */ + private data class IncomingRetargeting( + val imports: Map> = emptyMap(), + val edits: Map> = emptyMap(), + ) + /** - * Finds callers that will lose visibility of a moved declaration and need an explicit import. + * Captures how callers must change so references to moved declarations keep resolving. * - * A caller loses that visibility in two ways: it formerly shared the declaration's package, or - * it imported the whole old package with a wildcard. A direct import is rewritten elsewhere and + * A caller loses visibility in two ways: it formerly shared the declaration's package, or it + * imported the whole old package with a wildcard. A direct import is rewritten elsewhere and * keeps the binding, and a caller that ends in the declaration's final package keeps the - * implicit binding. Text-based retargeting cannot invent an import, so these callers are - * discovered from resolved references before any directive changes. + * implicit binding. Qualified references are rewritten in place instead. * * Every moved declaration is searched directly rather than through the processor's usages: - * the processor only reports functions, properties, and classes, while a moved type alias also - * loses its implicit same-package binding. + * the processor only reports functions, properties, and classes, while moving a type alias + * also loses implicit visibility and can be referenced by its fully qualified name. * * @param movedFiles Kotlin sources whose declared package changes * @param targets mapped files with their final declared packages - * @return affected files mapped to the imports their references now require + * @return imports that callers still need and reference replacements captured from PSI */ - private fun findIncomingImportUpdates( + private fun findIncomingRetargeting( movedFiles: Collection, targets: Map, - ): Map> { + ): IncomingRetargeting { val movedDeclarations = movedFiles.flatMap { file -> file.declarations.filterIsInstance().mapNotNull { declaration -> - declaration.name?.let { name -> declaration to targets.getValue(file).child(Name.identifier(name)) } + declaration.name?.let { name -> + val unified = file.packageFqName.child(Name.identifier(name)) to + targets.getValue(file).child(Name.identifier(name)) + declaration to unified + } } }.toMap() - if (movedDeclarations.isEmpty()) return emptyMap() - val session = KotlinMoveUsageSearchService.getInstance() ?: return emptyMap() - val updates = linkedMapOf>() - movedDeclarations.forEach { (declaration, targetFqName) -> - val name = declaration.name ?: return@forEach + if (movedDeclarations.isEmpty()) return IncomingRetargeting() + val session = KotlinMoveUsageSearchService.getInstance() ?: return IncomingRetargeting() + val imports = linkedMapOf>() + val edits = linkedMapOf>() + movedDeclarations.forEach { (declaration, unified) -> + val (oldFqName, newFqName) = unified val declarationFile = declaration.containingKtFile - val originalPackage = declarationFile.packageFqName - val oldFqName = originalPackage.child(Name.identifier(name)) + val originalPackage = oldFqName.parent() session.findUsages(declaration).forEach { reference -> - val file = reference.element?.containingFile as? KtFile ?: return@forEach + val element = reference.element as? KtElement ?: return@forEach + val file = element.containingFile as? KtFile ?: return@forEach + captureQualifiedReference(element, oldFqName, newFqName, edits) if (file.virtualFile?.path == declarationFile.virtualFile?.path) return@forEach val finalPackage = targets[file] ?: file.packageFqName - if (finalPackage == targetFqName.parent()) return@forEach + if (finalPackage == newFqName.parent()) return@forEach val importsDirectly = file.importDirectives.any { directive -> !directive.isAllUnder && directive.importedFqName == oldFqName } @@ -226,10 +250,37 @@ class KaChangePackageComputer( directive.isAllUnder && directive.importedFqName == originalPackage } if (!sharesOriginalPackage && !importsOriginalPackage) return@forEach - updates.getOrPut(file, ::linkedSetOf) += targetFqName + imports.getOrPut(file, ::linkedSetOf) += newFqName } } - return updates + return IncomingRetargeting(imports, edits.mapValues { (_, fileEdits) -> fileEdits.toList() }) + } + + /** + * Records a replacement when [element] spells a moved declaration by its old fully qualified name. + * + * Simple-name references keep their text and follow their rewritten import; only a qualified + * chain that equals the old FQN needs replacing in place. + * + * @param element reference expression that K2 resolved to the moved declaration + * @param oldFqName declaration FQN before the package change + * @param newFqName declaration FQN after the package change + * @param edits per-file replacement accumulator + */ + private fun captureQualifiedReference( + element: KtElement, + oldFqName: FqName, + newFqName: FqName, + edits: MutableMap>, + ) { + // A root-package FQN is a bare name. Qualifying such a reference would rewrite + // declaration-like positions that cannot take a package prefix (`::moved`); imports + // alone keep root-package bindings intact. + if (oldFqName.parent().isRoot) return + val file = element.containingFile as? KtFile ?: return + val chain = element.qualifiedChain() + if (chain.text != oldFqName.asString()) return + edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, newFqName.asString()) } /** @@ -323,27 +374,44 @@ class KaChangePackageComputer( * The header is rebuilt as text rather than through PSI: the import list is invalid after the * package directive changed, and the standalone parser drops inserted whitespace nodes. * + * @param usageText source text of the caller after reference replacement + * @param imports declarations that need an explicit import + * @param declarationOffset captured start of the first declaration for files without a package + * directive and without imports, after file annotations and header comments * @return final source text with a canonical package/import boundary */ - private fun addSamePackageImports(usageText: String, imports: Set): String { + private fun addSamePackageImports( + usageText: String, + imports: Set, + declarationOffset: Int? = null, + ): String { if (imports.isEmpty()) return usageText - val packageMatch = PACKAGE_DIRECTIVE.find(usageText) ?: return usageText val existingFqNames = IMPORT_DIRECTIVE.findAll(usageText) .map { match -> FqName(match.groupValues[1]) } .toSet() val missingImports = imports.filterNot { it in existingFqNames } .map { imported -> "import ${imported.asString()}" } if (missingImports.isEmpty()) return usageText + val importLines = missingImports.joinToString(separator = "\n") val firstImport = IMPORT_DIRECTIVE.find(usageText) - return if (firstImport == null) { - usageText.substring(0, packageMatch.range.last + 1) + - "\n\n${missingImports.joinToString(separator = "\n")}" + - usageText.substring(packageMatch.range.last + 1) - } else { - usageText.substring(0, firstImport.range.first) + - "${missingImports.joinToString(separator = "\n")}\n" + + if (firstImport != null) { + return usageText.substring(0, firstImport.range.first) + + "$importLines\n" + usageText.substring(firstImport.range.first) } + val packageMatch = PACKAGE_DIRECTIVE.find(usageText) + if (packageMatch != null) { + val boundary = packageMatch.range.last + 1 + return usageText.substring(0, boundary) + + "\n\n$importLines" + + usageText.substring(boundary) + } + if (declarationOffset != null && declarationOffset in 0..usageText.length) { + return usageText.substring(0, declarationOffset) + + "$importLines\n\n" + + usageText.substring(declarationOffset) + } + return usageText } /** Captures usage files before K2 reference rebinding can invalidate individual usage elements. */ @@ -354,58 +422,59 @@ class KaChangePackageComputer( private data class TextEdit(val start: Int, val end: Int, val replacement: String) /** - * Captures the exact text ranges that must follow moved declarations. + * Captures the import directives that must follow moved declarations. * - * Only two kinds of text are captured: direct import directives and reference chains that K2 - * resolved to a moved declaration before mutation. Everything else — comments, string literals, - * declaration names, and receiver chains that merely spell the same text — is left alone. + * Paths are matched by FQN prefix, so an import of a nested declaration keeps its suffix, and + * only directives that actually target a moved declaration are recorded. References themselves + * are captured separately from K2-resolved usages. * * @param participants files whose text may change * @param movedDeclarations old top-level declaration FQNs mapped to their new FQNs - * @param usages external usages resolved before package mutation * @return per-file replacements keyed by captured source offsets */ - private fun captureTextEdits( + private fun captureImportEdits( participants: Collection, movedDeclarations: Map, - usages: Collection, ): Map> { if (movedDeclarations.isEmpty()) return emptyMap() val mappings = movedDeclarations.entries .sortedByDescending { (oldFqName, _) -> oldFqName.pathSegments().size } val edits = linkedMapOf>() participants.forEach { file -> + val fileText = file.text file.importDirectives.forEach { directive -> if (directive.isAllUnder) return@forEach - val importedText = directive.importPath?.pathStr ?: return@forEach - val replacement = rewriteImportedPath(importedText, mappings) ?: return@forEach - val offset = directive.textRange.startOffset + directive.text.indexOf(importedText) - edits.getOrPut(file, ::linkedSetOf) += - TextEdit(offset, offset + importedText.length, replacement) + val importedReference = directive.importedReference ?: return@forEach + // Ranges come from PSI: `importPath.pathStr` is a rendered path and can differ + // from the source spelling (backticks, whitespace), so searching for it corrupts. + val pathStart = directive.textRange.startOffset + importPathStart(directive.text) + val pathEnd = importedReference.textRange.endOffset + if (pathStart >= pathEnd) return@forEach + val replacement = rewriteImportedPath(fileText.substring(pathStart, pathEnd), mappings) + ?: return@forEach + edits.getOrPut(file, ::linkedSetOf) += TextEdit(pathStart, pathEnd, replacement) } } - usages.filterIsInstance().forEach { usage -> - val element = usage.element as? KtElement ?: return@forEach - val file = element.containingFile as? KtFile ?: return@forEach - val declaration = usage.referencedElement as? KtNamedDeclaration ?: return@forEach - val name = declaration.name ?: return@forEach - val oldFqName = declaration.containingKtFile.packageFqName.child(Name.identifier(name)) - val newFqName = movedDeclarations[oldFqName] ?: return@forEach - // A root-package FQN is a bare name. Qualifying such a reference would rewrite - // declaration-like positions that cannot take a package prefix (`::moved`); imports - // alone keep root-package bindings intact. - if (oldFqName.parent().isRoot) return@forEach - val chain = element.qualifiedChain() - if (chain.text != oldFqName.asString()) return@forEach - edits.getOrPut(file, ::linkedSetOf) += TextEdit( - chain.startOffset, - chain.endOffset, - newFqName.asString(), - ) - } return edits.mapValues { (_, fileEdits) -> fileEdits.toList() } } + /** + * Finds where an import path starts inside a directive's text. + * + * @param directiveText exact source text of the import directive + * @return offset of the first non-whitespace character after the `import` keyword + */ + private fun importPathStart(directiveText: String): Int { + val keywordEnd = directiveText.indexOf("import").let { index -> + if (index < 0) 0 else index + "import".length + } + var pathStart = keywordEnd + while (pathStart < directiveText.length && directiveText[pathStart].isWhitespace()) { + pathStart++ + } + return pathStart + } + /** * Rewrites the package prefix of an imported path, keeping alias and nested-class suffixes. * @@ -420,7 +489,7 @@ class KaChangePackageComputer( importedText: String, mappings: List>, ): String? { - val segments = importedText.split('.') + val segments = importedText.split('.').map { segment -> segment.trim() } mappings.forEach { (oldFqName, newFqName) -> val oldSegments = oldFqName.pathSegments().map { segment -> segment.asString() } if (segments.size < oldSegments.size) return@forEach diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index e6642e1dd..d89810a2f 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -625,6 +625,145 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies references to a moved type alias are retargeted even though the processor ignores it. + * + * Change Package usage discovery only reports functions, properties, and classes, so aliases + * need the declaration-level scan: a same-package caller must gain an explicit import, and a + * qualified type reference must follow the declaration. + */ + fun testApply_realSession_retargetsTypeAliasReferences() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-typealias") + try { + val sourceDirectory = temporaryRoot.resolve("source") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val aliasPath = sourceDirectory.resolve("Alias.kt") + val siblingPath = sourceDirectory.resolve("Sibling.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(aliasPath, "package old.source\n\ntypealias Handler = () -> Unit\n") + Files.writeString(siblingPath, "package old.source\n\nval sibling: Handler = {}\n") + Files.writeString(usagePath, "package usage\n\nval usageAlias: old.source.Handler = {}\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-typealias-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(aliasPath.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 + val siblingText = success.changedFiles[siblingPath.toString()].orEmpty() + assertTrue( + "A same-package caller must gain an explicit alias import: $siblingText", + siblingText.contains("import new.changed.Handler"), + ) + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue( + "A qualified alias reference must follow the declaration: $usageText", + usageText.contains("val usageAlias: new.changed.Handler = {}"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a root-package caller receives an import even though it has no package directive. + * + * The import must be inserted before the first declaration while keeping header comments. + */ + fun testApply_realSession_addsImportToCallerWithoutPackageDirective() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-root-caller") + try { + val sourcePath = temporaryRoot.resolve("Moved.kt") + val callerPath = temporaryRoot.resolve("Caller.kt") + Files.writeString(sourcePath, "fun moved() = 1\n") + Files.writeString(callerPath, "// Generated helper\n\nfun use() = moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-root-caller-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val callerText = success.changedFiles[callerPath.toString()].orEmpty() + assertEquals( + "A root-package caller must gain an import after its header comment", + "// Generated helper\n\nimport dest.moved\n\nfun use() = moved()\n", + callerText, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies import rewriting uses PSI ranges rather than the rendered import path text. + * + * A backticked segment makes `importPath.pathStr` differ from the source spelling; locating + * that rendered path in the directive returned -1 and corrupted text before the directive. + */ + fun testApply_realSession_rewritesBacktickedImportPath() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-backtick-import") + 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-backtick-import-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertTrue( + "A backticked import path must be rewritten without corrupting the header: $usageText", + usageText.startsWith("package usage\n\nimport new.changed.moved\n"), + ) + } 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")) From c6d43961fe38e0cbff2341ec5264a2c48233bf3d Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 09:17:00 +0300 Subject: [PATCH 05/55] Fixed import insertion boundaries and spelling-independent reference matching Import insertion points and existing-import detection now come from PSI captured before package mutation, so import-looking text inside raw strings or block comments neither counts as an existing import nor places new imports inside a literal. Qualified reference chains are matched by identifier segments instead of exact source text, so backticked and multi-line spellings are retargeted while the captured PSI range still delimits the replacement. Co-Authored-By: Claude Code --- .../refactoring/KaChangePackageComputer.kt | 146 ++++++++----- .../nbm/refactoring/KaChangePackageTest.kt | 192 ++++++++++++++++++ 2 files changed, 284 insertions(+), 54 deletions(-) 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 index 3f1a95cbc..2000e836b 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -54,14 +54,6 @@ import org.jetbrains.kotlin.psi.KtUserType class KaChangePackageComputer( private val ktFile: KtFile, ) { - private companion object { - /** Matches a file-level Kotlin package directive in source text. */ - val PACKAGE_DIRECTIVE = Regex("(?m)^package\\s+[^\\s;]+") - - /** Matches a non-aliased Kotlin import directive in source text. */ - val IMPORT_DIRECTIVE = Regex("(?m)^import\\s+([A-Za-z_][A-Za-z0-9_.]*)\\s*$") - } - /** 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. */ @@ -151,15 +143,13 @@ class KaChangePackageComputer( } else { emptyMap() } - // A caller without a package directive and without imports still needs a place to - // receive one: its first declaration, after file annotations and header comments. - val importInsertionOffsets = participants.mapNotNull { file -> + // Header facts for import insertion come from PSI as well: an import-looking line + // inside a raw string or block comment must neither count as an existing import nor + // serve as the boundary where new imports are placed. + val importInsertions = participants.mapNotNull { file -> val path = file.virtualFile?.path ?: return@mapNotNull null - if (targets[file] != null || !file.packageFqName.isRoot || file.importDirectives.isNotEmpty()) { - return@mapNotNull null - } - val offset = file.declarations.firstOrNull()?.textRange?.startOffset ?: return@mapNotNull null - path to offset + val insertion = importInsertionFor(file) ?: return@mapNotNull null + path to insertion }.toMap() // Snapshot before any mutation: updatePackageDirective invalidates standalone PSI. val originalTexts = participants.mapNotNull { file -> @@ -178,11 +168,14 @@ class KaChangePackageComputer( val text = targets[file]?.let { target -> ensurePackageDirectiveSeparator(file.text, target) } ?: file.text // Captured offsets refer to the original text; the package directive is the only // text before them that changed, so a single length delta realigns all edits. - val retargetedText = applyTextEdits(text, textEdits[file].orEmpty(), text.length - originalText.length) + val shift = text.length - originalText.length + val retargeted = applyTextEdits(text, textEdits[file].orEmpty(), shift) changedFiles[path] = addSamePackageImports( - retargetedText, + retargeted.text, samePackageImportUpdates[file].orEmpty(), - importInsertionOffsets[path], + importInsertions[path], + retargeted.edits, + shift, ) } ApplyOutcome.Success(changedFiles, originalTexts) @@ -279,7 +272,7 @@ class KaChangePackageComputer( if (oldFqName.parent().isRoot) return val file = element.containingFile as? KtFile ?: return val chain = element.qualifiedChain() - if (chain.text != oldFqName.asString()) return + if (!chain.matches(oldFqName)) return edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, newFqName.asString()) } @@ -368,50 +361,77 @@ class KaChangePackageComputer( return if (next.isWhitespace()) text else "$directive\n\n${text.substring(directive.length)}" } + /** PSI-captured facts required to insert imports without rescanning the changed source text. */ + private data class ImportInsertion( + val existingFqNames: Set, + val offset: Int, + val prefix: String, + val suffix: String, + ) + + /** + * Captures where a file's import block starts and which imports it already declares. + * + * The offset refers to the pre-mutation text. A file with imports gains new lines at its first + * directive; otherwise the insertion follows the package directive or, for a root-package + * file, precedes the first declaration so header comments and file annotations stay on top. + * Only real PSI import directives and the package directive are consulted, so text inside + * literals or comments cannot influence either the duplicate check or the boundary. + * + * @param file participant whose original PSI is still valid + * @return captured insertion point, or `null` when the file cannot host an import + */ + private fun importInsertionFor(file: KtFile): ImportInsertion? { + val existingFqNames = file.importDirectives + .filterNot { directive -> directive.isAllUnder } + .mapNotNull { directive -> directive.importedFqName } + .toSet() + file.importDirectives.firstOrNull()?.let { firstImport -> + return ImportInsertion(existingFqNames, firstImport.textRange.startOffset, "", "\n") + } + if (!file.packageFqName.isRoot) { + val directive = file.packageDirective ?: return null + if (directive.text.isBlank()) return null + return ImportInsertion(existingFqNames, directive.textRange.endOffset, "\n\n", "") + } + val declarationStart = file.declarations.firstOrNull()?.textRange?.startOffset ?: return null + return ImportInsertion(existingFqNames, declarationStart, "", "\n\n") + } + /** * Adds imports that no longer arrive implicitly from the package or a wildcard import. * - * The header is rebuilt as text rather than through PSI: the import list is invalid after the - * package directive changed, and the standalone parser drops inserted whitespace nodes. + * Placement and duplicate detection come from PSI captured before the package mutation. The + * insertion offset is realigned by the package change and by the net length of accepted + * replacements that end before it. * * @param usageText source text of the caller after reference replacement * @param imports declarations that need an explicit import - * @param declarationOffset captured start of the first declaration for files without a package - * directive and without imports, after file annotations and header comments - * @return final source text with a canonical package/import boundary + * @param insertion captured header facts for this file, or `null` when none were captured + * @param appliedEdits replacements already applied to [usageText], in its coordinates + * @param shift length difference caused by the package directive change + * @return final source text with imports added at the captured boundary */ private fun addSamePackageImports( usageText: String, imports: Set, - declarationOffset: Int? = null, + insertion: ImportInsertion?, + appliedEdits: List, + shift: Int, ): String { - if (imports.isEmpty()) return usageText - val existingFqNames = IMPORT_DIRECTIVE.findAll(usageText) - .map { match -> FqName(match.groupValues[1]) } - .toSet() - val missingImports = imports.filterNot { it in existingFqNames } + if (imports.isEmpty() || insertion == null) return usageText + val missingImports = imports.filterNot { imported -> imported in insertion.existingFqNames } .map { imported -> "import ${imported.asString()}" } if (missingImports.isEmpty()) return usageText + val shiftedOffset = insertion.offset + shift + val offset = shiftedOffset + appliedEdits + .filter { edit -> edit.end <= shiftedOffset } + .sumOf { edit -> edit.replacement.length - (edit.end - edit.start) } + if (offset !in 0..usageText.length) return usageText val importLines = missingImports.joinToString(separator = "\n") - val firstImport = IMPORT_DIRECTIVE.find(usageText) - if (firstImport != null) { - return usageText.substring(0, firstImport.range.first) + - "$importLines\n" + - usageText.substring(firstImport.range.first) - } - val packageMatch = PACKAGE_DIRECTIVE.find(usageText) - if (packageMatch != null) { - val boundary = packageMatch.range.last + 1 - return usageText.substring(0, boundary) + - "\n\n$importLines" + - usageText.substring(boundary) - } - if (declarationOffset != null && declarationOffset in 0..usageText.length) { - return usageText.substring(0, declarationOffset) + - "$importLines\n\n" + - usageText.substring(declarationOffset) - } - return usageText + return usageText.substring(0, offset) + + insertion.prefix + importLines + insertion.suffix + + usageText.substring(offset) } /** Captures usage files before K2 reference rebinding can invalidate individual usage elements. */ @@ -505,6 +525,21 @@ class KaChangePackageComputer( /** Source range and text of the qualified chain that ends at a reference expression. */ private data class QualifiedChain(val startOffset: Int, val endOffset: Int, val text: String) + /** + * Tells whether this chain's identifiers spell [fqName]. + * + * Backticks and whitespace around dots change the source spelling but not the identifiers K2 + * resolved, so segments are compared after trimming and unquoting while the captured PSI range + * still delimits the replacement. + * + * @param fqName fully qualified name of the declaration K2 resolved + * @return `true` when the chain text denotes exactly that FQN + */ + private fun QualifiedChain.matches(fqName: FqName): Boolean { + val segments = text.split('.').map { segment -> segment.trim().removeSurrounding("`") } + return segments == fqName.pathSegments().map { segment -> segment.asString() } + } + /** * Extends a reference expression through the qualified receiver chain that contains it. * @@ -533,6 +568,9 @@ class KaChangePackageComputer( } } + /** Retargeted text and the replacements accepted for it, in its coordinates. */ + private data class AppliedEdits(val text: String, val edits: List) + /** * Applies replacements captured on the pre-mutation text to the post-mutation text. * @@ -543,10 +581,10 @@ class KaChangePackageComputer( * @param text source text after package mutation * @param edits replacements captured on the original text * @param shift length difference between the mutated and original text - * @return text with all non-overlapping replacements applied + * @return text with all non-overlapping replacements applied plus the accepted edits */ - private fun applyTextEdits(text: String, edits: Collection, shift: Int): String { - if (edits.isEmpty()) return text + private fun applyTextEdits(text: String, edits: Collection, shift: Int): AppliedEdits { + if (edits.isEmpty()) return AppliedEdits(text, emptyList()) val shifted = edits.asSequence() .map { edit -> TextEdit(edit.start + shift, edit.end + shift, edit.replacement) } .filter { edit -> edit.start >= 0 && edit.end <= text.length && edit.start < edit.end } @@ -564,6 +602,6 @@ class KaChangePackageComputer( accepted.sortedByDescending { edit -> edit.start }.forEach { edit -> result.replace(edit.start, edit.end, edit.replacement) } - return result.toString() + return AppliedEdits(result.toString(), accepted) } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index d89810a2f..8f99b0787 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -764,6 +764,198 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an import-looking line inside a raw string never becomes the insertion boundary. + * + * Text scanning cannot distinguish executable header imports from literals; the import must be + * inserted after the package directive so the runtime string value stays intact. + */ + fun testApply_realSession_insertsImportAtPackageBoundaryDespiteImportLineInRawString() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-import-in-string") + 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 old.source\n\nfun use() = moved()\n\nval sample = \"\"\"\nimport unrelated.foo\n\"\"\"\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-import-in-string-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertEquals( + "The import must go after the package directive, not inside the literal", + "package old.source\n\nimport dest.moved\n\nfun use() = moved()\n\n" + + "val sample = \"\"\"\nimport unrelated.foo\n\"\"\"\n", + usageText, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a literal repeating the future import path does not suppress a required import. + * + * Only real import directives count as already present; otherwise the moved declaration stays + * unresolved while the literal keeps its original runtime value. + */ + fun testApply_realSession_insertsImportWhenRawStringRepeatsNewImportPath() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-import-duplicate-in-string") + 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 old.source\n\nfun use() = moved()\n\nval sample = \"\"\"\nimport new.changed.moved\n\"\"\"\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-import-duplicate-in-string-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertEquals( + "A literal must not count as an existing import", + "package old.source\n\nimport new.changed.moved\n\nfun use() = moved()\n\n" + + "val sample = \"\"\"\nimport new.changed.moved\n\"\"\"\n", + usageText, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a backticked qualified reference follows the moved declaration. + * + * K2 resolves such a chain even though its source spelling differs from the canonical FQN, so + * the comparison must work on identifier segments while replacement keeps the PSI range. + */ + fun testApply_realSession_retargetsBacktickedQualifiedReference() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-backtick-reference") + 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\nfun use() = old.source.`moved`()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-backtick-reference-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertEquals( + "A backticked qualified reference must follow the declaration", + "package usage\n\nfun use() = new.changed.moved()\n", + usageText, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a qualified reference split across lines follows the moved declaration. + * + * Whitespace around the dot changes the chain's source spelling but not the identifiers K2 + * resolved, and replacement must span the whole chain. + */ + fun testApply_realSession_retargetsQualifiedReferenceWithWhitespaceAroundDot() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-spaced-reference") + 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\nfun use() = old.source\n .moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-spaced-reference-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 + val usageText = success.changedFiles[usagePath.toString()].orEmpty() + assertEquals( + "A multi-line qualified reference must follow the declaration", + "package usage\n\nfun use() = new.changed.moved()\n", + usageText, + ) + } 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")) From 2933fd657efc9ce3348a3e641b5c42c574685567 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 13:08:35 +0300 Subject: [PATCH 06/55] Fixed reference offsets before package directives and escaping of generated names Replacement ranges are shifted by the package directive's length delta only when they start after the directive, so references in file annotations that precede it keep their original offsets. Generated qualified references and imports now render FQN segments with Kotlin source escaping, so declarations whose names need backticks stay valid after the move. Co-Authored-By: Claude Code --- .../refactoring/KaChangePackageComputer.kt | 78 +++++++++++++--- .../nbm/refactoring/KaChangePackageTest.kt | 93 +++++++++++++++++++ 2 files changed, 160 insertions(+), 11 deletions(-) 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 index 2000e836b..19739267f 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -54,6 +54,19 @@ import org.jetbrains.kotlin.psi.KtUserType class KaChangePackageComputer( private val ktFile: KtFile, ) { + private companion object { + /** + * Kotlin hard keywords, which cannot be written as bare identifiers in generated source. + * + * Soft keywords (`value`, `field`, `import`, ...) are legal identifiers and stay plain. + */ + val HARD_KEYWORDS = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", + "true", "try", "typealias", "typeof", "val", "var", "when", "while", + ) + } + /** 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. */ @@ -151,6 +164,12 @@ class KaChangePackageComputer( val insertion = importInsertionFor(file) ?: return@mapNotNull null path to insertion }.toMap() + // Offsets before the package directive end are unaffected by its replacement length; + // file annotations are the one place where Kotlin code precedes the directive. + val packageDirectiveEnds = participants.mapNotNull { file -> + val path = file.virtualFile?.path ?: return@mapNotNull null + path to file.packageDirective?.textRange?.endOffset + }.toMap() // Snapshot before any mutation: updatePackageDirective invalidates standalone PSI. val originalTexts = participants.mapNotNull { file -> file.virtualFile?.path?.let { path -> path to file.text } @@ -166,10 +185,11 @@ class KaChangePackageComputer( val path = file.virtualFile?.path ?: return@forEach val originalText = originalTexts[path] ?: return@forEach val text = targets[file]?.let { target -> ensurePackageDirectiveSeparator(file.text, target) } ?: file.text - // Captured offsets refer to the original text; the package directive is the only - // text before them that changed, so a single length delta realigns all edits. + // Captured offsets refer to the original text; only the package directive changed, + // so its length delta realigns every range that follows the directive. val shift = text.length - originalText.length - val retargeted = applyTextEdits(text, textEdits[file].orEmpty(), shift) + val shiftAfter = if (targets[file] != null) packageDirectiveEnds[path] else null + val retargeted = applyTextEdits(text, textEdits[file].orEmpty(), shift, shiftAfter) changedFiles[path] = addSamePackageImports( retargeted.text, samePackageImportUpdates[file].orEmpty(), @@ -273,7 +293,7 @@ class KaChangePackageComputer( val file = element.containingFile as? KtFile ?: return val chain = element.qualifiedChain() if (!chain.matches(oldFqName)) return - edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, newFqName.asString()) + edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, renderFqName(newFqName)) } /** @@ -421,7 +441,7 @@ class KaChangePackageComputer( ): String { if (imports.isEmpty() || insertion == null) return usageText val missingImports = imports.filterNot { imported -> imported in insertion.existingFqNames } - .map { imported -> "import ${imported.asString()}" } + .map { imported -> "import ${renderFqName(imported)}" } if (missingImports.isEmpty()) return usageText val shiftedOffset = insertion.offset + shift val offset = shiftedOffset + appliedEdits @@ -517,11 +537,37 @@ class KaChangePackageComputer( if (prefix != oldSegments) return@forEach val suffix = segments.drop(oldSegments.size) val separator = if (suffix.isEmpty()) "" else "." - return newFqName.asString() + suffix.joinToString(separator = ".", prefix = separator) + return renderFqName(newFqName) + suffix.joinToString(separator = ".", prefix = separator) } return null } + /** + * Renders a fully qualified name as Kotlin source text. + * + * [FqName.asString] joins raw names, so a declaration that needs backticks (a keyword or a name + * with spaces) would otherwise yield invalid source in generated references and imports. + * + * @param fqName name of a moved declaration or its new package prefix + * @return the FQN with each segment escaped when it cannot be written as a bare identifier + */ + private fun renderFqName(fqName: FqName): String = + fqName.pathSegments().joinToString(separator = ".") { segment -> renderIdentifier(segment.asString()) } + + /** + * Renders one identifier as Kotlin source, adding backticks when the bare spelling is invalid. + * + * @param name unescaped identifier as stored by Kotlin PSI + * @return the name unchanged when it is a plain non-keyword identifier, otherwise backticked + */ + private fun renderIdentifier(name: String): String { + val isPlain = name.isNotEmpty() && + (name.first().isLetter() || name.first() == '_') && + name.all { symbol -> symbol.isLetterOrDigit() || symbol == '_' } && + name !in HARD_KEYWORDS + return if (isPlain) name else "`$name`" + } + /** Source range and text of the qualified chain that ends at a reference expression. */ private data class QualifiedChain(val startOffset: Int, val endOffset: Int, val text: String) @@ -574,19 +620,29 @@ class KaChangePackageComputer( /** * Applies replacements captured on the pre-mutation text to the post-mutation text. * - * The package directive is the only text before the captured ranges that changes, so one length - * delta realigns every offset. Nested ranges are dropped in favour of the widest captured + * The package directive replacement is the only length change before the captured ranges, and + * only ranges starting after its end are shifted: a file annotation precedes the directive in + * source order and keeps its offsets. Nested ranges are dropped in favour of the widest captured * replacement, and edits are applied from the end so earlier offsets stay valid. * * @param text source text after package mutation * @param edits replacements captured on the original text - * @param shift length difference between the mutated and original text + * @param shift length difference caused by the package directive change + * @param shiftAfter original offset after which [shift] applies; `null` shifts every range * @return text with all non-overlapping replacements applied plus the accepted edits */ - private fun applyTextEdits(text: String, edits: Collection, shift: Int): AppliedEdits { + private fun applyTextEdits( + text: String, + edits: Collection, + shift: Int, + shiftAfter: Int?, + ): AppliedEdits { if (edits.isEmpty()) return AppliedEdits(text, emptyList()) val shifted = edits.asSequence() - .map { edit -> TextEdit(edit.start + shift, edit.end + shift, edit.replacement) } + .map { edit -> + val delta = if (shiftAfter == null || edit.start >= shiftAfter) shift else 0 + TextEdit(edit.start + delta, edit.end + delta, edit.replacement) + } .filter { edit -> edit.start >= 0 && edit.end <= text.length && edit.start < edit.end } .distinct() .sortedWith(compareBy { it.start }.thenByDescending { it.end }) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 8f99b0787..d1b604d6d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -956,6 +956,99 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a reference before the package directive is not shifted by the directive's new length. + * + * A file annotation precedes the package directive in source order but is captured in the same + * coordinate space; applying the delta to it moved the replacement into unrelated text. + */ + fun testApply_realSession_keepsFileAnnotationReferenceBeforePackageDirectiveAligned() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-file-annotation") + try { + val sourcePath = temporaryRoot.resolve("Moved.kt") + val originalText = "@file:Suppress(old.pkg.MESSAGE)\npackage old.pkg\n\nconst val MESSAGE = \"unused\"\n" + Files.writeString(sourcePath, originalText) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-file-annotation-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.much.longer.destination")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A reference before the package directive must keep its own offsets", + "@file:Suppress(new.much.longer.destination.MESSAGE)\n" + + "package new.much.longer.destination\n\nconst val MESSAGE = \"unused\"\n", + success.changedFiles[sourcePath.toString()].orEmpty(), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies generated references and imports escape declaration names that need backticks. + * + * A declaration named `two words` has no plain Kotlin spelling, so `FqName.asString()` would + * emit invalid source in the rewritten qualified reference and in the compensating import. + */ + fun testApply_realSession_escapesBacktickedDeclarationInGeneratedText() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-backtick-name") + try { + val sourceDirectory = temporaryRoot.resolve("source") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val siblingPath = sourceDirectory.resolve("Sibling.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + Files.writeString(sourcePath, "package old.pkg\n\nfun `two words`() = 1\n") + Files.writeString(siblingPath, "package old.pkg\n\nfun sibling() = `two words`()\n") + Files.writeString(usagePath, "package usage\n\nfun use() = old.pkg.`two words`()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-backtick-name-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A generated qualified reference must escape the declaration name", + "package usage\n\nfun use() = dest.`two words`()\n", + success.changedFiles[usagePath.toString()].orEmpty(), + ) + assertEquals( + "A generated import must escape the declaration name", + "package old.pkg\n\nimport dest.`two words`\n\nfun sibling() = `two words`()\n", + success.changedFiles[siblingPath.toString()].orEmpty(), + ) + } 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")) From a2bd45683cb7769748787364c7008038db82a59c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 14:28:37 +0300 Subject: [PATCH 07/55] Fixed rename validation and conflict checks for Kotlin files Generic Rename of a Kotlin file now rejects a hard-keyword basename that would produce an invalid class declaration and reports a blocking problem when the requested class name is already declared by another class, object, or type alias in the same package. Outgoing dependencies of a moved file now also cover Java classes and constructor calls, which have no Kotlin named declaration; the import is derived from the symbol's class id. Generated declaration names are rendered through KotlinSourceNames, and a package directive inserted into a root-package file is separated from file annotations and adjacent declarations. Co-Authored-By: Claude Code --- CHANGELOG.md | 2 +- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 134 ++++++----- .../kotlin/refactoring/KotlinSourceNames.kt | 69 ++++++ Nbm/pom.xml | 2 +- .../nbm/refactoring/KotlinRenamePlugin.kt | 96 +++++++- .../nbm/refactoring/KaChangePackageTest.kt | 210 ++++++++++++++++++ .../KotlinRenameClassifierConflictTest.kt | 144 ++++++++++++ .../KotlinRenameRefactoringTest.kt | 19 ++ .../nbm/refactoring/KotlinSourceNamesTest.kt | 68 ++++++ pom.xml | 2 +- 20 files changed, 690 insertions(+), 76 deletions(-) create mode 100644 KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 7ca2adceb..a90074e23 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,4 +1,4 @@ -- Changed **Refactor → Rename** for a Kotlin package folder to rename both the directory and the package: every Kotlin file inside receives the package derived from its new path (including files whose package did not match the directory), imports and supported Kotlin references in other files are updated, and **Undo Last Refactoring** restores the original paths, texts, and open-editor carets. On a Kotlin file, it also renames a matching top-level class and its supported Kotlin references. **Rename Kotlin Package Directory...** still renames only the directory, **Change Kotlin Package...** still changes only the package, and Rename is no longer offered for the `src/main/kotlin` and `src/test/kotlin` source roots. +- Changed **Refactor → Rename** for a Kotlin package folder to rename both the directory and the package: every Kotlin file inside receives the package derived from its new path (including files whose package did not match the directory), imports and supported Kotlin references in other files are updated, and **Undo Last Refactoring** restores the original paths, texts, and open-editor carets. On a Kotlin file, it also renames a matching top-level class and its supported Kotlin references, and reports a blocking conflict when the requested name cannot be used as a Kotlin class name or is already declared in the same package. **Rename Kotlin Package Directory...** still renames only the directory, **Change Kotlin Package...** still changes only the package, and Rename is no longer offered for the `src/main/kotlin` and `src/test/kotlin` source roots. - Added Java to Kotlin conversion: **Refactor → Convert to Kotlin** (in the main menu, the Java editor's right-click menu, and the context menu of a Java file in the Projects/Files windows) opens the standard Refactoring window with the file to convert, and pressing **Refactor** runs the conversion under NetBeans' progress dialog, which closes when the conversion finishes; the Java file is then replaced with a sibling Kotlin file produced by the IntelliJ IDEA K2 converter (getters/setters become properties, static-only classes become `object`s, inner classes become `inner class`es); nullability annotations on the Java declarations are carried over into Kotlin `?` types, the Kotlin file appears only after the conversion succeeds, a failed conversion leaves the Java file untouched, and **Undo Last Refactoring** restores the Java file and removes the Kotlin one in a single step. # 0.11.59 (2026-10-26) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 339887878..8eb7ee9e8 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index f966c43de..ffefbba32 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index a63a58dbd..a6891a47f 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index cac8bc8cb..e620e189f 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 62a13c6b6..c948c9fd4 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 2fcd4ad31..9d5eb1290 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index e07680858..bbdc563e8 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index a9af97786..38019922d 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 420b8c437..e10cbaabc 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 84cc54a13..9a16f831f 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 19739267f..9531032d8 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -20,6 +20,9 @@ import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol 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 @@ -54,19 +57,6 @@ import org.jetbrains.kotlin.psi.KtUserType class KaChangePackageComputer( private val ktFile: KtFile, ) { - private companion object { - /** - * Kotlin hard keywords, which cannot be written as bare identifiers in generated source. - * - * Soft keywords (`value`, `field`, `import`, ...) are legal identifiers and stay plain. - */ - val HARD_KEYWORDS = setOf( - "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", - "interface", "is", "null", "object", "package", "return", "super", "this", "throw", - "true", "try", "typealias", "typeof", "val", "var", "when", "while", - ) - } - /** 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. */ @@ -166,9 +156,10 @@ class KaChangePackageComputer( }.toMap() // Offsets before the package directive end are unaffected by its replacement length; // file annotations are the one place where Kotlin code precedes the directive. - val packageDirectiveEnds = participants.mapNotNull { file -> + val packageDirectiveRanges = participants.mapNotNull { file -> val path = file.virtualFile?.path ?: return@mapNotNull null - path to file.packageDirective?.textRange?.endOffset + val range = file.packageDirective?.textRange + path to (range?.startOffset to range?.endOffset) }.toMap() // Snapshot before any mutation: updatePackageDirective invalidates standalone PSI. val originalTexts = participants.mapNotNull { file -> @@ -184,11 +175,14 @@ class KaChangePackageComputer( participants.forEach { file -> val path = file.virtualFile?.path ?: return@forEach val originalText = originalTexts[path] ?: return@forEach - val text = targets[file]?.let { target -> ensurePackageDirectiveSeparator(file.text, target) } ?: file.text + val directiveRange = packageDirectiveRanges[path] + val text = targets[file]?.let { target -> + ensurePackageDirectiveSeparator(file.text, target, directiveRange?.first ?: 0) + } ?: file.text // Captured offsets refer to the original text; only the package directive changed, // so its length delta realigns every range that follows the directive. val shift = text.length - originalText.length - val shiftAfter = if (targets[file] != null) packageDirectiveEnds[path] else null + val shiftAfter = if (targets[file] != null) directiveRange?.second else null val retargeted = applyTextEdits(text, textEdits[file].orEmpty(), shift, shiftAfter) changedFiles[path] = addSamePackageImports( retargeted.text, @@ -293,7 +287,7 @@ class KaChangePackageComputer( val file = element.containingFile as? KtFile ?: return val chain = element.qualifiedChain() if (!chain.matches(oldFqName)) return - edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, renderFqName(newFqName)) + edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, KotlinSourceNames.renderFqName(newFqName)) } /** @@ -333,13 +327,8 @@ class KaChangePackageComputer( super.visitSimpleNameExpression(expression) if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return val reference = expression.references.filterIsInstance().firstOrNull() ?: return - val declaration = reference.resolveToSymbol()?.psi as? KtNamedDeclaration ?: return - val containingFile = declaration.containingKtFile - if (containingFile.virtualFile?.path in movedPaths) return - if (containingFile.packageFqName != originalPackage) return - if (declaration.parent !is KtFile) return - val name = declaration.name ?: return - val importFqName = originalPackage.child(Name.identifier(name)) + val symbol = reference.resolveToSymbol() ?: return + val importFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) ?: return if (importFqName in importedFqNames || originalPackage in starPackages) return if (expression.isPackageQualified(originalPackage)) return missingImports += importFqName @@ -352,6 +341,43 @@ class KaChangePackageComputer( return updates } + /** + * Resolves which top-level declaration a moved file's simple name still needs to import. + * + * Only declarations that stay behind in the file's former package qualify. Kotlin sources are + * identified through their PSI; Java classes have no [KtNamedDeclaration] and constructor calls + * resolve to a constructor symbol, so both are matched through the symbol's class id instead. + * + * @param symbol symbol resolved for the simple-name reference inside a moved file + * @param originalPackage package the moved file declares before the change + * @param movedPaths paths of the files whose package changes + * @return FQN of the declaration to import, or `null` when no import is required + */ + private fun samePackageTopLevelName( + symbol: KaSymbol, + originalPackage: FqName, + movedPaths: Set, + ): FqName? { + val psi = symbol.psi + if (psi is KtNamedDeclaration) { + val containingFile = psi.containingKtFile + if (containingFile.virtualFile?.path in movedPaths) return null + if (containingFile.packageFqName != originalPackage) return null + if (psi.parent !is KtFile) return null + val name = psi.name ?: return null + return originalPackage.child(Name.identifier(name)) + } + val classId = when (symbol) { + is KaConstructorSymbol -> symbol.containingClassId + is KaClassLikeSymbol -> symbol.classId + else -> null + } ?: return null + if (classId.isLocal || classId.isNestedClass) return null + if (classId.packageFqName != originalPackage) return null + if (psi?.containingFile?.virtualFile?.path in movedPaths) return null + return classId.asSingleFqName() + } + /** * Tells whether [this] is the selector of a qualified access whose receiver already spells out * [packageFqName], in which case the reference resolves without an import. @@ -365,20 +391,32 @@ class KaChangePackageComputer( } /** - * Separates a package directive from the first declaration of a source that had none. + * Separates a package directive from adjacent code when it replaced an empty directive. * * [K2ChangePackageRefactoringProcessor.updatePackages] replaces the empty directive of a - * root-package file in place, which leaves `package dest` glued to the next declaration in - * standalone PSI. Restore the mandatory line break before references are rewritten. + * root-package file in place, which leaves `package dest` glued to the next declaration or, + * for a file with file annotations, to the closing parenthesis of the last annotation. Both + * sides need whitespace so the directive stays a standalone line between them. * - * @return text whose package directive is followed by whitespace + * @param text source text after the package mutation + * @param targetPackage package written by the processor + * @param startOffset captured offset where the new directive begins + * @return text whose package directive is surrounded by whitespace or file boundaries */ - private fun ensurePackageDirectiveSeparator(text: String, targetPackage: FqName): String { + private fun ensurePackageDirectiveSeparator(text: String, targetPackage: FqName, startOffset: Int): String { if (targetPackage.isRoot) return text val directive = "package ${targetPackage.asString()}" - if (!text.startsWith(directive)) return text - val next = text.getOrNull(directive.length) ?: return text - return if (next.isWhitespace()) text else "$directive\n\n${text.substring(directive.length)}" + if (!text.startsWith(directive, startOffset)) return text + var result = text + var directiveEnd = startOffset + directive.length + if (startOffset > 0 && !result[startOffset - 1].isWhitespace()) { + result = result.substring(0, startOffset) + "\n" + result.substring(startOffset) + directiveEnd++ + } + if (directiveEnd < result.length && !result[directiveEnd].isWhitespace()) { + result = result.substring(0, directiveEnd) + "\n\n" + result.substring(directiveEnd) + } + return result } /** PSI-captured facts required to insert imports without rescanning the changed source text. */ @@ -441,7 +479,7 @@ class KaChangePackageComputer( ): String { if (imports.isEmpty() || insertion == null) return usageText val missingImports = imports.filterNot { imported -> imported in insertion.existingFqNames } - .map { imported -> "import ${renderFqName(imported)}" } + .map { imported -> "import ${KotlinSourceNames.renderFqName(imported)}" } if (missingImports.isEmpty()) return usageText val shiftedOffset = insertion.offset + shift val offset = shiftedOffset + appliedEdits @@ -537,37 +575,11 @@ class KaChangePackageComputer( if (prefix != oldSegments) return@forEach val suffix = segments.drop(oldSegments.size) val separator = if (suffix.isEmpty()) "" else "." - return renderFqName(newFqName) + suffix.joinToString(separator = ".", prefix = separator) + return KotlinSourceNames.renderFqName(newFqName) + suffix.joinToString(separator = ".", prefix = separator) } return null } - /** - * Renders a fully qualified name as Kotlin source text. - * - * [FqName.asString] joins raw names, so a declaration that needs backticks (a keyword or a name - * with spaces) would otherwise yield invalid source in generated references and imports. - * - * @param fqName name of a moved declaration or its new package prefix - * @return the FQN with each segment escaped when it cannot be written as a bare identifier - */ - private fun renderFqName(fqName: FqName): String = - fqName.pathSegments().joinToString(separator = ".") { segment -> renderIdentifier(segment.asString()) } - - /** - * Renders one identifier as Kotlin source, adding backticks when the bare spelling is invalid. - * - * @param name unescaped identifier as stored by Kotlin PSI - * @return the name unchanged when it is a plain non-keyword identifier, otherwise backticked - */ - private fun renderIdentifier(name: String): String { - val isPlain = name.isNotEmpty() && - (name.first().isLetter() || name.first() == '_') && - name.all { symbol -> symbol.isLetterOrDigit() || symbol == '_' } && - name !in HARD_KEYWORDS - return if (isPlain) name else "`$name`" - } - /** Source range and text of the qualified chain that ends at a reference expression. */ private data class QualifiedChain(val startOffset: Int, val endOffset: Int, val text: String) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt new file mode 100644 index 000000000..98bde0bbc --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt @@ -0,0 +1,69 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.refactoring + +import org.jetbrains.kotlin.name.FqName + +/** + * Renders Kotlin names as source text with the escaping the language requires. + * + * [FqName.asString] and raw declaration names join unescaped segments, which is not valid source + * for identifiers that need backticks (hard keywords or names with spaces). Generated references, + * imports, and package directives must use these helpers instead. + */ +object KotlinSourceNames { + + /** + * Kotlin hard keywords, which cannot be written as bare identifiers in generated source. + * + * Soft keywords (`value`, `field`, `import`, ...) are legal identifiers and stay plain. + */ + val HARD_KEYWORDS: Set = setOf( + "as", "break", "class", "continue", "do", "else", "false", "for", "fun", "if", "in", + "interface", "is", "null", "object", "package", "return", "super", "this", "throw", + "true", "try", "typealias", "typeof", "val", "var", "when", "while", + ) + + /** + * Tells whether an unescaped name can be written without backticks. + * + * @param name identifier as stored by Kotlin PSI + * @return `true` for a plain letter/underscore-led non-keyword identifier + */ + fun isPlainIdentifier(name: String): Boolean = + name.isNotEmpty() && + (name.first().isLetter() || name.first() == '_') && + name.all { symbol -> symbol.isLetterOrDigit() || symbol == '_' } && + name !in HARD_KEYWORDS + + /** + * Renders one identifier as Kotlin source, adding backticks when the bare spelling is invalid. + * + * @param name unescaped identifier as stored by Kotlin PSI + * @return the name unchanged when it is plain, otherwise backticked + */ + fun renderIdentifier(name: String): String = if (isPlainIdentifier(name)) name else "`$name`" + + /** + * Renders a fully qualified name as Kotlin source text. + * + * @param fqName fully qualified name of a declaration or a package + * @return the FQN with every segment escaped when it cannot be written bare + */ + fun renderFqName(fqName: FqName): String = + fqName.pathSegments().joinToString(separator = ".") { segment -> renderIdentifier(segment.asString()) } +} diff --git a/Nbm/pom.xml b/Nbm/pom.xml index ae0898409..cecc93c68 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index 7feb8644d..d8e6706af 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -21,11 +21,16 @@ import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceGrou import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import io.github.nbplugins.kotlin.refactoring.KaChangePackageComputer import io.github.nbplugins.kotlin.refactoring.KaRenameComputer +import io.github.nbplugins.kotlin.refactoring.KotlinSourceNames import io.github.nbplugins.kotlin.refactoring.TextChange +import org.jetbrains.kotlin.builder.KotlinPsiManager import org.jetbrains.kotlin.log.KotlinLogger import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.utils.ProjectUtils import org.netbeans.api.project.Project import org.netbeans.modules.csl.api.OffsetRange @@ -58,7 +63,9 @@ class KotlinRenameFilePlugin( override fun preCheck(): Problem? = validateKotlinFile(refactoring.file) /** @return a fatal problem when the requested basename is invalid. */ - override fun fastCheckParameters(): Problem? = validateIdentifier(refactoring.newName) + override fun fastCheckParameters(): Problem? = + validateIdentifier(refactoring.newName) + ?: validateClassName(refactoring.newName, refactoring.updatePrimaryClass) /** @return the same validation used before UI confirmation. */ override fun checkParameters(): Problem? = fastCheckParameters() @@ -70,10 +77,42 @@ class KotlinRenameFilePlugin( override fun prepare(bag: RefactoringElementsBag): Problem? { preCheck()?.let { return it } fastCheckParameters()?.let { return it } + classConflictProblem()?.let { return it } bag.add(refactoring, KotlinFindUsagesResultElement(OffsetRange.NONE, refactoring.file)) bag.add(refactoring, KotlinRenameFileApplyElement(refactoring)) return null } + + /** + * Reports a redeclaration conflict before the semantic rename is staged. + * + * The rename writes the new class name verbatim and does not run Kotlin's redeclaration checks, + * so an existing class, object, or type alias with the same name in the same package would + * become a second declaration of the same FQN. + * + * @return a blocking problem when the target class name is already declared, otherwise `null` + */ + private fun classConflictProblem(): Problem? { + if (!refactoring.updatePrimaryClass) return null + val project = ProjectUtils.getKotlinProjectForFileObject(refactoring.file) ?: return null + KotlinAnalysisAPISession.invalidate(project) + val session = KotlinAnalysisAPISession.getSession(project) + session.synchronizeOpenDocuments() + val source = session.getKtFileForPath(refactoring.file.path) ?: return null + val primaryClass = source.declarations.filterIsInstance() + .firstOrNull { declaration -> declaration.name == refactoring.file.name } + ?: return null + val files = KotlinPsiManager.getFilesByProject(project) + .mapNotNull { file -> session.getKtFileForPath(file.path) } + val conflict = findClassifierConflict(source, primaryClass, refactoring.newName, files) + ?: return null + val packageName = source.packageFqName + val location = if (packageName.isRoot) "the root package" else "package ${packageName.asString()}" + return Problem( + true, + "The Kotlin name ${refactoring.newName} is already declared in $location (${conflict.containingKtFile.name}).", + ) + } } /** @@ -140,7 +179,7 @@ class KotlinRenameFileApplyElement( val primaryClass = source.declarations.filterIsInstance() .firstOrNull { it.name == refactoring.file.name } ?: return - val allFiles = org.jetbrains.kotlin.builder.KotlinPsiManager.getFilesByProject(project) + val allFiles = KotlinPsiManager.getFilesByProject(project) val allKtFiles = allFiles.mapNotNull { session.getKtFileForPath(it.path) }.toSet() val changes = KaRenameComputer( source, @@ -455,6 +494,59 @@ private fun validateIdentifier(name: String): Problem? = if (name.matches(Regex( Problem(true, "The new Kotlin file or package-directory name must be a Kotlin identifier.") } +/** + * Rejects a basename that would become an invalid Kotlin class name in semantic mode. + * + * A physical file may be named after a hard keyword, but the matching top-level class is written + * verbatim into source text, where a bare keyword is not a valid identifier. + * + * @param name requested basename, already accepted as a plain identifier + * @param updatePrimaryClass whether generic Rename also renames the matching class + * @return a fatal problem when the basename cannot name a Kotlin class + */ +private fun validateClassName(name: String, updatePrimaryClass: Boolean): Problem? = + if (updatePrimaryClass && !KotlinSourceNames.isPlainIdentifier(name)) { + Problem(true, "The new Kotlin class name must not be a Kotlin keyword: $name") + } else { + null + } + +/** + * Finds a top-level classifier that already uses [newName] in the package of [source]. + * + * Renaming a file together with its primary class writes the new name verbatim, so a class, + * object, or type alias that already owns that name in the same package would be redeclared. + * + * @param source file whose matching class is renamed + * @param primaryClass top-level class renamed together with the file + * @param newName requested class name + * @param files Kotlin sources of the project + * @return the first conflicting declaration, or `null` when the name is free + */ +internal fun findClassifierConflict( + source: KtFile, + primaryClass: KtClass, + newName: String, + files: Collection, +): KtNamedDeclaration? { + val packageName = source.packageFqName + return files.asSequence() + .filter { file -> file.packageFqName == packageName } + .flatMap { file -> file.declarations.asSequence() } + .filter { declaration -> declaration is KtClassOrObject || declaration is KtTypeAlias } + .map { declaration -> declaration as KtNamedDeclaration } + .filter { declaration -> !declaration.isSameDeclaration(primaryClass) } + .firstOrNull { declaration -> declaration.name == newName } +} + +/** Tells whether this declaration is [primaryClass], tolerating distinct PSI instances of it. */ +private fun KtNamedDeclaration.isSameDeclaration(primaryClass: KtClass): Boolean = + this === primaryClass || + (this is KtClass && + name == primaryClass.name && + containingKtFile.virtualFile?.path == primaryClass.containingKtFile.virtualFile?.path && + textRange.startOffset == primaryClass.textRange.startOffset) + /** Creates position bounds for one preview source when a CloneableEditorSupport is available. */ private fun positionFor(file: FileObject): PositionBounds? = runCatching { val support = org.openide.loaders.DataObject.find(file).lookup.lookup(CloneableEditorSupport::class.java) ?: return null diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index d1b604d6d..03381ba71 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -20,8 +20,12 @@ 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.charset.StandardCharsets import java.nio.file.Files import java.nio.file.Path +import java.util.jar.JarEntry +import java.util.jar.JarOutputStream +import javax.tools.ToolProvider /** Integration tests for the standalone K2 [KaChangePackageComputer]. */ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { @@ -1049,9 +1053,215 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a new package directive is separated from the declaration when a header comment leads. + * + * The empty package directive of a root-package file sits at the first declaration token, after + * the comment, so the in-place replacement must still restore the line break. + */ + fun testApply_realSession_separatesPackageDirectiveAfterHeaderComment() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-header-comment") + try { + val sourcePath = temporaryRoot.resolve("Moved.kt") + Files.writeString(sourcePath, "// Generated file\nfun moved() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-header-comment-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The new directive must not be glued to the declaration", + "package dest\n\n// Generated file\nfun moved() = 1\n", + success.changedFiles[sourcePath.toString()].orEmpty(), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a file annotation stays before a newly written package directive. + * + * Kotlin requires file annotations to precede the package header, so a root-package file that + * gains a package must not move the directive above its annotation. + */ + fun testApply_realSession_keepsFileAnnotationBeforeNewPackageDirective() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-new-directive-annotation") + try { + val sourcePath = temporaryRoot.resolve("Moved.kt") + Files.writeString(sourcePath, "@file:JvmName(\"Moved\")\nfun moved() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-new-directive-annotation-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A file annotation must stay above the package directive", + "@file:JvmName(\"Moved\")\npackage dest\nfun moved() = 1\n", + success.changedFiles[sourcePath.toString()].orEmpty(), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a moved file imports a Java class that stays in its former package. + * + * Java classes have no Kotlin PSI declaration, so the outgoing-dependency pass must recognise + * them through their class id; otherwise the simple name loses its binding after the move. + */ + fun testApply_realSession_addsImportForOutgoingSamePackageJavaClass() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing-java") + val buildRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing-java-build") + try { + val javaJar = compileJavaClassJar(buildRoot, "old.source", "JavaHelper") ?: run { + println("Java compiler unavailable — skipping outgoing Java class test") + return + } + val oldPackageDirectory = temporaryRoot.resolve("old/source") + Files.createDirectories(oldPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + Files.writeString(sourcePath, "package old.source\n\nfun create() = JavaHelper()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-outgoing-java-integration", + binaryJars = listOf(stdlib, javaJar), + 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( + "A moved file must import a Java class that stays in its former package", + "package new.changed\n\nimport old.source.JavaHelper\n\nfun create() = JavaHelper()\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + buildRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a moved file imports a Kotlin class whose constructor it calls. + * + * A constructor call resolves to a constructor symbol rather than to the class declaration, + * so outgoing dependency capture must fall back to the constructed class id. + */ + fun testApply_realSession_addsImportForOutgoingSamePackageClassConstructor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing-constructor") + try { + val oldPackageDirectory = temporaryRoot.resolve("old/source") + Files.createDirectories(oldPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val staysPath = oldPackageDirectory.resolve("Stays.kt") + Files.writeString(sourcePath, "package old.source\n\nfun create() = Helper()\n") + Files.writeString(staysPath, "package old.source\n\nclass Helper\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-outgoing-constructor-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( + "A moved file must import a class that stays in its former package", + "package new.changed\n\nimport old.source.Helper\n\nfun create() = Helper()\n", + success.changedFiles[sourcePath.toString()], + ) + } 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() } + + /** + * Compiles [packageName].[className] with the JDK compiler and packs it into a JAR. + * + * @param directory scratch directory owned by the caller + * @param packageName Java package of the generated class + * @param className simple name of the generated class + * @return the compiled JAR, or `null` when no JDK compiler is available + */ + private fun compileJavaClassJar(directory: Path, packageName: String, className: String): Path? { + val compiler = ToolProvider.getSystemJavaCompiler() ?: return null + val packagePath = packageName.replace('.', '/') + val sourceFile = directory.resolve("src/$packagePath/$className.java") + Files.createDirectories(sourceFile.parent) + Files.writeString(sourceFile, "package $packageName;\n\npublic class $className {}\n") + val classesDirectory = directory.resolve("classes") + Files.createDirectories(classesDirectory) + var compiled = false + compiler.getStandardFileManager(null, null, StandardCharsets.UTF_8).use { fileManager -> + val units = fileManager.getJavaFileObjectsFromFiles(listOf(sourceFile.toFile())) + compiled = compiler.getTask( + null, + fileManager, + null, + listOf("-d", classesDirectory.toString()), + null, + units, + ).call() + } + if (!compiled) return null + val jarPath = directory.resolve("$className.jar") + JarOutputStream(Files.newOutputStream(jarPath)).use { jar -> + jar.putNextEntry(JarEntry("$packagePath/$className.class")) + Files.copy(classesDirectory.resolve("$packagePath/$className.class"), jar) + jar.closeEntry() + } + return jarPath + } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt new file mode 100644 index 000000000..6df68ccf8 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -0,0 +1,144 @@ +/******************************************************************************* + * 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 org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtFile +import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** Integration tests for the class-name conflict lookup used by generic file Rename. */ +class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifierConflictTest", "moveFile") { + + /** Verifies a class already declared in the same package blocks the rename. */ + fun testFindClassifierConflict_reportsClassInSamePackage() { + withSession( + name = "class", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo\n") + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Bar\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val types = session.ktFile(root.resolve("source/Types.kt")) + + val conflict = findClassifierConflict(source, primaryClass(source), "Bar", listOf(source, types)) + + assertEquals("Bar", conflict?.name) + } + } + + /** Verifies a class in another package does not block the rename. */ + fun testFindClassifierConflict_ignoresOtherPackages() { + withSession( + name = "other-package", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("other")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo\n") + Files.writeString(root.resolve("other/Types.kt"), "package q\n\nclass Bar\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val other = session.ktFile(root.resolve("other/Types.kt")) + + val conflict = findClassifierConflict(source, primaryClass(source), "Bar", listOf(source, other)) + + assertNull("another package cannot collide with the renamed class", conflict) + } + } + + /** Verifies the renamed class itself is not reported when the name is unchanged. */ + fun testFindClassifierConflict_ignoresTheRenamedClass() { + withSession( + name = "self", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + + val conflict = findClassifierConflict(source, primaryClass(source), "Foo", listOf(source)) + + assertNull("the declaration being renamed must not conflict with itself", conflict) + } + } + + /** Verifies a type alias with the target name also blocks the rename. */ + fun testFindClassifierConflict_reportsTypeAliasInSamePackage() { + withSession( + name = "typealias", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo\n") + Files.writeString(root.resolve("source/Aliases.kt"), "package p\n\ntypealias Bar = Int\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val aliases = session.ktFile(root.resolve("source/Aliases.kt")) + + val conflict = findClassifierConflict(source, primaryClass(source), "Bar", listOf(source, aliases)) + + assertEquals("Bar", conflict?.name) + } + } + + /** @return the single top-level class of [source]. */ + private fun primaryClass(source: KtFile): KtClass = source.declarations.filterIsInstance().single() + + /** @return the session source for [path], failing the test when it is absent. */ + private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = + getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") + + /** + * Writes files through [prepare], then runs [check] against a session created afterwards. + * + * The session snapshots its source roots at creation, so files must exist before it is built. + */ + private fun withSession( + name: String, + prepare: (Path) -> Unit, + check: (KotlinAnalysisAPISession, Path) -> Unit, + ) { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping rename conflict integration test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-conflict-$name") + try { + prepare(root) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-conflict-$name-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + check(session, root) + } finally { + root.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/KotlinRenameRefactoringTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt index cb3accce7..fb5c1e2dd 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameRefactoringTest.kt @@ -44,6 +44,25 @@ class KotlinRenameRefactoringTest : NbTestCase("KotlinRenameRefactoringTest") { assertTrue("generic Rename must update a matching top-level class", refactoring.updatePrimaryClass) } + /** Verifies semantic file Rename rejects a basename that cannot name a Kotlin class. */ + fun testFilePlugin_rejectsKeywordBasenameInSemanticMode() { + val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt") + val plugin = KotlinRenameFilePlugin(KotlinRenameFileRefactoring(file, "class", updatePrimaryClass = true)) + + assertNotNull( + "a hard keyword must not be written as a class name", + plugin.fastCheckParameters(), + ) + } + + /** Verifies the physical-only explicit action still accepts a keyword basename. */ + fun testFilePlugin_acceptsKeywordBasenameInPhysicalMode() { + val file = FileUtil.createMemoryFileSystem().root.createData("Before.kt") + val plugin = KotlinRenameFilePlugin(KotlinRenameFileRefactoring(file, "class")) + + assertNull("a physical file may be named after a keyword", plugin.fastCheckParameters()) + } + /** Verifies a directory carrier defaults to a physical-only rename. */ fun testDirectoryCarrier_retainsPhysicalSourceAndName() { val directory = FileUtil.createMemoryFileSystem().root.createFolder("before") diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt new file mode 100644 index 000000000..06e4f46ad --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt @@ -0,0 +1,68 @@ +/******************************************************************************* + * 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.KotlinSourceNames +import org.jetbrains.kotlin.name.FqName +import org.netbeans.junit.NbTestCase + +/** Unit tests for the Kotlin source-text name renderer. */ +class KotlinSourceNamesTest : NbTestCase("KotlinSourceNamesTest") { + + /** Verifies plain identifiers need no escaping. */ + fun testIsPlainIdentifier_acceptsPlainNames() { + assertTrue(KotlinSourceNames.isPlainIdentifier("moved")) + assertTrue(KotlinSourceNames.isPlainIdentifier("_private")) + assertTrue(KotlinSourceNames.isPlainIdentifier("Camel2Case")) + assertTrue("soft keywords are legal identifiers", KotlinSourceNames.isPlainIdentifier("value")) + } + + /** Verifies hard keywords cannot be written as bare identifiers. */ + fun testIsPlainIdentifier_rejectsHardKeywords() { + assertFalse(KotlinSourceNames.isPlainIdentifier("class")) + assertFalse(KotlinSourceNames.isPlainIdentifier("when")) + assertFalse(KotlinSourceNames.isPlainIdentifier("object")) + } + + /** Verifies names with spaces or a leading digit need backticks. */ + fun testIsPlainIdentifier_rejectsNonIdentifierSpellings() { + assertFalse(KotlinSourceNames.isPlainIdentifier("two words")) + assertFalse(KotlinSourceNames.isPlainIdentifier("2fast")) + assertFalse(KotlinSourceNames.isPlainIdentifier("")) + } + + /** Verifies a plain name is rendered unchanged. */ + fun testRenderIdentifier_keepsPlainName() { + assertEquals("moved", KotlinSourceNames.renderIdentifier("moved")) + } + + /** Verifies keywords and names with spaces are backticked. */ + fun testRenderIdentifier_backticksInvalidNames() { + assertEquals("`when`", KotlinSourceNames.renderIdentifier("when")) + assertEquals("`two words`", KotlinSourceNames.renderIdentifier("two words")) + } + + /** Verifies a plain FQN is rendered unchanged. */ + fun testRenderFqName_keepsPlainSegments() { + assertEquals("dest.moved", KotlinSourceNames.renderFqName(FqName("dest.moved"))) + } + + /** Verifies only the segments that need escaping are backticked. */ + fun testRenderFqName_escapesKeywordSegments() { + assertEquals("a.`when`.C", KotlinSourceNames.renderFqName(FqName("a.when.C"))) + } +} diff --git a/pom.xml b/pom.xml index 69a1f544c..527f9579d 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.17-SNAPSHOT + 0.12.18-SNAPSHOT pom Netbeans Plugin Kotlin From 93794165df2a71bf3e7ee1773417b167e2e96d21 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 14:58:56 +0300 Subject: [PATCH 08/55] Fixed wildcard and aliased imports during Kotlin package changes A caller that only uses nested declarations of a moved class is now discovered through its class-scoped wildcard import, and the import prefix follows the class to its new package. Package wildcards are left untouched so they keep binding declarations that stay behind. An aliased import no longer counts as an unaliased binding: a moved file that calls the original name and the alias receives the missing unaliased import next to the alias directive. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 51 +++++-- .../move/KotlinMoveUsageSearchService.kt | 12 ++ Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 26 ++++ .../nbm/refactoring/KaChangePackageTest.kt | 135 ++++++++++++++++++ pom.xml | 2 +- 16 files changed, 227 insertions(+), 21 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 8eb7ee9e8..c1c2b2491 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index ffefbba32..f67307b08 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index a6891a47f..e44a98b42 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index e620e189f..8e6818853 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index c948c9fd4..2b57825c5 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 9d5eb1290..2c68db02b 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index bbdc563e8..2883f4ec6 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 38019922d..0771d3462 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index e10cbaabc..211f29de4 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 9a16f831f..633e58902 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 9531032d8..79d7796c6 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -31,6 +31,7 @@ import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile @@ -112,6 +113,20 @@ class KaChangePackageComputer( } } }.toMap() + // Class-scoped wildcard imports (`import old.Outer.*`) follow the class only when the + // prefix is a moved class; package wildcards never move with it. + val movedClassFqNames = files.flatMap { file -> + file.declarations.filterIsInstance().mapNotNull { declaration -> + declaration.name?.let { name -> file.packageFqName.child(Name.identifier(name)) } + } + }.toSet() + val wildcardImportFiles = if (updateReferences) { + KotlinMoveUsageSearchService.getInstance() + ?.findWildcardImportingFiles(ktFile.project, movedClassFqNames) + .orEmpty() + } else { + emptyList() + } // Incoming changes fix callers that lose visibility of a moved declaration, either // because they shared its package or because they imported the whole old package. // Outgoing imports fix the moved file itself losing visibility of the declarations @@ -133,13 +148,13 @@ class KaChangePackageComputer( } else { emptyList() } - val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + samePackageImportUpdates.keys + incoming.edits.keys) + val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + wildcardImportFiles + samePackageImportUpdates.keys + incoming.edits.keys) .distinct() // Capture replacement ranges while PSI is still valid. Only import directives that // target a moved declaration and references that K2 resolved to one are retargeted, so // text that merely spells a moved FQN (comments, strings, unrelated chains) is untouched. val textEdits = if (updateReferences) { - val importEdits = captureImportEdits(participants, movedDeclarations) + val importEdits = captureImportEdits(participants, movedDeclarations, movedClassFqNames) (importEdits.keys + incoming.edits.keys).associateWith { file -> importEdits[file].orEmpty() + incoming.edits[file].orEmpty() } @@ -314,7 +329,12 @@ class KaChangePackageComputer( movedFiles.forEach { file -> val originalPackage = file.packageFqName if (originalPackage.isRoot || targets[file] == originalPackage) return@forEach - val importedFqNames = file.importDirectives.mapNotNull { it.importedFqName }.toSet() + // An aliased import binds only the alias, so it cannot satisfy a simple-name use of + // the original name; only an unaliased import counts as an existing binding. + val importedFqNames = file.importDirectives + .filterNot { directive -> directive.isAllUnder || directive.aliasName != null } + .mapNotNull { it.importedFqName } + .toSet() val starPackages = file.importDirectives .filter { it.isAllUnder } .mapNotNull { it.importedFqName } @@ -440,8 +460,10 @@ class KaChangePackageComputer( * @return captured insertion point, or `null` when the file cannot host an import */ private fun importInsertionFor(file: KtFile): ImportInsertion? { + // An alias binds only its alias, not the imported name, so an unaliased use still needs + // its own import even when an aliased directive for the same FQN exists. val existingFqNames = file.importDirectives - .filterNot { directive -> directive.isAllUnder } + .filterNot { directive -> directive.isAllUnder || directive.aliasName != null } .mapNotNull { directive -> directive.importedFqName } .toSet() file.importDirectives.firstOrNull()?.let { firstImport -> @@ -503,8 +525,10 @@ class KaChangePackageComputer( * Captures the import directives that must follow moved declarations. * * Paths are matched by FQN prefix, so an import of a nested declaration keeps its suffix, and - * only directives that actually target a moved declaration are recorded. References themselves - * are captured separately from K2-resolved usages. + * only directives that actually target a moved declaration are recorded. A wildcard import + * follows only when its prefix itself is a moved class; a package wildcard keeps binding + * declarations that stay behind. References themselves are captured separately from + * K2-resolved usages. * * @param participants files whose text may change * @param movedDeclarations old top-level declaration FQNs mapped to their new FQNs @@ -513,6 +537,7 @@ class KaChangePackageComputer( private fun captureImportEdits( participants: Collection, movedDeclarations: Map, + movedClassFqNames: Set, ): Map> { if (movedDeclarations.isEmpty()) return emptyMap() val mappings = movedDeclarations.entries @@ -521,15 +546,23 @@ class KaChangePackageComputer( participants.forEach { file -> val fileText = file.text file.importDirectives.forEach { directive -> - if (directive.isAllUnder) return@forEach val importedReference = directive.importedReference ?: return@forEach // Ranges come from PSI: `importPath.pathStr` is a rendered path and can differ // from the source spelling (backticks, whitespace), so searching for it corrupts. val pathStart = directive.textRange.startOffset + importPathStart(directive.text) val pathEnd = importedReference.textRange.endOffset if (pathStart >= pathEnd) return@forEach - val replacement = rewriteImportedPath(fileText.substring(pathStart, pathEnd), mappings) - ?: return@forEach + val replacement = if (directive.isAllUnder) { + // A package wildcard keeps importing whatever stays behind, so only a + // wildcard rooted in a moved class follows that class to its new package. + val imported = directive.importedFqName ?: return@forEach + if (imported !in movedClassFqNames) return@forEach + val newPrefix = movedDeclarations[imported] ?: return@forEach + KotlinSourceNames.renderFqName(newPrefix) + } else { + rewriteImportedPath(fileText.substring(pathStart, pathEnd), mappings) + ?: return@forEach + } edits.getOrPut(file, ::linkedSetOf) += TextEdit(pathStart, pathEnd, replacement) } } 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 c8ad5ac89..a659fe119 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 @@ -48,6 +48,18 @@ interface KotlinMoveUsageSearchService { */ fun findImportingFiles(project: com.intellij.openapi.project.Project, movedImports: Map): List = emptyList() + /** + * Finds session files whose wildcard import is rooted in one of [classFqNames]. + * + * A caller may reference only nested declarations of a moved class, in which case the import + * prefix `import old.Outer.*` is the only place the moved class is named. + * + * @param project IntelliJ project whose standalone K2 session supplies candidate files + * @param classFqNames old fully qualified names of moved classes + * @return Kotlin files with a class-scoped wildcard import that follows a moved class + */ + fun findWildcardImportingFiles(project: com.intellij.openapi.project.Project, classFqNames: Set): List = emptyList() + companion object { fun getInstance(): KotlinMoveUsageSearchService? = ApplicationManager.getApplication()?.getService(KotlinMoveUsageSearchService::class.java) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index cecc93c68..56888eb15 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 e73f2cf4c..54859ca5c 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 @@ -116,4 +116,30 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { } return candidates } + + /** + * Finds Kotlin files with a wildcard import rooted in a moved class. + * + * Such a caller may reference only nested declarations of that class, so K2 usage discovery + * never reports it; the import prefix is the only place the moved class is named. + * + * @param project IntelliJ project owning the standalone K2 session + * @param classFqNames old fully qualified names of moved classes + * @return session files whose class-scoped wildcard import follows a moved class + */ + override fun findWildcardImportingFiles( + project: com.intellij.openapi.project.Project, + classFqNames: Set, + ): List { + if (classFqNames.isEmpty()) return emptyList() + val session = KotlinAnalysisAPISession.forProject(project) ?: return emptyList() + return session.fileMap.values.mapNotNull { lightFile -> + val file = session.getKtFileForPath(lightFile.path) ?: return@mapNotNull null + file.takeIf { candidate -> + candidate.importDirectives.any { directive -> + directive.isAllUnder && directive.importedFqName in classFqNames + } + } + } + } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 03381ba71..819c71fbc 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1221,6 +1221,141 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a wildcard import rooted in a moved class follows that class. + * + * `import old.pkg.Outer.*` binds nested declarations through `Outer`; once `Outer` moves the + * prefix must move with it, or every nested reference in the caller stops resolving. + */ + fun testApply_realSession_retargetsWildcardImportOfMovedClass() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-class-wildcard") + try { + val sourceDirectory = temporaryRoot.resolve("old/pkg") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Outer.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package old.pkg\n\nclass Outer {\n class Nested\n}\n") + Files.writeString(usagePath, "package usage\n\nimport old.pkg.Outer.*\n\nfun use(): Nested? = null\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-class-wildcard-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.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A wildcard import of a moved class must follow the class", + "package usage\n\nimport new.pkg.Outer.*\n\nfun use(): Nested? = null\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a package wildcard stays when the caller still uses declarations left behind. + * + * Rewriting `import old.pkg.*` wholesale would drop bindings for declarations that do not + * move; the caller instead gains an explicit import for the moved declaration. + */ + fun testApply_realSession_keepsPackageWildcardForDeclarationsStayingBehind() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-package-wildcard") + try { + val sourceDirectory = temporaryRoot.resolve("old/pkg") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val staysPath = sourceDirectory.resolve("Stays.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package old.pkg\n\nfun moved() = 1\n") + Files.writeString(staysPath, "package old.pkg\n\nfun stays() = 2\n") + Files.writeString(usagePath, "package usage\n\nimport old.pkg.*\n\nfun use() = moved() + stays()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-package-wildcard-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.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A package wildcard must keep binding declarations that stay behind", + "package usage\n\nimport new.pkg.moved\nimport old.pkg.*\n\nfun use() = moved() + stays()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an aliased import does not suppress the unaliased import a simple name needs. + * + * `import a.stays as alias` binds only `alias`; the same-package `stays()` call loses its + * binding after the move and needs `import a.stays` in addition to the alias directive. + */ + fun testApply_realSession_addsUnaliasedImportAlongsideAliasedImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-alias-import") + try { + val oldPackageDirectory = temporaryRoot.resolve("old") + Files.createDirectories(oldPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val staysPath = oldPackageDirectory.resolve("Stays.kt") + Files.writeString(sourcePath, "package a\n\nimport a.stays as alias\n\nfun moved() = stays() + alias()\n") + Files.writeString(staysPath, "package a\n\nfun stays() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-alias-import-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("b")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An aliased import must not count as an unaliased binding", + "package b\n\nimport a.stays\nimport a.stays as alias\n\nfun moved() = stays() + alias()\n", + success.changedFiles[sourcePath.toString()], + ) + } 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")) diff --git a/pom.xml b/pom.xml index 527f9579d..9e4a8a785 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.18-SNAPSHOT + 0.12.19-SNAPSHOT pom Netbeans Plugin Kotlin From 2c1219d0b89bdc8d093b5f4d1a7b56ed256e8c91 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 15:32:44 +0300 Subject: [PATCH 09/55] Fixed constructor call and aliased-import updates in Kotlin renames Renaming a class together with its file now also retargets explicit constructor calls: such a call resolves to a constructor symbol whose PSI is the primary constructor, not the class declaration, so the rename matches the constructor's class id as well. A caller that imported a moved declaration only through an alias is no longer treated as already importing the original name; it receives the unaliased import it needs while the alias directive is rewritten to the new package. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 5 ++- .../kotlin/refactoring/KaRenameComputer.kt | 11 ++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 43 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 45 +++++++++++++++++++ pom.xml | 2 +- 16 files changed, 114 insertions(+), 14 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index c1c2b2491..a2dff4f5a 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index f67307b08..c8ced0af1 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index e44a98b42..13281cec0 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 8e6818853..31724848a 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 2b57825c5..bd4af23bb 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 2c68db02b..2874fb664 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 2883f4ec6..db7d9d8c7 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 0771d3462..973b1db8d 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 211f29de4..586079a81 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 633e58902..874bb1fb8 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 79d7796c6..d143183ba 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -263,8 +263,11 @@ class KaChangePackageComputer( if (file.virtualFile?.path == declarationFile.virtualFile?.path) return@forEach val finalPackage = targets[file] ?: file.packageFqName if (finalPackage == newFqName.parent()) return@forEach + // An aliased directive binds only its alias; the original name still needs an + // explicit import, so only an unaliased import suppresses that update. val importsDirectly = file.importDirectives.any { directive -> - !directive.isAllUnder && directive.importedFqName == oldFqName + !directive.isAllUnder && directive.aliasName == null && + directive.importedFqName == oldFqName } if (importsDirectly) return@forEach val sharesOriginalPackage = file.packageFqName == originalPackage diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 000a0c4be..edfc688d2 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -24,10 +24,13 @@ import com.intellij.psi.PsiMethod import com.intellij.psi.PsiNamedElement import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.kdoc.lexer.KDocTokens import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction @@ -308,13 +311,19 @@ class KaRenameComputer( val ranges = mutableListOf>() runCatching { analyze(kaFile) { + // A constructor call resolves to a constructor symbol, whose PSI is the + // KtPrimaryConstructor rather than the class declaration being renamed, so + // compare class ids as well as PSI identity. + val targetClassId = (targetPsi as? KtClassOrObject)?.getClassOrObjectSymbol()?.classId kaFile.accept(object : KtTreeVisitorVoid() { override fun visitReferenceExpression(expression: KtReferenceExpression) { super.visitReferenceExpression(expression) val sym = runCatching { expression.mainReference?.resolveToSymbol() }.getOrNull() ?: return - if (sym.psi == targetPsi) { + val constructsTarget = targetClassId != null && + (sym as? KaConstructorSymbol)?.containingClassId == targetClassId + if (sym.psi == targetPsi || constructsTarget) { val tr = expression.textRange ranges.add(tr.startOffset to tr.endOffset) } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 56888eb15..5e21be86b 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 819c71fbc..fd43b3f0e 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1356,6 +1356,49 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an aliased import in a caller does not suppress the incoming unaliased import. + * + * `import a.moved as alias` keeps only `alias()` bound after the declaration moves; a caller + * that also calls `moved()` needs `import b.moved` in addition to the rewritten alias. + */ + fun testApply_realSession_addsIncomingImportWhenCallerOnlyAliasedIt() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-incoming-alias") + try { + val oldPackageDirectory = temporaryRoot.resolve("old") + Files.createDirectories(oldPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val callerPath = oldPackageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package a\n\nfun moved() = 1\n") + Files.writeString(callerPath, "package a\n\nimport a.moved as alias\n\nfun use() = moved() + alias()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-incoming-alias-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("b")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The caller must keep both the alias and an unaliased binding", + "package a\n\nimport b.moved\nimport b.moved as alias\n\nfun use() = moved() + alias()\n", + success.changedFiles[callerPath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 63bca1493..e0f37b8cc 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -22,6 +22,8 @@ import io.github.nbplugins.kotlin.refactoring.KaRenameComputer import io.github.nbplugins.kotlin.refactoring.TextChange import org.jetbrains.kotlin.builder.isKotlinFile import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path /** * Unit tests for [KaRenameComputer]. @@ -147,6 +149,49 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { /** Renames a class declaration and all its constructor call sites. */ fun testRenameClass() = runFixture("classrename") + /** Renames a class with an explicit primary constructor and its constructor call sites. */ + fun testRenameClassWithExplicitConstructor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping explicit-constructor rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-constructor") + try { + val sourceDirectory = root.resolve("source") + val callerDirectory = root.resolve("caller") + Files.createDirectories(sourceDirectory) + Files.createDirectories(callerDirectory) + val sourcePath = sourceDirectory.resolve("Foo.kt") + val callerPath = callerDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package a\n\nclass Foo(val x: Int)\n") + Files.writeString(callerPath, "package b\n\nimport a.Foo\n\nfun use() = Foo(1)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-constructor-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val changes = KaRenameComputer(source, source.text.indexOf("Foo"), setOf(source, caller), "Bar").compute() + + assertNotNull("compute() must return non-null for an explicit primary constructor", changes) + assertEquals( + "An explicit constructor call must follow the renamed class", + "package b\n\nimport a.Bar\n\nfun use() = Bar(1)\n", + applyChanges(caller.text, changes!![callerPath.toString()].orEmpty()), + ) + } finally { + root.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() } + /** Renames a method declaration and the single call site in the same file. */ fun testRenameFunction() = runFixture("function", newName = "fooFunc") diff --git a/pom.xml b/pom.xml index 9e4a8a785..bb8d7b67b 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.19-SNAPSHOT + 0.12.20-SNAPSHOT pom Netbeans Plugin Kotlin From 3e98ecc29e8cb3e09c04dc78f47456f1a246a58e Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 15:53:09 +0300 Subject: [PATCH 10/55] Fixed outgoing import for explicit constructor calls in Kotlin package changes A constructor call resolves to a constructor symbol whose PSI is the KtPrimaryConstructor inside the class declaration, not a top-level declaration. The outgoing-dependency lookup tested the PSI first and returned early, so a moved file calling `Helper(1)` lost `import a.Helper`. Resolve class-like and constructor symbols through their class id before falling back to the PSI branch. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 36 ++++++++------- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 45 +++++++++++++++++++ pom.xml | 2 +- 14 files changed, 77 insertions(+), 28 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index a2dff4f5a..b61bd0ac7 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index c8ced0af1..d655f6358 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 13281cec0..086a7ae66 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 31724848a..5f872c63b 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index bd4af23bb..021c52061 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 2874fb664..0395d0e5e 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index db7d9d8c7..a0cd47101 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 973b1db8d..1b6483abc 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 586079a81..a531d451f 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 874bb1fb8..c8f321064 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index d143183ba..ebf9c007c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -367,9 +367,12 @@ class KaChangePackageComputer( /** * Resolves which top-level declaration a moved file's simple name still needs to import. * - * Only declarations that stay behind in the file's former package qualify. Kotlin sources are - * identified through their PSI; Java classes have no [KtNamedDeclaration] and constructor calls - * resolve to a constructor symbol, so both are matched through the symbol's class id instead. + * Only declarations that stay behind in the file's former package qualify. Class-like and + * constructor symbols are matched through their class id first: a constructor call resolves to + * a constructor symbol whose PSI is the constructor inside the class declaration (never a + * [KtFile] child), so the PSI branch alone would reject explicit-constructor calls. Java classes + * also have no [KtNamedDeclaration] and are matched the same way. Remaining Kotlin top-level + * callables are identified through their PSI. * * @param symbol symbol resolved for the simple-name reference inside a moved file * @param originalPackage package the moved file declares before the change @@ -382,23 +385,24 @@ class KaChangePackageComputer( movedPaths: Set, ): FqName? { val psi = symbol.psi - if (psi is KtNamedDeclaration) { - val containingFile = psi.containingKtFile - if (containingFile.virtualFile?.path in movedPaths) return null - if (containingFile.packageFqName != originalPackage) return null - if (psi.parent !is KtFile) return null - val name = psi.name ?: return null - return originalPackage.child(Name.identifier(name)) - } val classId = when (symbol) { is KaConstructorSymbol -> symbol.containingClassId is KaClassLikeSymbol -> symbol.classId else -> null - } ?: return null - if (classId.isLocal || classId.isNestedClass) return null - if (classId.packageFqName != originalPackage) return null - if (psi?.containingFile?.virtualFile?.path in movedPaths) return null - return classId.asSingleFqName() + } + if (classId != null) { + if (classId.isLocal || classId.isNestedClass) return null + if (classId.packageFqName != originalPackage) return null + if (psi?.containingFile?.virtualFile?.path in movedPaths) return null + return classId.asSingleFqName() + } + if (psi !is KtNamedDeclaration) return null + val containingFile = psi.containingKtFile + if (containingFile.virtualFile?.path in movedPaths) return null + if (containingFile.packageFqName != originalPackage) return null + if (psi.parent !is KtFile) return null + val name = psi.name ?: return null + return originalPackage.child(Name.identifier(name)) } /** diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 5e21be86b..9f19d4ef6 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index fd43b3f0e..b9b1cf764 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1221,6 +1221,51 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a moved file imports a Kotlin class that declares an explicit primary constructor. + * + * The constructor symbol's PSI is a [org.jetbrains.kotlin.psi.KtPrimaryConstructor], which is a + * named declaration whose parent is the class rather than the file. Outgoing dependency capture + * must therefore consult the constructed class id before the top-level PSI check, otherwise the + * call loses its import and stops resolving after the move. + */ + fun testApply_realSession_addsImportForOutgoingSamePackageExplicitConstructor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing-explicit-constructor") + try { + val oldPackageDirectory = temporaryRoot.resolve("old/source") + Files.createDirectories(oldPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val staysPath = oldPackageDirectory.resolve("Stays.kt") + Files.writeString(sourcePath, "package old.source\n\nfun create() = Helper(1)\n") + Files.writeString(staysPath, "package old.source\n\nclass Helper(val x: Int)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-outgoing-explicit-constructor-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( + "A moved file must import a class whose explicit constructor it calls", + "package new.changed\n\nimport old.source.Helper\n\nfun create() = Helper(1)\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a wildcard import rooted in a moved class follows that class. * diff --git a/pom.xml b/pom.xml index bb8d7b67b..6d08d57c9 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.20-SNAPSHOT + 0.12.21-SNAPSHOT pom Netbeans Plugin Kotlin From df3c5b2f0b9771dca111b3eaa85c98f30b9d3cf6 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 16:20:27 +0300 Subject: [PATCH 11/55] Fixed aliased constructor references and package-qualified calls in Kotlin refactorings Renaming a class no longer rewrites references bound through an import alias: `import a.Foo as Alias` updates the imported name while `Alias(...)` keeps its name, since only the alias binds the symbol. Change Package no longer adds an outgoing import when the moved file already spells out the former package (`old.source.Helper(1)`), which previously produced a conflicting import next to an existing same-named class import. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 8 ++- .../kotlin/refactoring/KaRenameComputer.kt | 26 +++++++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 50 +++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 42 ++++++++++++++++ pom.xml | 2 +- 16 files changed, 135 insertions(+), 15 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index b61bd0ac7..4b7c0127b 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index d655f6358..ccfcd64d4 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 086a7ae66..9d5a6b4d7 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 5f872c63b..f34fb63a7 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 021c52061..09638287c 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 0395d0e5e..e7d445417 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index a0cd47101..2a4cf6ea9 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 1b6483abc..eace0b78c 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index a531d451f..719d0cca5 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index c8f321064..41ea839ff 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index ebf9c007c..8cc15f8ed 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -408,10 +408,14 @@ class KaChangePackageComputer( /** * Tells whether [this] is the selector of a qualified access whose receiver already spells out * [packageFqName], in which case the reference resolves without an import. + * + * A callee is wrapped in a [KtCallExpression] (`a.Helper(1)`), so the qualified access is found + * through that call node rather than directly through the callee's parent. */ private fun KtSimpleNameExpression.isPackageQualified(packageFqName: FqName): Boolean { - val qualified = parent as? KtDotQualifiedExpression ?: return false - if (qualified.selectorExpression != this) return false + val selector = (parent as? KtCallExpression)?.takeIf { it.calleeExpression == this } ?: this + val qualified = selector.parent as? KtDotQualifiedExpression ?: return false + if (qualified.selectorExpression != selector) return false val receiverText = qualified.receiverExpression.text val packageName = packageFqName.asString() return packageName == receiverText || packageName.startsWith("$receiverText.") diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index edfc688d2..3ac300949 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -32,10 +32,12 @@ import org.jetbrains.kotlin.kdoc.lexer.KDocTokens import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtReferenceExpression +import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import java.util.logging.Logger @@ -306,7 +308,13 @@ class KaRenameComputer( return result } - /** Returns all reference expression ranges in [kaFile] that resolve to [targetPsi]. */ + /** + * Returns all reference expression ranges in [kaFile] that resolve to [targetPsi]. + * + * References bound through an import alias are excluded: `import a.Foo as Alias` binds only + * `Alias`, so the class name changes in the import path while `Alias` references keep their + * name. + */ private fun searchFileForUsages(kaFile: KtFile, targetPsi: PsiElement): List> { val ranges = mutableListOf>() runCatching { @@ -315,9 +323,25 @@ class KaRenameComputer( // KtPrimaryConstructor rather than the class declaration being renamed, so // compare class ids as well as PSI identity. val targetClassId = (targetPsi as? KtClassOrObject)?.getClassOrObjectSymbol()?.classId + // `import a.Foo as Alias` binds only the alias: the class name changes in the + // import path, while every `Alias` reference must keep its name. + val targetFqName = targetClassId?.asSingleFqName() + val aliasNames: Set = if (targetFqName == null) { + emptySet() + } else { + kaFile.importDirectives.mapNotNullTo(mutableSetOf()) { directive -> + directive.aliasName?.takeIf { directive.importedFqName == targetFqName } + } + } kaFile.accept(object : KtTreeVisitorVoid() { override fun visitReferenceExpression(expression: KtReferenceExpression) { super.visitReferenceExpression(expression) + val referencedName = (expression as? KtSimpleNameExpression)?.getReferencedName() + if (referencedName != null && referencedName in aliasNames && + PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) == null + ) { + return + } val sym = runCatching { expression.mainReference?.resolveToSymbol() }.getOrNull() ?: return diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 9f19d4ef6..46e44be9f 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index b9b1cf764..b801dd9c6 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1266,6 +1266,56 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a fully qualified call to the former package needs no import. + * + * `old.source.Helper(1)` spells out the package itself, so adding `import old.source.Helper` + * would only collide with the caller's existing `import other.Helper`. + */ + fun testApply_realSession_skipsImportForPackageQualifiedConstructorCall() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-qualified-constructor") + try { + val oldPackageDirectory = temporaryRoot.resolve("old/source") + val otherPackageDirectory = temporaryRoot.resolve("other") + Files.createDirectories(oldPackageDirectory) + Files.createDirectories(otherPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val staysPath = oldPackageDirectory.resolve("Stays.kt") + val otherPath = otherPackageDirectory.resolve("Helper.kt") + Files.writeString( + sourcePath, + "package old.source\n\nimport other.Helper\n\nfun create() = old.source.Helper(1).x + Helper(2).x\n", + ) + Files.writeString(staysPath, "package old.source\n\nclass Helper(val x: Int)\n") + Files.writeString(otherPath, "package other\n\nclass Helper(val x: Int)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-qualified-constructor-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( + "A package-qualified call must not add a conflicting import", + "package new.changed\n\nimport other.Helper\n\nfun create() = old.source.Helper(1).x + Helper(2).x\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a wildcard import rooted in a moved class follows that class. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index e0f37b8cc..69f84b8cd 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -186,6 +186,48 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a constructor call bound through an import alias keeps the alias. + * + * `import a.Foo as Alias` binds only `Alias`; the class name changes in the import path, but + * rewriting the call to the new class name would leave it unresolved in the caller's package. + */ + fun testRenameClassWithAliasedConstructorCall() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping aliased-constructor rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-alias-constructor") + try { + val sourceDirectory = root.resolve("source") + val callerDirectory = root.resolve("caller") + Files.createDirectories(sourceDirectory) + Files.createDirectories(callerDirectory) + val sourcePath = sourceDirectory.resolve("Foo.kt") + val callerPath = callerDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package a\n\nclass Foo(val x: Int)\n") + Files.writeString(callerPath, "package b\n\nimport a.Foo as Alias\n\nfun use() = Alias(1)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-alias-constructor-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val changes = KaRenameComputer(source, source.text.indexOf("Foo"), setOf(source, caller), "Bar").compute() + + assertNotNull("compute() must return non-null for an aliased constructor call", changes) + assertEquals( + "An aliased constructor call must keep the alias while the import path follows the class", + "package b\n\nimport a.Bar as Alias\n\nfun use() = Alias(1)\n", + applyChanges(caller.text, changes!![callerPath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/pom.xml b/pom.xml index 6d08d57c9..f810e9bca 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.21-SNAPSHOT + 0.12.22-SNAPSHOT pom Netbeans Plugin Kotlin From e383873e1bbb4a6da0430304f63693bb97028f07 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 17:15:30 +0300 Subject: [PATCH 12/55] Fixed alias-bound references in Kotlin package changes and renames Change Package no longer adds an unaliased import when the moved file already reaches the declaration through an import alias, which previously collided with a same-named class imported from another package. Rename now suppresses only references actually bound through an alias: a redundant `import p.Foo as Foo` no longer prevents qualified `p.Foo(1)` and `p.Foo` type references from following the rename. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 16 +++ .../kotlin/refactoring/KaRenameComputer.kt | 27 ++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 100 ++++++++++-------- .../kotlin/nbm/refactoring/KaRenameTest.kt | 45 ++++++++ pom.xml | 2 +- 16 files changed, 154 insertions(+), 58 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 4b7c0127b..544d90918 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index ccfcd64d4..c858542be 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 9d5a6b4d7..5e856ea3a 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index f34fb63a7..6d91bf6f9 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 09638287c..bd61699db 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index e7d445417..7fd8f6908 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 2a4cf6ea9..e1ca1b015 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index eace0b78c..035461388 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 719d0cca5..d5ac6c396 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 41ea839ff..a811354e8 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 8cc15f8ed..3ff27b6cd 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -354,6 +354,8 @@ class KaChangePackageComputer( val importFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) ?: return if (importFqName in importedFqNames || originalPackage in starPackages) return if (expression.isPackageQualified(originalPackage)) return + if (expression.isPackageQualified(originalPackage)) return + if (expression.isAliasBound(importFqName)) return missingImports += importFqName } }) @@ -405,6 +407,20 @@ class KaChangePackageComputer( return originalPackage.child(Name.identifier(name)) } + /** + * Tells whether [this] is spelled with an import alias that binds [fqName]. + * + * `import a.Helper as OriginalHelper` already keeps the dependency: the alias resolves the + * declaration without a further import, and adding the unaliased name would collide with a + * same-named class imported from elsewhere. + */ + private fun KtSimpleNameExpression.isAliasBound(fqName: FqName): Boolean { + val name = getReferencedName() + return containingKtFile.importDirectives.any { directive -> + directive.aliasName == name && directive.importedFqName == fqName + } + } + /** * Tells whether [this] is the selector of a qualified access whose receiver already spells out * [packageFqName], in which case the reference resolves without an import. diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 3ac300949..243fa1f95 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -30,7 +30,9 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.kdoc.lexer.KDocTokens import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration @@ -39,6 +41,7 @@ import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtReferenceExpression import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtUserType import java.util.logging.Logger /** @@ -192,6 +195,24 @@ class KaRenameComputer( null } + /** + * Tells whether [this] uses an import alias rather than being part of a qualified access. + * + * `import p.Foo as Foo` makes the alias and the class name equal, so only the syntactic + * position distinguishes an alias usage from `p.Foo(1)` or a `p.Foo` type: an alias usage is + * an unqualified name, while a qualified access is the selector of a dot access or a qualified + * user type. References inside import directives always follow the imported declaration. + */ + private fun KtSimpleNameExpression.isAliasUsage(): Boolean { + if (PsiTreeUtil.getParentOfType(this, KtImportDirective::class.java) != null) return false + val selector = (parent as? KtCallExpression)?.takeIf { it.calleeExpression == this } ?: this + val qualified = selector.parent as? KtDotQualifiedExpression + if (qualified?.selectorExpression == selector) return false + val userType = parent as? KtUserType + if (userType != null && userType.referenceExpression == this && userType.qualifier != null) return false + return true + } + /** * Adds a [TextChange] for the name identifier of [targetDecl] to [into]. * @@ -336,9 +357,9 @@ class KaRenameComputer( kaFile.accept(object : KtTreeVisitorVoid() { override fun visitReferenceExpression(expression: KtReferenceExpression) { super.visitReferenceExpression(expression) - val referencedName = (expression as? KtSimpleNameExpression)?.getReferencedName() - if (referencedName != null && referencedName in aliasNames && - PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) == null + val simpleName = expression as? KtSimpleNameExpression + if (simpleName != null && simpleName.getReferencedName() in aliasNames && + simpleName.isAliasUsage() ) { return } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 46e44be9f..ef9507f82 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index b801dd9c6..b52c4b36d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1316,6 +1316,63 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a reference bound through an import alias needs no unaliased import. + * + * The moved file binds `a.Helper` as `OriginalHelper`, so using that name keeps the dependency; + * adding `import a.Helper` would collide with the existing `import c.Helper` and break the + * unqualified `Helper` usage. + */ + fun testApply_realSession_keepsAliasedOutgoingImportWithoutAddingUnaliasedOne() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing-alias") + try { + val oldPackageDirectory = temporaryRoot.resolve("old/source") + val otherPackageDirectory = temporaryRoot.resolve("other") + Files.createDirectories(oldPackageDirectory) + Files.createDirectories(otherPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val staysPath = oldPackageDirectory.resolve("Stays.kt") + val otherPath = otherPackageDirectory.resolve("Helper.kt") + Files.writeString( + sourcePath, + "package old.source\n\n" + + "import old.source.Helper as OriginalHelper\n" + + "import other.Helper\n\n" + + "fun create() = OriginalHelper(1).x + Helper(2).x\n", + ) + Files.writeString(staysPath, "package old.source\n\nclass Helper(val x: Int)\n") + Files.writeString(otherPath, "package other\n\nclass Helper(val x: Int)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-outgoing-alias-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( + "An alias-bound reference must not add an unaliased import", + "package new.changed\n\n" + + "import old.source.Helper as OriginalHelper\n" + + "import other.Helper\n\n" + + "fun create() = OriginalHelper(1).x + Helper(2).x\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a wildcard import rooted in a moved class follows that class. * @@ -1408,49 +1465,6 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } - /** - * Verifies an aliased import does not suppress the unaliased import a simple name needs. - * - * `import a.stays as alias` binds only `alias`; the same-package `stays()` call loses its - * binding after the move and needs `import a.stays` in addition to the alias directive. - */ - fun testApply_realSession_addsUnaliasedImportAlongsideAliasedImport() { - val stdlib = findStdlibJar() ?: run { - println("kotlin-stdlib not on test classpath — skipping Change Package integration test") - return - } - val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-alias-import") - try { - val oldPackageDirectory = temporaryRoot.resolve("old") - Files.createDirectories(oldPackageDirectory) - val sourcePath = oldPackageDirectory.resolve("Moved.kt") - val staysPath = oldPackageDirectory.resolve("Stays.kt") - Files.writeString(sourcePath, "package a\n\nimport a.stays as alias\n\nfun moved() = stays() + alias()\n") - Files.writeString(staysPath, "package a\n\nfun stays() = 1\n") - val session = KotlinAnalysisAPISession.createWithJars( - moduleName = "change-package-alias-import-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("b")), - updateReferences = true, - ) - - assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) - val success = outcome as KaChangePackageComputer.ApplyOutcome.Success - assertEquals( - "An aliased import must not count as an unaliased binding", - "package b\n\nimport a.stays\nimport a.stays as alias\n\nfun moved() = stays() + alias()\n", - success.changedFiles[sourcePath.toString()], - ) - } finally { - temporaryRoot.toFile().deleteRecursively() - } - } - /** * Verifies an aliased import in a caller does not suppress the incoming unaliased import. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 69f84b8cd..a35e4bb36 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -228,6 +228,51 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies qualified references stay renamed when a redundant alias has the class's own name. + * + * `import p.Foo as Foo` makes the alias name indistinguishable from the class name, but + * `p.Foo(1)` and `p.Foo` are spelled through the package, so they must still follow the rename. + */ + fun testRenameClassKeepsQualifiedReferencesWithRedundantAlias() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping redundant-alias rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-redundant-alias") + try { + val sourceDirectory = root.resolve("source") + val callerDirectory = root.resolve("caller") + Files.createDirectories(sourceDirectory) + Files.createDirectories(callerDirectory) + val sourcePath = sourceDirectory.resolve("Foo.kt") + val callerPath = callerDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Foo(val x: Int)\n") + Files.writeString( + callerPath, + "package b\n\nimport p.Foo as Foo\n\nfun use(): p.Foo = p.Foo(1)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-redundant-alias-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val changes = KaRenameComputer(source, source.text.indexOf("Foo"), setOf(source, caller), "Bar").compute() + + assertNotNull("compute() must return non-null for a redundant alias", changes) + assertEquals( + "Qualified references must follow the rename even when an alias reuses the class name", + "package b\n\nimport p.Bar as Foo\n\nfun use(): p.Bar = p.Bar(1)\n", + applyChanges(caller.text, changes!![callerPath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/pom.xml b/pom.xml index f810e9bca..0ed9a6146 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.22-SNAPSHOT + 0.12.23-SNAPSHOT pom Netbeans Plugin Kotlin From aad58a3f9e23b5eafc8ac304c29ce3b86e6eea49 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 17:38:07 +0300 Subject: [PATCH 13/55] Fixed conflicting imports for package-qualified Kotlin references A type reference spelled with its package (`a.Helper`) is wrapped in a KtUserType rather than a dot expression, so outgoing dependency capture added an unneeded `import a.Helper`. Package-qualified user types are now recognized alongside dot-qualified accesses. Incoming retargeting no longer requests an import for a usage it rewrites in place (`a.moved()` becomes `b.moved()`), which previously conflicted with an unrelated same-named import in a wildcard importer. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 31 ++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 99 +++++++++++++++++++ pom.xml | 2 +- 14 files changed, 133 insertions(+), 21 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 544d90918..28f9a2479 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index c858542be..0b4c043f7 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 5e856ea3a..4d8c37f66 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 6d91bf6f9..9d79e6b1e 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index bd61699db..92283ad4a 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 7fd8f6908..5720dbd1f 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index e1ca1b015..6ad7a67c0 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 035461388..7ab0d631e 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index d5ac6c396..ca215879a 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index a811354e8..9f67c0a7c 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 3ff27b6cd..98c2639d8 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -259,7 +259,9 @@ class KaChangePackageComputer( session.findUsages(declaration).forEach { reference -> val element = reference.element as? KtElement ?: return@forEach val file = element.containingFile as? KtFile ?: return@forEach - captureQualifiedReference(element, oldFqName, newFqName, edits) + // A qualified chain is rewritten in place, so this usage needs no import; an + // unqualified usage in the same file still adds one when the loop reaches it. + if (captureQualifiedReference(element, oldFqName, newFqName, edits)) return@forEach if (file.virtualFile?.path == declarationFile.virtualFile?.path) return@forEach val finalPackage = targets[file] ?: file.packageFqName if (finalPackage == newFqName.parent()) return@forEach @@ -291,21 +293,23 @@ class KaChangePackageComputer( * @param oldFqName declaration FQN before the package change * @param newFqName declaration FQN after the package change * @param edits per-file replacement accumulator + * @return `true` when a qualified chain was replaced in place and therefore needs no import */ private fun captureQualifiedReference( element: KtElement, oldFqName: FqName, newFqName: FqName, edits: MutableMap>, - ) { + ): Boolean { // A root-package FQN is a bare name. Qualifying such a reference would rewrite // declaration-like positions that cannot take a package prefix (`::moved`); imports // alone keep root-package bindings intact. - if (oldFqName.parent().isRoot) return - val file = element.containingFile as? KtFile ?: return + if (oldFqName.parent().isRoot) return false + val file = element.containingFile as? KtFile ?: return false val chain = element.qualifiedChain() - if (!chain.matches(oldFqName)) return + if (!chain.matches(oldFqName)) return false edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, KotlinSourceNames.renderFqName(newFqName)) + return true } /** @@ -426,13 +430,22 @@ class KaChangePackageComputer( * [packageFqName], in which case the reference resolves without an import. * * A callee is wrapped in a [KtCallExpression] (`a.Helper(1)`), so the qualified access is found - * through that call node rather than directly through the callee's parent. + * through that call node rather than directly through the callee's parent. A type reference is + * wrapped in a [KtUserType] whose qualifier spells the receiver (`a.Helper`). */ private fun KtSimpleNameExpression.isPackageQualified(packageFqName: FqName): Boolean { val selector = (parent as? KtCallExpression)?.takeIf { it.calleeExpression == this } ?: this - val qualified = selector.parent as? KtDotQualifiedExpression ?: return false - if (qualified.selectorExpression != selector) return false - val receiverText = qualified.receiverExpression.text + val receiverText = when (val qualified = selector.parent as? KtDotQualifiedExpression) { + null -> { + val userType = parent as? KtUserType + if (userType == null || userType.referenceExpression != this) return false + userType.qualifier?.text ?: return false + } + else -> { + if (qualified.selectorExpression != selector) return false + qualified.receiverExpression.text + } + } val packageName = packageFqName.asString() return packageName == receiverText || packageName.startsWith("$receiverText.") } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index ef9507f82..f28e9aab2 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index b52c4b36d..300e2b1c8 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -534,6 +534,55 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a package-qualified call from a wildcard importer needs no explicit import. + * + * `a.moved()` is rewritten in place to `b.moved()`, so requesting `import b.moved` would + * collide with the caller's unrelated `import c.moved`. + */ + fun testApply_realSession_skipsIncomingImportForPackageQualifiedWildcardUsage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-qualified") + try { + val sourceDirectory = temporaryRoot.resolve("a") + val usageDirectory = temporaryRoot.resolve("call") + val otherDirectory = temporaryRoot.resolve("c") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + Files.createDirectories(otherDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + val otherPath = otherDirectory.resolve("Other.kt") + Files.writeString(sourcePath, "package a\n\nfun moved() = 1\n") + Files.writeString(usagePath, "package call\n\nimport a.*\nimport c.moved\n\nfun use() = a.moved() + moved()\n") + Files.writeString(otherPath, "package c\n\nfun moved() = 2\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-qualified-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("b")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A qualified usage must not add a conflicting incoming import", + "package call\n\nimport a.*\nimport c.moved\n\nfun use() = b.moved() + moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies an explicit import of a default-package declaration follows it into a named package. * @@ -1373,6 +1422,56 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a package-qualified type reference needs no import. + * + * `a.Helper` as a type names the package itself, so the moved file must keep its existing + * `import c.Helper` usable without an extra unaliased `import a.Helper`. + */ + fun testApply_realSession_skipsImportForPackageQualifiedType() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-qualified-type") + try { + val oldPackageDirectory = temporaryRoot.resolve("a") + val otherPackageDirectory = temporaryRoot.resolve("c") + Files.createDirectories(oldPackageDirectory) + Files.createDirectories(otherPackageDirectory) + val sourcePath = oldPackageDirectory.resolve("Moved.kt") + val staysPath = oldPackageDirectory.resolve("Stays.kt") + val otherPath = otherPackageDirectory.resolve("Helper.kt") + Files.writeString( + sourcePath, + "package a\n\nimport c.Helper\n\nfun create(): a.Helper? = null\n\nfun other(): Helper = Helper(1)\n", + ) + Files.writeString(staysPath, "package a\n\nclass Helper(val x: Int)\n") + Files.writeString(otherPath, "package c\n\nclass Helper(val x: Int)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-qualified-type-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("b")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A package-qualified type must not add a conflicting import", + "package b\n\nimport c.Helper\n\nfun create(): a.Helper? = null\n\nfun other(): Helper = Helper(1)\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a wildcard import rooted in a moved class follows that class. * diff --git a/pom.xml b/pom.xml index 0ed9a6146..38bd42f85 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.23-SNAPSHOT + 0.12.24-SNAPSHOT pom Netbeans Plugin Kotlin From ec90594f2962f796db2b93aa6e651b38c6deb89f Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 18:01:59 +0300 Subject: [PATCH 14/55] Fixed incoming imports for alias-bound Kotlin references A caller that reaches a moved declaration only through `import a.moved as alias` needs no unaliased import: the rewritten alias directive keeps the binding. Requesting `import b.moved` made the caller's unrelated same-named import ambiguous, so the incoming pass now skips alias-bound usages the same way the outgoing pass already does. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 4 ++ Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 67 +++++++++++++++++-- pom.xml | 2 +- 14 files changed, 76 insertions(+), 19 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 28f9a2479..06ad2603e 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 0b4c043f7..6649ed30a 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 4d8c37f66..e9d241858 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 9d79e6b1e..ce4846c2d 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 92283ad4a..4a54bffd2 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 5720dbd1f..854b01ef0 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 6ad7a67c0..b2cfa93fe 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 7ab0d631e..6825c2a6a 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index ca215879a..902dcfdee 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 9f67c0a7c..3a8d808f9 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 98c2639d8..9a8f9aa99 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -277,6 +277,10 @@ class KaChangePackageComputer( directive.isAllUnder && directive.importedFqName == originalPackage } if (!sharesOriginalPackage && !importsOriginalPackage) return@forEach + // The rewritten alias directive keeps this usage bound, so it needs no + // unaliased import of its own. + val simpleName = element as? KtSimpleNameExpression + if (simpleName != null && simpleName.isAliasBound(oldFqName)) return@forEach imports.getOrPut(file, ::linkedSetOf) += newFqName } } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index f28e9aab2..820b7d232 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 300e2b1c8..fc0b2f6d3 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -583,6 +583,58 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an alias-bound usage of a moved declaration needs no incoming unaliased import. + * + * The rewritten `import b.moved as alias` keeps `alias()` bound, so adding `import b.moved` + * would only make the caller's unrelated `moved()` call ambiguous. + */ + fun testApply_realSession_skipsIncomingImportForAliasBoundWildcardUsage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-alias") + try { + val sourceDirectory = temporaryRoot.resolve("a") + val usageDirectory = temporaryRoot.resolve("call") + val otherDirectory = temporaryRoot.resolve("c") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + Files.createDirectories(otherDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val usagePath = usageDirectory.resolve("Usage.kt") + val otherPath = otherDirectory.resolve("Other.kt") + Files.writeString(sourcePath, "package a\n\nfun moved() = 1\n") + Files.writeString( + usagePath, + "package call\n\nimport a.*\nimport a.moved as alias\nimport c.moved\n\nfun use() = alias() + moved()\n", + ) + Files.writeString(otherPath, "package c\n\nfun moved() = 2\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-alias-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("b")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An alias-bound usage must not add a conflicting incoming import", + "package call\n\nimport a.*\nimport b.moved as alias\nimport c.moved\n\nfun use() = alias() + moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies an explicit import of a default-package declaration follows it into a named package. * @@ -1565,12 +1617,13 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } /** - * Verifies an aliased import in a caller does not suppress the incoming unaliased import. + * Verifies a caller's alias-bound usage needs no incoming unaliased import. * - * `import a.moved as alias` keeps only `alias()` bound after the declaration moves; a caller - * that also calls `moved()` needs `import b.moved` in addition to the rewritten alias. + * The caller formerly shared the declaration's package, but it reaches the declaration only + * through `import a.moved as alias`. Rewriting that directive keeps `alias()` bound, so no + * `import b.moved` is required. */ - fun testApply_realSession_addsIncomingImportWhenCallerOnlyAliasedIt() { + fun testApply_realSession_skipsIncomingImportForAliasBoundUsage() { val stdlib = findStdlibJar() ?: run { println("kotlin-stdlib not on test classpath — skipping Change Package integration test") return @@ -1582,7 +1635,7 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { val sourcePath = oldPackageDirectory.resolve("Moved.kt") val callerPath = oldPackageDirectory.resolve("Use.kt") Files.writeString(sourcePath, "package a\n\nfun moved() = 1\n") - Files.writeString(callerPath, "package a\n\nimport a.moved as alias\n\nfun use() = moved() + alias()\n") + Files.writeString(callerPath, "package a\n\nimport a.moved as alias\n\nfun use() = alias()\n") val session = KotlinAnalysisAPISession.createWithJars( moduleName = "change-package-incoming-alias-integration", binaryJars = listOf(stdlib), @@ -1598,8 +1651,8 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) val success = outcome as KaChangePackageComputer.ApplyOutcome.Success assertEquals( - "The caller must keep both the alias and an unaliased binding", - "package a\n\nimport b.moved\nimport b.moved as alias\n\nfun use() = moved() + alias()\n", + "The rewritten alias must be the only import the caller needs", + "package a\n\nimport b.moved as alias\n\nfun use() = alias()\n", success.changedFiles[callerPath.toString()], ) } finally { diff --git a/pom.xml b/pom.xml index 38bd42f85..c5d8edbae 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.24-SNAPSHOT + 0.12.25-SNAPSHOT pom Netbeans Plugin Kotlin From 214c01cf06a91e12bff5366829a83292a690c151 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 18:21:44 +0300 Subject: [PATCH 15/55] Fixed default-package outgoing imports and constructor usage discovery A file moving out of the default package now imports the root-package declarations it still calls, instead of losing them silently. Usage discovery for Change Package now recognizes explicit constructor calls: such a call resolves to a constructor symbol, so the constructed class id is compared with the moved class. Former package mates regain an import and package-qualified calls are retargeted. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 2 +- Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 13 ++- .../nbm/refactoring/KaChangePackageTest.kt | 94 +++++++++++++++++++ pom.xml | 2 +- 15 files changed, 118 insertions(+), 15 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 06ad2603e..9f2f97995 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 6649ed30a..6879bd2a0 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index e9d241858..e89e6348d 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index ce4846c2d..ec83282e2 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 4a54bffd2..744b084f0 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 854b01ef0..01a6ac2a0 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index b2cfa93fe..45e9591a4 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 6825c2a6a..58441f1ee 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 902dcfdee..2f586263b 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 3a8d808f9..067e1cad2 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 9a8f9aa99..14e62debd 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -339,7 +339,7 @@ class KaChangePackageComputer( val updates = linkedMapOf>() movedFiles.forEach { file -> val originalPackage = file.packageFqName - if (originalPackage.isRoot || targets[file] == originalPackage) return@forEach + if (targets[file] == originalPackage) return@forEach // An aliased import binds only the alias, so it cannot satisfy a simple-name use of // the original name; only an unaliased import counts as an existing binding. val importedFqNames = file.importDirectives diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 820b7d232..ec1a692f5 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 54859ca5c..06cdfc834 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 @@ -20,12 +20,15 @@ import com.intellij.psi.PsiReference 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.analysis.api.compat.getClassOrObjectSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol 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.KtClassOrObject import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtSimpleNameExpression @@ -49,6 +52,10 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { val ktFile = session.getKtFileForPath(kaFile.path) ?: continue runCatching { analyze(ktFile) { + // A constructor call resolves to a constructor symbol whose PSI is the class's + // primary constructor rather than the class declaration, so compare the + // constructed class id with the moved class in addition to PSI identity. + val targetClassId = (declaration as? KtClassOrObject)?.getClassOrObjectSymbol()?.classId ktFile.accept(object : KtTreeVisitorVoid() { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) @@ -66,8 +73,10 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { // can bindToElement() on — only its callee child, a // KtNameReferenceExpression, does). val reference = expression.references.filterIsInstance().firstOrNull() ?: return - val resolved = runCatching { reference.resolveToSymbol()?.psi }.getOrNull() - if (resolved == declaration) { + val symbol = runCatching { reference.resolveToSymbol() }.getOrNull() ?: return + val constructsTarget = targetClassId != null && + (symbol as? KaConstructorSymbol)?.containingClassId == targetClassId + if (symbol.psi == declaration || constructsTarget) { references += reference } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index fc0b2f6d3..acb39562b 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1524,6 +1524,100 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a file leaving the default package imports the declarations that stay behind. + * + * Root-package declarations are visible only to files in the root package, so moving the file + * into a named package must add a bare `import stays`. + */ + fun testApply_realSession_addsRootPackageImportWhenLeavingDefaultPackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-root-outgoing") + try { + val sourcePath = temporaryRoot.resolve("Moved.kt") + val staysPath = temporaryRoot.resolve("Stays.kt") + Files.writeString(sourcePath, "fun moved() = stays()\n") + Files.writeString(staysPath, "fun stays() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-root-outgoing-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A file leaving the default package must import the declarations that stay in it", + "package dest\n\nimport stays\n\nfun moved() = stays()\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an explicit constructor call is retargeted with its moved class. + * + * A constructor call resolves to a constructor symbol whose PSI is the class's primary + * constructor, so usage discovery must compare the constructed class id; both the unqualified + * call in a former package mate and the qualified call elsewhere must follow the move. + */ + fun testApply_realSession_addsIncomingImportForExplicitConstructorCall() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-incoming-constructor") + try { + val sourceDirectory = temporaryRoot.resolve("a") + val qualifiedDirectory = temporaryRoot.resolve("c") + Files.createDirectories(sourceDirectory) + Files.createDirectories(qualifiedDirectory) + val sourcePath = sourceDirectory.resolve("Helper.kt") + val matePath = sourceDirectory.resolve("Mate.kt") + val qualifiedPath = qualifiedDirectory.resolve("Other.kt") + Files.writeString(sourcePath, "package a\n\nclass Helper(val x: Int)\n") + Files.writeString(matePath, "package a\n\nfun use() = Helper(1)\n") + Files.writeString(qualifiedPath, "package c\n\nfun use() = a.Helper(1)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-incoming-constructor-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("b")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A former package mate must import the moved class", + "package a\n\nimport b.Helper\n\nfun use() = Helper(1)\n", + success.changedFiles[matePath.toString()], + ) + assertEquals( + "A package-qualified constructor call must follow the moved class", + "package c\n\nfun use() = b.Helper(1)\n", + success.changedFiles[qualifiedPath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a wildcard import rooted in a moved class follows that class. * diff --git a/pom.xml b/pom.xml index c5d8edbae..edfead371 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.25-SNAPSHOT + 0.12.26-SNAPSHOT pom Netbeans Plugin Kotlin From cc816ee3242f2a0a82f8a90f3bdeab57e4786f76 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 19:14:27 +0300 Subject: [PATCH 16/55] Excluded constructor-delegation keywords and discovered nested imports KaRenameComputer now skips KtConstructorDelegationReferenceExpression, so a secondary constructor's this(...)/super(...) keyword is never rewritten into the new class name. KotlinMoveUsageSearchServiceImpl.findImportingFiles now matches an import when the imported FQN or any of its prefixes is a moved declaration, so a caller that only writes `import old.Outer.Nested` becomes a participant and its nested import prefix is rewritten with the moved class. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../kotlin/refactoring/KaRenameComputer.kt | 5 ++ Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 28 ++++++- .../nbm/refactoring/KaChangePackageTest.kt | 45 ++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 82 +++++++++++++++++++ pom.xml | 2 +- 16 files changed, 170 insertions(+), 14 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 9f2f97995..bd292e6d8 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 6879bd2a0..3057bedab 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index e89e6348d..280454a39 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index ec83282e2..58c7daeec 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 744b084f0..0b72eb197 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 01a6ac2a0..4b5761b12 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 45e9591a4..964f249f5 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 58441f1ee..859e3118e 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 2f586263b..bd1d2cc74 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 067e1cad2..fbd85ed0e 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 243fa1f95..582115c00 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -32,6 +32,7 @@ import org.jetbrains.kotlin.kdoc.lexer.KDocTokens import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtConstructorDelegationReferenceExpression import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective @@ -357,6 +358,10 @@ class KaRenameComputer( kaFile.accept(object : KtTreeVisitorVoid() { override fun visitReferenceExpression(expression: KtReferenceExpression) { super.visitReferenceExpression(expression) + // `this(...)`/`super(...)` in a secondary constructor delegates to a + // constructor of the renamed class, but the keyword itself is syntax and + // must never be rewritten into the new class name. + if (expression is KtConstructorDelegationReferenceExpression) return val simpleName = expression as? KtSimpleNameExpression if (simpleName != null && simpleName.getReferencedName() in aliasNames && simpleName.isAliasUsage() diff --git a/Nbm/pom.xml b/Nbm/pom.xml index ec1a692f5..cbd1e5e36 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 06cdfc834..95d754d01 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 @@ -92,12 +92,16 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { } /** - * Finds Kotlin files that directly import a moved top-level declaration. + * Finds Kotlin files that directly import a moved declaration or one nested inside it. * * 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. * + * A file importing `old.Outer.Nested` names the moved class `old.Outer` only as a path prefix, + * so no `Nested` usage resolves to the moved declaration and usage search cannot discover the + * file. Such imports are matched here by their moved-declaration prefix. + * * @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 @@ -113,7 +117,7 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { file.takeIf { candidate -> candidate.importDirectives.any { directive -> directive.aliasName == null && !directive.isAllUnder && directive.importedFqName?.let { imported -> - MoveImport(imported.parent(), imported.shortName().asString()) in movedImports + importsMovedDeclaration(imported, movedImports) } == true } } @@ -126,6 +130,26 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { return candidates } + /** + * Tells whether [imported] names a moved declaration, directly or as a nested prefix. + * + * @param imported fully qualified path of the import directive + * @param movedImports original package/name keys mapped to final packages + * @return `true` when [imported] or one of its prefixes is a moved declaration + */ + private fun importsMovedDeclaration(imported: FqName, movedImports: Map): Boolean { + fun isMoved(fqName: FqName): Boolean = + MoveImport(fqName.parent(), fqName.shortName().asString()) in movedImports + + if (isMoved(imported)) return true + var prefix = imported.parent() + while (!prefix.isRoot) { + if (isMoved(prefix)) return true + prefix = prefix.parent() + } + return false + } + /** * Finds Kotlin files with a wildcard import rooted in a moved class. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index acb39562b..3959d9032 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1663,6 +1663,51 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a caller importing a nested declaration directly follows the enclosing class move. + * + * `import old.pkg.Outer.Nested` names the moved class only as a path prefix, so usage search + * cannot discover the caller through `Nested` references; the import itself must be enough. + */ + fun testApply_realSession_retargetsDirectNestedImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-nested-import") + try { + val sourceDirectory = temporaryRoot.resolve("old/pkg") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Outer.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package old.pkg\n\nclass Outer {\n class Nested\n}\n") + Files.writeString(usagePath, "package usage\n\nimport old.pkg.Outer.Nested\n\nfun use(): Nested? = null\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-nested-import-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.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A direct import of a nested declaration must follow the moved class", + "package usage\n\nimport new.pkg.Outer.Nested\n\nfun use(): Nested? = null\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a package wildcard stays when the caller still uses declarations left behind. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index a35e4bb36..1608ad6f5 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -273,6 +273,88 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a secondary constructor's `this(...)` delegation is not renamed with its class. + * + * The delegation call resolves to a constructor of the same class, but its `this` keyword is + * syntax, not a reference to the class name. + */ + fun testRenameClassKeepsThisConstructorDelegation() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping constructor-delegation rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-this-delegation") + try { + val sourceDirectory = root.resolve("source") + Files.createDirectories(sourceDirectory) + val sourcePath = sourceDirectory.resolve("Foo.kt") + Files.writeString( + sourcePath, + "package a\n\nclass Foo(val n: Int) {\n constructor() : this(0)\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-this-delegation-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val changes = KaRenameComputer(source, source.text.indexOf("Foo"), setOf(source), "Bar").compute() + + assertNotNull("compute() must return non-null for a delegated constructor", changes) + assertEquals( + "A this(...) delegation must keep its keyword", + "package a\n\nclass Bar(val n: Int) {\n constructor() : this(0)\n}\n", + applyChanges(source.text, changes!![sourcePath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a base class rename does not rewrite a subclass's `super(...)` delegation. + * + * `super(0)` resolves to the renamed base constructor, but the keyword must stay while the + * supertype reference in the class header does follow the rename. + */ + fun testRenameBaseClassKeepsSuperConstructorDelegation() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping super-delegation rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-super-delegation") + try { + val baseDirectory = root.resolve("base") + val derivedDirectory = root.resolve("derived") + Files.createDirectories(baseDirectory) + Files.createDirectories(derivedDirectory) + val basePath = baseDirectory.resolve("Base.kt") + val derivedPath = derivedDirectory.resolve("Derived.kt") + Files.writeString(basePath, "package a\n\nopen class Base(val n: Int)\n") + Files.writeString(derivedPath, "package a\n\nclass Derived : Base {\n constructor() : super(0)\n}\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-super-delegation-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val base = session.getKtFileForPath(basePath.toString()) ?: error("Could not obtain source PSI") + val derived = session.getKtFileForPath(derivedPath.toString()) ?: error("Could not obtain derived PSI") + + val changes = KaRenameComputer(base, base.text.indexOf("Base"), setOf(base, derived), "Renamed").compute() + + assertNotNull("compute() must return non-null for a subclass", changes) + assertEquals( + "The supertype must follow the rename while super(...) keeps its keyword", + "package a\n\nclass Derived : Renamed {\n constructor() : super(0)\n}\n", + applyChanges(derived.text, changes!![derivedPath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/pom.xml b/pom.xml index edfead371..01549ae7f 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.26-SNAPSHOT + 0.12.27-SNAPSHOT pom Netbeans Plugin Kotlin From 77ed81664daba6f2a85f41cfe8528b415108c2b1 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 19:42:45 +0300 Subject: [PATCH 17/55] Retargeted wildcard imports rooted in nested moved classes A caller writing `import old.pkg.Outer.Inner.*` names the moved class only as a path prefix, so neither usage search nor the exact-prefix wildcard discovery found it. KotlinMoveUsageSearchServiceImpl.findWildcardImportingFiles now accepts a wildcard whose root is the moved class or a class nested inside it, and KaChangePackageComputer rewrites the longest moved-class prefix while keeping the nested suffix and the trailing `.*`. Package wildcards still stay. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 42 ++++++++++++++-- Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 20 +++++++- .../nbm/refactoring/KaChangePackageTest.kt | 48 +++++++++++++++++++ pom.xml | 2 +- 15 files changed, 116 insertions(+), 18 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index bd292e6d8..67b87effa 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 3057bedab..df0f9c7b3 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 280454a39..b4c503c3d 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 58c7daeec..89c4f5e91 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 0b72eb197..a8341f76f 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 4b5761b12..5296fefef 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 964f249f5..56dd0eff0 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 859e3118e..49886e0dc 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index bd1d2cc74..bd525c939 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index fbd85ed0e..2630767e7 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 14e62debd..58460d191 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -598,11 +598,18 @@ class KaChangePackageComputer( if (pathStart >= pathEnd) return@forEach val replacement = if (directive.isAllUnder) { // A package wildcard keeps importing whatever stays behind, so only a - // wildcard rooted in a moved class follows that class to its new package. + // wildcard rooted in a moved class (or a class nested inside it) follows + // that class to its new package, keeping the nested suffix and `.*`. val imported = directive.importedFqName ?: return@forEach - if (imported !in movedClassFqNames) return@forEach - val newPrefix = movedDeclarations[imported] ?: return@forEach - KotlinSourceNames.renderFqName(newPrefix) + val movedClass = longestMovedClassPrefix(imported, movedClassFqNames) ?: return@forEach + val newPrefix = movedDeclarations[movedClass] ?: return@forEach + val suffixSegments = imported.pathSegments().drop(movedClass.pathSegments().size) + val suffixText = if (suffixSegments.isEmpty()) { + "" + } else { + suffixSegments.joinToString(separator = ".", prefix = ".") + } + KotlinSourceNames.renderFqName(newPrefix) + suffixText } else { rewriteImportedPath(fileText.substring(pathStart, pathEnd), mappings) ?: return@forEach @@ -613,6 +620,33 @@ class KaChangePackageComputer( return edits.mapValues { (_, fileEdits) -> fileEdits.toList() } } + /** + * Returns the deepest moved class whose FQN is [imported] or a prefix of it. + * + * Matching the longest prefix keeps a nested wildcard (`old.Outer.Inner.*`) attached to the + * moved class (`old.Outer`) with `Inner` as its suffix. + * + * @param imported FQN named by a wildcard import directive + * @param movedClassFqNames old FQNs of moved top-level classes + * @return the longest matching moved class FQN, or `null` when none matches + */ + private fun longestMovedClassPrefix(imported: FqName, movedClassFqNames: Set): FqName? = + movedClassFqNames + .filter { candidate -> candidate.isPrefixOf(imported) } + .maxByOrNull { candidate -> candidate.pathSegments().size } + + /** + * Tells whether this FQN equals [fqName] or is one of its package/class prefixes. + * + * @param fqName fully qualified name to test + * @return `true` when this FQN names the same declaration or one of its enclosing segments + */ + private fun FqName.isPrefixOf(fqName: FqName): Boolean { + val prefix = pathSegments() + val segments = fqName.pathSegments() + return segments.size >= prefix.size && segments.subList(0, prefix.size) == prefix + } + /** * Finds where an import path starts inside a directive's text. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index cbd1e5e36..1d41ddb51 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 95d754d01..1c337e426 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 @@ -154,7 +154,9 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { * Finds Kotlin files with a wildcard import rooted in a moved class. * * Such a caller may reference only nested declarations of that class, so K2 usage discovery - * never reports it; the import prefix is the only place the moved class is named. + * never reports it; the import prefix is the only place the moved class is named. A wildcard + * rooted in a class nested inside a moved class (`old.Outer.Inner.*`) names the moved class as + * a path prefix, so prefixes are matched as well. * * @param project IntelliJ project owning the standalone K2 session * @param classFqNames old fully qualified names of moved classes @@ -170,9 +172,23 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { val file = session.getKtFileForPath(lightFile.path) ?: return@mapNotNull null file.takeIf { candidate -> candidate.importDirectives.any { directive -> - directive.isAllUnder && directive.importedFqName in classFqNames + directive.isAllUnder && directive.importedFqName?.let { imported -> + classFqNames.any { movedClass -> movedClass.isPrefixOf(imported) } + } == true } } } } + + /** + * Tells whether this FQN equals [fqName] or is one of its package/class prefixes. + * + * @param fqName fully qualified name to test + * @return `true` when this FQN names the same declaration or one of its enclosing segments + */ + private fun FqName.isPrefixOf(fqName: FqName): Boolean { + val prefix = pathSegments() + val segments = fqName.pathSegments() + return segments.size >= prefix.size && segments.subList(0, prefix.size) == prefix + } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 3959d9032..95567e5bd 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1663,6 +1663,54 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a wildcard rooted in a class nested inside a moved class follows that move. + * + * `import old.pkg.Outer.Inner.*` names `Outer` only as a prefix, so the caller is not found by + * usage search; the wildcard prefix must be retargeted with its nested suffix and `.*` kept. + */ + fun testApply_realSession_retargetsWildcardImportRootedInNestedClass() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-nested-wildcard") + try { + val sourceDirectory = temporaryRoot.resolve("old/pkg") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Outer.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString( + sourcePath, + "package old.pkg\n\nclass Outer {\n class Inner {\n class Nested\n }\n}\n", + ) + Files.writeString(usagePath, "package usage\n\nimport old.pkg.Outer.Inner.*\n\nfun use(): Nested? = null\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-nested-wildcard-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.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A wildcard import rooted in a nested class must keep its suffix and follow the move", + "package usage\n\nimport new.pkg.Outer.Inner.*\n\nfun use(): Nested? = null\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a caller importing a nested declaration directly follows the enclosing class move. * diff --git a/pom.xml b/pom.xml index 01549ae7f..b9ccd8290 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.27-SNAPSHOT + 0.12.28-SNAPSHOT pom Netbeans Plugin Kotlin From fe3dd6fa3fab2a23272b1bc5eb78f19171f54eb4 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 20:02:01 +0300 Subject: [PATCH 18/55] Escaped keyword-named nested classes in rebuilt wildcard imports The nested-wildcard branch rebuilt the import suffix from raw FqName segments, so a class named with a hard keyword lost its backticks and the rewritten import became invalid. Suffix segments now go through KotlinSourceNames.renderIdentifier like the moved-class prefix. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 4 +- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 51 +++++++++++++++++++ pom.xml | 2 +- 14 files changed, 66 insertions(+), 13 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 67b87effa..e7215e43e 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index df0f9c7b3..9d75a2222 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index b4c503c3d..3c681d57b 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 89c4f5e91..ba0fc99de 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index a8341f76f..f0537ae1a 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 5296fefef..4092e523f 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 56dd0eff0..8bc29a046 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 49886e0dc..37d2b8f69 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index bd525c939..d5b84774c 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 2630767e7..ed5588d2e 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 58460d191..088ea436c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -607,7 +607,9 @@ class KaChangePackageComputer( val suffixText = if (suffixSegments.isEmpty()) { "" } else { - suffixSegments.joinToString(separator = ".", prefix = ".") + suffixSegments.joinToString(separator = ".", prefix = ".") { segment -> + KotlinSourceNames.renderIdentifier(segment.asString()) + } } KotlinSourceNames.renderFqName(newPrefix) + suffixText } else { diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 1d41ddb51..247f14396 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 95567e5bd..feff55da1 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1711,6 +1711,57 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a nested wildcard suffix keeps the backticks its class name requires. + * + * The suffix is rebuilt from the resolved FQN, so a keyword-named nested class must be escaped + * again or the generated import stops being valid Kotlin. + */ + fun testApply_realSession_escapesKeywordNamedNestedWildcard() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-escaped-wildcard") + try { + val sourceDirectory = temporaryRoot.resolve("old/pkg") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Outer.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString( + sourcePath, + "package old.pkg\n\nclass Outer {\n class `when` {\n class Nested\n }\n}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.pkg.Outer.`when`.*\n\nfun use(): Nested? = null\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-escaped-wildcard-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.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A keyword-named nested wildcard must keep its backticks", + "package usage\n\nimport new.pkg.Outer.`when`.*\n\nfun use(): Nested? = null\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a caller importing a nested declaration directly follows the enclosing class move. * diff --git a/pom.xml b/pom.xml index b9ccd8290..12796c424 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.28-SNAPSHOT + 0.12.29-SNAPSHOT pom Netbeans Plugin Kotlin From eed183999587bf7027726dbc910d9b1ee9aa77eb Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 20:22:22 +0300 Subject: [PATCH 19/55] Rejected all-underscore identifiers as plain Kotlin names Kotlin reserves names made of underscores only unless they are backticked, so KotlinSourceNames.isPlainIdentifier now returns false for them. Class renames to `_` are rejected by validateClassName, and renderIdentifier keeps the backticks required by a declaration named `_`. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt | 4 ++++ Nbm/pom.xml | 2 +- .../nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt | 3 +++ pom.xml | 2 +- 14 files changed, 19 insertions(+), 12 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index e7215e43e..1b6653318 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 9d75a2222..f107e86e6 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 3c681d57b..b0bdacafc 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index ba0fc99de..07fc28ef1 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index f0537ae1a..fcd9b82e2 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 4092e523f..89b1a42c9 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 8bc29a046..a190c6391 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 37d2b8f69..9ffa529e9 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index d5b84774c..63daa91c7 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index ed5588d2e..668fd26bc 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt index 98bde0bbc..9c9f2630b 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt @@ -41,6 +41,9 @@ object KotlinSourceNames { /** * Tells whether an unescaped name can be written without backticks. * + * Names made of underscores only (`_`, `__`, ...) are reserved in Kotlin and need backticks + * just like hard keywords, so they are not plain. + * * @param name identifier as stored by Kotlin PSI * @return `true` for a plain letter/underscore-led non-keyword identifier */ @@ -48,6 +51,7 @@ object KotlinSourceNames { name.isNotEmpty() && (name.first().isLetter() || name.first() == '_') && name.all { symbol -> symbol.isLetterOrDigit() || symbol == '_' } && + name.any { symbol -> symbol != '_' } && name !in HARD_KEYWORDS /** diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 247f14396..e9ab42551 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt index 06e4f46ad..aaeef9f7c 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt @@ -43,6 +43,8 @@ class KotlinSourceNamesTest : NbTestCase("KotlinSourceNamesTest") { assertFalse(KotlinSourceNames.isPlainIdentifier("two words")) assertFalse(KotlinSourceNames.isPlainIdentifier("2fast")) assertFalse(KotlinSourceNames.isPlainIdentifier("")) + assertFalse("all-underscore names are reserved", KotlinSourceNames.isPlainIdentifier("_")) + assertFalse("all-underscore names are reserved", KotlinSourceNames.isPlainIdentifier("___")) } /** Verifies a plain name is rendered unchanged. */ @@ -54,6 +56,7 @@ class KotlinSourceNamesTest : NbTestCase("KotlinSourceNamesTest") { fun testRenderIdentifier_backticksInvalidNames() { assertEquals("`when`", KotlinSourceNames.renderIdentifier("when")) assertEquals("`two words`", KotlinSourceNames.renderIdentifier("two words")) + assertEquals("`_`", KotlinSourceNames.renderIdentifier("_")) } /** Verifies a plain FQN is rendered unchanged. */ diff --git a/pom.xml b/pom.xml index 12796c424..bfbfaf776 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.29-SNAPSHOT + 0.12.30-SNAPSHOT pom Netbeans Plugin Kotlin From 35665b58ba3c2fefbecfbe8f5fa3475e325d51c4 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 20:56:50 +0300 Subject: [PATCH 20/55] Kept extension receivers that spell the old package in incoming retargeting `a.decorate()` on a local `a: String` matched the old FQN `a.decorate` by text alone, so the incoming pass replaced the whole chain with `b.decorate()` and dropped the receiver. The qualified chain now carries its leftmost receiver reference, and the chain is rewritten in place only when that reference does not resolve to a non-package symbol; otherwise the receiver is preserved and the moved declaration's import is retargeted instead. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 51 +++++++++++++++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 49 ++++++++++++++++++ pom.xml | 2 +- 14 files changed, 107 insertions(+), 17 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 1b6653318..aa7227e48 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index f107e86e6..ff9601677 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index b0bdacafc..3d96cc5e8 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 07fc28ef1..1c5407cac 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index fcd9b82e2..994cb1a21 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 89b1a42c9..b0658b385 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index a190c6391..cf51c77ca 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 9ffa529e9..74dcc3bea 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 63daa91c7..fc720d343 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 668fd26bc..80b63c645 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 088ea436c..6c0ecfdd2 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -22,12 +22,14 @@ import com.intellij.usageView.UsageInfo import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol 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.idea.references.KtReference +import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtCallExpression @@ -37,6 +39,7 @@ import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtReferenceExpression import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import org.jetbrains.kotlin.psi.KtUserType @@ -291,7 +294,10 @@ class KaChangePackageComputer( * Records a replacement when [element] spells a moved declaration by its old fully qualified name. * * Simple-name references keep their text and follow their rewritten import; only a qualified - * chain that equals the old FQN needs replacing in place. + * chain that equals the old FQN needs replacing in place. The chain text alone is not enough: + * an extension receiver can spell the old package name (`a.decorate()` on a local `a: String` + * bound to `a.decorate`), and replacing that chain would drop the receiver. The chain's + * leftmost name must therefore not resolve to a non-package symbol. * * @param element reference expression that K2 resolved to the moved declaration * @param oldFqName declaration FQN before the package change @@ -312,6 +318,15 @@ class KaChangePackageComputer( val file = element.containingFile as? KtFile ?: return false val chain = element.qualifiedChain() if (!chain.matches(oldFqName)) return false + // K2 resolves package references to `null` in this position, so only a concrete + // non-package symbol proves the chain root is a value/class rather than a package. + val root = chain.rootReference + if (root != null) { + val rootSymbol = runCatching { + analyze(file) { root.mainReference?.resolveToSymbol() } + }.getOrNull() + if (rootSymbol != null && rootSymbol !is KaPackageSymbol) return false + } edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, KotlinSourceNames.renderFqName(newFqName)) return true } @@ -693,8 +708,18 @@ class KaChangePackageComputer( return null } - /** Source range and text of the qualified chain that ends at a reference expression. */ - private data class QualifiedChain(val startOffset: Int, val endOffset: Int, val text: String) + /** + * Source range, text and leftmost reference of the qualified chain ending at a reference. + * + * [rootReference] is the leftmost simple name of the receiver, which tells a package qualifier + * apart from an expression that merely spells the same text. + */ + private data class QualifiedChain( + val startOffset: Int, + val endOffset: Int, + val text: String, + val rootReference: KtReferenceExpression?, + ) /** * Tells whether this chain's identifiers spell [fqName]. @@ -722,23 +747,39 @@ class KaChangePackageComputer( var current: KtElement = this var start = textRange.startOffset val end = textRange.endOffset + var rootReference: KtReferenceExpression? = null while (true) { val parent = current.parent when { parent is KtDotQualifiedExpression && parent.selectorExpression == current -> { start = parent.receiverExpression.textRange.startOffset + rootReference = parent.receiverExpression.leadingReference() current = parent } parent is KtCallExpression && parent.calleeExpression == current -> current = parent parent is KtUserType && parent.referenceExpression == current -> { - parent.qualifier?.let { qualifier -> start = qualifier.textRange.startOffset } + parent.qualifier?.let { qualifier -> + start = qualifier.textRange.startOffset + rootReference = qualifier.leadingReference() + } current = parent } - else -> return QualifiedChain(start, end, containingFile.text.substring(start, end)) + else -> return QualifiedChain(start, end, containingFile.text.substring(start, end), rootReference) } } } + /** + * Returns the leftmost simple name of this receiver expression's qualified chain. + * + * @return the leftmost reference, or `null` when the receiver is not a name-based chain + */ + private fun KtElement.leadingReference(): KtReferenceExpression? = when (this) { + is KtDotQualifiedExpression -> receiverExpression.leadingReference() + is KtUserType -> qualifier?.leadingReference() ?: referenceExpression + else -> this as? KtReferenceExpression + } + /** Retargeted text and the replacements accepted for it, in its coordinates. */ private data class AppliedEdits(val text: String, val edits: List) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index e9ab42551..ac2a847d9 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index feff55da1..aa14270e5 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1565,6 +1565,55 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an extension receiver spelled like the old package is not treated as a qualifier. + * + * `a.decorate()` calls the extension on a local `a: String`; matching the chain text against + * the old FQN `a.decorate` would replace the receiver, so the receiver must be known to + * resolve to a package before the whole chain is rewritten. + */ + fun testApply_realSession_keepsExtensionReceiverSpelledLikePackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-extension-receiver") + try { + val sourceDirectory = temporaryRoot.resolve("old") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Extension.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package a\n\nfun String.decorate() = this\n") + Files.writeString( + usagePath, + "package usage\n\nimport a.decorate\n\nfun use(a: String) = a.decorate()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-extension-receiver-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("b")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The extension receiver must survive while the import follows the move", + "package usage\n\nimport b.decorate\n\nfun use(a: String) = a.decorate()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies an explicit constructor call is retargeted with its moved class. * diff --git a/pom.xml b/pom.xml index bfbfaf776..3ee2980fa 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.30-SNAPSHOT + 0.12.31-SNAPSHOT pom Netbeans Plugin Kotlin From c8107b6d3978c9ea8e0ec979769beb1082b4c357 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 21:21:04 +0300 Subject: [PATCH 21/55] Matched qualified references and imports by PSI identifiers Segment comparison split source text on dots, so a comment between path segments (`old./* note */pkg.moved()`, `import old./* note */pkg.Moved`) left comment tokens inside a segment and the usage/import was never retargeted. Qualified chains now carry their PSI identifier names, and import paths derive names from PSI as well; replacements keep the PSI-derived range and re-render the suffix with identifier escaping. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 89 ++++++++++++------ Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 93 +++++++++++++++++++ pom.xml | 2 +- 14 files changed, 167 insertions(+), 39 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index aa7227e48..ae0287207 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index ff9601677..3f98d0700 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 3d96cc5e8..8b97710aa 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 1c5407cac..0b8a9349c 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 994cb1a21..53e0cf02d 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index b0658b385..1fd6b8b6d 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index cf51c77ca..be5b8a35e 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 74dcc3bea..dd2871bf6 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index fc720d343..ff9b7675b 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 80b63c645..cf9201d5c 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 6c0ecfdd2..7023b1173 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -16,6 +16,7 @@ *******************************************************************************/ package io.github.nbplugins.kotlin.refactoring +import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo @@ -30,6 +31,7 @@ import org.jetbrains.kotlin.idea.k2.refactoring.move.descriptor.K2ChangePackageD import org.jetbrains.kotlin.idea.k2.refactoring.move.processor.K2ChangePackageRefactoringProcessor import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.idea.references.mainReference +import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtCallExpression @@ -603,7 +605,6 @@ class KaChangePackageComputer( .sortedByDescending { (oldFqName, _) -> oldFqName.pathSegments().size } val edits = linkedMapOf>() participants.forEach { file -> - val fileText = file.text file.importDirectives.forEach { directive -> val importedReference = directive.importedReference ?: return@forEach // Ranges come from PSI: `importPath.pathStr` is a rendered path and can differ @@ -628,7 +629,7 @@ class KaChangePackageComputer( } KotlinSourceNames.renderFqName(newPrefix) + suffixText } else { - rewriteImportedPath(fileText.substring(pathStart, pathEnd), mappings) + rewriteImportedPath(directive.identifiersWithin(pathStart, pathEnd), mappings) ?: return@forEach } edits.getOrPut(file, ::linkedSetOf) += TextEdit(pathStart, pathEnd, replacement) @@ -684,64 +685,98 @@ class KaChangePackageComputer( /** * Rewrites the package prefix of an imported path, keeping alias and nested-class suffixes. * - * Import paths are compared segment by segment rather than as token sequences so a backticked - * segment still matches its plain FQN. + * Segments are PSI identifiers, so backticks, whitespace, and interspersed comments in the + * source text do not affect matching. The suffix is rendered again with the escaping Kotlin + * requires. * - * @param importedText path text as written in the directive + * @param segments identifier names of the imported path, in source order * @param mappings moved declarations ordered from the longest FQN to the shortest * @return retargeted path text, or `null` when the import does not target a moved declaration */ private fun rewriteImportedPath( - importedText: String, + segments: List, mappings: List>, ): String? { - val segments = importedText.split('.').map { segment -> segment.trim() } mappings.forEach { (oldFqName, newFqName) -> val oldSegments = oldFqName.pathSegments().map { segment -> segment.asString() } if (segments.size < oldSegments.size) return@forEach - val prefix = segments.take(oldSegments.size).map { segment -> segment.removeSurrounding("`") } - if (prefix != oldSegments) return@forEach + if (segments.take(oldSegments.size) != oldSegments) return@forEach val suffix = segments.drop(oldSegments.size) - val separator = if (suffix.isEmpty()) "" else "." - return KotlinSourceNames.renderFqName(newFqName) + suffix.joinToString(separator = ".", prefix = separator) + val suffixText = suffix.joinToString(separator = ".", prefix = if (suffix.isEmpty()) "" else ".") { name -> + KotlinSourceNames.renderIdentifier(name) + } + return KotlinSourceNames.renderFqName(newFqName) + suffixText } return null } /** - * Source range, text and leftmost reference of the qualified chain ending at a reference. + * Collects the simple-name identifiers of this element inside `[start, end)`. + * + * Identifiers come from PSI, so whitespace, backticks, and comments between them are ignored + * while the captured replacement range stays in source coordinates. Type arguments and call + * arguments located after [end] are excluded. + * + * @param start first offset of the region to read + * @param end offset just past the region + * @return referenced names in source order + */ + private fun PsiElement.identifiersWithin(start: Int, end: Int): List { + val found = mutableListOf>() + fun visit(element: PsiElement) { + val range = element.textRange + if (range.endOffset <= start || range.startOffset >= end) return + if (element is KtSimpleNameExpression) { + if (range.startOffset >= start && range.endOffset <= end) { + found += range.startOffset to element.getReferencedName() + } + return + } + // Import path qualifiers are plain identifier tokens rather than name references. + if (element.node?.elementType == KtTokens.IDENTIFIER) { + found += range.startOffset to element.text.removeSurrounding("`") + return + } + for (child in element.children) visit(child) + } + visit(this) + return found.sortedBy { (offset, _) -> offset }.map { (_, name) -> name } + } + + /** + * Source range, PSI identifiers and leftmost reference of the qualified chain ending at a + * reference. * - * [rootReference] is the leftmost simple name of the receiver, which tells a package qualifier - * apart from an expression that merely spells the same text. + * [identifiers] ignores source spelling (backticks, comments), and [rootReference] is the + * leftmost simple name of the receiver, which tells a package qualifier apart from an + * expression that merely spells the same text. */ private data class QualifiedChain( val startOffset: Int, val endOffset: Int, - val text: String, + val identifiers: List, val rootReference: KtReferenceExpression?, ) /** - * Tells whether this chain's identifiers spell [fqName]. + * Tells whether this chain's PSI identifiers spell [fqName]. * - * Backticks and whitespace around dots change the source spelling but not the identifiers K2 - * resolved, so segments are compared after trimming and unquoting while the captured PSI range - * still delimits the replacement. + * Backticks, whitespace, and comments between segments change the source spelling but not the + * identifiers K2 resolved, so only the names are compared while the captured PSI range still + * delimits the replacement. * * @param fqName fully qualified name of the declaration K2 resolved - * @return `true` when the chain text denotes exactly that FQN + * @return `true` when the chain identifiers denote exactly that FQN */ - private fun QualifiedChain.matches(fqName: FqName): Boolean { - val segments = text.split('.').map { segment -> segment.trim().removeSurrounding("`") } - return segments == fqName.pathSegments().map { segment -> segment.asString() } - } + private fun QualifiedChain.matches(fqName: FqName): Boolean = + identifiers == fqName.pathSegments().map { segment -> segment.asString() } /** * Extends a reference expression through the qualified receiver chain that contains it. * * `old.pkg.moved()` visits the `moved` reference; climbing through qualified expressions, their - * call nodes, and user types yields the `old.pkg.moved` text while dropping any argument list - * or type argument suffix. The chain stops at any other construct. + * call nodes, and user types yields the identifiers `old`, `pkg`, `moved` while dropping any + * argument list or type argument suffix. The chain stops at any other construct. */ private fun KtElement.qualifiedChain(): QualifiedChain { var current: KtElement = this @@ -764,7 +799,7 @@ class KaChangePackageComputer( } current = parent } - else -> return QualifiedChain(start, end, containingFile.text.substring(start, end), rootReference) + else -> return QualifiedChain(start, end, current.identifiersWithin(start, end), rootReference) } } } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index ac2a847d9..35c4368c2 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index aa14270e5..bd09e25bb 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1565,6 +1565,99 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an import path containing a comment still follows its moved declaration. + * + * Segment matching must come from PSI identifiers; splitting source text leaves comment tokens + * inside a segment and the directive is then never rewritten. + */ + fun testApply_realSession_retargetsImportWithInterspersedComment() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-import-comment") + try { + val sourceDirectory = temporaryRoot.resolve("old/pkg") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package old.pkg\n\nclass Moved\n") + Files.writeString( + usagePath, + "package usage\n\nimport old./* note */pkg.Moved\n\nfun use(): Moved? = null\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-import-comment-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.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An import path with an interspersed comment must still be retargeted", + "package usage\n\nimport new.pkg.Moved\n\nfun use(): Moved? = null\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a qualified call containing a comment is still rewritten in place. + * + * `old./* note */pkg.moved()` spells the old FQN in PSI terms even though its text contains a + * comment token; the chain must be recognized and rewritten without that token. + */ + fun testApply_realSession_retargetsQualifiedCallWithInterspersedComment() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-call-comment") + try { + val sourceDirectory = temporaryRoot.resolve("old/pkg") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Moved.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package old.pkg\n\nfun moved() = 1\n") + Files.writeString(usagePath, "package usage\n\nfun use() = old./* note */pkg.moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-call-comment-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.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A qualified call with an interspersed comment must be rewritten in place", + "package usage\n\nfun use() = new.pkg.moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies an extension receiver spelled like the old package is not treated as a qualifier. * diff --git a/pom.xml b/pom.xml index 3ee2980fa..d7c74a16a 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.31-SNAPSHOT + 0.12.32-SNAPSHOT pom Netbeans Plugin Kotlin From 603a597783dadd5cced6cb371f9fe3a3521a864e Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Mon, 2 Nov 2026 23:36:08 +0300 Subject: [PATCH 22/55] Kept call receivers when matching qualified reference chains Qualified chains were matched by identifier names alone, so `a.b().decorate()` collapsed to the names `a, b, decorate` and matched the moved FQN `a.b.decorate` whenever the receiver call did not resolve to a symbol (the root-symbol guard only caught the resolved case). Replacing the chain then deleted the receiver call. A chain now qualifies for in-place replacement only when every receiver is a plain name chain; calls, index accesses, postfix operators and parenthesized receivers reject the chain. The leftmost reference is likewise taken from a simple name only. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 60 ++++++++++++++----- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 51 ++++++++++++++++ pom.xml | 2 +- 14 files changed, 108 insertions(+), 27 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index ae0287207..284901c16 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 3f98d0700..ce4b5c8bc 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 8b97710aa..00ca22d65 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 0b8a9349c..0e2db00c8 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 53e0cf02d..244f33e0e 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 1fd6b8b6d..2ab8329cd 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index be5b8a35e..c05a37c40 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index dd2871bf6..7f1adefcf 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index ff9b7675b..5183ffd6f 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index cf9201d5c..252477ef0 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 7023b1173..fad5cab9c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -41,7 +41,6 @@ import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration -import org.jetbrains.kotlin.psi.KtReferenceExpression import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import org.jetbrains.kotlin.psi.KtUserType @@ -318,7 +317,7 @@ class KaChangePackageComputer( // alone keep root-package bindings intact. if (oldFqName.parent().isRoot) return false val file = element.containingFile as? KtFile ?: return false - val chain = element.qualifiedChain() + val chain = element.qualifiedChain() ?: return false if (!chain.matches(oldFqName)) return false // K2 resolves package references to `null` in this position, so only a concrete // non-package symbol proves the chain root is a value/class rather than a package. @@ -744,18 +743,17 @@ class KaChangePackageComputer( } /** - * Source range, PSI identifiers and leftmost reference of the qualified chain ending at a - * reference. + * Source range, PSI identifiers and leftmost name of the qualified chain ending at a reference. * * [identifiers] ignores source spelling (backticks, comments), and [rootReference] is the - * leftmost simple name of the receiver, which tells a package qualifier apart from an - * expression that merely spells the same text. + * leftmost simple name of the chain, which tells a package qualifier apart from an expression + * that merely spells the same text. */ private data class QualifiedChain( val startOffset: Int, val endOffset: Int, val identifiers: List, - val rootReference: KtReferenceExpression?, + val rootReference: KtSimpleNameExpression?, ) /** @@ -776,24 +774,34 @@ class KaChangePackageComputer( * * `old.pkg.moved()` visits the `moved` reference; climbing through qualified expressions, their * call nodes, and user types yields the identifiers `old`, `pkg`, `moved` while dropping any - * argument list or type argument suffix. The chain stops at any other construct. + * argument list or type argument suffix. + * + * The chain must consist only of names. A receiver that is a call, index access, or any other + * expression (`a.b().moved()`) would contribute its own identifiers to the captured range while + * the replacement deletes the expression itself, so such a chain is rejected rather than + * matched by name alone. + * + * @return the chain ending at this element, or `null` when its receiver is not a plain name chain */ - private fun KtElement.qualifiedChain(): QualifiedChain { + private fun KtElement.qualifiedChain(): QualifiedChain? { var current: KtElement = this var start = textRange.startOffset val end = textRange.endOffset - var rootReference: KtReferenceExpression? = null + var rootReference: KtSimpleNameExpression? = null while (true) { val parent = current.parent when { parent is KtDotQualifiedExpression && parent.selectorExpression == current -> { - start = parent.receiverExpression.textRange.startOffset - rootReference = parent.receiverExpression.leadingReference() + val receiver = parent.receiverExpression + if (!receiver.isNameChain()) return null + start = receiver.textRange.startOffset + rootReference = receiver.leadingReference() current = parent } parent is KtCallExpression && parent.calleeExpression == current -> current = parent parent is KtUserType && parent.referenceExpression == current -> { parent.qualifier?.let { qualifier -> + if (!qualifier.isNameChain()) return null start = qualifier.textRange.startOffset rootReference = qualifier.leadingReference() } @@ -804,15 +812,37 @@ class KaChangePackageComputer( } } + /** + * Tells whether this element is a chain of names joined by dots. + * + * Only such a chain denotes exactly the identifiers it spells and can be replaced by a + * package-qualified name as a whole. Calls, index accesses, postfix operators, parentheses and + * any other expression must keep their own structure. + * + * @return `true` when the element consists solely of simple names, qualified name chains and + * user types + */ + private fun KtElement.isNameChain(): Boolean = when (this) { + is KtSimpleNameExpression -> true + is KtDotQualifiedExpression -> + selectorExpression?.isNameChain() == true && receiverExpression.isNameChain() + is KtUserType -> referenceExpression != null && (qualifier?.isNameChain() ?: true) + else -> false + } + /** * Returns the leftmost simple name of this receiver expression's qualified chain. * - * @return the leftmost reference, or `null` when the receiver is not a name-based chain + * A call or any other expression is not a name and yields `null`: resolving it would not tell + * whether the chain starts from a package. + * + * @return the leftmost name, or `null` when the receiver is not a name-based chain */ - private fun KtElement.leadingReference(): KtReferenceExpression? = when (this) { + private fun KtElement.leadingReference(): KtSimpleNameExpression? = when (this) { is KtDotQualifiedExpression -> receiverExpression.leadingReference() is KtUserType -> qualifier?.leadingReference() ?: referenceExpression - else -> this as? KtReferenceExpression + is KtSimpleNameExpression -> this + else -> null } /** Retargeted text and the replacements accepted for it, in its coordinates. */ diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 35c4368c2..e9f221041 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index bd09e25bb..53ab34ccb 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1707,6 +1707,57 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a call receiver that happens to spell the old package is left alone. + * + * `a.b()` in `a.b().decorate()` is a call on a local value whose identifiers match the moved + * package path by coincidence. The receiver call is unresolved, so the chain root resolves to + * no symbol and cannot prove the chain is an expression; replacing the chain with the new FQN + * would delete the receiver and leave a dangling call, so only chains made purely of names may + * be rewritten. + */ + fun testApply_realSession_keepsCallReceiverBeforeQualifiedName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-call-receiver") + try { + val sourceDirectory = temporaryRoot.resolve("old") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Extension.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package a\n\nfun String.decorate() = this\n") + Files.writeString( + usagePath, + "package usage\n\nimport a.decorate\n\nfun use() = a().decorate()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-call-receiver-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("x")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A call receiver must not be replaced by the package FQN", + "package usage\n\nimport x.decorate\n\nfun use() = a().decorate()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies an explicit constructor call is retargeted with its moved class. * diff --git a/pom.xml b/pom.xml index d7c74a16a..08c2c5c14 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.32-SNAPSHOT + 0.12.33-SNAPSHOT pom Netbeans Plugin Kotlin From ac2f2e882d3f754aa21764c3e38809941c632eea Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 08:14:31 +0300 Subject: [PATCH 23/55] Kept instance receivers and subpackage imports during retargeting KaRenameComputer now replaces only simple names that spell the renamed class, so the `this`/`super` keywords and calls through Kotlin type aliases are never rewritten into the new class name. rewriteImportedPath requires an exact path match for callable mappings: a top-level function may share its name with a package, so only moved classes may keep a nested suffix. `import p.tools.utility` therefore stays intact when only `fun tools()` changes package. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 15 +++- .../kotlin/refactoring/KaRenameComputer.kt | 9 ++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 55 +++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 80 +++++++++++++++++++ pom.xml | 2 +- 16 files changed, 167 insertions(+), 16 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 284901c16..d8e18c104 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index ce4b5c8bc..7fb470655 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 00ca22d65..8403f2af8 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 0e2db00c8..f156d1ba8 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 244f33e0e..ccf69aeeb 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 2ab8329cd..cf220e0a2 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index c05a37c40..79a0d39bd 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 7f1adefcf..08ea81b58 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 5183ffd6f..c0abc8a84 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 252477ef0..237aa95f4 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index fad5cab9c..266936a74 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -628,8 +628,11 @@ class KaChangePackageComputer( } KotlinSourceNames.renderFqName(newPrefix) + suffixText } else { - rewriteImportedPath(directive.identifiersWithin(pathStart, pathEnd), mappings) - ?: return@forEach + rewriteImportedPath( + directive.identifiersWithin(pathStart, pathEnd), + mappings, + movedClassFqNames, + ) ?: return@forEach } edits.getOrPut(file, ::linkedSetOf) += TextEdit(pathStart, pathEnd, replacement) } @@ -688,18 +691,26 @@ class KaChangePackageComputer( * source text do not affect matching. The suffix is rendered again with the escaping Kotlin * requires. * + * Only moved classifiers own a suffix: their nested declarations really do follow them, so + * `import old.Outer.Nested` becomes `import new.Outer.Nested`. A top-level function or property + * shares its name with packages freely (`fun tools()` and package `p.tools`), so a callable + * mapping matches the imported path exactly and never captures a longer path. + * * @param segments identifier names of the imported path, in source order * @param mappings moved declarations ordered from the longest FQN to the shortest + * @param classifierFqNames original FQNs of moved classes and objects * @return retargeted path text, or `null` when the import does not target a moved declaration */ private fun rewriteImportedPath( segments: List, mappings: List>, + classifierFqNames: Set, ): String? { mappings.forEach { (oldFqName, newFqName) -> val oldSegments = oldFqName.pathSegments().map { segment -> segment.asString() } if (segments.size < oldSegments.size) return@forEach if (segments.take(oldSegments.size) != oldSegments) return@forEach + if (oldFqName !in classifierFqNames && segments.size != oldSegments.size) return@forEach val suffix = segments.drop(oldSegments.size) val suffixText = suffix.joinToString(separator = ".", prefix = if (suffix.isEmpty()) "" else ".") { name -> KotlinSourceNames.renderIdentifier(name) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 582115c00..daf556b6f 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -362,8 +362,13 @@ class KaRenameComputer( // constructor of the renamed class, but the keyword itself is syntax and // must never be rewritten into the new class name. if (expression is KtConstructorDelegationReferenceExpression) return - val simpleName = expression as? KtSimpleNameExpression - if (simpleName != null && simpleName.getReferencedName() in aliasNames && + // Only a simple name spelling the class name can be rewritten. Instance + // receivers (`this`, `super`) resolve to the class as well but are syntax, + // and a type-alias call spells the alias rather than the class name. + val simpleName = expression as? KtSimpleNameExpression ?: return + val targetName = (targetPsi as? KtNamedDeclaration)?.name + if (targetName != null && simpleName.getReferencedName() != targetName) return + if (simpleName.getReferencedName() in aliasNames && simpleName.isAliasUsage() ) { return diff --git a/Nbm/pom.xml b/Nbm/pom.xml index e9f221041..743da5cf2 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 53ab34ccb..e2231e81d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -1758,6 +1758,61 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a moved function does not capture imports of an unrelated subpackage. + * + * Kotlin allows a package and a top-level function to share a name (`p.tools` and `p.tools()`). + * Moving only the function must not rewrite a caller's `import p.tools.utility`, which belongs + * to the unrelated package that stays put; suffix-preserving rewrites are valid for moved + * classifiers only, because their nested declarations really do follow them. + */ + fun testApply_realSession_keepsSubpackageImportMatchingMovedFunctionPrefix() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-callable-prefix") + try { + val sourceDirectory = temporaryRoot.resolve("source") + val utilityDirectory = temporaryRoot.resolve("utility") + val usageDirectory = temporaryRoot.resolve("usage") + Files.createDirectories(sourceDirectory) + Files.createDirectories(utilityDirectory) + Files.createDirectories(usageDirectory) + val sourcePath = sourceDirectory.resolve("Tools.kt") + val utilityPath = utilityDirectory.resolve("Utility.kt") + val usagePath = usageDirectory.resolve("Use.kt") + Files.writeString(sourcePath, "package p\n\nfun tools() = 1\n") + Files.writeString(utilityPath, "package p.tools\n\nfun utility() = 2\n") + Files.writeString( + usagePath, + "package usage\n\nimport p.tools.utility\n\nval value = utility()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-callable-prefix-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("dest")), + 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 dest") == true) + assertEquals( + "An import of the unrelated p.tools package must stay untouched", + "package usage\n\nimport p.tools.utility\n\nval value = utility()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies an explicit constructor call is retargeted with its moved class. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 1608ad6f5..d11c7e64a 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -355,6 +355,86 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies an instance receiver keeps the `this` keyword during a class rename. + * + * `this` resolves to the containing class, but it is an instance expression, not a reference + * to the class name; replacing it with the new name would produce invalid Kotlin. + */ + fun testRenameClassKeepsInstanceReceiver() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping instance-receiver rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-instance-receiver") + try { + val sourceDirectory = root.resolve("source") + Files.createDirectories(sourceDirectory) + val sourcePath = sourceDirectory.resolve("Foo.kt") + Files.writeString( + sourcePath, + "package a\n\nclass Foo {\n fun self() = this\n fun make() = Foo()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-instance-receiver-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val changes = KaRenameComputer(source, source.text.indexOf("Foo"), setOf(source), "Bar").compute() + + assertNotNull("compute() must return non-null for an instance receiver", changes) + assertEquals( + "The this receiver must keep its keyword while constructor calls follow the rename", + "package a\n\nclass Bar {\n fun self() = this\n fun make() = Bar()\n}\n", + applyChanges(source.text, changes!![sourcePath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a constructor call spelled through a Kotlin type alias keeps the alias name. + * + * `IntFoo()` resolves to a constructor of `Foo`, but its referenced name is the type alias; + * only the alias definition's `Foo` reference follows the rename. + */ + fun testRenameClassKeepsTypeAliasConstructorCall() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping type-alias rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-type-alias") + try { + val sourceDirectory = root.resolve("source") + Files.createDirectories(sourceDirectory) + val sourcePath = sourceDirectory.resolve("Foo.kt") + Files.writeString( + sourcePath, + "package a\n\nclass Foo\n\ntypealias IntFoo = Foo\n\nfun make() = IntFoo()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-type-alias-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val changes = KaRenameComputer(source, source.text.indexOf("Foo"), setOf(source), "Bar").compute() + + assertNotNull("compute() must return non-null for a type alias", changes) + assertEquals( + "A call through the type alias must keep the alias name", + "package a\n\nclass Bar\n\ntypealias IntFoo = Bar\n\nfun make() = IntFoo()\n", + applyChanges(source.text, changes!![sourcePath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/pom.xml b/pom.xml index 08c2c5c14..f9a56da66 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.33-SNAPSHOT + 0.12.34-SNAPSHOT pom Netbeans Plugin Kotlin From 0d1747eca5948ddbfe815c5e0e672062d0fc723b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 09:15:45 +0300 Subject: [PATCH 24/55] Fixed the remaining package-rename review findings - Qualified references rewritten to a bare root-package name now request the explicit import they need; a new package segment shadowed by a local declaration shortens the chain to a bare name plus an import instead, and a shadowed short name or an already-imported name is reported as a conflict. - Constructor calls spelled through a type alias import the alias rather than the expanded class, and an explicit import that would rebind an unmoved overload is rejected. - Outgoing imports cover array-access convention operators, value receivers spelled like the package, and top-level callables read from dependency JARs. - Obsolete package wildcards are retargeted or removed once the old package keeps no sources; annotation-only root-package files receive imports. - Aliased nested imports make their caller a participant of the change. - File-class Rename rejects same-signature top-level functions and caller scope collisions, searches all session sources (custom Gradle roots), and keeps the `@` prefix of a receiver label. - Version bumped to 0.12.35-SNAPSHOT. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 489 +++++++++++++++-- .../kotlin/refactoring/KaRenameComputer.kt | 11 +- .../refactoring/KotlinImportBindingCheck.kt | 157 ++++++ .../move/KotlinMoveUsageSearchService.kt | 18 + Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 27 +- .../nbm/refactoring/KotlinRenamePlugin.kt | 94 +++- .../nbm/refactoring/KaChangePackageTest.kt | 496 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 40 ++ .../KotlinImportBindingCheckTest.kt | 150 ++++++ .../KotlinRenameClassifierConflictTest.kt | 109 +++- pom.xml | 2 +- 22 files changed, 1527 insertions(+), 88 deletions(-) create mode 100644 KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index d8e18c104..e58225de7 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 7fb470655..8ba535ab1 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 8403f2af8..e2cd70666 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index f156d1ba8..fa881d4e8 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index ccf69aeeb..ba71fa65d 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index cf220e0a2..96c6dbbd0 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 79a0d39bd..1b6eec81b 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 08ea81b58..71aceff6d 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index c0abc8a84..b1785be05 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 237aa95f4..c0f79d871 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 266936a74..511c6bd8c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -21,10 +21,13 @@ import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol 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 @@ -34,16 +37,31 @@ import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name +import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtCatchClause +import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.psi.KtUserType +import org.jetbrains.kotlin.psi.KtWhenExpression /** * Applies IDEA K2 Change Package semantics without its IDE-specific processor lifecycle. @@ -104,11 +122,6 @@ class KaChangePackageComputer( // Reuse the copied upstream Change Package processor for usage discovery, conflict // checking, and package rewriting; only its IDE-bound retargeting pass is replaced. 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 movedDeclarations = files.flatMap { file -> file.declarations.mapNotNull { declaration -> (declaration as? KtNamedDeclaration)?.name?.let { name -> @@ -117,6 +130,20 @@ class KaChangePackageComputer( } } }.toMap() + // Incoming changes fix callers that lose visibility of a moved declaration, either + // because they shared its package or because they imported the whole old package. + // Outgoing imports fix the moved file itself losing visibility of the declarations + // that stay behind in its former package. Both run before any mutation and can add + // conflicts of their own when a rewrite would rebind an unrelated name. + val incoming = if (updateReferences) { + findIncomingRetargeting(files, targets) + } else { + IncomingRetargeting() + } + val conflicts = processors.flatMap { processor -> + processor.findConflicts(usages.filterIsInstance()).values() + } + incoming.conflicts + if (conflicts.isNotEmpty()) return ApplyOutcome.Conflicts(conflicts.distinct()) // Class-scoped wildcard imports (`import old.Outer.*`) follow the class only when the // prefix is a moved class; package wildcards never move with it. val movedClassFqNames = files.flatMap { file -> @@ -131,11 +158,6 @@ class KaChangePackageComputer( } else { emptyList() } - // Incoming changes fix callers that lose visibility of a moved declaration, either - // because they shared its package or because they imported the whole old package. - // Outgoing imports fix the moved file itself losing visibility of the declarations - // that stay behind in its former package. - val incoming = if (updateReferences) findIncomingRetargeting(files, targets) else IncomingRetargeting() val outgoingImports = if (updateReferences) findOutgoingSamePackageImports(files, targets) else emptyMap() val samePackageImportUpdates = (incoming.imports.keys + outgoingImports.keys).associateWith { file -> incoming.imports[file].orEmpty() + outgoingImports[file].orEmpty() @@ -158,7 +180,13 @@ class KaChangePackageComputer( // target a moved declaration and references that K2 resolved to one are retargeted, so // text that merely spells a moved FQN (comments, strings, unrelated chains) is untouched. val textEdits = if (updateReferences) { - val importEdits = captureImportEdits(participants, movedDeclarations, movedClassFqNames) + val movedPaths = files.mapNotNull { file -> file.virtualFile?.path }.toSet() + val importEdits = captureImportEdits( + participants, + movedDeclarations, + movedClassFqNames, + movedPaths, + ) (importEdits.keys + incoming.edits.keys).associateWith { file -> importEdits[file].orEmpty() + incoming.edits[file].orEmpty() } @@ -221,6 +249,7 @@ class KaChangePackageComputer( private data class IncomingRetargeting( val imports: Map> = emptyMap(), val edits: Map> = emptyMap(), + val conflicts: List = emptyList(), ) /** @@ -256,6 +285,7 @@ class KaChangePackageComputer( val session = KotlinMoveUsageSearchService.getInstance() ?: return IncomingRetargeting() val imports = linkedMapOf>() val edits = linkedMapOf>() + val conflicts = mutableListOf() movedDeclarations.forEach { (declaration, unified) -> val (oldFqName, newFqName) = unified val declarationFile = declaration.containingKtFile @@ -263,9 +293,11 @@ class KaChangePackageComputer( session.findUsages(declaration).forEach { reference -> val element = reference.element as? KtElement ?: return@forEach val file = element.containingFile as? KtFile ?: return@forEach - // A qualified chain is rewritten in place, so this usage needs no import; an - // unqualified usage in the same file still adds one when the loop reaches it. - if (captureQualifiedReference(element, oldFqName, newFqName, edits)) return@forEach + // A qualified chain rewritten to a full package path keeps resolving on its own. + // A root-package target becomes a bare name, and a shadowed package segment is + // rewritten to a bare name as well, so both still need an explicit import. + val rewrite = captureQualifiedReference(element, oldFqName, newFqName, edits, conflicts) + if (rewrite == QualifiedRewrite.REWRITTEN) return@forEach if (file.virtualFile?.path == declarationFile.virtualFile?.path) return@forEach val finalPackage = targets[file] ?: file.packageFqName if (finalPackage == newFqName.parent()) return@forEach @@ -276,19 +308,92 @@ class KaChangePackageComputer( directive.importedFqName == oldFqName } if (importsDirectly) return@forEach - val sharesOriginalPackage = file.packageFqName == originalPackage - val importsOriginalPackage = file.importDirectives.any { directive -> - directive.isAllUnder && directive.importedFqName == originalPackage + // Only an unqualified usage that loses its implicit or wildcard binding needs the + // import; a usage already rewritten through its own qualifier does not. + if (rewrite == QualifiedRewrite.NONE) { + val sharesOriginalPackage = file.packageFqName == originalPackage + val importsOriginalPackage = file.importDirectives.any { directive -> + directive.isAllUnder && directive.importedFqName == originalPackage + } + if (!sharesOriginalPackage && !importsOriginalPackage) return@forEach + // The rewritten alias directive keeps this usage bound, so it needs no + // unaliased import of its own. + val simpleName = element as? KtSimpleNameExpression + if (simpleName != null && simpleName.isAliasBound(oldFqName)) return@forEach } - if (!sharesOriginalPackage && !importsOriginalPackage) return@forEach - // The rewritten alias directive keeps this usage bound, so it needs no - // unaliased import of its own. - val simpleName = element as? KtSimpleNameExpression - if (simpleName != null && simpleName.isAliasBound(oldFqName)) return@forEach imports.getOrPut(file, ::linkedSetOf) += newFqName } } - return IncomingRetargeting(imports, edits.mapValues { (_, fileEdits) -> fileEdits.toList() }) + // An explicit import takes precedence over wildcard imports and same-package + // declarations, so it may capture references that point elsewhere today. + val movedPsi = movedDeclarations.keys + val movedOldFqNames = movedDeclarations.values.map { (oldFqName, _) -> oldFqName }.toSet() + imports.forEach { (file, planned) -> + planned.forEach { newFqName -> + val shortName = newFqName.shortName().asString() + when { + file.hasConflictingImport(newFqName, movedOldFqNames) -> conflicts += + "Cannot update ${file.name}: it already imports another declaration named $shortName." + hasConflictingBinding(file, shortName, movedPsi) -> conflicts += + "Cannot update ${file.name}: $shortName already refers to another declaration there." + } + } + } + return IncomingRetargeting( + imports, + edits.mapValues { (_, fileEdits) -> fileEdits.toList() }, + conflicts, + ) + } + + /** + * Tells whether [file] already imports a different declaration under the name [imported] binds. + * + * Adding a second explicit import with the same simple name is a conflicting import, and one + * naming a moved declaration's old path is retargeted instead of being a conflict. + * + * @param imported declaration the file is about to import + * @param movedOldFqNames original FQNs of the moved declarations + * @return `true` when an existing unaliased import binds the same simple name elsewhere + */ + private fun KtFile.hasConflictingImport(imported: FqName, movedOldFqNames: Set): Boolean = + importDirectives.any { directive -> + val importedFqName = directive.importedFqName + !directive.isAllUnder && directive.aliasName == null && importedFqName != null && + importedFqName != imported && importedFqName !in movedOldFqNames && + importedFqName.shortName() == imported.shortName() + } + + /** + * Tells whether an explicit import of [name] would capture another declaration in [file]. + * + * An explicit import takes precedence over a wildcard import and over same-package + * declarations, so inserting one for the moved declaration can silently rebind an existing + * unqualified reference — an unrelated overload, a same-package function, or a classifier + * from another package. References that already resolve to a moved declaration are the ones + * the import is meant for and are ignored. + * + * @param file caller that is about to receive the import + * @param name simple name the import binds + * @param movedDeclarations PSI of the declarations whose references the import retargets + * @return `true` when some unqualified reference in [file] would change its target + */ + private fun hasConflictingBinding( + file: KtFile, + name: String, + movedDeclarations: Set, + ): Boolean = KotlinImportBindingCheck.hasConflictingReference(file, name, movedDeclarations) + + /** What a captured qualified chain needs after its replacement. */ + private enum class QualifiedRewrite { + /** The reference does not spell the old FQN, so it keeps its text and its import. */ + NONE, + + /** The chain was replaced by the new fully qualified name and resolves on its own. */ + REWRITTEN, + + /** The chain was replaced by a bare name, so the caller now needs an explicit import. */ + NEEDS_IMPORT, } /** @@ -300,25 +405,32 @@ class KaChangePackageComputer( * bound to `a.decorate`), and replacing that chain would drop the receiver. The chain's * leftmost name must therefore not resolve to a non-package symbol. * + * The new package's first segment can itself be captured by a local binding + * (`fun use(dest: Holder) = old.moved()`), in which case the replacement would call a member + * instead of the moved declaration. The chain is then reduced to the bare name and an import + * is requested; when that name is shadowed as well, the caller reports a conflict instead. + * * @param element reference expression that K2 resolved to the moved declaration * @param oldFqName declaration FQN before the package change * @param newFqName declaration FQN after the package change * @param edits per-file replacement accumulator - * @return `true` when a qualified chain was replaced in place and therefore needs no import + * @param conflicts accumulator for changes that cannot be expressed safely + * @return what the caller still needs to do for this reference */ private fun captureQualifiedReference( element: KtElement, oldFqName: FqName, newFqName: FqName, edits: MutableMap>, - ): Boolean { + conflicts: MutableList, + ): QualifiedRewrite { // A root-package FQN is a bare name. Qualifying such a reference would rewrite // declaration-like positions that cannot take a package prefix (`::moved`); imports // alone keep root-package bindings intact. - if (oldFqName.parent().isRoot) return false - val file = element.containingFile as? KtFile ?: return false - val chain = element.qualifiedChain() ?: return false - if (!chain.matches(oldFqName)) return false + if (oldFqName.parent().isRoot) return QualifiedRewrite.NONE + val file = element.containingFile as? KtFile ?: return QualifiedRewrite.NONE + val chain = element.qualifiedChain() ?: return QualifiedRewrite.NONE + if (!chain.matches(oldFqName)) return QualifiedRewrite.NONE // K2 resolves package references to `null` in this position, so only a concrete // non-package symbol proves the chain root is a value/class rather than a package. val root = chain.rootReference @@ -326,10 +438,141 @@ class KaChangePackageComputer( val rootSymbol = runCatching { analyze(file) { root.mainReference?.resolveToSymbol() } }.getOrNull() - if (rootSymbol != null && rootSymbol !is KaPackageSymbol) return false + if (rootSymbol != null && rootSymbol !is KaPackageSymbol) return QualifiedRewrite.NONE + } + // A root target has no receiver to rewrite: `old.moved()` becomes the bare `moved`. + if (newFqName.parent().isRoot) { + edits.getOrPut(file, ::linkedSetOf) += TextEdit( + chain.startOffset, + chain.endOffset, + KotlinSourceNames.renderFqName(newFqName), + ) + return QualifiedRewrite.NEEDS_IMPORT + } + val packageHead = newFqName.parent().pathSegments().first().asString() + if (file.hasDeclarationNamed(packageHead)) { + // The new package's first segment would resolve to a local declaration instead of + // the package, so the chain is shortened and the declaration imported instead. + val shortName = newFqName.shortName().asString() + if (element.isNameShadowedInScope(shortName)) { + conflicts += "Cannot update ${file.name}: both $packageHead and $shortName refer " + + "to other declarations there." + return QualifiedRewrite.NONE + } + edits.getOrPut(file, ::linkedSetOf) += TextEdit( + chain.startOffset, + chain.endOffset, + KotlinSourceNames.renderIdentifier(shortName), + ) + return QualifiedRewrite.NEEDS_IMPORT + } + edits.getOrPut(file, ::linkedSetOf) += TextEdit( + chain.startOffset, + chain.endOffset, + KotlinSourceNames.renderFqName(newFqName), + ) + return QualifiedRewrite.REWRITTEN + } + + /** + * Tells whether any declaration named [name] appears anywhere in this file. + * + * A package qualifier is resolved as an ordinary name in scope, so a property, parameter, + * local function, classifier, or import alias with the same name can hide the package. The + * scan is deliberately file-wide: missing a binding would silently change which declaration + * is called, while an unrelated match only costs a fallback to an explicit import. + * + * @param name identifier to look for + * @return `true` when the file declares or aliases that name at any level + */ + private fun KtFile.hasDeclarationNamed(name: String): Boolean { + var found = false + accept(object : KtTreeVisitorVoid() { + override fun visitProperty(property: KtProperty) { + if (property.name == name) found = true + super.visitProperty(property) + } + + override fun visitParameter(parameter: KtParameter) { + if (parameter.name == name) found = true + super.visitParameter(parameter) + } + + override fun visitDestructuringDeclarationEntry(multiDeclarationEntry: KtDestructuringDeclarationEntry) { + if (multiDeclarationEntry.name == name) found = true + super.visitDestructuringDeclarationEntry(multiDeclarationEntry) + } + + override fun visitNamedFunction(function: KtNamedFunction) { + if (function.name == name) found = true + super.visitNamedFunction(function) + } + + override fun visitClassOrObject(classOrObject: KtClassOrObject) { + if (classOrObject.name == name) found = true + super.visitClassOrObject(classOrObject) + } + + override fun visitTypeAlias(typeAlias: KtTypeAlias) { + if (typeAlias.name == name) found = true + super.visitTypeAlias(typeAlias) + } + + override fun visitImportDirective(importDirective: KtImportDirective) { + if (importDirective.aliasName == name) found = true + super.visitImportDirective(importDirective) + } + }) + return found + } + + /** + * Tells whether [name] is bound to a local or member declaration visible at this element. + * + * Only bindings that actually reach this position matter: a local declared later in the same + * block, or a member of an unrelated class, never captures the rewritten short name. Class + * members and import aliases are file-wide because a nested function cannot see past them. + * + * @param name identifier to look for in the enclosing scopes + * @return `true` when a value, function, or nested declaration here would win over an import + */ + private fun KtElement.isNameShadowedInScope(name: String): Boolean { + if (containingKtFile.importDirectives.any { directive -> directive.aliasName == name }) return true + var child: PsiElement = this + var current: PsiElement? = parent + while (current != null) { + val shadowed = when (current) { + is KtBlockExpression -> current.statements + .takeWhile { statement -> statement.textRange.startOffset < child.textRange.startOffset } + .any { statement -> (statement as? KtDeclaration)?.declaresName(name) == true } + is KtClassBody -> current.declarations.any { declaration -> declaration.declaresName(name) } + is KtFunction -> current.valueParameters.any { parameter -> parameter.name == name } + is KtPropertyAccessor -> current.parameterList?.parameters?.any { parameter -> parameter.name == name } == true + is KtForExpression -> current.loopParameter?.name == name + is KtCatchClause -> current.catchParameter?.name == name + is KtWhenExpression -> current.subjectVariable?.name == name + else -> false + } + if (shadowed) return true + child = current + current = current.parent } - edits.getOrPut(file, ::linkedSetOf) += TextEdit(chain.startOffset, chain.endOffset, KotlinSourceNames.renderFqName(newFqName)) - return true + return false + } + + /** + * Tells whether this statement or member declaration binds [name]. + * + * @param name identifier to look for + * @return `true` when this declaration introduces that name in its scope + */ + private fun KtDeclaration.declaresName(name: String): Boolean = when (this) { + is KtProperty -> this.name == name + is KtNamedFunction -> this.name == name + is KtClassOrObject -> this.name == name + is KtTypeAlias -> this.name == name + is KtDestructuringDeclaration -> entries.any { entry -> entry.name == name } + else -> false } /** @@ -375,13 +618,50 @@ class KaChangePackageComputer( if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return val reference = expression.references.filterIsInstance().firstOrNull() ?: return val symbol = reference.resolveToSymbol() ?: return - val importFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) ?: return + val resolvedFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) + ?: return + // The reference may already be bound through an import alias of the + // resolved declaration; the alias directive stays valid after a move. + if (expression.isAliasBound(resolvedFqName)) return + // A call may spell a same-package type alias rather than the class it + // expands to: K2 reports the constructor of the underlying class, but + // the source keeps calling the alias, so the alias itself is imported. + val referencedName = expression.getReferencedName() + val importFqName = if (referencedName == resolvedFqName.shortName().asString()) { + resolvedFqName + } else { + originalPackage.child(Name.identifier(referencedName)) + } if (importFqName in importedFqNames || originalPackage in starPackages) return - if (expression.isPackageQualified(originalPackage)) return - if (expression.isPackageQualified(originalPackage)) return - if (expression.isAliasBound(importFqName)) return + // An extension access on a value receiver can spell the package name + // (`p.extra` on a parameter `p`), so a variable receiver keeps the + // access dependent on an import and bypasses the spelling test. + val receiverIsVariable = expression.qualifiedReceiver()?.let { receiver -> + val symbol = runCatching { + receiver.leadingReference()?.mainReference?.resolveToSymbol() + }.getOrNull() + symbol is KaVariableSymbol + } == true + if (!receiverIsVariable && expression.isPackageQualified(originalPackage)) return missingImports += importFqName } + + // Convention operators are not spelled as simple names: `box[0]` depends + // on a top-level `operator fun Box.get` that same-package visibility + // provided, so its reference has to be captured separately. + override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { + super.visitArrayAccessExpression(expression) + expression.references.filterIsInstance().forEach { reference -> + val symbol = runCatching { reference.resolveToSymbol() }.getOrNull() + ?: return@forEach + val importFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) + ?: return@forEach + if (importFqName in importedFqNames || originalPackage in starPackages) { + return@forEach + } + missingImports += importFqName + } + } }) } } @@ -422,6 +702,15 @@ class KaChangePackageComputer( if (psi?.containingFile?.virtualFile?.path in movedPaths) return null return classId.asSingleFqName() } + // A callable from a Kotlin dependency JAR has no source PSI; its id is the only way to + // tell that it is a top-level declaration of the package the moved file is leaving. + if (symbol is KaCallableSymbol) { + val callableId = symbol.callableId ?: return null + if (callableId.className != null) return null + if (callableId.packageName != originalPackage) return null + if (psi?.containingFile?.virtualFile?.path in movedPaths) return null + return callableId.asSingleFqName() + } if (psi !is KtNamedDeclaration) return null val containingFile = psi.containingKtFile if (containingFile.virtualFile?.path in movedPaths) return null @@ -446,26 +735,44 @@ class KaChangePackageComputer( } /** - * Tells whether [this] is the selector of a qualified access whose receiver already spells out - * [packageFqName], in which case the reference resolves without an import. + * Returns the receiver of the qualified access this expression selects. * * A callee is wrapped in a [KtCallExpression] (`a.Helper(1)`), so the qualified access is found * through that call node rather than directly through the callee's parent. A type reference is * wrapped in a [KtUserType] whose qualifier spells the receiver (`a.Helper`). + * + * @return the receiver expression, or `null` when this name is not a qualified selector */ - private fun KtSimpleNameExpression.isPackageQualified(packageFqName: FqName): Boolean { + private fun KtSimpleNameExpression.qualifiedReceiver(): KtElement? { val selector = (parent as? KtCallExpression)?.takeIf { it.calleeExpression == this } ?: this - val receiverText = when (val qualified = selector.parent as? KtDotQualifiedExpression) { + return when (val qualified = selector.parent as? KtDotQualifiedExpression) { null -> { val userType = parent as? KtUserType - if (userType == null || userType.referenceExpression != this) return false - userType.qualifier?.text ?: return false + if (userType == null || userType.referenceExpression != this) return null + userType.qualifier ?: return null } else -> { - if (qualified.selectorExpression != selector) return false - qualified.receiverExpression.text + if (qualified.selectorExpression != selector) return null + qualified.receiverExpression } } + } + + /** + * Tells whether [this] is the selector of a qualified access whose receiver spells out + * [packageFqName], in which case the reference resolves without an import. + * + * The receiver text alone cannot prove that it denotes the package: an extension access such as + * `p.extra` on a parameter named `p` spells the package of the top-level extension property it + * resolves to while actually using a value receiver. Callers therefore resolve + * [qualifiedReceiver]'s leftmost name first and treat a variable symbol as an ordinary receiver. + * + * @param packageFqName package the reference may already name in full + * @return `true` when the receiver text is that package or a prefix of it + */ + private fun KtSimpleNameExpression.isPackageQualified(packageFqName: FqName): Boolean { + val receiver = qualifiedReceiver() ?: return false + val receiverText = receiver.text val packageName = packageFqName.asString() return packageName == receiverText || packageName.startsWith("$receiverText.") } @@ -513,8 +820,10 @@ class KaChangePackageComputer( * The offset refers to the pre-mutation text. A file with imports gains new lines at its first * directive; otherwise the insertion follows the package directive or, for a root-package * file, precedes the first declaration so header comments and file annotations stay on top. - * Only real PSI import directives and the package directive are consulted, so text inside - * literals or comments cannot influence either the duplicate check or the boundary. + * A root-package file without declarations (for example one holding only file annotations) + * appends the imports at its end. Only real PSI import directives and the package directive + * are consulted, so text inside literals or comments cannot influence either the duplicate + * check or the boundary. * * @param file participant whose original PSI is still valid * @return captured insertion point, or `null` when the file cannot host an import @@ -534,7 +843,13 @@ class KaChangePackageComputer( if (directive.text.isBlank()) return null return ImportInsertion(existingFqNames, directive.textRange.endOffset, "\n\n", "") } - val declarationStart = file.declarations.firstOrNull()?.textRange?.startOffset ?: return null + // A root-package file may contain only file annotations. Kotlin allows imports after the + // annotation list, so the end of the file is a valid insertion point there. + val declarationStart = file.declarations.firstOrNull()?.textRange?.startOffset + if (declarationStart == null) { + val prefix = if (file.text.endsWith("\n")) "\n" else "\n\n" + return ImportInsertion(existingFqNames, file.textLength, prefix, "") + } return ImportInsertion(existingFqNames, declarationStart, "", "\n\n") } @@ -587,17 +902,20 @@ class KaChangePackageComputer( * Paths are matched by FQN prefix, so an import of a nested declaration keeps its suffix, and * only directives that actually target a moved declaration are recorded. A wildcard import * follows only when its prefix itself is a moved class; a package wildcard keeps binding - * declarations that stay behind. References themselves are captured separately from - * K2-resolved usages. + * declarations that stay behind and is retargeted or dropped only when no source file remains + * in its package. References themselves are captured separately from K2-resolved usages. * * @param participants files whose text may change * @param movedDeclarations old top-level declaration FQNs mapped to their new FQNs + * @param movedClassFqNames original FQNs of moved classes and objects + * @param movedPaths paths of the files whose package changes * @return per-file replacements keyed by captured source offsets */ private fun captureImportEdits( participants: Collection, movedDeclarations: Map, movedClassFqNames: Set, + movedPaths: Set, ): Map> { if (movedDeclarations.isEmpty()) return emptyMap() val mappings = movedDeclarations.entries @@ -611,12 +929,17 @@ class KaChangePackageComputer( val pathStart = directive.textRange.startOffset + importPathStart(directive.text) val pathEnd = importedReference.textRange.endOffset if (pathStart >= pathEnd) return@forEach - val replacement = if (directive.isAllUnder) { + if (directive.isAllUnder) { // A package wildcard keeps importing whatever stays behind, so only a // wildcard rooted in a moved class (or a class nested inside it) follows // that class to its new package, keeping the nested suffix and `.*`. val imported = directive.importedFqName ?: return@forEach - val movedClass = longestMovedClassPrefix(imported, movedClassFqNames) ?: return@forEach + val movedClass = longestMovedClassPrefix(imported, movedClassFqNames) + if (movedClass == null) { + obsoletePackageWildcardEdit(file, directive, imported, movedDeclarations, movedPaths) + ?.let { edit -> edits.getOrPut(file, ::linkedSetOf) += edit } + return@forEach + } val newPrefix = movedDeclarations[movedClass] ?: return@forEach val suffixSegments = imported.pathSegments().drop(movedClass.pathSegments().size) val suffixText = if (suffixSegments.isEmpty()) { @@ -626,20 +949,70 @@ class KaChangePackageComputer( KotlinSourceNames.renderIdentifier(segment.asString()) } } - KotlinSourceNames.renderFqName(newPrefix) + suffixText - } else { - rewriteImportedPath( - directive.identifiersWithin(pathStart, pathEnd), - mappings, - movedClassFqNames, - ) ?: return@forEach + edits.getOrPut(file, ::linkedSetOf) += + TextEdit(pathStart, pathEnd, KotlinSourceNames.renderFqName(newPrefix) + suffixText) + return@forEach } + val replacement = rewriteImportedPath( + directive.identifiersWithin(pathStart, pathEnd), + mappings, + movedClassFqNames, + ) ?: return@forEach edits.getOrPut(file, ::linkedSetOf) += TextEdit(pathStart, pathEnd, replacement) } } return edits.mapValues { (_, fileEdits) -> fileEdits.toList() } } + /** + * Builds the edit that keeps a package wildcard resolvable when its package loses every source. + * + * A wildcard import of a package that no longer exists is an unresolved reference even when + * every moved usage was retargeted. When all declarations that left [imported] gathered in one + * new package, the wildcard follows them and keeps binding the same names. Otherwise the + * directive is removed; the incoming pass already captured an explicit import for every moved + * declaration the caller uses. + * + * @param file file containing the directive + * @param directive wildcard import whose prefix is not a moved class + * @param imported package named by the wildcard + * @param movedDeclarations old top-level declaration FQNs mapped to their new FQNs + * @param movedPaths paths of the files whose package changes + * @return replacement edit, or `null` when the package keeps declarations or nothing moved + */ + private fun obsoletePackageWildcardEdit( + file: KtFile, + directive: KtImportDirective, + imported: FqName, + movedDeclarations: Map, + movedPaths: Set, + ): TextEdit? { + val movedFromPackage = movedDeclarations.filterKeys { oldFqName -> oldFqName.parent() == imported } + if (movedFromPackage.isEmpty()) return null + val service = KotlinMoveUsageSearchService.getInstance() ?: return null + if (service.hasSourceFilesInPackage(ktFile.project, imported, movedPaths)) return null + val targetPackages = movedFromPackage.values.map { newFqName -> newFqName.parent() }.distinct() + if (targetPackages.size == 1) { + val pathEnd = directive.importedReference?.textRange?.endOffset ?: return null + return TextEdit( + directive.textRange.startOffset + importPathStart(directive.text), + pathEnd, + KotlinSourceNames.renderFqName(targetPackages.single()), + ) + } + // The package splits into several target packages, so no single wildcard keeps the + // bindings. Remove the directive together with the rest of its line. + val text = file.text + var end = directive.textRange.endOffset + while (end < text.length && + (text[end] == ' ' || text[end] == '\t' || text[end] == '\r' || text[end] == '\n') + ) { + end++ + } + val start = directive.textRange.startOffset + return if (start < end) TextEdit(start, end, "") else null + } + /** * Returns the deepest moved class whose FQN is [imported] or a prefix of it. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index daf556b6f..d8a371d69 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -36,6 +36,7 @@ import org.jetbrains.kotlin.psi.KtConstructorDelegationReferenceExpression import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtImportDirective +import org.jetbrains.kotlin.psi.KtLabelReferenceExpression import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty @@ -379,8 +380,14 @@ class KaRenameComputer( val constructsTarget = targetClassId != null && (sym as? KaConstructorSymbol)?.containingClassId == targetClassId if (sym.psi == targetPsi || constructsTarget) { - val tr = expression.textRange - ranges.add(tr.startOffset to tr.endOffset) + // A receiver label such as `this@Foo` spans the `@` as well; replacing + // the whole element would drop it and leave `thisBar`. + val range = if (expression is KtLabelReferenceExpression) { + expression.getReferencedNameElement()?.textRange ?: expression.textRange + } else { + expression.textRange + } + ranges.add(range.startOffset to range.endOffset) } } }) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt new file mode 100644 index 000000000..2d1f25459 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -0,0 +1,157 @@ +/******************************************************************************* + * 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.PsiElement +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol +import org.jetbrains.kotlin.idea.references.KtReference +import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtImportDirective +import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtSimpleNameExpression +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtUserType + +/** + * Detects references that inserting an explicit import would silently retarget. + * + * An explicit import takes precedence over wildcard imports, other explicit imports are a + * conflicting import, and imports also win over declarations of the importing file's own package. + * A refactoring that adds or rewrites an import must therefore prove that every unqualified + * reference the imported name could capture already points at the declaration it is meant for; + * otherwise the refactoring would change which declaration is called while still compiling. + */ +object KotlinImportBindingCheck { + + /** + * Tells whether importing [name] into [file] would capture a reference to another declaration. + * + * Only unqualified references are considered: a selector keeps resolving through its receiver + * whatever the file imports. Locals, parameters, and members keep binding to themselves as + * well, so only symbols that an import can actually shadow count. + * + * @param file caller that is about to import a declaration named [name] + * @param name simple name the import binds + * @param excludedDeclarations declarations the import is meant to retarget; references that + * resolve to one of them, or to a constructor of a class among them, are not conflicts + * @return `true` when some unqualified reference in [file] would change its target + */ + fun hasConflictingReference( + file: KtFile, + name: String, + excludedDeclarations: Set, + ): Boolean = runCatching { + analyze(file) { + var conflicting = false + file.accept(object : KtTreeVisitorVoid() { + override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { + super.visitSimpleNameExpression(expression) + if (conflicting) return + if (expression.getReferencedName() != name) return + if (expression.isQualifiedSelector()) return + if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return + val symbol = expression.references.filterIsInstance().firstOrNull() + ?.resolveToSymbol() ?: return + if (symbol.belongsTo(excludedDeclarations)) return + if (!symbol.isTopLevelBinding()) return + conflicting = true + } + }) + conflicting + } + }.getOrDefault(false) + + /** + * Tells whether this symbol is one of the declarations whose references the import retargets. + * + * A constructor call resolves to a constructor whose PSI belongs to the constructed class, so + * the enclosing classifier is compared as well. + * + * @param excludedDeclarations PSI of the declarations the import is meant for + * @return `true` when this symbol is one of them or a constructor of one + */ + private fun KaSymbol.belongsTo(excludedDeclarations: Set): Boolean { + val element = psi ?: return false + if (element in excludedDeclarations) return true + val enclosingClass = element.enclosingClassOrObject() + return enclosingClass != null && enclosingClass in excludedDeclarations + } + + /** + * Tells whether an explicit import could change how this symbol is referenced. + * + * Only top-level declarations compete with an import: locals, parameters, and members are + * resolved lexically or through their receiver and keep binding to themselves. + * + * @return `true` for a top-level class, constructor, callable, or property + */ + private fun KaSymbol.isTopLevelBinding(): Boolean = when (this) { + is KaConstructorSymbol -> containingClassId?.let { classId -> !classId.isLocal } == true + is KaCallableSymbol -> callableId?.let { callableId -> callableId.className == null } == true + is KaClassLikeSymbol -> classId?.let { classId -> !classId.isLocal } == true + is KaVariableSymbol -> psi is KtProperty && psi?.parent is KtFile + else -> false + } + + /** + * Returns the innermost class or object containing this element, or `null` at file level. + * + * @return the enclosing classifier, or `null` when this element is a top-level declaration + */ + private fun PsiElement.enclosingClassOrObject(): KtClassOrObject? { + var current: PsiElement? = this + while (current != null && current !is KtFile) { + if (current is KtClassOrObject) return current + current = current.parent + } + return null + } + + /** + * Tells whether this name is the selector of a qualified access. + * + * Only an unqualified name is rebound by an import; `receiver.name` keeps resolving through + * its receiver whatever the file imports. + * + * @return `true` when this expression follows a dot or a type qualifier + */ + private fun KtSimpleNameExpression.isQualifiedSelector(): Boolean { + (parent as? KtCallExpression) + ?.takeIf { call -> call.calleeExpression == this } + ?.let { call -> + (call.parent as? KtDotQualifiedExpression)?.let { qualified -> + return qualified.selectorExpression == call + } + } + (parent as? KtDotQualifiedExpression)?.let { qualified -> + return qualified.selectorExpression == this + } + return (parent as? KtUserType)?.let { userType -> + userType.referenceExpression == this && userType.qualifier != null + } == true + } +} 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 a659fe119..e15ec63af 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 @@ -60,6 +60,24 @@ interface KotlinMoveUsageSearchService { */ fun findWildcardImportingFiles(project: com.intellij.openapi.project.Project, classFqNames: Set): List = emptyList() + /** + * Tells whether a source file other than [excludedPaths] declares [packageFqName]. + * + * A package wildcard import becomes unresolved when the last source file leaves its package, + * so the package-change pass retargets or drops such imports only when nothing remains. The + * default assumes declarations remain, which leaves wildcard imports untouched. + * + * @param project IntelliJ project whose standalone K2 session supplies candidate files + * @param packageFqName package whose remaining declarations are checked + * @param excludedPaths paths of files whose package is changing in this operation + * @return `true` when at least one other source file still declares [packageFqName] + */ + fun hasSourceFilesInPackage( + project: com.intellij.openapi.project.Project, + packageFqName: FqName, + excludedPaths: Set, + ): Boolean = true + companion object { fun getInstance(): KotlinMoveUsageSearchService? = ApplicationManager.getApplication()?.getService(KotlinMoveUsageSearchService::class.java) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 743da5cf2..a58a2e044 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 1c337e426..c01d3e4f1 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 @@ -100,11 +100,12 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { * * A file importing `old.Outer.Nested` names the moved class `old.Outer` only as a path prefix, * so no `Nested` usage resolves to the moved declaration and usage search cannot discover the - * file. Such imports are matched here by their moved-declaration prefix. + * file. Such imports are matched here by their moved-declaration prefix. Aliased directives + * are included as well: their path still follows the moved declaration while the alias stays. * * @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 + * @return session files with a direct import that needs rewriting */ override fun findImportingFiles( project: com.intellij.openapi.project.Project, @@ -116,7 +117,7 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { 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 -> + !directive.isAllUnder && directive.importedFqName?.let { imported -> importsMovedDeclaration(imported, movedImports) } == true } @@ -180,6 +181,26 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { } } + /** + * Tells whether any session source outside [excludedPaths] still declares [packageFqName]. + * + * @param project IntelliJ project owning the standalone K2 session + * @param packageFqName package whose remaining declarations are checked + * @param excludedPaths paths of files whose package is changing in this operation + * @return `true` when at least one other source file still declares [packageFqName] + */ + override fun hasSourceFilesInPackage( + project: com.intellij.openapi.project.Project, + packageFqName: FqName, + excludedPaths: Set, + ): Boolean { + val session = KotlinAnalysisAPISession.forProject(project) ?: return true + return session.fileMap.values.any { lightFile -> + lightFile.path !in excludedPaths && + session.getKtFileForPath(lightFile.path)?.packageFqName == packageFqName + } + } + /** * Tells whether this FQN equals [fqName] or is one of its package/class prefixes. * diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index d8e6706af..32c667a83 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -21,15 +21,18 @@ import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceGrou import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import io.github.nbplugins.kotlin.refactoring.KaChangePackageComputer import io.github.nbplugins.kotlin.refactoring.KaRenameComputer +import io.github.nbplugins.kotlin.refactoring.KotlinImportBindingCheck import io.github.nbplugins.kotlin.refactoring.KotlinSourceNames import io.github.nbplugins.kotlin.refactoring.TextChange import org.jetbrains.kotlin.builder.KotlinPsiManager import org.jetbrains.kotlin.log.KotlinLogger import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNamedDeclaration +import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.utils.ProjectUtils import org.netbeans.api.project.Project @@ -104,13 +107,21 @@ class KotlinRenameFilePlugin( ?: return null val files = KotlinPsiManager.getFilesByProject(project) .mapNotNull { file -> session.getKtFileForPath(file.path) } - val conflict = findClassifierConflict(source, primaryClass, refactoring.newName, files) + val conflict = findRenameConflict(source, primaryClass, refactoring.newName, files) + if (conflict != null) { + val packageName = source.packageFqName + val location = if (packageName.isRoot) "the root package" else "package ${packageName.asString()}" + return Problem( + true, + "The Kotlin name ${refactoring.newName} is already declared in $location (${conflict.containingKtFile.name}).", + ) + } + val caller = findCallerScopeConflict(source, primaryClass, refactoring.newName, files) ?: return null - val packageName = source.packageFqName - val location = if (packageName.isRoot) "the root package" else "package ${packageName.asString()}" return Problem( true, - "The Kotlin name ${refactoring.newName} is already declared in $location (${conflict.containingKtFile.name}).", + "Renaming the Kotlin class ${primaryClass.name} to ${refactoring.newName} would change " + + "which ${refactoring.newName} the references in ${caller.name} use.", ) } } @@ -179,8 +190,13 @@ class KotlinRenameFileApplyElement( val primaryClass = source.declarations.filterIsInstance() .firstOrNull { it.name == refactoring.file.name } ?: return + // The session knows every registered source, including custom Gradle source roots outside + // `src`, which `KotlinPsiManager.getFilesByProject` does not walk. Search all of them so + // the file's own constructor calls are retargeted as well. + val allKtFiles = session.fileMap.values + .mapNotNull { lightFile -> session.getKtFileForPath(lightFile.path) } + .toSet() + source val allFiles = KotlinPsiManager.getFilesByProject(project) - val allKtFiles = allFiles.mapNotNull { session.getKtFileForPath(it.path) }.toSet() val changes = KaRenameComputer( source, primaryClass.nameIdentifier?.textRange?.startOffset ?: return, @@ -512,10 +528,12 @@ private fun validateClassName(name: String, updatePrimaryClass: Boolean): Proble } /** - * Finds a top-level classifier that already uses [newName] in the package of [source]. + * Finds a top-level declaration that already owns [newName] in the package of [source]. * * Renaming a file together with its primary class writes the new name verbatim, so a class, - * object, or type alias that already owns that name in the same package would be redeclared. + * object, or type alias that already has that name in the same package would be redeclared. A + * top-level function with the same call signature as the renamed primary constructor conflicts + * with it as well: `class Before` plus `fun After()` in one package cannot both become `After`. * * @param source file whose matching class is renamed * @param primaryClass top-level class renamed together with the file @@ -523,22 +541,80 @@ private fun validateClassName(name: String, updatePrimaryClass: Boolean): Proble * @param files Kotlin sources of the project * @return the first conflicting declaration, or `null` when the name is free */ -internal fun findClassifierConflict( +internal fun findRenameConflict( source: KtFile, primaryClass: KtClass, newName: String, files: Collection, ): KtNamedDeclaration? { val packageName = source.packageFqName + val constructorParameterTypes = primaryClass.primaryConstructorParameters + .map { parameter -> parameter.typeReference?.text } return files.asSequence() .filter { file -> file.packageFqName == packageName } .flatMap { file -> file.declarations.asSequence() } - .filter { declaration -> declaration is KtClassOrObject || declaration is KtTypeAlias } + .filter { declaration -> + declaration is KtClassOrObject || declaration is KtTypeAlias || + (declaration is KtNamedFunction && + declaration.conflictsWithConstructor(constructorParameterTypes)) + } .map { declaration -> declaration as KtNamedDeclaration } .filter { declaration -> !declaration.isSameDeclaration(primaryClass) } .firstOrNull { declaration -> declaration.name == newName } } +/** + * Finds a caller whose existing references would resolve differently after the import rewrite. + * + * Renaming the primary class turns `import p.Before` into `import p.After`. A caller that already + * refers to a different top-level `After` — from its own package or another import — keeps those + * references untouched during discovery, because they do not resolve to the renamed class. The + * rewritten import takes precedence over them afterwards, so they silently start using the + * renamed class. The rename is rejected instead of changing unrelated call sites. + * + * @param source file whose primary class is renamed + * @param primaryClass class renamed together with the file + * @param newName requested class name + * @param files Kotlin sources of the project + * @return the first caller whose references would change their target, or `null` when none would + */ +internal fun findCallerScopeConflict( + source: KtFile, + primaryClass: KtClass, + newName: String, + files: Collection, +): KtFile? { + val className = primaryClass.name ?: return null + val oldFqName = source.packageFqName.child(Name.identifier(className)) + return files.firstOrNull { file -> + file.virtualFile?.path != source.virtualFile?.path && + file.importDirectives.any { directive -> + !directive.isAllUnder && directive.aliasName == null && + directive.importedFqName == oldFqName + } && + KotlinImportBindingCheck.hasConflictingReference(file, newName, setOf(primaryClass)) + } +} + +/** + * Tells whether this top-level function has the same call signature as a primary constructor. + * + * Matching parameter count and type spellings is what makes Kotlin report conflicting overloads. + * An extension function has an implicit receiver and therefore cannot conflict with a constructor. + * + * @param constructorParameterTypes type spellings of the renamed primary constructor parameters + * @return `true` when a call to either declaration could not be told apart + */ +private fun KtNamedFunction.conflictsWithConstructor(constructorParameterTypes: List): Boolean { + if (receiverTypeReference != null) return false + if (valueParameters.size != constructorParameterTypes.size) return false + return valueParameters.indices.all { index -> + val functionType = valueParameters[index].typeReference?.text + val constructorType = constructorParameterTypes[index] + functionType == null || constructorType == null || functionType == constructorType + } +} + /** Tells whether this declaration is [primaryClass], tolerating distinct PSI instances of it. */ private fun KtNamedDeclaration.isSameDeclaration(primaryClass: KtClass): Boolean = this === primaryClass || diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index e2231e81d..16248c984 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -505,6 +505,7 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { 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(sourceDirectory.resolve("Stayed.kt"), "package old.source\n\nfun stayed() = 0\n") Files.writeString(usagePath, "package usage\n\nimport old.source.*\n\nfun use() = moved()\n") val session = KotlinAnalysisAPISession.createWithJars( moduleName = "change-package-wildcard-integration", @@ -557,6 +558,7 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { val usagePath = usageDirectory.resolve("Usage.kt") val otherPath = otherDirectory.resolve("Other.kt") Files.writeString(sourcePath, "package a\n\nfun moved() = 1\n") + Files.writeString(sourceDirectory.resolve("Stayed.kt"), "package a\n\nfun stayed() = 0\n") Files.writeString(usagePath, "package call\n\nimport a.*\nimport c.moved\n\nfun use() = a.moved() + moved()\n") Files.writeString(otherPath, "package c\n\nfun moved() = 2\n") val session = KotlinAnalysisAPISession.createWithJars( @@ -606,6 +608,7 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { val usagePath = usageDirectory.resolve("Usage.kt") val otherPath = otherDirectory.resolve("Other.kt") Files.writeString(sourcePath, "package a\n\nfun moved() = 1\n") + Files.writeString(sourceDirectory.resolve("Stayed.kt"), "package a\n\nfun stayed() = 0\n") Files.writeString( usagePath, "package call\n\nimport a.*\nimport a.moved as alias\nimport c.moved\n\nfun use() = alias() + moved()\n", @@ -2146,6 +2149,499 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a fully qualified caller of a declaration that moves into the root package gets an + * explicit import. + * + * Replacing `old.moved()` with a root-package `moved()` leaves a bare name, and named packages + * do not see root-package declarations implicitly. + */ + fun testApply_realSession_addsImportWhenQualifiedTargetBecomesRootPackageName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-root-target") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString(usagePath, "package usage\n\nfun use() = old.moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-root-target-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.ROOT), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A root-package target must be imported into a named package", + "package usage\n\nimport moved\n\nfun use() = moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a rewritten qualified reference avoids a new package segment captured by a parameter. + * + * `dest.moved()` would resolve `dest` to the parameter instead of the package and silently + * call a member, so the reference must be shortened and the declaration imported instead. + */ + fun testApply_realSession_importsInsteadOfQualifyingThroughShadowedPackageName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-shadowed-package") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nclass Holder { fun moved() = 2 }\n" + + "fun use(dest: Holder) = old.moved()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-shadowed-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A shadowed package name must fall back to an import", + "package usage\n\nimport dest.moved\n\nclass Holder { fun moved() = 2 }\n" + + "fun use(dest: Holder) = moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies the package change is rejected when neither the package nor the bare name is usable. + * + * Rewriting the reference either way would call another declaration, so nothing may be staged. + */ + fun testApply_realSession_conflictsWhenPackageAndNameAreBothShadowed() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-shadowed-both") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nclass Holder { fun moved() = 2 }\n" + + "fun use(dest: Holder, moved: Int) = old.moved() + moved\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-shadowed-both-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the shadowed caller: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an explicit import would not silently retarget an unmoved overload. + * + * A wildcard import keeps both `choose` overloads visible, and the explicit import of the moved + * one would take precedence over the overload that stays behind. + */ + fun testApply_realSession_conflictsWhenImportWouldRebindUnmovedOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-overload-rebind") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + val otherPath = temporaryRoot.resolve("old/Int.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun choose(n: Number) = 1\n") + Files.writeString(otherPath, "package old\n\nfun choose(n: Int) = 2\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use() = choose(1) + choose(1.5)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-overload-rebind-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the rebound overload: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an index access imports both the class and the convention operator left behind. + * + * `box[0]` depends on a top-level `operator fun Box.get` that same-package visibility provided + * and that has no simple-name node of its own. + */ + fun testApply_realSession_addsImportForOutgoingArrayAccessOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-array-access") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(sourcePath, "package p\n\nfun f(box: Box) = box[0]\n") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\noperator fun Box.get(index: Int) = index\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-array-access-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("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An index access needs the operator dependency imported as well", + "package q\n\nimport p.Box\nimport p.get\n\nfun f(box: Box) = box[0]\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a default-package caller holding only a file annotation receives the import it needs. + * + * A file annotation is not a declaration, so the file has no declaration boundary; the import + * has to be appended at the end instead. + */ + fun testApply_realSession_insertsImportIntoAnnotationOnlyRootFile() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-annotation-only") + try { + Files.createDirectories(temporaryRoot.resolve("source")) + val sourcePath = temporaryRoot.resolve("source/Marker.kt") + val usagePath = temporaryRoot.resolve("source/Usage.kt") + Files.writeString(sourcePath, "annotation class Marker\n") + Files.writeString(usagePath, "@file:OptIn(Marker::class)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-annotation-only-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An annotation-only caller must receive the import at its end", + "@file:OptIn(Marker::class)\n\nimport dest.Marker", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a wildcard import is dropped when its package empties into several new packages. + * + * No single retargeted wildcard keeps the bindings, so explicit imports replace it and the + * now unresolved directive is removed. + */ + fun testApply_realSession_removesObsoletePackageWildcardWhenPackageSplits() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-split") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val firstPath = temporaryRoot.resolve("old/First.kt") + val secondPath = temporaryRoot.resolve("old/Second.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(firstPath, "package old\n\nfun a() = 1\n") + Files.writeString(secondPath, "package old\n\nfun b() = 2\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use() = a() + b()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-split-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val first = session.getKtFileForPath(firstPath.toString()) ?: error("Could not obtain first PSI") + val second = session.getKtFileForPath(secondPath.toString()) ?: error("Could not obtain second PSI") + + val outcome = KaChangePackageComputer(first).apply( + linkedMapOf(first to FqName("dest1"), second to FqName("dest2")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An emptied package wildcard must be removed in favour of explicit imports", + "package usage\n\nimport dest1.a\nimport dest2.b\nfun use() = a() + b()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an aliased nested import keeps the caller a participant of the package change. + * + * The alias hides the usage from K2 reference discovery, and only the import path names the + * moved class, so the directive itself has to be retargeted. + */ + fun testApply_realSession_retargetsAliasedNestedImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-aliased-nested") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Outer.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nclass Outer { class Inner }\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.Outer.Inner as I\n\nval value: I? = null\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-aliased-nested-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An aliased nested import must follow the moved enclosing class", + "package usage\n\nimport dest.Outer.Inner as I\n\nval value: I? = null\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an extension access on a variable named like the package still needs its import. + * + * `p.extra` spells the extension property's package, but `p` is the function's parameter, not + * the package. Treating the spelling as a qualifier would drop the import the file now needs. + */ + fun testApply_realSession_keepsExtensionAccessOnValueReceiverNamedLikePackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing-receiver") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val movedPath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(movedPath, "package p\n\nfun read(p: String) = p.extra\n") + Files.writeString( + temporaryRoot.resolve("p/Extra.kt"), + "package p\n\nval String.extra: Int get() = length\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-outgoing-receiver-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(movedPath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A value receiver spelled like the package does not make the access self-sufficient", + "package q\n\nimport p.extra\n\nfun read(p: String) = p.extra\n", + success.changedFiles[movedPath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a constructor call through a type alias imports the alias, not the expanded class. + * + * The call still spells `Alias`, so importing `Impl` would leave it unresolved even though K2 + * reports the underlying constructor. + */ + fun testApply_realSession_importsTypeAliasUsedByConstructorCall() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-alias-constructor") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val movedPath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString(movedPath, "package old\n\nfun make() = Alias()\n") + Files.writeString( + temporaryRoot.resolve("old/Alias.kt"), + "package old\n\ntypealias Alias = Impl\n\nclass Impl\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-alias-constructor-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(movedPath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The referenced alias itself must be imported", + "package dest\n\nimport old.Alias\n\nfun make() = Alias()\n", + success.changedFiles[movedPath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a top-level callable read from a dependency JAR still receives its import. + * + * Binary declarations have no Kotlin source PSI, so the dependency on the old package can only + * be recognised through the callable id. The stdlib function `listOf` lives in + * `kotlin.collections`, which this file declares as its package before the move. + */ + fun testApply_realSession_importsBinaryTopLevelCallableFromOldPackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-binary-callable") + try { + val movedPath = temporaryRoot.resolve("Moved.kt") + Files.writeString(movedPath, "package kotlin.collections\n\nfun make() = listOf(1)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-binary-callable-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(movedPath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A binary top-level callable is only identifiable through its callable id", + "package dest\n\nimport kotlin.collections.listOf\n\nfun make() = listOf(1)\n", + success.changedFiles[movedPath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index d11c7e64a..d410de2bd 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -435,6 +435,46 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a labeled `this` receiver keeps its `@` while the label follows the rename. + * + * The label reference spans `@Foo`, but only the identifier is replaced; otherwise the edit + * would produce `thisBar`. + */ + fun testRenameClassKeepsLabelPrefix() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping receiver-label rename test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-receiver-label") + try { + val sourceDirectory = root.resolve("source") + Files.createDirectories(sourceDirectory) + val sourcePath = sourceDirectory.resolve("Foo.kt") + Files.writeString( + sourcePath, + "package a\n\nclass Foo {\n fun self() = this@Foo\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-receiver-label-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val changes = KaRenameComputer(source, source.text.indexOf("Foo"), setOf(source), "Bar").compute() + + assertNotNull("compute() must return non-null for a receiver label", changes) + assertEquals( + "Only the label identifier follows the rename, the @ prefix stays", + "package a\n\nclass Bar {\n fun self() = this@Bar\n}\n", + applyChanges(source.text, changes!![sourcePath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt new file mode 100644 index 000000000..e13971369 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -0,0 +1,150 @@ +/******************************************************************************* + * 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.KotlinImportBindingCheck +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtFile +import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** Integration tests for the import-binding collision check used by the refactorings. */ +class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTest", "moveFile") { + + /** Verifies an unqualified reference to another top-level declaration is reported. */ + fun testHasConflictingReference_reportsUnqualifiedBinding() { + withSession( + name = "unqualified", + prepare = { root -> + Files.createDirectories(root.resolve("other")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("other/Types.kt"), "package q\n\nclass Bar\n") + Files.writeString(root.resolve("usage/Usage.kt"), "package usage\n\nimport q.Bar\n\nfun use() = Bar()\n") + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + assertTrue( + "an unqualified Bar() would be rebound by the import", + KotlinImportBindingCheck.hasConflictingReference(usage, "Bar", emptySet()), + ) + } + } + + /** Verifies a reference the import is meant to retarget is not reported. */ + fun testHasConflictingReference_ignoresExcludedDeclaration() { + withSession( + name = "excluded", + prepare = { root -> + Files.createDirectories(root.resolve("other")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("other/Types.kt"), "package q\n\nclass Bar\n") + Files.writeString(root.resolve("usage/Usage.kt"), "package usage\n\nimport q.Bar\n\nfun use() = Bar()\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("other/Types.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val declared = types.declarations.filterIsInstance().single() + + assertFalse( + "the import exists to retarget this very reference", + KotlinImportBindingCheck.hasConflictingReference(usage, "Bar", setOf(declared)), + ) + } + } + + /** Verifies a qualified access keeps resolving through its receiver. */ + fun testHasConflictingReference_ignoresQualifiedAccess() { + withSession( + name = "qualified", + prepare = { root -> + Files.createDirectories(root.resolve("other")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("other/Types.kt"), "package q\n\nclass Bar\n") + Files.writeString(root.resolve("usage/Usage.kt"), "package usage\n\nfun use() = q.Bar()\n") + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + assertFalse( + "a qualified access is not rebound by an import", + KotlinImportBindingCheck.hasConflictingReference(usage, "Bar", emptySet()), + ) + } + } + + /** Verifies a local binding that shadows the name is not an import collision. */ + fun testHasConflictingReference_ignoresLocalBinding() { + withSession( + name = "local", + prepare = { root -> + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nfun use(): Int { val Bar = 1; return Bar }\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + assertFalse( + "a local variable shadowing the name is unaffected by an import", + KotlinImportBindingCheck.hasConflictingReference(usage, "Bar", emptySet()), + ) + } + } + + /** @return the session source for [path], failing the test when it is absent. */ + private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = + getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") + + /** + * Writes files through [prepare], then runs [check] against a session created afterwards. + * + * The session snapshots its source roots at creation, so files must exist before it is built. + */ + private fun withSession( + name: String, + prepare: (Path) -> Unit, + check: (KotlinAnalysisAPISession, Path) -> Unit, + ) { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping import binding integration test") + return + } + val root = Files.createTempDirectory("nbkotlin-import-binding-$name") + try { + prepare(root) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "import-binding-$name-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + check(session, root) + } finally { + root.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/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt index 6df68ccf8..99b0381ba 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -39,7 +39,7 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie val source = session.ktFile(root.resolve("source/Foo.kt")) val types = session.ktFile(root.resolve("source/Types.kt")) - val conflict = findClassifierConflict(source, primaryClass(source), "Bar", listOf(source, types)) + val conflict = findRenameConflict(source, primaryClass(source), "Bar", listOf(source, types)) assertEquals("Bar", conflict?.name) } @@ -59,7 +59,7 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie val source = session.ktFile(root.resolve("source/Foo.kt")) val other = session.ktFile(root.resolve("other/Types.kt")) - val conflict = findClassifierConflict(source, primaryClass(source), "Bar", listOf(source, other)) + val conflict = findRenameConflict(source, primaryClass(source), "Bar", listOf(source, other)) assertNull("another package cannot collide with the renamed class", conflict) } @@ -76,7 +76,7 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie ) { session, root -> val source = session.ktFile(root.resolve("source/Foo.kt")) - val conflict = findClassifierConflict(source, primaryClass(source), "Foo", listOf(source)) + val conflict = findRenameConflict(source, primaryClass(source), "Foo", listOf(source)) assertNull("the declaration being renamed must not conflict with itself", conflict) } @@ -95,12 +95,113 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie val source = session.ktFile(root.resolve("source/Foo.kt")) val aliases = session.ktFile(root.resolve("source/Aliases.kt")) - val conflict = findClassifierConflict(source, primaryClass(source), "Bar", listOf(source, aliases)) + val conflict = findRenameConflict(source, primaryClass(source), "Bar", listOf(source, aliases)) assertEquals("Bar", conflict?.name) } } + /** + * Verifies a top-level function callable the same way as the new constructor blocks the rename. + * + * A zero-argument `fun Bar()` and a renamed `class Before` becoming `class Bar` cannot coexist + * in one package, since both are called as `Bar()`. + */ + fun testFindRenameConflict_reportsSameSignatureFunction() { + withSession( + name = "function", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar() = 1\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + val conflict = findRenameConflict(source, primaryClass(source), "Bar", listOf(source, functions)) + + assertEquals("Bar", conflict?.name) + } + } + + /** Verifies a function with a different signature does not conflict with the constructor. */ + fun testFindRenameConflict_ignoresFunctionWithDifferentSignature() { + withSession( + name = "function-signature", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: Int) = n\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + val conflict = findRenameConflict(source, primaryClass(source), "Bar", listOf(source, functions)) + + assertNull("a function taking parameters cannot collide with a no-arg constructor", conflict) + } + } + + /** + * Verifies a caller whose existing binding would be shadowed by the rewritten import blocks + * the rename. + * + * The caller imports `p.Foo` and already calls `q.Bar` from its own package. After the rename + * its import becomes `p.Bar`, which takes precedence over `q.Bar` without rewriting those + * call sites. + */ + fun testFindCallerScopeConflict_reportsReboundSamePackageClass() { + withSession( + name = "caller-scope", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("other")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("other/Types.kt"), "package q\n\nclass Bar\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package q\n\nimport p.Foo\n\nfun use() = Foo(1) + Bar()\n", + ) + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val types = session.ktFile(root.resolve("other/Types.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findCallerScopeConflict( + source, + primaryClass(source), + "Bar", + listOf(source, types, usage), + ) + + assertEquals("Usage.kt", conflict?.virtualFile?.name) + } + } + + /** Verifies a caller that never mentions the new name is not reported. */ + fun testFindCallerScopeConflict_ignoresCallerWithoutNewName() { + withSession( + name = "caller-scope-clean", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("usage/Usage.kt"), "package q\n\nimport p.Foo\n\nfun use() = Foo(1)\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findCallerScopeConflict(source, primaryClass(source), "Bar", listOf(source, usage)) + + assertNull("only a caller that already names Bar can be rebound", conflict) + } + } + /** @return the single top-level class of [source]. */ private fun primaryClass(source: KtFile): KtClass = source.declarations.filterIsInstance().single() diff --git a/pom.xml b/pom.xml index f9a56da66..59d33cac9 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.34-SNAPSHOT + 0.12.35-SNAPSHOT pom Netbeans Plugin Kotlin From feb40ae76eb54bf052a07c55ee883d4d038cfa40 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 10:02:43 +0300 Subject: [PATCH 25/55] Fixed binding conflicts in the package and class rename passes - Added extension-overload detection to KotlinImportBindingCheck so an explicit import that would reselect a same-named extension on a value receiver is reported instead of skipped as a qualified selector. - Added KaRenameComputer.findCaptureConflicts and wired it into the class-rename preflight, so a rewritten reference that would bind to a member, local, parameter, or import is rejected before staging. - Made the package-qualifier safety check honour ordinary imports, not only aliases. - Restricted the spelling-based outgoing import to constructors and classifiers, keeping the resolved name for convention operators. - Replaced hasSourceFilesInPackage with hasDeclarationsInPackage, which also counts declarations compiled into dependency JARs before a wildcard import is retargeted. - Treated a root-package destination as a wildcard-removal case instead of rendering the invalid `import .*`. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 103 +++----- .../kotlin/refactoring/KaRenameComputer.kt | 79 +++++- .../refactoring/KotlinImportBindingCheck.kt | 144 ++++++++++- .../move/KotlinMoveUsageSearchService.kt | 14 +- Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 30 ++- .../nbm/refactoring/KotlinRenamePlugin.kt | 37 ++- .../nbm/refactoring/KaChangePackageTest.kt | 236 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 118 +++++++++ .../KotlinImportBindingCheckTest.kt | 95 +++++++ pom.xml | 2 +- 21 files changed, 776 insertions(+), 104 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index e58225de7..eade15c9e 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 8ba535ab1..5afc7fc83 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index e2cd70666..647217832 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index fa881d4e8..933af87eb 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index ba71fa65d..8c6870a6a 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 96c6dbbd0..be4063e7d 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 1b6eec81b..2d4549e85 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 71aceff6d..d5f239707 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index b1785be05..c41168dc5 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index c0f79d871..4a613f39a 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 511c6bd8c..086a86b33 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -38,30 +38,22 @@ import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression -import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression -import org.jetbrains.kotlin.psi.KtCatchClause -import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration -import org.jetbrains.kotlin.psi.KtDestructuringDeclaration import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile -import org.jetbrains.kotlin.psi.KtForExpression -import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtParameter import org.jetbrains.kotlin.psi.KtProperty -import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.psi.KtUserType -import org.jetbrains.kotlin.psi.KtWhenExpression /** * Applies IDEA K2 Change Package semantics without its IDE-specific processor lifecycle. @@ -475,15 +467,16 @@ class KaChangePackageComputer( } /** - * Tells whether any declaration named [name] appears anywhere in this file. + * Tells whether [name] is bound anywhere in this file. * * A package qualifier is resolved as an ordinary name in scope, so a property, parameter, - * local function, classifier, or import alias with the same name can hide the package. The - * scan is deliberately file-wide: missing a binding would silently change which declaration - * is called, while an unrelated match only costs a fallback to an explicit import. + * local function, classifier, import alias, or imported declaration with the same name can + * hide the package. The scan is deliberately file-wide: missing a binding would silently + * change which declaration is called, while an unrelated match only costs a fallback to an + * explicit import. * * @param name identifier to look for - * @return `true` when the file declares or aliases that name at any level + * @return `true` when the file declares, imports, or aliases that name at any level */ private fun KtFile.hasDeclarationNamed(name: String): Boolean { var found = false @@ -519,7 +512,14 @@ class KaChangePackageComputer( } override fun visitImportDirective(importDirective: KtImportDirective) { - if (importDirective.aliasName == name) found = true + // A plain import binds the imported declaration's simple name, so `import other.dest` + // hides a package `dest` exactly like an alias does. + val boundName = importDirective.aliasName + ?: importDirective.importedFqName + ?.takeUnless { importDirective.isAllUnder } + ?.shortName() + ?.asString() + if (boundName == name) found = true super.visitImportDirective(importDirective) } }) @@ -529,51 +529,11 @@ class KaChangePackageComputer( /** * Tells whether [name] is bound to a local or member declaration visible at this element. * - * Only bindings that actually reach this position matter: a local declared later in the same - * block, or a member of an unrelated class, never captures the rewritten short name. Class - * members and import aliases are file-wide because a nested function cannot see past them. - * * @param name identifier to look for in the enclosing scopes * @return `true` when a value, function, or nested declaration here would win over an import */ - private fun KtElement.isNameShadowedInScope(name: String): Boolean { - if (containingKtFile.importDirectives.any { directive -> directive.aliasName == name }) return true - var child: PsiElement = this - var current: PsiElement? = parent - while (current != null) { - val shadowed = when (current) { - is KtBlockExpression -> current.statements - .takeWhile { statement -> statement.textRange.startOffset < child.textRange.startOffset } - .any { statement -> (statement as? KtDeclaration)?.declaresName(name) == true } - is KtClassBody -> current.declarations.any { declaration -> declaration.declaresName(name) } - is KtFunction -> current.valueParameters.any { parameter -> parameter.name == name } - is KtPropertyAccessor -> current.parameterList?.parameters?.any { parameter -> parameter.name == name } == true - is KtForExpression -> current.loopParameter?.name == name - is KtCatchClause -> current.catchParameter?.name == name - is KtWhenExpression -> current.subjectVariable?.name == name - else -> false - } - if (shadowed) return true - child = current - current = current.parent - } - return false - } - - /** - * Tells whether this statement or member declaration binds [name]. - * - * @param name identifier to look for - * @return `true` when this declaration introduces that name in its scope - */ - private fun KtDeclaration.declaresName(name: String): Boolean = when (this) { - is KtProperty -> this.name == name - is KtNamedFunction -> this.name == name - is KtClassOrObject -> this.name == name - is KtTypeAlias -> this.name == name - is KtDestructuringDeclaration -> entries.any { entry -> entry.name == name } - else -> false - } + private fun KtElement.isNameShadowedInScope(name: String): Boolean = + KotlinImportBindingCheck.isNameBoundInScope(this, name) /** * Finds declarations that stay in a moved file's former package and need an explicit import. @@ -626,11 +586,15 @@ class KaChangePackageComputer( // A call may spell a same-package type alias rather than the class it // expands to: K2 reports the constructor of the underlying class, but // the source keeps calling the alias, so the alias itself is imported. + // Convention operators spell `+` while resolving to `plus`, and an + // import of the spelled symbol would be unresolved; only classifiers + // resolved through a constructor or class-like symbol can be aliases. val referencedName = expression.getReferencedName() - val importFqName = if (referencedName == resolvedFqName.shortName().asString()) { - resolvedFqName - } else { - originalPackage.child(Name.identifier(referencedName)) + val importFqName = when { + referencedName == resolvedFqName.shortName().asString() -> resolvedFqName + symbol is KaConstructorSymbol || symbol is KaClassLikeSymbol -> + originalPackage.child(Name.identifier(referencedName)) + else -> resolvedFqName } if (importFqName in importedFqNames || originalPackage in starPackages) return // An extension access on a value receiver can spell the package name @@ -969,9 +933,10 @@ class KaChangePackageComputer( * * A wildcard import of a package that no longer exists is an unresolved reference even when * every moved usage was retargeted. When all declarations that left [imported] gathered in one - * new package, the wildcard follows them and keeps binding the same names. Otherwise the - * directive is removed; the incoming pass already captured an explicit import for every moved - * declaration the caller uses. + * new package, the wildcard follows them and keeps binding the same names. Otherwise — several + * destination packages, or the root package that no wildcard can name — the directive is + * removed; the incoming pass already captured an explicit import for every moved declaration + * the caller uses. * * @param file file containing the directive * @param directive wildcard import whose prefix is not a moved class @@ -990,9 +955,12 @@ class KaChangePackageComputer( val movedFromPackage = movedDeclarations.filterKeys { oldFqName -> oldFqName.parent() == imported } if (movedFromPackage.isEmpty()) return null val service = KotlinMoveUsageSearchService.getInstance() ?: return null - if (service.hasSourceFilesInPackage(ktFile.project, imported, movedPaths)) return null + if (service.hasDeclarationsInPackage(ktFile.project, imported, movedPaths)) return null val targetPackages = movedFromPackage.values.map { newFqName -> newFqName.parent() }.distinct() - if (targetPackages.size == 1) { + // A root-package destination cannot be spelled as a wildcard at all: `import *` is not a + // valid directive, and root-package declarations need no import. Removal is the only + // option that keeps the file compiling. + if (targetPackages.size == 1 && !targetPackages.single().isRoot) { val pathEnd = directive.importedReference?.textRange?.endOffset ?: return null return TextEdit( directive.textRange.startOffset + importPathStart(directive.text), @@ -1000,8 +968,9 @@ class KaChangePackageComputer( KotlinSourceNames.renderFqName(targetPackages.single()), ) } - // The package splits into several target packages, so no single wildcard keeps the - // bindings. Remove the directive together with the rest of its line. + // The package splits into several target packages (or the declarations became root-package + // ones), so no single wildcard keeps the bindings. Remove the directive together with the + // rest of its line. val text = file.text var end = directive.textRange.endOffset while (end < text.length && diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index d8a371d69..fafb102ac 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -331,6 +331,51 @@ class KaRenameComputer( return result } + /** + * Finds a call site whose rewritten name would bind to a different declaration. + * + * Renaming `Before` to `After` rewrites every unqualified `Before` reference verbatim, so a + * file that also binds `After` lexically — a local, parameter, member, file-level declaration, + * or import — makes the rewritten text resolve to that binding instead. A constructor call + * then becomes a call of a member function and the code still compiles with a new meaning. + * Type positions are skipped because Kotlin resolves type names in a separate namespace, + * receiver labels because they name a labeled scope, and import directives because they follow + * the rename rather than capture it. + * + * @return the first file whose rewritten reference would capture, or `null` when every + * rewritten reference keeps its target + */ + fun findCaptureConflicts(): KtFile? { + if (newName.isBlank()) return null + val targetPsi = resolveTargetPsi() ?: return null + val targets = mutableSetOf(targetPsi) + collectOverrideTargets(targetPsi, targets) + for (kaFile in projectKtFiles) { + for (target in targets) { + for (element in collectReferenceElements(kaFile, target)) { + if (element.isTypeReference()) continue + // A receiver label such as `this@After` names a labeled scope, not a + // declaration, so a local or member of that name cannot capture it. + if (element is KtLabelReferenceExpression) continue + if (PsiTreeUtil.getParentOfType(element, KtImportDirective::class.java) != null) continue + if (KotlinImportBindingCheck.isNameBoundInScope(element, newName)) return kaFile + } + } + } + return null + } + + /** + * Tells whether this name spells a type rather than a value. + * + * A type name lives in a [KtUserType]; a constructor call or a value reference does not. + * Kotlin resolves the two namespaces independently, so only value bindings can capture a + * rewritten value reference. + * + * @return `true` when this reference is part of a type usage + */ + private fun KtSimpleNameExpression.isTypeReference(): Boolean = parent is KtUserType + /** * Returns all reference expression ranges in [kaFile] that resolve to [targetPsi]. * @@ -338,8 +383,27 @@ class KaRenameComputer( * `Alias`, so the class name changes in the import path while `Alias` references keep their * name. */ - private fun searchFileForUsages(kaFile: KtFile, targetPsi: PsiElement): List> { - val ranges = mutableListOf>() + private fun searchFileForUsages(kaFile: KtFile, targetPsi: PsiElement): List> = + collectReferenceElements(kaFile, targetPsi).map { expression -> + // A receiver label such as `this@Foo` spans the `@` as well; replacing the whole + // element would drop it and leave `thisBar`. + val range = if (expression is KtLabelReferenceExpression) { + expression.getReferencedNameElement()?.textRange ?: expression.textRange + } else { + expression.textRange + } + range.startOffset to range.endOffset + } + + /** + * Collects the simple-name references in [kaFile] that resolve to [targetPsi]. + * + * @param kaFile file to search + * @param targetPsi declaration being renamed + * @return reference expressions that spell and resolve to the renamed declaration + */ + private fun collectReferenceElements(kaFile: KtFile, targetPsi: PsiElement): List { + val elements = mutableListOf() runCatching { analyze(kaFile) { // A constructor call resolves to a constructor symbol, whose PSI is the @@ -380,19 +444,12 @@ class KaRenameComputer( val constructsTarget = targetClassId != null && (sym as? KaConstructorSymbol)?.containingClassId == targetClassId if (sym.psi == targetPsi || constructsTarget) { - // A receiver label such as `this@Foo` spans the `@` as well; replacing - // the whole element would drop it and leave `thisBar`. - val range = if (expression is KtLabelReferenceExpression) { - expression.getReferencedNameElement()?.textRange ?: expression.textRange - } else { - expression.textRange - } - ranges.add(range.startOffset to range.endOffset) + elements.add(simpleName) } } }) } }.onFailure { e -> LOG.warning("KaRenameComputer: search failed in ${kaFile.name}: $e") } - return ranges + return elements } } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 2d1f25459..8753a3eec 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -18,23 +18,38 @@ package io.github.nbplugins.kotlin.refactoring import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol import org.jetbrains.kotlin.idea.references.KtReference +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtCatchClause +import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclaration import org.jetbrains.kotlin.psi.KtDotQualifiedExpression +import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression +import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtImportDirective +import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.psi.KtUserType +import org.jetbrains.kotlin.psi.KtWhenExpression /** * Detects references that inserting an explicit import would silently retarget. @@ -72,7 +87,10 @@ object KotlinImportBindingCheck { super.visitSimpleNameExpression(expression) if (conflicting) return if (expression.getReferencedName() != name) return - if (expression.isQualifiedSelector()) return + // A selector resolves through its receiver, except for an extension call on + // a value: an explicit import outranks the wildcard or same-package binding + // that currently selects the extension. + if (expression.isQualifiedSelector() && !expression.isValueReceiverExtension()) return if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return val symbol = expression.references.filterIsInstance().firstOrNull() ?.resolveToSymbol() ?: return @@ -85,6 +103,84 @@ object KotlinImportBindingCheck { } }.getOrDefault(false) + /** + * Tells whether [name] is already bound to something else at [element]. + * + * Used before rewriting a declaration name into a scope where the new name may already be + * taken. Unlike [hasConflictingReference], which asks what an *import* could capture, this + * asks what the rewritten *text* would resolve to: locals, parameters, members, file-level + * declarations, and import bindings all take part. + * + * @param element position whose rewritten text must keep its meaning + * @param name new simple name the position is about to spell + * @param ignoredImports imported FQNs that already bind the declaration being renamed, so + * re-spelling [name] through them is not a capture + * @return `true` when another declaration would win over the intended target + */ + fun isNameBoundInScope( + element: KtElement, + name: String, + ignoredImports: Set = emptySet(), + ): Boolean { + val file = element.containingKtFile + if (file.importDirectives.any { directive -> directive.bindsName(name, ignoredImports) }) return true + if (file.declarations.any { declaration -> declaration.declaresName(name) }) return true + var child: PsiElement = element + var current: PsiElement? = element.parent + while (current != null) { + val bound = when (current) { + is KtBlockExpression -> current.statements + .takeWhile { statement -> statement.textRange.startOffset < child.textRange.startOffset } + .any { statement -> (statement as? KtDeclaration)?.declaresName(name) == true } + is KtClassBody -> current.declarations.any { declaration -> declaration.declaresName(name) } + is KtFunction -> current.valueParameters.any { parameter -> parameter.name == name } + is KtPropertyAccessor -> + current.parameterList?.parameters?.any { parameter -> parameter.name == name } == true + is KtForExpression -> current.loopParameter?.name == name + is KtCatchClause -> current.catchParameter?.name == name + is KtWhenExpression -> current.subjectVariable?.name == name + else -> false + } + if (bound) return true + child = current + current = current.parent + } + return false + } + + /** + * Tells whether this import directive introduces [name] into its file. + * + * An alias binds only the alias; a plain directive binds the imported declaration's simple + * name. Wildcard directives bind nothing by name and are skipped. + * + * @param name identifier the directive may bind + * @param ignoredImports imported FQNs that are already the intended target + * @return `true` when this directive is the visible source of [name] + */ + private fun KtImportDirective.bindsName(name: String, ignoredImports: Set): Boolean { + if (isAllUnder) return false + val imported = importedFqName + if (imported != null && imported in ignoredImports) return false + val alias = aliasName + return if (alias != null) alias == name else imported?.shortName()?.asString() == name + } + + /** + * Tells whether this statement or member declaration binds [name]. + * + * @param name identifier to look for + * @return `true` when this declaration introduces that name in its scope + */ + private fun KtDeclaration.declaresName(name: String): Boolean = when (this) { + is KtProperty -> this.name == name + is KtNamedFunction -> this.name == name + is KtClassOrObject -> this.name == name + is KtTypeAlias -> this.name == name + is KtDestructuringDeclaration -> entries.any { entry -> entry.name == name } + else -> false + } + /** * Tells whether this symbol is one of the declarations whose references the import retargets. * @@ -154,4 +250,50 @@ object KotlinImportBindingCheck { userType.referenceExpression == this && userType.qualifier != null } == true } + + /** + * Tells whether this selector is an extension called on a value rather than on a type. + * + * `1.choose()` and `box.choose()` bind the extension through extension-overload resolution, + * where an explicit import of another same-named extension changes which one is chosen. + * `Int.choose()` and `p.choose()` name a type or package as the receiver, so an import cannot + * affect them. + * + * @return `true` when an import of this name could change the selected extension + */ + context(_: KaSession) + private fun KtSimpleNameExpression.isValueReceiverExtension(): Boolean { + val qualified = qualifiedAccess() ?: return false + val symbol = references.filterIsInstance().firstOrNull() + ?.resolveToSymbol() as? KaCallableSymbol ?: return false + if (!symbol.isExtension) return false + val receiverRoot = qualified.receiverExpression.leadingReference() ?: return true + val receiverSymbol = runCatching { + receiverRoot.references.filterIsInstance().firstOrNull()?.resolveToSymbol() + }.getOrNull() + return receiverSymbol !is KaPackageSymbol && receiverSymbol !is KaClassLikeSymbol + } + + /** + * Returns the qualified access this name selects, unwrapping a call's callee. + * + * @return the `.`-qualified expression whose selector this is, or `null` otherwise + */ + private fun KtSimpleNameExpression.qualifiedAccess(): KtDotQualifiedExpression? { + val selector = (parent as? KtCallExpression)?.takeIf { call -> call.calleeExpression == this } ?: this + val qualified = selector.parent as? KtDotQualifiedExpression ?: return null + return qualified.takeIf { expression -> expression.selectorExpression == selector } + } + + /** + * Returns the leftmost simple name of this receiver expression's qualified chain. + * + * @return the leftmost name, or `null` when the receiver is not a name-based chain + */ + private fun KtElement.leadingReference(): KtSimpleNameExpression? = when (this) { + is KtDotQualifiedExpression -> receiverExpression.leadingReference() + is KtUserType -> qualifier?.leadingReference() ?: referenceExpression + is KtSimpleNameExpression -> this + else -> null + } } 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 e15ec63af..b75eacda2 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 @@ -61,18 +61,20 @@ interface KotlinMoveUsageSearchService { fun findWildcardImportingFiles(project: com.intellij.openapi.project.Project, classFqNames: Set): List = emptyList() /** - * Tells whether a source file other than [excludedPaths] declares [packageFqName]. + * Tells whether [packageFqName] keeps at least one declaration outside [excludedPaths]. * - * A package wildcard import becomes unresolved when the last source file leaves its package, - * so the package-change pass retargets or drops such imports only when nothing remains. The - * default assumes declarations remain, which leaves wildcard imports untouched. + * A package wildcard import becomes unresolved when the last declaration leaves its package, + * so the package-change pass retargets or drops such imports only when nothing remains. A + * dependency JAR may declare a package of the same name; the wildcard keeps binding those + * symbols, so they count as remaining declarations. The default assumes declarations remain, + * which leaves wildcard imports untouched. * * @param project IntelliJ project whose standalone K2 session supplies candidate files * @param packageFqName package whose remaining declarations are checked * @param excludedPaths paths of files whose package is changing in this operation - * @return `true` when at least one other source file still declares [packageFqName] + * @return `true` when a source file or a dependency declaration still belongs to the package */ - fun hasSourceFilesInPackage( + fun hasDeclarationsInPackage( project: com.intellij.openapi.project.Project, packageFqName: FqName, excludedPaths: Set, diff --git a/Nbm/pom.xml b/Nbm/pom.xml index a58a2e044..7f6f88387 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 c01d3e4f1..6770fc176 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,9 @@ 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.analysis.api.compat.getClassOrObjectSymbol +import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.findPackage 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 @@ -182,23 +184,43 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { } /** - * Tells whether any session source outside [excludedPaths] still declares [packageFqName]. + * Tells whether [packageFqName] keeps any declaration outside [excludedPaths]. + * + * Source files are checked first because they are cheap to enumerate. A dependency JAR can + * declare a package of the same name, in which case a wildcard import of that package keeps + * resolving even after the last source file leaves it. * * @param project IntelliJ project owning the standalone K2 session * @param packageFqName package whose remaining declarations are checked * @param excludedPaths paths of files whose package is changing in this operation - * @return `true` when at least one other source file still declares [packageFqName] + * @return `true` when a source file or a dependency declaration still belongs to the package */ - override fun hasSourceFilesInPackage( + override fun hasDeclarationsInPackage( project: com.intellij.openapi.project.Project, packageFqName: FqName, excludedPaths: Set, ): Boolean { val session = KotlinAnalysisAPISession.forProject(project) ?: return true - return session.fileMap.values.any { lightFile -> + val sourceRemains = session.fileMap.values.any { lightFile -> lightFile.path !in excludedPaths && session.getKtFileForPath(lightFile.path)?.packageFqName == packageFqName } + if (sourceRemains) return true + val probe = session.fileMap.values + .firstNotNullOfOrNull { lightFile -> session.getKtFileForPath(lightFile.path) } + ?: return true + return runCatching { + analyze(probe) { + val packageSymbol = findPackage(packageFqName) ?: return@analyze false + // Dependency declarations carry light PSI rather than none, so a declaration is + // attributed to the moving files by its source path: what remains belongs either + // to another source file or to a dependency JAR. + packageSymbol.packageScope.declarations.any { declaration -> + val path = declaration.psi?.containingFile?.virtualFile?.path + path == null || path !in excludedPaths + } + } + }.getOrDefault(true) } /** diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index 32c667a83..15ee23ac8 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -105,8 +105,7 @@ class KotlinRenameFilePlugin( val primaryClass = source.declarations.filterIsInstance() .firstOrNull { declaration -> declaration.name == refactoring.file.name } ?: return null - val files = KotlinPsiManager.getFilesByProject(project) - .mapNotNull { file -> session.getKtFileForPath(file.path) } + val files = session.fileMap.values.mapNotNull { lightFile -> session.getKtFileForPath(lightFile.path) } val conflict = findRenameConflict(source, primaryClass, refactoring.newName, files) if (conflict != null) { val packageName = source.packageFqName @@ -117,13 +116,45 @@ class KotlinRenameFilePlugin( ) } val caller = findCallerScopeConflict(source, primaryClass, refactoring.newName, files) - ?: return null + ?: return captureConflictProblem(source, primaryClass, files) return Problem( true, "Renaming the Kotlin class ${primaryClass.name} to ${refactoring.newName} would change " + "which ${refactoring.newName} the references in ${caller.name} use.", ) } + + /** + * Reports a renamed reference whose new spelling would bind to another declaration. + * + * The rename rewrites unqualified references verbatim, so a file that already binds the new + * name lexically — a member function, property, parameter, or local — would make the rewritten + * reference resolve to that binding. A constructor call then becomes a call of the member + * function and the code still compiles with a different meaning. + * + * @param source KtFile declaring the renamed class + * @param primaryClass class being renamed + * @param files project files searched for rewritten references + * @return a blocking problem when a rewritten reference would capture, otherwise `null` + */ + private fun captureConflictProblem( + source: KtFile, + primaryClass: KtClass, + files: List, + ): Problem? { + val offset = primaryClass.nameIdentifier?.textRange?.startOffset ?: return null + val capture = KaRenameComputer( + cursorKtFile = source, + offset = offset, + projectKtFiles = files.toSet(), + newName = refactoring.newName, + ).findCaptureConflicts() ?: return null + return Problem( + true, + "Renaming the Kotlin class ${primaryClass.name} to ${refactoring.newName} would make " + + "the rewritten references in ${capture.name} resolve to a different declaration.", + ) + } } /** diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 16248c984..4fc80a968 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -2642,6 +2642,242 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a convention operator keeps the resolved callable name in its outgoing import. + * + * The operation reference spells `+` while the declaration is `plus`, so building the import + * from the spelled name would produce the unresolvable `import p.+`. + */ + fun testApply_realSession_importsConventionOperatorByName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-operator-import") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val movedPath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(movedPath, "package p\n\nfun use(a: p.Box, b: p.Box) = a + b\n") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\noperator fun Box.plus(other: Box) = Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-operator-import-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(movedPath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A convention operator must be imported by its declared name", + "package q\n\nimport p.plus\n\nfun use(a: p.Box, b: p.Box) = a + b\n", + success.changedFiles[movedPath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a package wildcard stays when the package also exists in a dependency JAR. + * + * The Java dependency declares `old.source`, so the caller's `import old.source.*` keeps + * binding `JavaHelper` even after the last project source leaves that package. Retargeting + * the wildcard would leave the binary usage unresolved. + */ + fun testApply_realSession_keepsPackageWildcardBindingDependencyDeclarations() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-binary-wildcard") + val buildRoot = Files.createTempDirectory("nbkotlin-change-package-binary-wildcard-build") + try { + val javaJar = compileJavaClassJar(buildRoot, "old.source", "JavaHelper") ?: run { + println("Java compiler unavailable — skipping binary wildcard test") + return + } + Files.createDirectories(temporaryRoot.resolve("old/source")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val movedPath = temporaryRoot.resolve("old/source/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(movedPath, "package old.source\n\nfun movedByUs() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.source.*\n\n" + + "fun use() = movedByUs() + JavaHelper().hashCode()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-binary-wildcard-integration", + binaryJars = listOf(stdlib, javaJar), + sourceRoots = listOf(temporaryRoot), + ) + val source = session.getKtFileForPath(movedPath.toString()) ?: error("Could not obtain source PSI") + + val outcome = KaChangePackageComputer(source).apply( + mapOf(source to FqName("new.pkg")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A wildcard that still binds dependency declarations must stay", + "package usage\n\nimport new.pkg.movedByUs\nimport old.source.*\n\n" + + "fun use() = movedByUs() + JavaHelper().hashCode()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + buildRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an emptied package wildcard is removed when the declarations move to the root package. + * + * A root package cannot be named by a wildcard, so rendering the destination would produce the + * invalid `import .*`; the directive is removed and the explicit import stays. + */ + fun testApply_realSession_removesObsoleteWildcardWhenTargetIsRootPackage() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-root") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString(usagePath, "package usage\n\nimport old.*\n\nfun use() = moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-root-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.ROOT), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A root-package destination removes the wildcard instead of rendering `.*`", + "package usage\n\nimport moved\nfun use() = moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an explicit import would not silently retarget an unmoved extension overload. + * + * Both extensions are visible through the wildcard, and `1.choose()` selects the more specific + * `Int` extension. Importing the moved `Number` extension explicitly makes it outrank the + * wildcard-bound one, changing which function the call uses. + */ + fun testApply_realSession_conflictsWhenImportWouldRebindUnmovedExtensionOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-extension-rebind") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString(sourcePath, "package old\n\nfun Number.choose() = 1\n") + Files.writeString(temporaryRoot.resolve("old/Int.kt"), "package old\n\nfun Int.choose() = 2\n") + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nfun use() = 1.choose() + 1.5.choose()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-extension-rebind-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the rebound extension call: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a package qualifier hidden by an ordinary import falls back to an explicit import. + * + * `import other.dest` binds the name `dest` to an object, so rewriting `old.moved()` to + * `dest.moved()` would call that object's member. The chain is shortened instead. + */ + fun testApply_realSession_importsInsteadOfQualifyingThroughImportedPackageName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-imported-qualifier") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("other")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString(temporaryRoot.resolve("other/Dest.kt"), "package other\n\nobject dest {\n fun moved() = 2\n}\n") + Files.writeString( + usagePath, + "package usage\n\nimport other.dest\n\nfun use() = old.moved()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-imported-qualifier-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An imported binding of the package name must fall back to an explicit import", + "package usage\n\nimport dest.moved\nimport other.dest\n\nfun use() = moved()\n", + success.changedFiles[usagePath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index d410de2bd..58c9c3d7a 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -475,6 +475,124 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a rewrite that would bind to a member function is reported as a conflict. + * + * Renaming `Before` to `After` rewrites `Before()` to `After()`. Inside `Caller`, that name + * already resolves to the member function, so the constructor call would silently become a + * call of `Caller.After`. + */ + fun testFindCaptureConflicts_reportsMemberFunctionCapture() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-member") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nclass Caller {\n fun After() = \"other\"\n fun make() = Before().toString()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-member-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertEquals("The caller's member function captures the rewritten constructor call", caller, conflict) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a rewrite with no competing binding reports no capture conflict. + * + * The caller has no declaration or import named `After`, so the rewritten constructor call + * keeps constructing the renamed class. + */ + fun testFindCaptureConflicts_ignoresFilesWithoutTheNewName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-clean") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nclass Caller {\n fun make() = Before().toString()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-clean-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertNull("No binding of the new name exists, so the rewrite is safe", conflict) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a type-position reference is not treated as a capture. + * + * `val value: Before?` becomes `val value: After?`; a member function named `After` does not + * participate in type-name resolution, so it cannot capture the rewritten type. + */ + fun testFindCaptureConflicts_ignoresTypePositions() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-type") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nclass Caller {\n fun After() = \"other\"\n val value: Before? = null\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-type-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertNull("A member function cannot capture a type-name rewrite", conflict) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index e13971369..b12d27328 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -20,6 +20,7 @@ import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import io.github.nbplugins.kotlin.refactoring.KotlinImportBindingCheck import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction import utils.KotlinTestCase import java.nio.file.Files import java.nio.file.Path @@ -110,6 +111,100 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies an extension call on a value receiver is reported. + * + * An explicit import outranks the wildcard binding that selects the extension today, so the + * call would silently switch to the imported overload. + */ + fun testHasConflictingReference_reportsExtensionOnValueReceiver() { + withSession( + name = "extension", + prepare = { root -> + Files.createDirectories(root.resolve("other")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("other/Types.kt"), "package q\n\nfun Int.choose() = 1\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport q.*\n\nfun use() = 1.choose()\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + assertTrue( + "an explicit import would reselect the extension used by this call", + KotlinImportBindingCheck.hasConflictingReference(usage, "choose", emptySet()), + ) + } + } + + /** Verifies a member function of an enclosing class binds the name lexically. */ + fun testIsNameBoundInScope_reportsMemberFunction() { + withSession( + name = "member", + prepare = { root -> + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nclass Caller {\n fun After() = 1\n fun make() = 2\n}\n", + ) + }, + ) { session, root -> + val make = session.ktFile(root.resolve("usage/Usage.kt")) + .declarations.filterIsInstance().single() + .declarations.filterIsInstance().single { function -> function.name == "make" } + + assertTrue( + "the enclosing class already declares the name", + KotlinImportBindingCheck.isNameBoundInScope(make, "After"), + ) + } + } + + /** Verifies an ordinary import binds the imported declaration's simple name. */ + fun testIsNameBoundInScope_reportsImportedBinding() { + withSession( + name = "imported", + prepare = { root -> + Files.createDirectories(root.resolve("other")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("other/Types.kt"), "package other\n\nobject dest\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport other.dest\n\nfun use() = 1\n", + ) + }, + ) { session, root -> + val use = session.ktFile(root.resolve("usage/Usage.kt")) + .declarations.filterIsInstance().single() + + assertTrue( + "an import binds the name even without an alias", + KotlinImportBindingCheck.isNameBoundInScope(use, "dest"), + ) + } + } + + /** Verifies an unrelated name is not reported as bound. */ + fun testIsNameBoundInScope_ignoresUnrelatedName() { + withSession( + name = "unrelated", + prepare = { root -> + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("usage/Usage.kt"), "package usage\n\nfun use() = 1\n") + }, + ) { session, root -> + val use = session.ktFile(root.resolve("usage/Usage.kt")) + .declarations.filterIsInstance().single() + + assertFalse( + "nothing in the file binds the requested name", + KotlinImportBindingCheck.isNameBoundInScope(use, "missing"), + ) + } + } + /** @return the session source for [path], failing the test when it is absent. */ private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") diff --git a/pom.xml b/pom.xml index 59d33cac9..a3f870798 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.35-SNAPSHOT + 0.12.36-SNAPSHOT pom Netbeans Plugin Kotlin From 2eb2ecfc61181d1cf52acf8fdbe203326471bca7 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 10:32:16 +0300 Subject: [PATCH 26/55] Fixed remaining import and capture edge cases in rename passes Rewriting a direct import now checks that the unmoved overloads it used to bind are not needed; an emptied package wildcard is removed instead of being broadened to the destination package, shortening to a root-package name is rejected when a member captures it, type positions are checked against classifiers, and type alias calls spelled through a same-package alias that expands to another package still get an import. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 175 +++++++++++++---- .../kotlin/refactoring/KaRenameComputer.kt | 16 +- .../refactoring/KotlinImportBindingCheck.kt | 106 +++++++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 185 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 40 ++++ pom.xml | 2 +- 17 files changed, 493 insertions(+), 53 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index eade15c9e..5244de8b4 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 5afc7fc83..41fa4d1d1 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 647217832..fc4bb8e16 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 933af87eb..1b408d734 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 8c6870a6a..91a7a35b1 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index be4063e7d..f45bcef07 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 2d4549e85..834376551 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index d5f239707..a5fe27e9f 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index c41168dc5..6751932bf 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 4a613f39a..033f58636 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 086a86b33..0c76aee9d 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -20,14 +20,18 @@ import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo +import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.findPackage 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 @@ -288,7 +292,7 @@ class KaChangePackageComputer( // A qualified chain rewritten to a full package path keeps resolving on its own. // A root-package target becomes a bare name, and a shadowed package segment is // rewritten to a bare name as well, so both still need an explicit import. - val rewrite = captureQualifiedReference(element, oldFqName, newFqName, edits, conflicts) + val rewrite = captureQualifiedReference(element, declaration, oldFqName, newFqName, edits, conflicts) if (rewrite == QualifiedRewrite.REWRITTEN) return@forEach if (file.virtualFile?.path == declarationFile.virtualFile?.path) return@forEach val finalPackage = targets[file] ?: file.packageFqName @@ -331,6 +335,40 @@ class KaChangePackageComputer( } } } + // A rewritten direct import follows the moved declaration, but the overload set it bound + // stays behind: a caller that used one of the remaining overloads would silently switch to + // the moved declaration. Only files whose package also changes lose same-package visibility + // of those overloads, so files staying in the old package are unaffected. + val movedPaths = movedFiles.mapNotNull { file -> file.virtualFile?.path }.toSet() + val movedImports = movedDeclarations.values.associate { (oldFqName, newFqName) -> + MoveImport(oldFqName.parent(), oldFqName.shortName().asString()) to newFqName.parent() + } + session.findImportingFiles(ktFile.project, movedImports).forEach { file -> + file.importDirectives.forEach directives@{ directive -> + val importedFqName = directive.importedFqName ?: return@directives + if (directive.isAllUnder || directive.aliasName != null) return@directives + if (importedFqName !in movedOldFqNames) return@directives + // A file that stays in the old package keeps every remaining overload visible + // there, so the rewritten import cannot change what its calls resolve to. + if (file.packageFqName == importedFqName.parent() && + file.virtualFile?.path !in movedPaths + ) { + return@directives + } + // A wildcard of the old package keeps every remaining overload visible, so the + // rewritten direct import cannot change what a call resolves to. + if (file.importDirectives.any { candidate -> + candidate.isAllUnder && candidate.importedFqName == importedFqName.parent() + } + ) { + return@directives + } + if (KotlinImportBindingCheck.hasConflictingImportRewrite(file, importedFqName, movedPaths)) { + conflicts += "Cannot update ${file.name}: import ${importedFqName.asString()} " + + "also binds declarations that stay behind." + } + } + } return IncomingRetargeting( imports, edits.mapValues { (_, fileEdits) -> fileEdits.toList() }, @@ -403,6 +441,8 @@ class KaChangePackageComputer( * is requested; when that name is shadowed as well, the caller reports a conflict instead. * * @param element reference expression that K2 resolved to the moved declaration + * @param movedDeclaration declaration being moved; a package change keeps its name, so it + * must not be mistaken for a capture of the shortened reference * @param oldFqName declaration FQN before the package change * @param newFqName declaration FQN after the package change * @param edits per-file replacement accumulator @@ -411,6 +451,7 @@ class KaChangePackageComputer( */ private fun captureQualifiedReference( element: KtElement, + movedDeclaration: KtNamedDeclaration, oldFqName: FqName, newFqName: FqName, edits: MutableMap>, @@ -434,6 +475,19 @@ class KaChangePackageComputer( } // A root target has no receiver to rewrite: `old.moved()` becomes the bare `moved`. if (newFqName.parent().isRoot) { + val shortName = newFqName.shortName().asString() + // A member or local of that name outranks an import, so the shortened call would bind + // to it instead; the chain cannot stay either, since the old package disappears. + if (element.isNameShadowedInScope( + shortName, + ignoredImports = setOf(oldFqName), + ignoredDeclarations = setOf(movedDeclaration), + ) + ) { + conflicts += "Cannot update ${file.name}: $shortName already refers to another " + + "declaration there." + return QualifiedRewrite.NONE + } edits.getOrPut(file, ::linkedSetOf) += TextEdit( chain.startOffset, chain.endOffset, @@ -446,7 +500,12 @@ class KaChangePackageComputer( // The new package's first segment would resolve to a local declaration instead of // the package, so the chain is shortened and the declaration imported instead. val shortName = newFqName.shortName().asString() - if (element.isNameShadowedInScope(shortName)) { + if (element.isNameShadowedInScope( + shortName, + ignoredImports = setOf(oldFqName), + ignoredDeclarations = setOf(movedDeclaration), + ) + ) { conflicts += "Cannot update ${file.name}: both $packageHead and $shortName refer " + "to other declarations there." return QualifiedRewrite.NONE @@ -530,10 +589,20 @@ class KaChangePackageComputer( * Tells whether [name] is bound to a local or member declaration visible at this element. * * @param name identifier to look for in the enclosing scopes + * @param ignoredImports imported FQNs that bind the moved declaration itself + * @param ignoredDeclarations declarations that are the moved target themselves * @return `true` when a value, function, or nested declaration here would win over an import */ - private fun KtElement.isNameShadowedInScope(name: String): Boolean = - KotlinImportBindingCheck.isNameBoundInScope(this, name) + private fun KtElement.isNameShadowedInScope( + name: String, + ignoredImports: Set = emptySet(), + ignoredDeclarations: Set = emptySet(), + ): Boolean = KotlinImportBindingCheck.isNameBoundInScope( + this, + name, + ignoredImports, + ignoredDeclarations, + ) /** * Finds declarations that stay in a moved file's former package and need an explicit import. @@ -578,23 +647,27 @@ class KaChangePackageComputer( if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return val reference = expression.references.filterIsInstance().firstOrNull() ?: return val symbol = reference.resolveToSymbol() ?: return - val resolvedFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) - ?: return - // The reference may already be bound through an import alias of the - // resolved declaration; the alias directive stays valid after a move. - if (expression.isAliasBound(resolvedFqName)) return + val referencedName = expression.getReferencedName() + val topLevelFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) // A call may spell a same-package type alias rather than the class it // expands to: K2 reports the constructor of the underlying class, but // the source keeps calling the alias, so the alias itself is imported. // Convention operators spell `+` while resolving to `plus`, and an // import of the spelled symbol would be unresolved; only classifiers // resolved through a constructor or class-like symbol can be aliases. - val referencedName = expression.getReferencedName() - val importFqName = when { - referencedName == resolvedFqName.shortName().asString() -> resolvedFqName - symbol is KaConstructorSymbol || symbol is KaClassLikeSymbol -> - originalPackage.child(Name.identifier(referencedName)) - else -> resolvedFqName + val importFqName = if (topLevelFqName != null) { + // The reference may already be bound through an import alias of + // the resolved declaration; the alias directive stays valid. + if (expression.isAliasBound(topLevelFqName)) return + when { + referencedName == topLevelFqName.shortName().asString() -> topLevelFqName + symbol is KaConstructorSymbol || symbol is KaClassLikeSymbol -> + originalPackage.child(Name.identifier(referencedName)) + else -> topLevelFqName + } + } else { + samePackageTypeAliasName(expression, symbol, originalPackage, movedPaths) + ?: return } if (importFqName in importedFqNames || originalPackage in starPackages) return // An extension access on a value receiver can spell the package name @@ -684,6 +757,51 @@ class KaChangePackageComputer( return originalPackage.child(Name.identifier(name)) } + /** + * Resolves a type alias that a moved file still needs to import from its former package. + * + * A constructor call through a type alias resolves to the constructor of the underlying class, + * which may live in another package. The source keeps spelling the alias, so the alias itself + * must be imported when the file leaves the package that declared it. + * + * @param expression simple-name reference inside a moved file + * @param symbol symbol K2 resolved for the reference + * @param originalPackage package the moved file declares before the change + * @param movedPaths paths of the files whose package changes + * @return FQN of the same-package type alias to import, or `null` when there is none + */ + context(_: KaSession) + private fun samePackageTypeAliasName( + expression: KtSimpleNameExpression, + symbol: KaSymbol, + originalPackage: FqName, + movedPaths: Set, + ): FqName? { + val classId = when (symbol) { + is KaConstructorSymbol -> symbol.containingClassId + is KaClassLikeSymbol -> symbol.classId + else -> null + } ?: return null + if (classId.isLocal || classId.isNestedClass) return null + val referencedName = expression.getReferencedName() + // Only a differently spelled class reference can be a type alias call. + if (referencedName == classId.shortClassName.asString()) return null + val file = expression.containingKtFile + // Any explicit import of the spelled name binds it wherever it lives and stays valid. + val importedName = file.importDirectives.any { directive -> + !directive.isAllUnder && + (directive.aliasName ?: directive.importedFqName?.shortName()?.asString()) == referencedName + } + if (importedName) return null + // The alias must really be declared in the package the file is leaving, and must not + // travel with the file itself. + val alias = (findPackage(originalPackage)?.packageScope + ?.classifiers?.firstOrNull { classifier -> classifier.name?.asString() == referencedName } + as? KaTypeAliasSymbol) ?: return null + if (alias.psi?.containingFile?.virtualFile?.path in movedPaths) return null + return originalPackage.child(Name.identifier(referencedName)) + } + /** * Tells whether [this] is spelled with an import alias that binds [fqName]. * @@ -932,11 +1050,11 @@ class KaChangePackageComputer( * Builds the edit that keeps a package wildcard resolvable when its package loses every source. * * A wildcard import of a package that no longer exists is an unresolved reference even when - * every moved usage was retargeted. When all declarations that left [imported] gathered in one - * new package, the wildcard follows them and keeps binding the same names. Otherwise — several - * destination packages, or the root package that no wildcard can name — the directive is - * removed; the incoming pass already captured an explicit import for every moved declaration - * the caller uses. + * every moved usage was retargeted, so the directive is removed once no declaration stays + * behind. Retargeting it to the single destination package would be worse: it would also + * import every unrelated declaration already in that package, which can capture names the + * caller currently resolves through another wildcard import. The incoming pass already + * captured an explicit import for every moved declaration the caller uses. * * @param file file containing the directive * @param directive wildcard import whose prefix is not a moved class @@ -956,21 +1074,8 @@ class KaChangePackageComputer( if (movedFromPackage.isEmpty()) return null val service = KotlinMoveUsageSearchService.getInstance() ?: return null if (service.hasDeclarationsInPackage(ktFile.project, imported, movedPaths)) return null - val targetPackages = movedFromPackage.values.map { newFqName -> newFqName.parent() }.distinct() - // A root-package destination cannot be spelled as a wildcard at all: `import *` is not a - // valid directive, and root-package declarations need no import. Removal is the only - // option that keeps the file compiling. - if (targetPackages.size == 1 && !targetPackages.single().isRoot) { - val pathEnd = directive.importedReference?.textRange?.endOffset ?: return null - return TextEdit( - directive.textRange.startOffset + importPathStart(directive.text), - pathEnd, - KotlinSourceNames.renderFqName(targetPackages.single()), - ) - } - // The package splits into several target packages (or the declarations became root-package - // ones), so no single wildcard keeps the bindings. Remove the directive together with the - // rest of its line. + // The package keeps no declaration, so neither the old wildcard nor a broadened one is + // safe: remove the directive together with the rest of its line. val text = file.text var end = directive.textRange.endOffset while (end < text.length && diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index fafb102ac..c3f132dd5 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -338,9 +338,9 @@ class KaRenameComputer( * file that also binds `After` lexically — a local, parameter, member, file-level declaration, * or import — makes the rewritten text resolve to that binding instead. A constructor call * then becomes a call of a member function and the code still compiles with a new meaning. - * Type positions are skipped because Kotlin resolves type names in a separate namespace, - * receiver labels because they name a labeled scope, and import directives because they follow - * the rename rather than capture it. + * Type positions are checked against classifiers only, because Kotlin resolves type names in a + * separate namespace; receiver labels are skipped because they name a labeled scope, and + * import directives because they follow the rename rather than capture it. * * @return the first file whose rewritten reference would capture, or `null` when every * rewritten reference keeps its target @@ -353,12 +353,18 @@ class KaRenameComputer( for (kaFile in projectKtFiles) { for (target in targets) { for (element in collectReferenceElements(kaFile, target)) { - if (element.isTypeReference()) continue // A receiver label such as `this@After` names a labeled scope, not a // declaration, so a local or member of that name cannot capture it. if (element is KtLabelReferenceExpression) continue if (PsiTreeUtil.getParentOfType(element, KtImportDirective::class.java) != null) continue - if (KotlinImportBindingCheck.isNameBoundInScope(element, newName)) return kaFile + // Kotlin resolves types and values in separate namespaces: a type position is + // only captured by another classifier, a value position only by another value. + val captured = if (element.isTypeReference()) { + KotlinImportBindingCheck.isClassifierBoundInScope(element, newName) + } else { + KotlinImportBindingCheck.isNameBoundInScope(element, newName) + } + if (captured) return kaFile } } } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 8753a3eec..eac3805aa 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -48,6 +48,7 @@ import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.KtTypeParameterListOwner import org.jetbrains.kotlin.psi.KtUserType import org.jetbrains.kotlin.psi.KtWhenExpression @@ -103,6 +104,80 @@ object KotlinImportBindingCheck { } }.getOrDefault(false) + /** + * Tells whether rewriting an import of [imported] to another package would rebind references. + * + * `import old.choose` binds the whole overload set of that name, so following the declaration + * to a new package drops every overload that stays behind. A reference that currently uses + * one of those overloads would then resolve to the moved declaration instead — or stop + * resolving at all — while the file still compiles. + * + * Only references this import actually selects are considered: a chain qualified by the + * package spelling keeps resolving on its own, and qualified selectors keep resolving through + * their receiver unless they are extension calls on a value. References that resolve into one + * of [movingPaths] follow the import and are fine. + * + * @param file caller whose import directive is being rewritten + * @param imported original FQN of the moved declaration + * @param movingPaths paths of the files whose package changes + * @return `true` when a reference would bind to the moved declaration instead of its own + */ + fun hasConflictingImportRewrite( + file: KtFile, + imported: FqName, + movingPaths: Set, + ): Boolean = runCatching { + analyze(file) { + var conflicting = false + file.accept(object : KtTreeVisitorVoid() { + override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { + super.visitSimpleNameExpression(expression) + if (conflicting) return + if (expression.getReferencedName() != imported.shortName().asString()) return + if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return + if (expression.isQualifiedSelector() && !expression.isValueReceiverExtension()) return + val symbol = expression.references.filterIsInstance().firstOrNull() + ?.resolveToSymbol() ?: return + val path = symbol.psi?.containingFile?.virtualFile?.path + if (path != null && path in movingPaths) return + if (!symbol.isTopLevelBinding()) return + if (symbol.declaredPackage() != imported.parent()) return + conflicting = true + } + }) + conflicting + } + }.getOrDefault(false) + + /** + * Tells whether [name] is already bound to a classifier at [element]. + * + * Kotlin resolves type references in a namespace separate from values, so a rewritten type + * position can only be captured by a class, object, type alias, or type parameter of that + * name — not by a function or property. Imports are treated conservatively because telling a + * classifier import from a callable import needs resolution. + * + * @param element position whose rewritten text denotes a type + * @param name new simple name the position is about to spell + * @return `true` when another classifier would win over the intended target + */ + fun isClassifierBoundInScope(element: KtElement, name: String): Boolean { + val file = element.containingKtFile + if (file.importDirectives.any { directive -> directive.bindsName(name, emptySet()) }) return true + if (file.declarations.any { declaration -> declaration.declaresClassifierName(name) }) return true + var current: PsiElement? = element.parent + while (current != null) { + val bound = when (current) { + is KtClassBody -> current.declarations.any { declaration -> declaration.declaresClassifierName(name) } + is KtTypeParameterListOwner -> current.typeParameters.any { parameter -> parameter.name == name } + else -> false + } + if (bound) return true + current = current.parent + } + return false + } + /** * Tells whether [name] is already bound to something else at [element]. * @@ -115,16 +190,21 @@ object KotlinImportBindingCheck { * @param name new simple name the position is about to spell * @param ignoredImports imported FQNs that already bind the declaration being renamed, so * re-spelling [name] through them is not a capture + * @param ignoredDeclarations declarations that are the intended target themselves; a package + * change leaves their names in place, so they must not be reported as captures * @return `true` when another declaration would win over the intended target */ fun isNameBoundInScope( element: KtElement, name: String, ignoredImports: Set = emptySet(), + ignoredDeclarations: Set = emptySet(), ): Boolean { val file = element.containingKtFile if (file.importDirectives.any { directive -> directive.bindsName(name, ignoredImports) }) return true - if (file.declarations.any { declaration -> declaration.declaresName(name) }) return true + if (file.declarations.any { declaration -> + declaration !in ignoredDeclarations && declaration.declaresName(name) + }) return true var child: PsiElement = element var current: PsiElement? = element.parent while (current != null) { @@ -181,6 +261,30 @@ object KotlinImportBindingCheck { else -> false } + /** + * Tells whether this statement or member declaration binds [name] in the type namespace. + * + * @param name identifier to look for + * @return `true` when this declaration introduces a classifier of that name + */ + private fun KtDeclaration.declaresClassifierName(name: String): Boolean = when (this) { + is KtClassOrObject -> this.name == name + is KtTypeAlias -> this.name == name + else -> false + } + + /** + * Returns the package this symbol is declared in, or `null` for members and locals. + * + * @return the declaring package, or `null` when the symbol is not a top-level declaration + */ + private fun KaSymbol.declaredPackage(): FqName? = when (this) { + is KaConstructorSymbol -> containingClassId?.takeUnless { classId -> classId.isLocal }?.packageFqName + is KaCallableSymbol -> callableId?.takeIf { callableId -> callableId.className == null }?.packageName + is KaClassLikeSymbol -> classId?.takeUnless { classId -> classId.isLocal }?.packageFqName + else -> (psi?.parent as? KtFile)?.packageFqName + } + /** * Tells whether this symbol is one of the declarations whose references the import retargets. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 7f6f88387..ebbbebe18 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 4fc80a968..bf470c0db 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -2878,6 +2878,191 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies rewriting a direct import does not silently drop an overload that stays behind. + * + * `import old.choose` binds the whole overload set; rewriting it to `dest.choose` removes the + * unmoved `choose(Int)`, so `choose(1)` would resolve to the moved `Number` overload instead. + */ + fun testApply_realSession_conflictsWhenRewrittenImportLosesUnmovedOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-rewritten-import") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString(sourcePath, "package old\n\nfun choose(n: Number) = 1\n") + Files.writeString(temporaryRoot.resolve("old/Int.kt"), "package old\n\nfun choose(n: Int) = 2\n") + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.choose\n\nfun use() = choose(1)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-rewritten-import-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose import loses an overload: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an emptied package wildcard is removed instead of broadened to its destination. + * + * Retargeting `import old.*` to `import dest.*` also imports unrelated declarations already in + * `dest`, which can capture names that currently resolve through another wildcard import. + */ + fun testApply_realSession_removesObsoleteWildcardInsteadOfBroadeningDestination() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-broaden") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("dest")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString(temporaryRoot.resolve("lib/Lib.kt"), "package lib\n\nfun choose(n: Number) = 2\n") + Files.writeString(temporaryRoot.resolve("dest/Existing.kt"), "package dest\n\nfun choose(n: Int) = 3\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.*\nimport lib.*\n\nfun use() = moved() + choose(1)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-broaden-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An emptied wildcard is removed rather than importing the whole destination package", + "package usage\n\nimport dest.moved\nimport lib.*\n\nfun use() = moved() + choose(1)\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies shortening to a root-package name is rejected when a member captures that name. + * + * `old.moved()` becomes `moved()`, but the enclosing class declares a member with that name, + * which outranks the root-package import, so the call would silently change meaning. + */ + fun testApply_realSession_conflictsWhenRootTargetNameIsCapturedByMember() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-root-capture") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nclass Caller {\n" + + " fun moved() = 2\n" + + " fun use() = old.moved()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-root-capture-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.ROOT), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the capturing caller: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a same-package type alias that expands to another package is still imported. + * + * `Alias()` resolves to the constructor of `other.Impl`, so the expanded declaration is not in + * the package being left; the source keeps spelling `Alias`, which only the alias declaration + * in the old package provides. + */ + fun testApply_realSession_importsSamePackageTypeAliasExpandingElsewhere() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-expanding-alias") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + Files.createDirectories(temporaryRoot.resolve("other")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(sourcePath, "package p\n\nfun make() = Alias()\n") + Files.writeString(temporaryRoot.resolve("p/Aliases.kt"), "package p\n\ntypealias Alias = other.Impl\n") + Files.writeString(temporaryRoot.resolve("other/Impl.kt"), "package other\n\nclass Impl\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-expanding-alias-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The type alias must be imported by the name the source spells", + "package dest\n\nimport p.Alias\n\nfun make() = Alias()\n", + success.changedFiles[sourcePath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 58c9c3d7a..11e4d2ff9 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -593,6 +593,46 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a type-position rewrite that would bind to a nested classifier is reported. + * + * Renaming `Before` to `After` rewrites `val value: Before?` to `After?`; inside `Caller` that + * name already denotes the nested class `Caller.After`, so the property type would silently + * change while the file keeps compiling. + */ + fun testFindCaptureConflicts_reportsNestedClassifierCapture() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-classifier") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nclass Caller {\n class After\n val value: Before? = null\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-classifier-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertEquals("The caller's nested class captures the rewritten type", caller, conflict) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/pom.xml b/pom.xml index a3f870798..1b10cb5e1 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.36-SNAPSHOT + 0.12.37-SNAPSHOT pom Netbeans Plugin Kotlin From 972faa985019eef11e96298c89d2faf85175f066 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 11:00:36 +0300 Subject: [PATCH 27/55] Fixed import priority, operator and inherited-member edge cases in rename The rewritten-import conflict pass now runs for every caller: an explicit import outranks both a wildcard of the old package and the caller's own package, so leaving those overloads visible does not preserve which overload a call selects. Convention operators are matched by their resolved callable name (`a + b` resolves to `plus`), so an explicit import that would capture an unmoved operator overload is reported. Inherited members of enclosing classes are resolved through the member scope, so a class rename cannot silently bind a rewritten call to a superclass member. Qualified selectors are skipped by the capture check because they keep resolving through their receiver, and a destination package segment that a sibling same-package declaration binds is replaced by an import instead of being used as a qualifier. Differently spelled class references are imported only when the name really denotes a type alias of the original package. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 54 ++-- .../kotlin/refactoring/KaRenameComputer.kt | 9 +- .../refactoring/KotlinImportBindingCheck.kt | 103 +++++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 232 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 81 ++++++ pom.xml | 2 +- 17 files changed, 456 insertions(+), 47 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 5244de8b4..a1007793b 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 41fa4d1d1..0780299f1 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index fc4bb8e16..b4fe62592 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 1b408d734..4288cbc56 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 91a7a35b1..2e8c17aa0 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index f45bcef07..156f56f83 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 834376551..a1bfc6bab 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index a5fe27e9f..95e6c76f1 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 6751932bf..e9bb2f349 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 033f58636..8af0e8f83 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 0c76aee9d..52d35bdd8 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -337,8 +337,9 @@ class KaChangePackageComputer( } // A rewritten direct import follows the moved declaration, but the overload set it bound // stays behind: a caller that used one of the remaining overloads would silently switch to - // the moved declaration. Only files whose package also changes lose same-package visibility - // of those overloads, so files staying in the old package are unaffected. + // the moved declaration. An explicit import outranks both a wildcard of the old package and + // the caller's own package, so keeping those overloads visible does not preserve which one + // a call selects — every rewritten import has to be checked. val movedPaths = movedFiles.mapNotNull { file -> file.virtualFile?.path }.toSet() val movedImports = movedDeclarations.values.associate { (oldFqName, newFqName) -> MoveImport(oldFqName.parent(), oldFqName.shortName().asString()) to newFqName.parent() @@ -348,21 +349,6 @@ class KaChangePackageComputer( val importedFqName = directive.importedFqName ?: return@directives if (directive.isAllUnder || directive.aliasName != null) return@directives if (importedFqName !in movedOldFqNames) return@directives - // A file that stays in the old package keeps every remaining overload visible - // there, so the rewritten import cannot change what its calls resolve to. - if (file.packageFqName == importedFqName.parent() && - file.virtualFile?.path !in movedPaths - ) { - return@directives - } - // A wildcard of the old package keeps every remaining overload visible, so the - // rewritten direct import cannot change what a call resolves to. - if (file.importDirectives.any { candidate -> - candidate.isAllUnder && candidate.importedFqName == importedFqName.parent() - } - ) { - return@directives - } if (KotlinImportBindingCheck.hasConflictingImportRewrite(file, importedFqName, movedPaths)) { conflicts += "Cannot update ${file.name}: import ${importedFqName.asString()} " + "also binds declarations that stay behind." @@ -496,7 +482,11 @@ class KaChangePackageComputer( return QualifiedRewrite.NEEDS_IMPORT } val packageHead = newFqName.parent().pathSegments().first().asString() - if (file.hasDeclarationNamed(packageHead)) { + // Same-package declarations are visible without an import, so a sibling file's + // `object dest` hides the package just like a declaration of the caller file does. + if (file.hasDeclarationNamed(packageHead) || + KotlinImportBindingCheck.isDeclaredInPackage(file, packageHead) + ) { // The new package's first segment would resolve to a local declaration instead of // the package, so the chain is shortened and the declaration imported instead. val shortName = newFqName.shortName().asString() @@ -650,20 +640,28 @@ class KaChangePackageComputer( val referencedName = expression.getReferencedName() val topLevelFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) // A call may spell a same-package type alias rather than the class it - // expands to: K2 reports the constructor of the underlying class, but - // the source keeps calling the alias, so the alias itself is imported. - // Convention operators spell `+` while resolving to `plus`, and an - // import of the spelled symbol would be unresolved; only classifiers - // resolved through a constructor or class-like symbol can be aliases. + // expands to: K2 can report the constructor of the underlying class, + // but the source keeps calling the alias, so the alias itself is + // imported. Convention operators spell `+` while resolving to `plus`, + // and an import of the spelled symbol would be unresolved; only a + // spelled name that resolves to a type alias of the original package + // may be imported, never one synthesized from a class reference. val importFqName = if (topLevelFqName != null) { // The reference may already be bound through an import alias of // the resolved declaration; the alias directive stays valid. if (expression.isAliasBound(topLevelFqName)) return - when { - referencedName == topLevelFqName.shortName().asString() -> topLevelFqName - symbol is KaConstructorSymbol || symbol is KaClassLikeSymbol -> - originalPackage.child(Name.identifier(referencedName)) - else -> topLevelFqName + if (referencedName == topLevelFqName.shortName().asString()) { + topLevelFqName + } else if (symbol is KaConstructorSymbol || symbol is KaClassLikeSymbol) { + // A differently spelled class reference denotes a type alias; + // only an alias declared in the original package can be + // imported by that name. + samePackageTypeAliasName(expression, symbol, originalPackage, movedPaths) + ?: return + } else { + // A convention operator spells `+` while resolving to `plus`; + // the resolved callable id is the only usable spelling. + topLevelFqName } } else { samePackageTypeAliasName(expression, symbol, originalPackage, movedPaths) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index c3f132dd5..53e3a7acb 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -339,8 +339,9 @@ class KaRenameComputer( * or import — makes the rewritten text resolve to that binding instead. A constructor call * then becomes a call of a member function and the code still compiles with a new meaning. * Type positions are checked against classifiers only, because Kotlin resolves type names in a - * separate namespace; receiver labels are skipped because they name a labeled scope, and - * import directives because they follow the rename rather than capture it. + * separate namespace; receiver labels are skipped because they name a labeled scope, qualified + * selectors because they keep resolving through their receiver, and import directives because + * they follow the rename rather than capture it. * * @return the first file whose rewritten reference would capture, or `null` when every * rewritten reference keeps its target @@ -357,6 +358,10 @@ class KaRenameComputer( // declaration, so a local or member of that name cannot capture it. if (element is KtLabelReferenceExpression) continue if (PsiTreeUtil.getParentOfType(element, KtImportDirective::class.java) != null) continue + // A qualified selector keeps resolving through its receiver: `p.Before()` + // becomes `p.After()` and still names the constructor, so no lexical binding + // of the new name can capture it. + if (KotlinImportBindingCheck.isQualifiedReference(element)) continue // Kotlin resolves types and values in separate namespaces: a type position is // only captured by another classifier, a value position only by another value. val captured = if (element.isTypeReference()) { diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index eac3805aa..6dd535735 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -20,6 +20,9 @@ import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol +import org.jetbrains.kotlin.analysis.api.components.memberScope +import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol @@ -27,6 +30,8 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.findPackage +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtBlockExpression @@ -43,6 +48,7 @@ import org.jetbrains.kotlin.psi.KtForExpression import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtSimpleNameExpression @@ -87,7 +93,10 @@ object KotlinImportBindingCheck { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) if (conflicting) return - if (expression.getReferencedName() != name) return + val referencedName = expression.getReferencedName() + // A convention operator spells `plus` as `+`, so an operation reference can + // still bind the name the import introduces even though its text differs. + if (referencedName != name && expression !is KtOperationReferenceExpression) return // A selector resolves through its receiver, except for an extension call on // a value: an explicit import outranks the wildcard or same-package binding // that currently selects the extension. @@ -95,6 +104,7 @@ object KotlinImportBindingCheck { if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return val symbol = expression.references.filterIsInstance().firstOrNull() ?.resolveToSymbol() ?: return + if (referencedName != name && symbol.declaredName() != name) return if (symbol.belongsTo(excludedDeclarations)) return if (!symbol.isTopLevelBinding()) return conflicting = true @@ -133,11 +143,16 @@ object KotlinImportBindingCheck { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) if (conflicting) return - if (expression.getReferencedName() != imported.shortName().asString()) return + val importedName = imported.shortName().asString() + val referencedName = expression.getReferencedName() + // A convention operator spells `plus` as `+`, so an operation reference can + // still bind the name the rewritten import selects. + if (referencedName != importedName && expression !is KtOperationReferenceExpression) return if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return if (expression.isQualifiedSelector() && !expression.isValueReceiverExtension()) return val symbol = expression.references.filterIsInstance().firstOrNull() ?.resolveToSymbol() ?: return + if (referencedName != importedName && symbol.declaredName() != importedName) return val path = symbol.psi?.containingFile?.virtualFile?.path if (path != null && path in movingPaths) return if (!symbol.isTopLevelBinding()) return @@ -175,7 +190,7 @@ object KotlinImportBindingCheck { if (bound) return true current = current.parent } - return false + return isNameBoundByInheritedMember(element, name, typesOnly = true) } /** @@ -184,7 +199,7 @@ object KotlinImportBindingCheck { * Used before rewriting a declaration name into a scope where the new name may already be * taken. Unlike [hasConflictingReference], which asks what an *import* could capture, this * asks what the rewritten *text* would resolve to: locals, parameters, members, file-level - * declarations, and import bindings all take part. + * declarations, import bindings, and inherited members all take part. * * @param element position whose rewritten text must keep its meaning * @param name new simple name the position is about to spell @@ -225,9 +240,66 @@ object KotlinImportBindingCheck { child = current current = current.parent } - return false + return isNameBoundByInheritedMember(element, name, typesOnly = false) } + /** + * Tells whether a declaration of the same package is visible under [name] in [file]. + * + * Same-package declarations need no import, so a package qualifier can resolve to a + * classifier or callable declared in another file of the caller's own package. Treating such + * a name as a free package segment would silently change which declaration a rewritten + * qualified reference calls. + * + * @param file file whose declared package is searched + * @param name identifier to look for + * @return `true` when a declaration of the same package introduces that name + */ + fun isDeclaredInPackage(file: KtFile, name: String): Boolean = runCatching { + analyze(file) { + val scope = findPackage(file.packageFqName)?.packageScope ?: return@analyze false + scope.declarations.any { declaration -> declaration.declaredName() == name } + } + }.getOrDefault(false) + + /** + * Tells whether a member inherited into an enclosing class binds [name]. + * + * Only declarations physically present in the enclosing class bodies are visible through + * PSI; a superclass member (`class Caller : Base()` calling an inherited `fun After()`) lives + * in another file but is still in scope unqualified, so the resolved member scope must be + * consulted instead. + * + * @param element position whose rewritten text must keep its meaning + * @param name identifier to look for + * @param typesOnly `true` to search the type namespace only, `false` to include values + * @return `true` when an inherited member of that name is in scope + */ + private fun isNameBoundByInheritedMember( + element: KtElement, + name: String, + typesOnly: Boolean, + ): Boolean = runCatching { + analyze(element) { + var current: PsiElement? = element.parent + while (current != null && current !is KtFile) { + if (current is KtClassOrObject) { + val scope = current.getClassOrObjectSymbol()?.memberScope + if (scope != null) { + val bound = if (typesOnly) { + scope.classifiers.any { classifier -> classifier.name?.asString() == name } + } else { + scope.declarations.any { declaration -> declaration.declaredName() == name } + } + if (bound) return@analyze true + } + } + current = current.parent + } + false + } + }.getOrDefault(false) + /** * Tells whether this import directive introduces [name] into its file. * @@ -273,6 +345,16 @@ object KotlinImportBindingCheck { else -> false } + /** + * Returns the simple name this symbol declares, or `null` when it has none. + * + * A convention operator reference is spelled by its operator (`+`), so comparing the + * reference text alone misses references whose resolved callable carries the imported name. + * + * @return the declared name, or `null` for anonymous declarations and constructors + */ + private fun KaSymbol.declaredName(): String? = (this as? KaNamedSymbol)?.name?.asString() + /** * Returns the package this symbol is declared in, or `null` for members and locals. * @@ -331,6 +413,17 @@ object KotlinImportBindingCheck { return null } + /** + * Tells whether [expression] is the selector of a qualified access. + * + * Such a name keeps resolving through its receiver, so neither an import nor a rename can + * rebind it to a lexical declaration of the same name. + * + * @param expression reference expression to inspect + * @return `true` when the expression follows a dot or a type qualifier + */ + fun isQualifiedReference(expression: KtSimpleNameExpression): Boolean = expression.isQualifiedSelector() + /** * Tells whether this name is the selector of a qualified access. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index ebbbebe18..083dad952 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index bf470c0db..3d67a6dad 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3063,6 +3063,238 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a rewritten direct import is checked even when the old package stays wildcarded. + * + * The caller keeps `import old.*`, which still binds the staying `Int` overload, but the + * rewritten `import dest.choose` outranks that wildcard, so `choose(1)` would silently switch + * to the moved `Number` overload. + */ + fun testApply_realSession_conflictsWhenDirectImportOutranksRemainingWildcard() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-priority") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString(sourcePath, "package old\n\nfun choose(n: Number) = 1\n") + Files.writeString(temporaryRoot.resolve("old/Int.kt"), "package old\n\nfun choose(n: Int) = 2\n") + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.choose\nimport old.*\n\nfun use() = choose(1)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-priority-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose rewritten import outranks the wildcard: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a rewritten direct import is checked even when the caller stays in the old package. + * + * Same-package declarations cannot outrank an explicit import, so after the redundant + * `import old.choose` becomes `import dest.choose`, `choose(1)` would bind the moved + * `Number` overload instead of the staying same-package `Int` one. + */ + fun testApply_realSession_conflictsWhenDirectImportOutranksSamePackageOverload() { + 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-priority") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString(sourcePath, "package old\n\nfun choose(n: Number) = 1\n") + Files.writeString(temporaryRoot.resolve("old/Int.kt"), "package old\n\nfun choose(n: Int) = 2\n") + Files.writeString( + temporaryRoot.resolve("old/Usage.kt"), + "package old\n\nimport old.choose\n\nfun use() = choose(1)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-same-package-priority-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose rewritten import outranks the package: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies convention operators count as references an explicit import could capture. + * + * `a + b` is spelled `+` but resolves to a callable named `plus`; adding an explicit import of + * the moved `Box.plus` outranks the wildcard binding of the staying `Child.plus`, so the + * expression would silently select the other extension. + */ + fun testApply_realSession_conflictsWhenConventionOperatorLosesUnmovedOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-operator") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Box.kt") + Files.writeString( + sourcePath, + "package old\n\nopen class Box\n\noperator fun Box.plus(other: Box) = 1\n", + ) + Files.writeString( + temporaryRoot.resolve("old/Child.kt"), + "package old\n\nclass Child : Box()\n\noperator fun Child.plus(other: Box) = 2\n", + ) + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nfun use(a: Child, b: Box) = (a + b) + (b + b)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-operator-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose operator reference would be captured: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a destination package segment bound by a sibling file is not used as a qualifier. + * + * The caller's package declares `object dest` in another file, and same-package declarations + * are visible without an import, so `dest.moved()` would select that object's member instead + * of the moved function. The reference is shortened and the declaration imported instead. + */ + fun testApply_realSession_shortensWhenSamePackageObjectCapturesDestinationSegment() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-sibling-segment") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString(temporaryRoot.resolve("usage/Helper.kt"), "package usage\n\nobject dest {\n fun moved() = 2\n}\n") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(usagePath, "package usage\n\nfun use() = old.moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-sibling-segment-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The captured package segment must be replaced by an import of the moved declaration", + "package usage\n\nimport dest.moved\n\nfun use() = moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an explicitly imported type alias stays bound when its declaring file moves. + * + * `Alias()` resolves to the constructor of the unmoved `p.Impl`, but the source spells the + * alias from `other`; that import stays valid after the package change, so no unresolved + * `p.Alias` may be synthesized. + */ + fun testApply_realSession_keepsExplicitlyImportedTypeAlias() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-imported-alias") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + Files.createDirectories(temporaryRoot.resolve("other")) + val sourcePath = temporaryRoot.resolve("p/Move.kt") + Files.writeString(sourcePath, "package p\n\nimport other.Alias\n\nfun make() = Alias()\n") + Files.writeString(temporaryRoot.resolve("other/Aliases.kt"), "package other\n\ntypealias Alias = p.Impl\n") + Files.writeString(temporaryRoot.resolve("p/Impl.kt"), "package p\n\nclass Impl\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-imported-alias-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The existing import of the alias already binds the name", + "package dest\n\nimport other.Alias\n\nfun make() = Alias()\n", + success.changedFiles[sourcePath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 11e4d2ff9..9d565840d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -633,6 +633,87 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies an inherited member capturing the new name is reported as a conflict. + * + * `class Caller : Base()` inherits `Base.After`, which is in scope unqualified. Renaming + * `Before` to `After` rewrites `Before()` to `After()`, turning the constructor call into a + * call of the inherited function while the file keeps compiling. + */ + fun testFindCaptureConflicts_reportsInheritedMemberCapture() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-inherited") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nopen class Base {\n fun After() = \"other\"\n}\n\n" + + "class Caller : Base() {\n fun make() = Before().toString()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-inherited-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertEquals("The inherited member function captures the rewritten constructor call", caller, conflict) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a fully qualified reference is not checked against lexical bindings. + * + * Renaming `Before` to `After` only changes the selector of `p.Before()`, which still names + * the constructor as `p.After()`; an unrelated member named `After` cannot capture it. + */ + fun testFindCaptureConflicts_ignoresQualifiedReference() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-qualified") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nclass Caller {\n fun After() = \"other\"\n" + + " fun make() = p.Before().toString()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-qualified-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertNull("A qualified selector keeps resolving through its receiver", conflict) + } finally { + root.toFile().deleteRecursively() + } + } + /** Locates Kotlin stdlib supplied by the Maven test runtime. */ private fun findStdlibJar(): Path? = System.getProperty("java.class.path") .split(System.getProperty("path.separator")) diff --git a/pom.xml b/pom.xml index 1b10cb5e1..e9914bbe6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.37-SNAPSHOT + 0.12.38-SNAPSHOT pom Netbeans Plugin Kotlin From 1289f90b0064ae19020802e62fb9aa5fd1fd1f07 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 12:14:19 +0300 Subject: [PATCH 28/55] Fixed import rebinding edge cases for aliases, companion extensions and destructuring An aliased import binds its alias, so the rewritten-import conflict pass now matches call sites by the bound name instead of skipping aliased directives: retargeting `import old.choose as pick` drops the overloads that stay behind just like an unaliased directive does. An extension on a companion object or a named object spells a class-like receiver while still being selected by overload resolution, so it is treated as import-sensitive rather than as a self-sufficient qualified reference. A destructuring declaration spells no name for its `componentN` operator; its multi-target reference is now resolved to the convention function declared in the original package so the moved file re-imports it. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 33 +++- .../refactoring/KotlinImportBindingCheck.kt | 23 ++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 146 ++++++++++++++++++ pom.xml | 2 +- 15 files changed, 208 insertions(+), 18 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index a1007793b..ec98e3b50 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 0780299f1..7395a25e3 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index b4fe62592..257ea69c2 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 4288cbc56..9b806fe09 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 2e8c17aa0..0e989287a 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 156f56f83..2f92af3d2 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index a1bfc6bab..26e4a4dac 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 95e6c76f1..aab7a6ab6 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index e9bb2f349..c736ed3fd 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 8af0e8f83..aba9824ef 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 52d35bdd8..906a84e68 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -24,6 +24,7 @@ import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol +import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol @@ -347,9 +348,15 @@ class KaChangePackageComputer( session.findImportingFiles(ktFile.project, movedImports).forEach { file -> file.importDirectives.forEach directives@{ directive -> val importedFqName = directive.importedFqName ?: return@directives - if (directive.isAllUnder || directive.aliasName != null) return@directives + if (directive.isAllUnder) return@directives if (importedFqName !in movedOldFqNames) return@directives - if (KotlinImportBindingCheck.hasConflictingImportRewrite(file, importedFqName, movedPaths)) { + // An aliased directive binds its alias, so its call sites have to be matched by + // that name when the rewritten directive loses the overloads staying behind. + val boundName = directive.aliasName ?: importedFqName.shortName().asString() + if (KotlinImportBindingCheck.hasConflictingImportRewrite( + file, importedFqName, movedPaths, boundName, + ) + ) { conflicts += "Cannot update ${file.name}: import ${importedFqName.asString()} " + "also binds declarations that stay behind." } @@ -681,6 +688,28 @@ class KaChangePackageComputer( missingImports += importFqName } + // A destructuring declaration spells no name for the operator it calls: + // `val (x) = box` depends on a top-level `operator fun Box.component1` + // that only the original package's visibility provided, so the convention + // reference has to be picked up separately. Its reference resolves to both + // the entry's own property and the convention function. + override fun visitDestructuringDeclarationEntry( + multiDeclarationEntry: KtDestructuringDeclarationEntry, + ) { + super.visitDestructuringDeclarationEntry(multiDeclarationEntry) + multiDeclarationEntry.references.filterIsInstance().forEach { reference -> + val symbols = runCatching { reference.resolveToSymbols() }.getOrNull() + ?: return@forEach + val importFqName = symbols.firstNotNullOfOrNull { symbol -> + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } ?: return@forEach + if (importFqName in importedFqNames || originalPackage in starPackages) { + return@forEach + } + missingImports += importFqName + } + } + // Convention operators are not spelled as simple names: `box[0]` depends // on a top-level `operator fun Box.get` that same-package visibility // provided, so its reference has to be captured separately. diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 6dd535735..13022b761 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -21,10 +21,12 @@ import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol +import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol @@ -130,12 +132,15 @@ object KotlinImportBindingCheck { * @param file caller whose import directive is being rewritten * @param imported original FQN of the moved declaration * @param movingPaths paths of the files whose package changes + * @param boundName name the directive binds at its call sites — the alias when the directive + * is aliased, the declaration's simple name otherwise * @return `true` when a reference would bind to the moved declaration instead of its own */ fun hasConflictingImportRewrite( file: KtFile, imported: FqName, movingPaths: Set, + boundName: String = imported.shortName().asString(), ): Boolean = runCatching { analyze(file) { var conflicting = false @@ -146,13 +151,14 @@ object KotlinImportBindingCheck { val importedName = imported.shortName().asString() val referencedName = expression.getReferencedName() // A convention operator spells `plus` as `+`, so an operation reference can - // still bind the name the rewritten import selects. - if (referencedName != importedName && expression !is KtOperationReferenceExpression) return + // still bind the name the rewritten import selects. An aliased directive is + // spelled only as its alias at call sites. + if (referencedName != boundName && expression !is KtOperationReferenceExpression) return if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return if (expression.isQualifiedSelector() && !expression.isValueReceiverExtension()) return val symbol = expression.references.filterIsInstance().firstOrNull() ?.resolveToSymbol() ?: return - if (referencedName != importedName && symbol.declaredName() != importedName) return + if (referencedName != boundName && symbol.declaredName() != importedName) return val path = symbol.psi?.containingFile?.virtualFile?.path if (path != null && path in movingPaths) return if (!symbol.isTopLevelBinding()) return @@ -454,7 +460,8 @@ object KotlinImportBindingCheck { * `1.choose()` and `box.choose()` bind the extension through extension-overload resolution, * where an explicit import of another same-named extension changes which one is chosen. * `Int.choose()` and `p.choose()` name a type or package as the receiver, so an import cannot - * affect them. + * affect them — except for an extension on a companion object or a named object, which is + * spelled with a class-like receiver while still being selected by overload resolution. * * @return `true` when an import of this name could change the selected extension */ @@ -464,6 +471,14 @@ object KotlinImportBindingCheck { val symbol = references.filterIsInstance().firstOrNull() ?.resolveToSymbol() as? KaCallableSymbol ?: return false if (!symbol.isExtension) return false + // `Box.choose(1)` spells the class-like receiver `Box`, but the extension declared on + // `Box.Companion` is chosen by overload resolution, so an import can still rebind it. + val receiverClass = symbol.receiverParameter?.returnType?.expandedSymbol + if (receiverClass?.classKind == KaClassKind.COMPANION_OBJECT || + receiverClass?.classKind == KaClassKind.OBJECT + ) { + return true + } val receiverRoot = qualified.receiverExpression.leadingReference() ?: return true val receiverSymbol = runCatching { receiverRoot.references.filterIsInstance().firstOrNull()?.resolveToSymbol() diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 083dad952..a4bb47131 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 3d67a6dad..7c5b7fd68 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3295,6 +3295,152 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a rewritten aliased import is checked for the overloads it stops binding. + * + * `import old.choose as pick` binds the whole overload set, so retargeting it to + * `import dest.choose as pick` while the `Int` overload stays behind would silently make + * `pick(1)` select the moved `Number` overload. + */ + fun testApply_realSession_conflictsWhenAliasedImportLosesUnmovedOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-aliased-import") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString(sourcePath, "package old\n\nfun choose(n: Number) = 1\n") + Files.writeString(temporaryRoot.resolve("old/Int.kt"), "package old\n\nfun choose(n: Int) = 2\n") + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.choose as pick\n\nfun use() = pick(1)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-aliased-import-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose aliased import loses an overload: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a companion-object extension call is treated as import-sensitive. + * + * `Box.choose(1.5)` and `Box.choose(1)` both bind extensions through overload resolution even + * though the receiver spells a class name. Adding `import dest.choose` for the moved `Number` + * overload outranks the wildcard binding of the staying `Int` overload, so `Box.choose(1)` + * would silently select the moved one. + */ + fun testApply_realSession_conflictsWhenCompanionExtensionLosesUnmovedOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-companion-extension") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Box.kt") + Files.writeString( + sourcePath, + "package old\n\nclass Box {\n companion object\n}\n\n" + + "fun Box.Companion.choose(n: Number) = 1\n", + ) + Files.writeString( + temporaryRoot.resolve("old/Int.kt"), + "package old\n\nfun Box.Companion.choose(n: Int) = 2\n", + ) + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nfun use() = Box.choose(1) + Box.choose(1.5)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-companion-extension-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose companion extension would be captured: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a destructuring declaration's `componentN` operator is imported on a package change. + * + * `val (x) = box` calls the top-level `operator fun Box.component1`, which the file only sees + * through same-package visibility. After the move the convention function must be imported + * explicitly, exactly like a named reference. + */ + fun testApply_realSession_importsDestructuringOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-destructuring") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Move.kt") + Files.writeString(sourcePath, "package p\n\nfun use(box: Box): Int {\n val (x) = box\n return x\n}\n") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\noperator fun Box.component1() = 1\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-destructuring-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("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The destructuring convention operator must be imported by its declared name", + "package q\n\nimport p.Box\nimport p.component1\n\n" + + "fun use(box: Box): Int {\n val (x) = box\n return x\n}\n", + success.changedFiles[sourcePath.toString()], + ) + } 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")) diff --git a/pom.xml b/pom.xml index e9914bbe6..0dcc34351 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.38-SNAPSHOT + 0.12.39-SNAPSHOT pom Netbeans Plugin Kotlin From ffb71cacf58039a36e855e11747e804a59fcd043 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 12:50:33 +0300 Subject: [PATCH 29/55] Fixed overload and scope rebinding edges in rename and package change Outgoing imports are now validated before anything is staged: an explicit import outranks the same-package declarations a moved file gains, so adding one would capture references to declarations moving with that file or to another package. A destination package segment is resolved as an ordinary name in scope, so inherited members and imported bindings now force the shortened chain with an explicit import instead of rewriting to a chain that calls another member. Class rename checks same-package callers that already name the new class: the renamed constructor can win overload resolution over an existing function of that name without any import being involved. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 51 +++++++++- .../refactoring/KotlinImportBindingCheck.kt | 87 +++++++++++++++++ Nbm/pom.xml | 2 +- .../nbm/refactoring/KotlinRenamePlugin.kt | 33 +++++++ .../nbm/refactoring/KaChangePackageTest.kt | 97 +++++++++++++++++++ .../KotlinRenameClassifierConflictTest.kt | 58 +++++++++++ pom.xml | 2 +- 17 files changed, 334 insertions(+), 16 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index ec98e3b50..be512d798 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 7395a25e3..bdf17eac1 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 257ea69c2..78c3b2d4a 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 9b806fe09..8a272db39 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 0e989287a..f06526f75 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 2f92af3d2..ea1b8b42c 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 26e4a4dac..5c6c4de5e 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index aab7a6ab6..8955363ab 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index c736ed3fd..babed6d34 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index aba9824ef..fc0ea93cc 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 906a84e68..479c248d4 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -137,9 +137,13 @@ class KaChangePackageComputer( } else { IncomingRetargeting() } + // Outgoing imports are validated before anything is staged as well: an explicit + // import outranks the same-package declarations the moved file gains, so it can + // capture references to declarations moving along with that file. + val outgoingImports = if (updateReferences) findOutgoingSamePackageImports(files, targets) else emptyMap() val conflicts = processors.flatMap { processor -> processor.findConflicts(usages.filterIsInstance()).values() - } + incoming.conflicts + } + incoming.conflicts + outgoingImportConflicts(outgoingImports, files, movedDeclarations.keys) if (conflicts.isNotEmpty()) return ApplyOutcome.Conflicts(conflicts.distinct()) // Class-scoped wildcard imports (`import old.Outer.*`) follow the class only when the // prefix is a moved class; package wildcards never move with it. @@ -155,7 +159,6 @@ class KaChangePackageComputer( } else { emptyList() } - val outgoingImports = if (updateReferences) findOutgoingSamePackageImports(files, targets) else emptyMap() val samePackageImportUpdates = (incoming.imports.keys + outgoingImports.keys).associateWith { file -> incoming.imports[file].orEmpty() + outgoingImports[file].orEmpty() } @@ -369,6 +372,40 @@ class KaChangePackageComputer( ) } + /** + * Reports outgoing imports that cannot be added without changing an existing reference. + * + * An outgoing import keeps a moved file bound to a declaration left in its former package, + * but an explicit import outranks the same-package declarations the file gains in its new + * package. A second import of the same simple name is a conflicting import, so both cases + * abort the change before any text is staged. + * + * @param outgoingImports moved files mapped to the imports their references now require + * @param movedFiles Kotlin sources whose declared package changes + * @param movedOldFqNames original FQNs of the declarations moving with those files + * @return conflict messages for imports that cannot be added safely + */ + private fun outgoingImportConflicts( + outgoingImports: Map>, + movedFiles: Collection, + movedOldFqNames: Set, + ): List { + val movedPaths = movedFiles.mapNotNull { file -> file.virtualFile?.path }.toSet() + return outgoingImports.flatMap { (file, imports) -> + imports.mapNotNull { importFqName -> + val name = importFqName.shortName().asString() + when { + file.hasConflictingImport(importFqName, movedOldFqNames) -> + "Cannot update ${file.name}: it already imports another declaration named $name." + KotlinImportBindingCheck.hasConflictingOutgoingImport(file, importFqName, movedPaths) -> + "Cannot update ${file.name}: importing $name would capture a reference " + + "to a declaration that moves with the file." + else -> null + } + } + } + } + /** * Tells whether [file] already imports a different declaration under the name [imported] binds. * @@ -489,9 +526,15 @@ class KaChangePackageComputer( return QualifiedRewrite.NEEDS_IMPORT } val packageHead = newFqName.parent().pathSegments().first().asString() - // Same-package declarations are visible without an import, so a sibling file's - // `object dest` hides the package just like a declaration of the caller file does. + // The package segment is resolved as an ordinary name at this position, so a local, + // parameter, member, inherited member, or import of the caller can hide the package. + // A sibling file's `object dest` is visible through the package as well. if (file.hasDeclarationNamed(packageHead) || + KotlinImportBindingCheck.isNameBoundInScope( + element, + packageHead, + ignoredDeclarations = setOf(movedDeclaration), + ) || KotlinImportBindingCheck.isDeclaredInPackage(file, packageHead) ) { // The new package's first segment would resolve to a local declaration instead of diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 13022b761..e7816aebb 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -170,6 +170,93 @@ object KotlinImportBindingCheck { } }.getOrDefault(false) + /** + * Tells whether adding [imported] to a moved file would capture one of its own references. + * + * An outgoing import keeps a moved file bound to a declaration left in its former package, + * but an explicit import outranks the same-package declarations of the file's new package and + * any wildcard import. A reference that currently resolves to a declaration moving with the + * file, or to a declaration of another package, would silently bind to the staying declaration + * instead. + * + * @param file moved file that is about to receive the import + * @param imported FQN of the declaration that stays behind + * @param movingPaths paths of the files whose package changes + * @return `true` when an unqualified reference would bind to the staying declaration instead + */ + fun hasConflictingOutgoingImport( + file: KtFile, + imported: FqName, + movingPaths: Set, + ): Boolean = runCatching { + analyze(file) { + var conflicting = false + file.accept(object : KtTreeVisitorVoid() { + override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { + super.visitSimpleNameExpression(expression) + if (conflicting) return + val importedName = imported.shortName().asString() + val referencedName = expression.getReferencedName() + if (referencedName != importedName && expression !is KtOperationReferenceExpression) return + if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return + if (expression.isQualifiedSelector() && !expression.isValueReceiverExtension()) return + val symbol = expression.references.filterIsInstance().firstOrNull() + ?.resolveToSymbol() ?: return + if (referencedName != importedName && symbol.declaredName() != importedName) return + if (!symbol.isTopLevelBinding()) return + val path = symbol.psi?.containingFile?.virtualFile?.path + if (path != null && path in movingPaths) { + conflicting = true + return + } + if (symbol.declaredPackage() != imported.parent()) conflicting = true + } + }) + conflicting + } + }.getOrDefault(false) + + /** + * Tells whether [file] already refers to a same-package declaration named [name]. + * + * Adding a top-level declaration to a package changes overload resolution for unqualified + * references to that name, so a same-package reference that currently resolves to another + * top-level callable can silently bind to the added declaration. An explicit import of the + * name from elsewhere keeps winning over the package, so it is not a conflict. + * + * @param file caller that already names the declaration + * @param name simple name of the declaration being added + * @param packageName package the declaration is added to + * @param excludedDeclarations declarations the added name is meant for + * @return `true` when a reference to another same-package declaration would be affected + */ + fun hasSamePackageReferenceToOtherDeclaration( + file: KtFile, + name: String, + packageName: FqName, + excludedDeclarations: Set, + ): Boolean = runCatching { + analyze(file) { + var conflicting = false + file.accept(object : KtTreeVisitorVoid() { + override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { + super.visitSimpleNameExpression(expression) + if (conflicting) return + if (expression.getReferencedName() != name) return + if (expression.isQualifiedSelector()) return + if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return + val symbol = expression.references.filterIsInstance().firstOrNull() + ?.resolveToSymbol() ?: return + if (symbol.belongsTo(excludedDeclarations)) return + if (!symbol.isTopLevelBinding()) return + if (symbol.declaredPackage() != packageName) return + conflicting = true + } + }) + conflicting + } + }.getOrDefault(false) + /** * Tells whether [name] is already bound to a classifier at [element]. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index a4bb47131..3cf1c3242 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index 15ee23ac8..ec9bfc726 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -116,6 +116,7 @@ class KotlinRenameFilePlugin( ) } val caller = findCallerScopeConflict(source, primaryClass, refactoring.newName, files) + ?: findSamePackageOverloadConflict(source, primaryClass, refactoring.newName, files) ?: return captureConflictProblem(source, primaryClass, files) return Problem( true, @@ -627,6 +628,38 @@ internal fun findCallerScopeConflict( } } +/** + * Finds a same-package caller whose existing reference to the new name would rebind. + * + * The renamed class adds a constructor under the new name to its package, and a same-package + * caller needs no import for it. Overload resolution can then prefer the constructor over an + * existing function of that name — `After(1)` with `fun After(n: Number)` switches to the newly + * named `After(Int)` constructor — while the file still compiles. + * + * @param source file whose primary class is renamed + * @param primaryClass class renamed together with the file + * @param newName requested class name + * @param files Kotlin sources of the project + * @return the first caller whose references would change their target, or `null` when none would + */ +internal fun findSamePackageOverloadConflict( + source: KtFile, + primaryClass: KtClass, + newName: String, + files: Collection, +): KtFile? { + val packageName = source.packageFqName + return files.firstOrNull { file -> + file.packageFqName == packageName && + KotlinImportBindingCheck.hasSamePackageReferenceToOtherDeclaration( + file, + newName, + packageName, + setOf(primaryClass), + ) + } +} + /** * Tells whether this top-level function has the same call signature as a primary constructor. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 7c5b7fd68..67ae4f168 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3441,6 +3441,103 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an outgoing import that would capture a moving overload is reported. + * + * The caller moves to `dest` together with the `Int` overload while the `Number` overload + * stays in `old`. Keeping the latter visible requires `import old.choose`, but that explicit + * import outranks the moved `dest.choose(Int)`, so `choose(1)` would silently switch overload. + */ + fun testApply_realSession_conflictsWhenOutgoingImportCapturesMovingOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-outgoing-capture") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val intPath = temporaryRoot.resolve("old/Int.kt") + Files.writeString(intPath, "package old\n\nfun choose(n: Int) = 2\n") + Files.writeString(temporaryRoot.resolve("old/Number.kt"), "package old\n\nfun choose(n: Number) = 1\n") + val usagePath = temporaryRoot.resolve("old/Usage.kt") + Files.writeString(usagePath, "package old\n\nfun use() = choose(1) + choose(1.5)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-outgoing-capture-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(temporaryRoot), + ) + val intSource = session.getKtFileForPath(intPath.toString()) ?: error("Could not obtain source PSI") + val usageSource = session.getKtFileForPath(usagePath.toString()) ?: error("Could not obtain usage PSI") + + val outcome = KaChangePackageComputer(usageSource).apply( + mapOf(intSource to FqName("dest"), usageSource to FqName("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose outgoing import would capture a moved overload: " + + conflicts.messages, + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a destination package segment inherited from a superclass is not treated as free. + * + * `Base` exposes `val dest: Holder` and `Holder` has its own `moved()`, so rewriting + * `old.moved()` to `dest.moved()` would call the holder's member. The chain must be shortened + * and the moved declaration imported instead. + */ + fun testApply_realSession_shortensWhenDestinationSegmentIsInherited() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-inherited-segment") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString( + temporaryRoot.resolve("usage/Base.kt"), + "package usage\n\nopen class Base {\n val dest: Holder = Holder()\n}\n\n" + + "class Holder {\n fun moved() = 2\n}\n", + ) + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + usagePath, + "package usage\n\nclass Caller : Base() {\n fun use() = old.moved()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-inherited-segment-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An inherited destination segment must be replaced by an import of the moved declaration", + "package usage\n\nimport dest.moved\n\nclass Caller : Base() {\n fun use() = moved()\n}\n", + success.changedFiles[usagePath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt index 99b0381ba..efb49595d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -202,6 +202,64 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie } } + /** + * Verifies a same-package caller using the new name blocks the rename. + * + * `fun After(n: Number)` already exists in the package, and the renamed `Before(Int)` becomes + * an `After(Int)` constructor. The caller needs no import for either, but `After(1)` would + * start selecting the more specific constructor while still compiling. + */ + fun testFindSamePackageOverloadConflict_reportsCallerUsingNewName() { + withSession( + name = "same-package-overload", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After(n: Number) = \"other\"\n") + Files.writeString(root.resolve("usage/Usage.kt"), "package p\n\nfun use() = After(1).toString()\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, functions, usage), + ) + + assertEquals("Usage.kt", conflict?.virtualFile?.name) + } + } + + /** Verifies a same-package caller of an unrelated name is not reported. */ + fun testFindSamePackageOverloadConflict_ignoresCallerWithoutNewName() { + withSession( + name = "same-package-overload-clean", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("usage/Usage.kt"), "package p\n\nfun use() = Foo(1)\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, usage), + ) + + assertNull("a caller that never names After cannot switch overload", conflict) + } + } + /** @return the single top-level class of [source]. */ private fun primaryClass(source: KtFile): KtClass = source.declarations.filterIsInstance().single() diff --git a/pom.xml b/pom.xml index 0dcc34351..5a4735ddd 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.39-SNAPSHOT + 0.12.40-SNAPSHOT pom Netbeans Plugin Kotlin From b16edb443ba135a1546ebf6a30c2168d80436fde Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 13:39:56 +0300 Subject: [PATCH 30/55] Fixed implicit-scope capture and convention import edge cases Class rename now treats the members of an extension receiver or receiver lambda as in scope when checking whether the new name is already bound, and considers classifiers declared earlier in an enclosing block when checking rewritten type positions. Change Package now imports the iterator operator a moved for loop depends on, and resolves a same-spelled type alias from the old package instead of assuming the resolved class name is spelled directly. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 25 +++++- .../refactoring/KotlinImportBindingCheck.kt | 32 +++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 88 +++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 80 +++++++++++++++++ pom.xml | 2 +- 16 files changed, 230 insertions(+), 19 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index be512d798..acab48101 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index bdf17eac1..1e27d733f 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 78c3b2d4a..3bff814b4 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 8a272db39..48f44deb8 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index f06526f75..d4f698338 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index ea1b8b42c..707653d4c 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 5c6c4de5e..fa29060a1 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 8955363ab..76e5ed642 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index babed6d34..8e1558202 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index fc0ea93cc..ab578fc20 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 479c248d4..a7339d16f 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -50,6 +50,7 @@ import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtForExpression import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction @@ -753,6 +754,25 @@ class KaChangePackageComputer( } } + // A for loop spells no name for the convention it calls: `for (x in box)` + // depends on a top-level `operator fun Box.iterator` that only the + // original package's visibility provided. Its reference resolves to both + // the loop variable's property and the convention function. + override fun visitForExpression(expression: KtForExpression) { + super.visitForExpression(expression) + expression.references.filterIsInstance().forEach { reference -> + val symbols = runCatching { reference.resolveToSymbols() }.getOrNull() + ?: return@forEach + val importFqName = symbols.firstNotNullOfOrNull { symbol -> + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } ?: return@forEach + if (importFqName in importedFqNames || originalPackage in starPackages) { + return@forEach + } + missingImports += importFqName + } + } + // Convention operators are not spelled as simple names: `box[0]` depends // on a top-level `operator fun Box.get` that same-package visibility // provided, so its reference has to be captured separately. @@ -854,8 +874,9 @@ class KaChangePackageComputer( } ?: return null if (classId.isLocal || classId.isNestedClass) return null val referencedName = expression.getReferencedName() - // Only a differently spelled class reference can be a type alias call. - if (referencedName == classId.shortClassName.asString()) return null + // The resolved constructor may belong to the alias itself or to the class it expands to, + // depending on how K2 names the symbol; the package-scope lookup below settles it either + // way, so a name equality with the class id must not skip it. val file = expression.containingKtFile // Any explicit import of the spelled name binds it wherever it lives and stays valid. val importedName = file.importDirectives.any { directive -> diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index e7816aebb..a8cc9dcfe 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -21,6 +21,7 @@ import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol +import org.jetbrains.kotlin.analysis.api.components.KaScopeImplicitReceiverValue import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope @@ -273,14 +274,23 @@ object KotlinImportBindingCheck { val file = element.containingKtFile if (file.importDirectives.any { directive -> directive.bindsName(name, emptySet()) }) return true if (file.declarations.any { declaration -> declaration.declaresClassifierName(name) }) return true + var child: PsiElement = element var current: PsiElement? = element.parent while (current != null) { val bound = when (current) { + // A local class or type alias is visible only after its declaration and only + // inside the block that declares it; it is not a member of any enclosing class. + is KtBlockExpression -> current.statements + .takeWhile { statement -> statement.textRange.startOffset < child.textRange.startOffset } + .any { statement -> + (statement as? KtDeclaration)?.declaresClassifierName(name) == true + } is KtClassBody -> current.declarations.any { declaration -> declaration.declaresClassifierName(name) } is KtTypeParameterListOwner -> current.typeParameters.any { parameter -> parameter.name == name } else -> false } if (bound) return true + child = current current = current.parent } return isNameBoundByInheritedMember(element, name, typesOnly = true) @@ -356,17 +366,18 @@ object KotlinImportBindingCheck { }.getOrDefault(false) /** - * Tells whether a member inherited into an enclosing class binds [name]. + * Tells whether a member of an enclosing or implicit receiver binds [name]. * * Only declarations physically present in the enclosing class bodies are visible through * PSI; a superclass member (`class Caller : Base()` calling an inherited `fun After()`) lives - * in another file but is still in scope unqualified, so the resolved member scope must be - * consulted instead. + * in another file but is still in scope unqualified. An extension function's receiver + * (`fun Receiver.make()`) and a receiver lambda contribute their members through implicit + * receivers rather than PSI ancestors, so K2's scope tower is consulted as well. * * @param element position whose rewritten text must keep its meaning * @param name identifier to look for * @param typesOnly `true` to search the type namespace only, `false` to include values - * @return `true` when an inherited member of that name is in scope + * @return `true` when an inherited member or implicit receiver member of that name is in scope */ private fun isNameBoundByInheritedMember( element: KtElement, @@ -389,7 +400,18 @@ object KotlinImportBindingCheck { } current = current.parent } - false + // Extension receivers and receiver lambdas are not PSI ancestors of the position, but + // their members shadow top-level declarations exactly like those of an enclosing class. + element.containingKtFile.scopeContext(element).implicitValues + .filterIsInstance() + .any { receiver -> + val scope = receiver.type.expandedSymbol?.memberScope ?: return@any false + if (typesOnly) { + scope.classifiers.any { classifier -> classifier.name?.asString() == name } + } else { + scope.declarations.any { declaration -> declaration.declaredName() == name } + } + } } }.getOrDefault(false) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 3cf1c3242..d1b379453 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 67ae4f168..49fdbd94c 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3538,6 +3538,94 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a for loop imports the convention operator left behind. + * + * `for (x in box)` spells no name for `iterator`, which same-package visibility provided; after + * the move, that top-level function must be imported explicitly or the loop stops compiling. + */ + fun testApply_realSession_addsImportForOutgoingForLoopIterator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-for-loop") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(sourcePath, "package p\n\nfun use(box: Box) {\n for (x in box) println(x)\n}\n") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\noperator fun Box.iterator(): Iterator = emptyList().iterator()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-for-loop-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("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "A for loop needs the iterator operator dependency imported as well", + "package q\n\nimport p.Box\nimport p.iterator\n\n" + + "fun use(box: Box) {\n for (x in box) println(x)\n}\n", + success.changedFiles[sourcePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a type alias whose name equals the expanded class is still imported. + * + * `typealias Impl = other.Impl` is invisible after the move, so `Impl()` needs `import p.Impl` + * even though the resolved constructor's simple name already matches the spelled name. + */ + fun testApply_realSession_importsSameNamedTypeAliasExpandingElsewhere() { + 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-named-alias") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + Files.createDirectories(temporaryRoot.resolve("other")) + val sourcePath = temporaryRoot.resolve("p/Move.kt") + Files.writeString(sourcePath, "package p\n\nfun make() = Impl()\n") + Files.writeString(temporaryRoot.resolve("p/Aliases.kt"), "package p\n\ntypealias Impl = other.Impl\n") + Files.writeString(temporaryRoot.resolve("other/Impl.kt"), "package other\n\nclass Impl\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-same-named-alias-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The type alias must be imported by the name the source spells", + "package dest\n\nimport p.Impl\n\nfun make() = Impl()\n", + success.changedFiles[sourcePath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 9d565840d..2d22ef615 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -674,6 +674,86 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a member of an extension receiver capturing the new name is reported. + * + * Inside `fun Receiver.make()`, the receiver's members are in scope unqualified. Renaming + * `Before` to `After` rewrites `Before()` to `After()`, which the receiver's member function + * would capture while the file keeps compiling. + */ + fun testFindCaptureConflicts_reportsExtensionReceiverCapture() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-receiver") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nclass Receiver {\n fun After() = \"other\"\n}\n\n" + + "fun Receiver.make() = Before().toString()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-receiver-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertEquals("The extension receiver's member captures the rewritten constructor call", caller, conflict) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a block-local classifier capturing a rewritten type is reported. + * + * `class After` declared earlier in the function body is visible unqualified, so rewriting + * `x is Before` to `x is After` would silently test the local class instead. + */ + fun testFindCaptureConflicts_reportsBlockLocalClassifierCapture() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-block-classifier") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nfun accepts(x: Any): Boolean {\n class After\n return x is Before\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-block-classifier-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertEquals("The block-local class captures the rewritten type", caller, conflict) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a fully qualified reference is not checked against lexical bindings. * diff --git a/pom.xml b/pom.xml index 5a4735ddd..16cf7b785 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.40-SNAPSHOT + 0.12.41-SNAPSHOT pom Netbeans Plugin Kotlin From f09a644759264cf380f8d4225e342a8d3314698e Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 14:18:26 +0300 Subject: [PATCH 31/55] Fixed same-package, convention and object-receiver capture edges in rename and package change Rename capture detection now also consults the caller's package scope when the caller shares the renamed declaration's package, because same-package functions need no import and take part in overload resolution. Import-safety checks now inspect convention references of array access, destructuring entries and for loops, which spell no name for the callable they invoke. Outgoing-import discovery treats any resolved non-package receiver (including top-level objects) as a value, so its extension call keeps its import dependency. Calls bound to a same-spelled type alias are left untouched because the alias keeps its own name. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 14 +-- .../kotlin/refactoring/KaRenameComputer.kt | 62 ++++++++++++- .../refactoring/KotlinImportBindingCheck.kt | 69 ++++++++++++++ Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 89 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 91 +++++++++++++++++++ pom.xml | 2 +- 17 files changed, 326 insertions(+), 23 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index acab48101..075572d4a 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 1e27d733f..f073c6b57 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 3bff814b4..a9ba2579d 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 48f44deb8..fa4037eda 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index d4f698338..977413896 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 707653d4c..0ffc545bb 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index fa29060a1..47e3ee792 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 76e5ed642..841b523aa 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 8e1558202..1ec94f820 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index ab578fc20..69fa95fe0 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index a7339d16f..cd3f1b204 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -31,7 +31,6 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol -import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol import org.jetbrains.kotlin.analysis.api.symbols.findPackage import org.jetbrains.kotlin.idea.k2.refactoring.move.KotlinMoveUsageSearchService import org.jetbrains.kotlin.idea.k2.refactoring.move.MoveImport @@ -720,15 +719,18 @@ class KaChangePackageComputer( } if (importFqName in importedFqNames || originalPackage in starPackages) return // An extension access on a value receiver can spell the package name - // (`p.extra` on a parameter `p`), so a variable receiver keeps the - // access dependent on an import and bypasses the spelling test. - val receiverIsVariable = expression.qualifiedReceiver()?.let { receiver -> + // (`p.extra` on a parameter `p`, or on a top-level `object p`), so a + // resolved receiver keeps the access dependent on an import and + // bypasses the spelling test. K2 resolves package references to + // `null` in this position, so any concrete non-package symbol proves + // the chain root is a value rather than a package. + val receiverIsValue = expression.qualifiedReceiver()?.let { receiver -> val symbol = runCatching { receiver.leadingReference()?.mainReference?.resolveToSymbol() }.getOrNull() - symbol is KaVariableSymbol + symbol != null && symbol !is KaPackageSymbol } == true - if (!receiverIsVariable && expression.isPackageQualified(originalPackage)) return + if (!receiverIsValue && expression.isPackageQualified(originalPackage)) return missingImports += importFqName } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 53e3a7acb..2db2240ee 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -23,13 +23,18 @@ import com.intellij.psi.PsiField import com.intellij.psi.PsiMethod import com.intellij.psi.PsiNamedElement import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol +import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol +import org.jetbrains.kotlin.analysis.api.symbols.findPackage import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.kdoc.lexer.KDocTokens import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtConstructorDelegationReferenceExpression @@ -215,6 +220,31 @@ class KaRenameComputer( return true } + /** + * Tells whether the spelled [name] denotes a type alias rather than the class it expands to. + * + * A constructor call through a same-spelled type alias resolves to the expanded class's + * constructor, so the class-id match alone cannot tell `Before()` from + * `typealias Before = p.Before`. The alias declaration keeps its own name when the class is + * renamed, so its call sites must stay untouched. Type aliases are top-level only, so the + * file's explicit imports and its own package scope are enough to find the declaration the + * name binds. + * + * @param name simple name spelled by this reference + * @return `true` when that name is bound to a type alias visible in this file + */ + context(_: KaSession) + private fun KtSimpleNameExpression.spellsTypeAlias(name: String): Boolean { + val file = containingKtFile + val explicit = file.importDirectives.firstOrNull { directive -> + !directive.isAllUnder && directive.aliasName == null && + directive.importedFqName?.shortName()?.asString() == name + }?.importedFqName + val packageName = explicit?.parent() ?: file.packageFqName + return findPackage(packageName)?.packageScope?.classifiers + ?.firstOrNull { classifier -> classifier.name?.asString() == name } is KaTypeAliasSymbol + } + /** * Adds a [TextChange] for the name identifier of [targetDecl] to [into]. * @@ -351,6 +381,10 @@ class KaRenameComputer( val targetPsi = resolveTargetPsi() ?: return null val targets = mutableSetOf(targetPsi) collectOverrideTargets(targetPsi, targets) + // Declarations of the caller's own package need no import, so a sibling declaration of the + // new name is in scope unqualified; only a file that shares the renamed declaration's + // package relies on that visibility. + val targetPackage = (targetPsi as? KtNamedDeclaration)?.containingKtFile?.packageFqName for (kaFile in projectKtFiles) { for (target in targets) { for (element in collectReferenceElements(kaFile, target)) { @@ -367,7 +401,8 @@ class KaRenameComputer( val captured = if (element.isTypeReference()) { KotlinImportBindingCheck.isClassifierBoundInScope(element, newName) } else { - KotlinImportBindingCheck.isNameBoundInScope(element, newName) + KotlinImportBindingCheck.isNameBoundInScope(element, newName) || + isDeclaredInPackageScope(kaFile, targetPackage) } if (captured) return kaFile } @@ -376,6 +411,24 @@ class KaRenameComputer( return null } + /** + * Tells whether a declaration of the caller's own package binds the new name. + * + * A same-package function is visible unqualified and takes part in overload resolution, so the + * rewritten `After(1)` may select it instead of the renamed constructor even when the file + * never spells `After`. The check applies only when the file shares the package of the renamed + * declaration: elsewhere the rewritten reference is bound by an explicit import, which wins + * over same-package declarations. + * + * @param file caller being searched + * @param targetPackage package of the declaration being renamed, or `null` when unknown + * @return `true` when the file's package declares that name + */ + private fun isDeclaredInPackageScope(file: KtFile, targetPackage: FqName?): Boolean = + targetPackage != null && + file.packageFqName == targetPackage && + KotlinImportBindingCheck.isDeclaredInPackage(file, newName) + /** * Tells whether this name spells a type rather than a value. * @@ -444,10 +497,9 @@ class KaRenameComputer( val simpleName = expression as? KtSimpleNameExpression ?: return val targetName = (targetPsi as? KtNamedDeclaration)?.name if (targetName != null && simpleName.getReferencedName() != targetName) return - if (simpleName.getReferencedName() in aliasNames && - simpleName.isAliasUsage() - ) { - return + if (simpleName.isAliasUsage()) { + val referencedName = simpleName.getReferencedName() + if (referencedName in aliasNames || simpleName.spellsTypeAlias(referencedName)) return } val sym = runCatching { expression.mainReference?.resolveToSymbol() diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index a8cc9dcfe..cc3561c75 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -26,6 +26,7 @@ import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol +import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol @@ -37,6 +38,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.findPackage import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtCatchClause @@ -44,6 +46,7 @@ import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration import org.jetbrains.kotlin.psi.KtDestructuringDeclaration +import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement import org.jetbrains.kotlin.psi.KtFile @@ -112,6 +115,31 @@ object KotlinImportBindingCheck { if (!symbol.isTopLevelBinding()) return conflicting = true } + + // Convention references spell no name for the callable they invoke: `n[0]` calls + // `get`, `val (a) = box` calls `componentN`, and `for (x in box)` calls + // `iterator`. An import of that name can retarget them exactly like a spelled + // reference, so they go through the same check. + override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { + super.visitArrayAccessExpression(expression) + if (!conflicting) { + expression.capturesTopLevelName(name, excludedDeclarations) { conflicting = true } + } + } + + override fun visitDestructuringDeclarationEntry(entry: KtDestructuringDeclarationEntry) { + super.visitDestructuringDeclarationEntry(entry) + if (!conflicting) { + entry.capturesTopLevelName(name, excludedDeclarations) { conflicting = true } + } + } + + override fun visitForExpression(expression: KtForExpression) { + super.visitForExpression(expression) + if (!conflicting) { + expression.capturesTopLevelName(name, excludedDeclarations) { conflicting = true } + } + } }) conflicting } @@ -514,6 +542,47 @@ object KotlinImportBindingCheck { else -> false } + /** + * Passes every symbol resolved by the references of this element to [action]. + * + * Convention constructs (`n[0]`, `val (a) = box`, `for (x in box)`) expose their callable only + * through the references of the whole element, and a multi-reference such as the for loop's + * resolves to several symbols, so all of them are reported. + * + * @param action invoked once per resolved symbol + */ + context(_: KaSession) + private fun KtElement.forEachConventionSymbol(action: (KaSymbol) -> Unit) { + references.filterIsInstance().forEach { reference -> + runCatching { reference.resolveToSymbols() }.getOrNull()?.forEach(action) + } + } + + /** + * Tells whether this convention construct would start calling a top-level [name] declaration. + * + * A convention call spells no name, so comparing reference text cannot see it; the resolved + * callable carries the name instead. References that already point at one of + * [excludedDeclarations] keep doing so and are not captures. + * + * @param name the name the import introduces + * @param excludedDeclarations declarations the import is meant for + * @param onCapture invoked when a capture is found + */ + context(_: KaSession) + private fun KtElement.capturesTopLevelName( + name: String, + excludedDeclarations: Set, + onCapture: () -> Unit, + ) { + forEachConventionSymbol { symbol -> + if (symbol.belongsTo(excludedDeclarations)) return@forEachConventionSymbol + if (symbol.declaredName() != name) return@forEachConventionSymbol + if (!symbol.isTopLevelBinding()) return@forEachConventionSymbol + onCapture() + } + } + /** * Returns the innermost class or object containing this element, or `null` at file level. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index d1b379453..23b4de6ff 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 49fdbd94c..fb18afb04 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3626,6 +3626,95 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an explicit import of a moved `get` operator does not silently retarget `n[0]`. + * + * `import old.*` binds both the `Int` operator that stays behind and the moved `Number` one, + * and the indexed accesses on a `Double` and an `Int` select different overloads. Adding + * `import dest.get` makes the explicit import outrank the wildcard, so `n[0]` would start + * calling the moved `Number` extension. + */ + fun testApply_realSession_conflictsWhenImportWouldRebindArrayAccessOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-array-conflict") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString(sourcePath, "package old\n\noperator fun Number.get(index: Int) = 1\n") + Files.writeString(temporaryRoot.resolve("old/Int.kt"), "package old\n\noperator fun Int.get(index: Int) = 2\n") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(n: Int) = (1.5).get(0) + n[0]\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-array-conflict-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the rebinding caller: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an object receiver is not mistaken for a package qualifier. + * + * `object p` in package `p` makes `p.extra()` a value-receiver extension call, so moving the + * caller must import both the object and its extension, not treat `p` as the package spelling. + */ + fun testApply_realSession_importsObjectReceiverExtension() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-object-receiver") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(sourcePath, "package p\n\nfun use() = p.extra()\n") + Files.writeString(temporaryRoot.resolve("p/Helpers.kt"), "package p\n\nobject p\n\nfun p.extra() = 1\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-object-receiver-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "An object receiver keeps the extension dependent on an import", + "package dest\n\nimport p.p\nimport p.extra\n\nfun use() = p.extra()\n", + success.changedFiles[sourcePath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 2d22ef615..67e105c9b 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -754,6 +754,97 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a same-package function of the new name capturing a rewritten constructor call is + * reported. + * + * `fun After(n: Int)` is declared in the caller's package, so the rewritten `After(1)` would + * select the function's exact `Int` parameter instead of the constructor taking `Number`. + */ + fun testFindCaptureConflicts_reportsSamePackageFunctionCapture() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-package-function") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val functionsPath = root.resolve("p/Functions.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before(val n: Number)\n") + Files.writeString(functionsPath, "package p\n\nfun After(n: Int) = \"other\"\n") + Files.writeString(callerPath, "package p\n\nfun use() = Before(1).toString()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-package-function-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val functions = session.getKtFileForPath(functionsPath.toString()) ?: error("Could not obtain functions PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, functions, caller), "After", + ).findCaptureConflicts() + + assertEquals("The same-package function captures the rewritten constructor call", caller, conflict) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a same-spelled type alias keeps its own name when the expanded class is renamed. + * + * `typealias Before = p.Before` in another package stays named `Before`; rewriting its call + * site to `After()` would leave it unresolved even though the alias's right-hand side follows + * the rename to `p.After`. + */ + fun testCompute_keepsSameSpelledTypeAliasCall() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping rename-compute test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-same-named-alias") + try { + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + val sourcePath = root.resolve("p/Before.kt") + val aliasesPath = root.resolve("q/Aliases.kt") + val usagePath = root.resolve("q/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString(aliasesPath, "package q\n\ntypealias Before = p.Before\n") + Files.writeString(usagePath, "package q\n\nfun make() = Before()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-same-named-alias-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val aliases = session.getKtFileForPath(aliasesPath.toString()) ?: error("Could not obtain aliases PSI") + val usage = session.getKtFileForPath(usagePath.toString()) ?: error("Could not obtain usage PSI") + + val changes = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, aliases, usage), "After", + ).compute() + + assertNotNull("compute() must return non-null for an alias declaration", changes) + assertEquals( + "Only the alias's right-hand side follows the rename", + "package q\n\ntypealias Before = p.After\n", + applyChanges(aliases.text, changes!![aliasesPath.toString()].orEmpty()), + ) + assertEquals( + "The alias call site keeps spelling the alias", + "package q\n\nfun make() = Before()\n", + applyChanges(usage.text, changes[usagePath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a fully qualified reference is not checked against lexical bindings. * diff --git a/pom.xml b/pom.xml index 16cf7b785..89ec7f3a6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.41-SNAPSHOT + 0.12.42-SNAPSHOT pom Netbeans Plugin Kotlin From 264e6f6ec8508d03d9ada816c352370e3e093365 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 14:57:28 +0300 Subject: [PATCH 32/55] Fixed wildcard, destructured-parameter and indexed-setter edges in rename and package change Same-package renamed declarations now also conflict with references bound through star or default imports, which the new package declaration outranks; explicit imports keep winning and stay untouched. Lexical capture detection now sees destructured function, lambda, for-loop, catch and accessor parameters. Compound indexed updates look up the implicit `set` extension by receiver type, because K2 exposes only `get` for them, so both operators keep their imports. Files whose only old-package dependency is an unused `import old.*` now join the Change Package participants, letting the cleanup pass drop the directive once the package is vacated. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 113 ++++++++++++++++-- .../refactoring/KotlinImportBindingCheck.kt | 40 +++++-- .../move/KotlinMoveUsageSearchService.kt | 17 +++ Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 26 ++++ .../nbm/refactoring/KaChangePackageTest.kt | 88 ++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 39 ++++++ .../KotlinRenameClassifierConflictTest.kt | 69 +++++++++++ pom.xml | 2 +- 19 files changed, 385 insertions(+), 31 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 075572d4a..5f85f302c 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index f073c6b57..29402210b 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index a9ba2579d..aa2c5804e 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index fa4037eda..1558d89be 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 977413896..2995afab2 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 0ffc545bb..5beaa1d53 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 47e3ee792..1320c1499 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 841b523aa..1095ebacc 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 1ec94f820..9e9aea71a 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 69fa95fe0..579b24aac 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index cd3f1b204..e4acf4684 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -22,6 +22,8 @@ import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.expressionType +import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols @@ -32,6 +34,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol import org.jetbrains.kotlin.analysis.api.symbols.findPackage +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol 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 @@ -42,6 +45,7 @@ import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtBinaryExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration @@ -58,6 +62,7 @@ import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.KtUnaryExpression import org.jetbrains.kotlin.psi.KtUserType /** @@ -159,6 +164,27 @@ class KaChangePackageComputer( } else { emptyList() } + val movedPaths = files.mapNotNull { file -> file.virtualFile?.path }.toSet() + // A caller whose only reference to the old package is an unused `import old.*` is + // invisible to both usage and direct-import search. Once no declaration stays in the + // package the directive becomes unresolved, so the importing file has to join the + // participants and let the wildcard cleanup pass drop it. + val vacatedWildcardImportFiles = if (updateReferences) { + KotlinMoveUsageSearchService.getInstance()?.let { service -> + val vacatedPackages = files.map { file -> file.packageFqName }.toSet() + .filterNot { packageFqName -> + service.hasDeclarationsInPackage(ktFile.project, packageFqName, movedPaths) + } + .toSet() + if (vacatedPackages.isEmpty()) { + emptyList() + } else { + service.findPackageWildcardImportingFiles(ktFile.project, vacatedPackages) + } + }.orEmpty() + } else { + emptyList() + } val samePackageImportUpdates = (incoming.imports.keys + outgoingImports.keys).associateWith { file -> incoming.imports[file].orEmpty() + outgoingImports[file].orEmpty() } @@ -174,13 +200,13 @@ class KaChangePackageComputer( } else { emptyList() } - val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + wildcardImportFiles + samePackageImportUpdates.keys + incoming.edits.keys) + val participants = (files + filesWithUsages(usages) + importOnlyUsageFiles + wildcardImportFiles + + vacatedWildcardImportFiles + samePackageImportUpdates.keys + incoming.edits.keys) .distinct() // Capture replacement ranges while PSI is still valid. Only import directives that // target a moved declaration and references that K2 resolved to one are retargeted, so // text that merely spells a moved FQN (comments, strings, unrelated chains) is untouched. val textEdits = if (updateReferences) { - val movedPaths = files.mapNotNull { file -> file.virtualFile?.path }.toSet() val importEdits = captureImportEdits( participants, movedDeclarations, @@ -777,18 +803,32 @@ class KaChangePackageComputer( // Convention operators are not spelled as simple names: `box[0]` depends // on a top-level `operator fun Box.get` that same-package visibility - // provided, so its reference has to be captured separately. + // provided, so its reference has to be captured separately. A compound + // indexed write (`box[0]++`, `box[0] += 1`) exposes only `get`; its + // setter half is resolved implicitly and is looked up separately below. override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { super.visitArrayAccessExpression(expression) - expression.references.filterIsInstance().forEach { reference -> - val symbol = runCatching { reference.resolveToSymbol() }.getOrNull() - ?: return@forEach - val importFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) - ?: return@forEach - if (importFqName in importedFqNames || originalPackage in starPackages) { - return@forEach + val resolvedOperators = expression.references + .filterIsInstance() + .flatMap { reference -> + runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() } - missingImports += importFqName + val retainedOperators = resolvedOperators.mapNotNull { symbol -> + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } + retainedOperators + .filter { importFqName -> + importFqName !in importedFqNames && originalPackage !in starPackages + } + .forEach { importFqName -> missingImports += importFqName } + // Only a convention that stays behind needs the companion setter, so a + // member operator keeps its resolution untouched. + if (expression.isCompoundWrite() && retainedOperators.isNotEmpty()) { + samePackageIndexedSetter(expression, originalPackage, movedPaths) + ?.takeIf { importFqName -> + importFqName !in importedFqNames && originalPackage !in starPackages + } + ?.let { importFqName -> missingImports += importFqName } } } }) @@ -849,6 +889,57 @@ class KaChangePackageComputer( return originalPackage.child(Name.identifier(name)) } + /** + * Tells whether this indexed access is updated implicitly, not merely assigned or read. + * + * A plain assignment (`box[0] = 1`) resolves its `set` through the array-access reference, but + * an increment or compound assignment (`box[0]++`, `box[0] += 1`) lowers to `get` then `set` + * and exposes only `get`. + * + * @return `true` when the compiler resolves a setter for this access implicitly + */ + private fun KtArrayAccessExpression.isCompoundWrite(): Boolean { + val parent = parent + return when (parent) { + is KtBinaryExpression -> parent.left == this && + parent.operationToken in KtTokens.ALL_ASSIGNMENTS && parent.operationToken != KtTokens.EQ + is KtUnaryExpression -> parent.baseExpression == this && + parent.operationToken in setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) + else -> false + } + } + + /** + * Finds the `set` extension a compound indexed write needs from the original package. + * + * K2 does not expose the implicit setter, so it is looked up by name and applicability on the + * receiver expression's type. Only a declaration that stays behind qualifies, mirroring the + * `get` operator of the same access. + * + * @param expression indexed access in a compound write position + * @param originalPackage package the moved file declares before the change + * @param movedPaths paths of the files whose package changes + * @return FQN of the remaining `set` extension, or `null` when none applies + */ + context(_: KaSession) + private fun samePackageIndexedSetter( + expression: KtArrayAccessExpression, + originalPackage: FqName, + movedPaths: Set, + ): FqName? { + val receiverType = expression.arrayExpression?.expressionType ?: return null + return findPackage(originalPackage)?.packageScope?.declarations + ?.filterIsInstance() + ?.firstNotNullOfOrNull { symbol -> + val receiver = symbol.receiverParameter?.returnType + if ((symbol as? KaNamedSymbol)?.name?.asString() != "set" || receiver == null) { + return@firstNotNullOfOrNull null + } + if (!receiverType.isSubtypeOf(receiver)) return@firstNotNullOfOrNull null + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } + } + /** * Resolves a type alias that a moved file still needs to import from its former package. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index cc3561c75..b3ddf3220 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -55,6 +55,7 @@ import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtOperationReferenceExpression +import org.jetbrains.kotlin.psi.KtParameter import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtPropertyAccessor import org.jetbrains.kotlin.psi.KtSimpleNameExpression @@ -250,8 +251,10 @@ object KotlinImportBindingCheck { * * Adding a top-level declaration to a package changes overload resolution for unqualified * references to that name, so a same-package reference that currently resolves to another - * top-level callable can silently bind to the added declaration. An explicit import of the - * name from elsewhere keeps winning over the package, so it is not a conflict. + * top-level callable can silently bind to the added declaration. A reference bound through a + * star or default import is outranked by the same-package declaration in the same way. An + * explicit import of the name from elsewhere keeps winning over the package, so it is not a + * conflict. * * @param file caller that already names the declaration * @param name simple name of the declaration being added @@ -278,8 +281,16 @@ object KotlinImportBindingCheck { ?.resolveToSymbol() ?: return if (symbol.belongsTo(excludedDeclarations)) return if (!symbol.isTopLevelBinding()) return - if (symbol.declaredPackage() != packageName) return - conflicting = true + // A reference that already binds a same-package declaration changes overload + // resolution once the new declaration joins the package. A reference bound + // through a star or default import is outranked by the package in the same + // way, while an explicit import keeps winning, so only star-bound references + // without an explicit import are affected. + if (symbol.declaredPackage() == packageName || + file.importDirectives.none { directive -> directive.bindsName(name, emptySet()) } + ) { + conflicting = true + } } }) conflicting @@ -359,11 +370,11 @@ object KotlinImportBindingCheck { .takeWhile { statement -> statement.textRange.startOffset < child.textRange.startOffset } .any { statement -> (statement as? KtDeclaration)?.declaresName(name) == true } is KtClassBody -> current.declarations.any { declaration -> declaration.declaresName(name) } - is KtFunction -> current.valueParameters.any { parameter -> parameter.name == name } + is KtFunction -> current.valueParameters.any { parameter -> parameter.bindsName(name) } is KtPropertyAccessor -> - current.parameterList?.parameters?.any { parameter -> parameter.name == name } == true - is KtForExpression -> current.loopParameter?.name == name - is KtCatchClause -> current.catchParameter?.name == name + current.parameterList?.parameters?.any { parameter -> parameter.bindsName(name) } == true + is KtForExpression -> current.loopParameter?.bindsName(name) == true + is KtCatchClause -> current.catchParameter?.bindsName(name) == true is KtWhenExpression -> current.subjectVariable?.name == name else -> false } @@ -476,6 +487,19 @@ object KotlinImportBindingCheck { else -> false } + /** + * Tells whether this parameter binds [name], including its destructured entries. + * + * `{ (After, _) -> ... }` introduces `After` through the parameter's destructuring declaration + * rather than the parameter's own name, so checking `getName` alone misses it. + * + * @param name identifier to look for + * @return `true` when the parameter or one of its destructured entries introduces that name + */ + private fun KtParameter.bindsName(name: String): Boolean = + this.name == name || + destructuringDeclaration?.entries?.any { entry -> entry.name == name } == true + /** * Tells whether this statement or member declaration binds [name] in the type namespace. * 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 b75eacda2..0723d0a67 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 @@ -60,6 +60,23 @@ interface KotlinMoveUsageSearchService { */ fun findWildcardImportingFiles(project: com.intellij.openapi.project.Project, classFqNames: Set): List = emptyList() + /** + * Finds session files whose package wildcard imports one of [packageFqNames]. + * + * Such a file may reference no moved declaration at all: its wildcard directive is the only + * place the old package is named. Once the package loses every declaration the directive is + * unresolved, so the file must take part in the cleanup pass even though neither usage search + * nor direct-import search reports it. + * + * @param project IntelliJ project whose standalone K2 session supplies candidate files + * @param packageFqNames packages named by wildcard import directives + * @return Kotlin files with a package-level wildcard import of one of those packages + */ + fun findPackageWildcardImportingFiles( + project: com.intellij.openapi.project.Project, + packageFqNames: Set, + ): List = emptyList() + /** * Tells whether [packageFqName] keeps at least one declaration outside [excludedPaths]. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 23b4de6ff..91461782f 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 6770fc176..25cec3580 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 @@ -183,6 +183,32 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { } } + /** + * Finds Kotlin files whose package wildcard imports one of [packageFqNames]. + * + * The whole session is scanned because such a caller references no moved declaration; the + * import directive itself is the only evidence that it depends on the old package. + * + * @param project IntelliJ project owning the standalone K2 session + * @param packageFqNames packages named by wildcard import directives + * @return session files with a package-level wildcard import of one of those packages + */ + override fun findPackageWildcardImportingFiles( + project: com.intellij.openapi.project.Project, + packageFqNames: Set, + ): List { + if (packageFqNames.isEmpty()) return emptyList() + val session = KotlinAnalysisAPISession.forProject(project) ?: return emptyList() + return session.fileMap.values.mapNotNull { lightFile -> + val file = session.getKtFileForPath(lightFile.path) ?: return@mapNotNull null + file.takeIf { candidate -> + candidate.importDirectives.any { directive -> + directive.isAllUnder && directive.importedFqName in packageFqNames + } + } + } + } + /** * Tells whether [packageFqName] keeps any declaration outside [excludedPaths]. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index fb18afb04..15bb92de3 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3715,6 +3715,94 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies both operators of an indexed update follow the moved file. + * + * `box[0]++` calls `get` and `set` through one convention reference, and neither name is + * spelled in the source. Both extensions stay in the former package, so the moved file needs + * an import for each of them. + */ + fun testApply_realSession_importsBothIndexedAccessOperators() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-indexed-update") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(sourcePath, "package p\n\nfun bump(box: Box) { box[0]++; box[0] += 1 }\n") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\noperator fun Box.get(index: Int) = 0\n\n" + + "operator fun Box.set(index: Int, value: Int) {}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-indexed-update-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val movedText = success.changedFiles[sourcePath.toString()].orEmpty() + assertTrue("The indexed update needs both operators imported:\n$movedText", "import p.get" in movedText) + assertTrue("The indexed update needs both operators imported:\n$movedText", "import p.set" in movedText) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an unused wildcard import of a vacated package is discovered and removed. + * + * The caller references no moved declaration, so usage search never reports it and only the + * wildcard directive itself names the old package. Leaving it in place produces an unresolved + * import once the last declaration leaves the package. + */ + fun testApply_realSession_removesUnusedOldPackageWildcard() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-obsolete-wildcard") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString(usagePath, "package usage\n\nimport old.*\n\nfun use() = 2\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-obsolete-wildcard-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The wildcard import of a package that lost every declaration must be removed", + "package usage\n\nfun use() = 2\n", + success.changedFiles[usagePath.toString()], + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 67e105c9b..7090ebba7 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -794,6 +794,45 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a destructured lambda parameter captures the rewritten constructor call. + * + * `map { (After, _) -> Before() }` binds `After` to a destructured entry, so the rewritten + * `After()` would invoke that value instead of constructing the class. + */ + fun testFindCaptureConflicts_reportsDestructuredLambdaParameter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-destructured-lambda") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package p\n\nfun use() = listOf({ \"other\" } to 0).map { (After, _) -> Before().toString() }\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-destructured-lambda-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertEquals("The destructured lambda entry captures the rewritten constructor call", caller, conflict) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a same-spelled type alias keeps its own name when the expanded class is renamed. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt index efb49595d..a304e2f1d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -260,6 +260,75 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie } } + /** + * Verifies a star-imported `After` of another package blocks the rename. + * + * A same-package declaration outranks a star import, so the caller's unchanged `After()` + * would start constructing the renamed `p.After` instead of `lib.After`. + */ + fun testFindSamePackageOverloadConflict_reportsStarImportedNewName() { + withSession( + name = "same-package-star-import", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.createDirectories(root.resolve("lib")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("lib/Library.kt"), "package lib\n\nclass After\n") + Files.writeString(root.resolve("usage/Usage.kt"), "package p\n\nimport lib.*\n\nfun use() = After()\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val library = session.ktFile(root.resolve("lib/Library.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, library, usage), + ) + + assertEquals("Usage.kt", conflict?.virtualFile?.name) + } + } + + /** + * Verifies an explicitly imported `After` of another package is left alone. + * + * An explicit import outranks a same-package declaration, so the caller keeps constructing + * `lib.After` after the rename and no conflict is reported. + */ + fun testFindSamePackageOverloadConflict_ignoresExplicitlyImportedNewName() { + withSession( + name = "same-package-explicit-import", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.createDirectories(root.resolve("lib")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("lib/Library.kt"), "package lib\n\nclass After\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package p\n\nimport lib.After\n\nfun use() = After()\n", + ) + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val library = session.ktFile(root.resolve("lib/Library.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, library, usage), + ) + + assertNull("an explicit import keeps winning over the package", conflict) + } + } + /** @return the single top-level class of [source]. */ private fun primaryClass(source: KtFile): KtClass = source.declarations.filterIsInstance().single() diff --git a/pom.xml b/pom.xml index 89ec7f3a6..aaf38f57e 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.42-SNAPSHOT + 0.12.43-SNAPSHOT pom Netbeans Plugin Kotlin From 5ec0fbf9e696cce5a3ed4ee10e69e959146ec4b2 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 15:37:32 +0300 Subject: [PATCH 33/55] Fixed convention, delegate and signature edges in rename and package change Covered the remaining silent-rebinding cases reported in review: - convention references are now checked when a rewritten direct import stops binding an overload that stays in the old package; - primary-constructor parameters are treated as scopes visible in initializers, and constructor signatures are compared by resolved parameter types; - an existing import binding the new class name rejects the rename even without usages; - delegate operators (getValue/setValue) are imported for moved delegated properties. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 59 ++++++- .../KotlinConstructorSignatureCheck.kt | 59 +++++++ .../refactoring/KotlinImportBindingCheck.kt | 109 +++++++++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KotlinRenamePlugin.kt | 68 ++++---- .../nbm/refactoring/KaChangePackageTest.kt | 97 ++++++++++++ .../KotlinConstructorSignatureCheckTest.kt | 145 ++++++++++++++++++ .../KotlinImportBindingCheckTest.kt | 56 ++++++- .../KotlinRenameClassifierConflictTest.kt | 61 ++++++++ pom.xml | 2 +- 20 files changed, 624 insertions(+), 54 deletions(-) create mode 100644 KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt create mode 100644 Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 5f85f302c..dcea27694 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 29402210b..7be7c13a4 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index aa2c5804e..163170789 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 1558d89be..f062868a2 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 2995afab2..0d81c53a9 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 5beaa1d53..cd2dea429 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 1320c1499..fb21623a1 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 1095ebacc..d43669e4e 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 9e9aea71a..0c2ad5294 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 579b24aac..f258a4463 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index e4acf4684..2d83d17db 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -35,6 +35,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeAliasSymbol import org.jetbrains.kotlin.analysis.api.symbols.findPackage import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.types.KaType 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 @@ -383,7 +384,7 @@ class KaChangePackageComputer( // that name when the rewritten directive loses the overloads staying behind. val boundName = directive.aliasName ?: importedFqName.shortName().asString() if (KotlinImportBindingCheck.hasConflictingImportRewrite( - file, importedFqName, movedPaths, boundName, + file, importedFqName, movedPaths, boundName, aliased = directive.aliasName != null, ) ) { conflicts += "Cannot update ${file.name}: import ${importedFqName.asString()} " + @@ -831,6 +832,30 @@ class KaChangePackageComputer( ?.let { importFqName -> missingImports += importFqName } } } + + // A delegated property spells neither accessor: `val value by Delegate()` + // calls a top-level `Delegate.getValue` (and `setValue` for a `var`) + // through overload resolution, so that operator has to follow the file + // when it leaves the package that made it visible. + override fun visitProperty(property: KtProperty) { + super.visitProperty(property) + val delegateType = property.delegateExpression?.expressionType ?: return + val accessorNames = + if (property.isVar) listOf("getValue", "setValue") else listOf("getValue") + accessorNames.asSequence() + .mapNotNull { accessorName -> + samePackageDelegateAccessor( + delegateType, + accessorName, + originalPackage, + movedPaths, + ) + } + .filter { importFqName -> + importFqName !in importedFqNames && originalPackage !in starPackages + } + .forEach { importFqName -> missingImports += importFqName } + } }) } } @@ -940,6 +965,38 @@ class KaChangePackageComputer( } } + /** + * Finds a delegate accessor operator a moved file still needs from the original package. + * + * K2 does not expose the implicit `getValue`/`setValue` call of a delegated property, so the + * operator is looked up by name and receiver applicability on the delegate expression's type. + * Only a declaration that stays behind qualifies, mirroring the indexed-operator lookup. + * + * @param delegateType type of the property's delegate expression + * @param accessorName `getValue` for a read or `setValue` for a write + * @param originalPackage package the moved file declares before the change + * @param movedPaths paths of the files whose package changes + * @return FQN of the remaining accessor extension, or `null` when none applies + */ + context(_: KaSession) + private fun samePackageDelegateAccessor( + delegateType: KaType, + accessorName: String, + originalPackage: FqName, + movedPaths: Set, + ): FqName? { + return findPackage(originalPackage)?.packageScope?.declarations + ?.filterIsInstance() + ?.firstNotNullOfOrNull { symbol -> + val receiver = symbol.receiverParameter?.returnType + if ((symbol as? KaNamedSymbol)?.name?.asString() != accessorName || receiver == null) { + return@firstNotNullOfOrNull null + } + if (!delegateType.isSubtypeOf(receiver)) return@firstNotNullOfOrNull null + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } + } + /** * Resolves a type alias that a moved file still needs to import from its former package. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt new file mode 100644 index 000000000..26951f6bd --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -0,0 +1,59 @@ +/******************************************************************************* + * 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 org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals +import org.jetbrains.kotlin.analysis.api.components.type +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtNamedFunction + +/** + * Decides whether a top-level function would collide with a class's primary constructor. + * + * Renaming a class writes its new name verbatim, so a same-package function whose call signature + * matches the constructor becomes a conflicting overload that Kotlin rejects. Parameter types are + * compared as resolved types, because `Int` and a fully qualified `kotlin.Int` are the same Kotlin + * type even though their source spellings differ. + */ +object KotlinConstructorSignatureCheck { + + /** + * Tells whether [function] has the same call signature as the primary constructor of [primaryClass]. + * + * An extension function has an implicit receiver and therefore cannot conflict with a + * constructor. A parameter whose type cannot be resolved makes the comparison inconclusive, + * which is reported as a conflict so the rename is rejected instead of producing uncompilable + * code. + * + * @param primaryClass class whose constructor the rename would introduce + * @param function same-package function that would share the class's new name + * @return `true` when a call could not tell the constructor and the function apart + */ + fun conflictsWithPrimaryConstructor(primaryClass: KtClass, function: KtNamedFunction): Boolean { + if (function.receiverTypeReference != null) return false + val constructorParameters = primaryClass.primaryConstructorParameters + if (function.valueParameters.size != constructorParameters.size) return false + return analyze(function) { + constructorParameters.indices.all { index -> + val constructorType = runCatching { constructorParameters[index].typeReference?.type }.getOrNull() + val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() + constructorType == null || functionType == null || functionType.semanticallyEquals(constructorType) + } + } + } +} diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index b3ddf3220..3443da585 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -42,6 +42,7 @@ import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtCatchClause +import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtDeclaration @@ -124,21 +125,30 @@ object KotlinImportBindingCheck { override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { super.visitArrayAccessExpression(expression) if (!conflicting) { - expression.capturesTopLevelName(name, excludedDeclarations) { conflicting = true } + expression.capturesTopLevelName( + name, + { symbol -> !symbol.belongsTo(excludedDeclarations) }, + ) { conflicting = true } } } override fun visitDestructuringDeclarationEntry(entry: KtDestructuringDeclarationEntry) { super.visitDestructuringDeclarationEntry(entry) if (!conflicting) { - entry.capturesTopLevelName(name, excludedDeclarations) { conflicting = true } + entry.capturesTopLevelName( + name, + { symbol -> !symbol.belongsTo(excludedDeclarations) }, + ) { conflicting = true } } } override fun visitForExpression(expression: KtForExpression) { super.visitForExpression(expression) if (!conflicting) { - expression.capturesTopLevelName(name, excludedDeclarations) { conflicting = true } + expression.capturesTopLevelName( + name, + { symbol -> !symbol.belongsTo(excludedDeclarations) }, + ) { conflicting = true } } } }) @@ -164,6 +174,8 @@ object KotlinImportBindingCheck { * @param movingPaths paths of the files whose package changes * @param boundName name the directive binds at its call sites — the alias when the directive * is aliased, the declaration's simple name otherwise + * @param aliased `true` when the rewritten directive binds an alias; an aliased directive + * provides no convention operator under the declaration's own name * @return `true` when a reference would bind to the moved declaration instead of its own */ fun hasConflictingImportRewrite( @@ -171,14 +183,15 @@ object KotlinImportBindingCheck { imported: FqName, movingPaths: Set, boundName: String = imported.shortName().asString(), + aliased: Boolean = false, ): Boolean = runCatching { analyze(file) { var conflicting = false + val importedName = imported.shortName().asString() file.accept(object : KtTreeVisitorVoid() { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) if (conflicting) return - val importedName = imported.shortName().asString() val referencedName = expression.getReferencedName() // A convention operator spells `plus` as `+`, so an operation reference can // still bind the name the rewritten import selects. An aliased directive is @@ -195,6 +208,39 @@ object KotlinImportBindingCheck { if (symbol.declaredPackage() != imported.parent()) return conflicting = true } + + // A convention call spells no name, so `n[0]` (`get`) keeps binding an overload + // the rewritten directive stops importing even though no reference names it. + // An aliased directive binds only its alias, so it provides no convention operator. + override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { + super.visitArrayAccessExpression(expression) + if (!conflicting && !aliased) { + expression.capturesTopLevelName( + importedName, + { symbol -> symbol.staysInOriginalPackage(imported, movingPaths) }, + ) { conflicting = true } + } + } + + override fun visitDestructuringDeclarationEntry(entry: KtDestructuringDeclarationEntry) { + super.visitDestructuringDeclarationEntry(entry) + if (!conflicting && !aliased) { + entry.capturesTopLevelName( + importedName, + { symbol -> symbol.staysInOriginalPackage(imported, movingPaths) }, + ) { conflicting = true } + } + } + + override fun visitForExpression(expression: KtForExpression) { + super.visitForExpression(expression) + if (!conflicting && !aliased) { + expression.capturesTopLevelName( + importedName, + { symbol -> symbol.staysInOriginalPackage(imported, movingPaths) }, + ) { conflicting = true } + } + } }) conflicting } @@ -370,6 +416,10 @@ object KotlinImportBindingCheck { .takeWhile { statement -> statement.textRange.startOffset < child.textRange.startOffset } .any { statement -> (statement as? KtDeclaration)?.declaresName(name) == true } is KtClassBody -> current.declarations.any { declaration -> declaration.declaresName(name) } + // A non-property primary-constructor parameter is not a member, but initializers + // and `init` blocks see it, so the rewritten text would call the parameter. + is KtClass -> element.isInsideClassInitializerScope(current) && + current.primaryConstructorParameters.any { parameter -> parameter.bindsName(name) } is KtFunction -> current.valueParameters.any { parameter -> parameter.bindsName(name) } is KtPropertyAccessor -> current.parameterList?.parameters?.any { parameter -> parameter.bindsName(name) } == true @@ -500,6 +550,27 @@ object KotlinImportBindingCheck { this.name == name || destructuringDeclaration?.entries?.any { entry -> entry.name == name } == true + /** + * Tells whether this position sees the primary-constructor parameters of [classOrObject]. + * + * Those parameters are in scope in property initializers, `init` blocks, and the constructor's + * own delegation call. A member function or property accessor opens a new scope that no longer + * sees them, and so does a nested classifier, so the walk stops there. + * + * @param classOrObject classifier whose primary constructor declares the parameters + * @return `true` when the element sits in an initializer scope of that classifier + */ + private fun PsiElement.isInsideClassInitializerScope(classOrObject: KtClassOrObject): Boolean { + var current: PsiElement? = this + while (current != null && current != classOrObject) { + if (current is KtNamedFunction || current is KtPropertyAccessor || current is KtClassOrObject) { + return false + } + current = current.parent + } + return current == classOrObject + } + /** * Tells whether this statement or member declaration binds [name] in the type namespace. * @@ -586,27 +657,45 @@ object KotlinImportBindingCheck { * Tells whether this convention construct would start calling a top-level [name] declaration. * * A convention call spells no name, so comparing reference text cannot see it; the resolved - * callable carries the name instead. References that already point at one of - * [excludedDeclarations] keep doing so and are not captures. + * callable carries the name instead. [isCapture] decides whether a resolved top-level + * declaration of that name is a problem for the caller — a reference that already points at + * an allowed declaration is not. * - * @param name the name the import introduces - * @param excludedDeclarations declarations the import is meant for + * @param name the name the import introduces or stops binding + * @param isCapture predicate telling whether the resolved symbol would be captured * @param onCapture invoked when a capture is found */ context(_: KaSession) private fun KtElement.capturesTopLevelName( name: String, - excludedDeclarations: Set, + isCapture: (KaSymbol) -> Boolean, onCapture: () -> Unit, ) { forEachConventionSymbol { symbol -> - if (symbol.belongsTo(excludedDeclarations)) return@forEachConventionSymbol if (symbol.declaredName() != name) return@forEachConventionSymbol if (!symbol.isTopLevelBinding()) return@forEachConventionSymbol + if (!isCapture(symbol)) return@forEachConventionSymbol onCapture() } } + /** + * Tells whether this symbol is a top-level declaration the rewritten import stops binding. + * + * A convention reference resolving to a declaration that stays in the original package loses + * the overload the directive provided; one resolving to a moving declaration follows the + * rewrite and keeps working. + * + * @param imported original FQN of the moved declaration + * @param movingPaths paths of the files whose package changes + * @return `true` when the symbol stays in the directive's original package + */ + private fun KaSymbol.staysInOriginalPackage(imported: FqName, movingPaths: Set): Boolean { + val path = psi?.containingFile?.virtualFile?.path + if (path != null && path in movingPaths) return false + return declaredPackage() == imported.parent() + } + /** * Returns the innermost class or object containing this element, or `null` at file level. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 91461782f..e229d3b20 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index ec9bfc726..8a0184bc1 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -21,6 +21,7 @@ import io.github.nbplugins.kotlin.nbm.projectsextensions.KotlinProjectSourceGrou import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import io.github.nbplugins.kotlin.refactoring.KaChangePackageComputer import io.github.nbplugins.kotlin.refactoring.KaRenameComputer +import io.github.nbplugins.kotlin.refactoring.KotlinConstructorSignatureCheck import io.github.nbplugins.kotlin.refactoring.KotlinImportBindingCheck import io.github.nbplugins.kotlin.refactoring.KotlinSourceNames import io.github.nbplugins.kotlin.refactoring.TextChange @@ -580,19 +581,20 @@ internal fun findRenameConflict( files: Collection, ): KtNamedDeclaration? { val packageName = source.packageFqName - val constructorParameterTypes = primaryClass.primaryConstructorParameters - .map { parameter -> parameter.typeReference?.text } return files.asSequence() .filter { file -> file.packageFqName == packageName } .flatMap { file -> file.declarations.asSequence() } - .filter { declaration -> - declaration is KtClassOrObject || declaration is KtTypeAlias || - (declaration is KtNamedFunction && - declaration.conflictsWithConstructor(constructorParameterTypes)) - } + .filter { declaration -> declaration.name == newName } .map { declaration -> declaration as KtNamedDeclaration } .filter { declaration -> !declaration.isSameDeclaration(primaryClass) } - .firstOrNull { declaration -> declaration.name == newName } + .firstOrNull { declaration -> + when (declaration) { + is KtClassOrObject, is KtTypeAlias -> true + is KtNamedFunction -> + KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor(primaryClass, declaration) + else -> false + } + } } /** @@ -602,7 +604,8 @@ internal fun findRenameConflict( * refers to a different top-level `After` — from its own package or another import — keeps those * references untouched during discovery, because they do not resolve to the renamed class. The * rewritten import takes precedence over them afterwards, so they silently start using the - * renamed class. The rename is rejected instead of changing unrelated call sites. + * renamed class. A second explicit import binding the same simple name is a conflicting import + * even without a call site. The rename is rejected instead of changing unrelated call sites. * * @param source file whose primary class is renamed * @param primaryClass class renamed together with the file @@ -617,17 +620,41 @@ internal fun findCallerScopeConflict( files: Collection, ): KtFile? { val className = primaryClass.name ?: return null - val oldFqName = source.packageFqName.child(Name.identifier(className)) + val packageName = source.packageFqName + val oldFqName = packageName.child(Name.identifier(className)) + val newFqName = packageName.child(Name.identifier(newName)) return files.firstOrNull { file -> file.virtualFile?.path != source.virtualFile?.path && file.importDirectives.any { directive -> !directive.isAllUnder && directive.aliasName == null && directive.importedFqName == oldFqName } && - KotlinImportBindingCheck.hasConflictingReference(file, newName, setOf(primaryClass)) + (file.hasConflictingImportBinding(newName, newFqName, oldFqName) || + KotlinImportBindingCheck.hasConflictingReference(file, newName, setOf(primaryClass))) } } +/** + * Tells whether rewriting this file's import of [oldFqName] to [newFqName] creates a conflict. + * + * Kotlin rejects two explicit imports that bind the same simple name to different declarations, + * whether or not either is used at a call site. A directive naming the rewritten target itself or + * an all-under import does not conflict. + * + * @param name simple name the rewritten directive binds + * @param newFqName FQN of the renamed declaration after the rename + * @param oldFqName FQN of the declaration before the rename + * @return `true` when another directive already binds [name] to a different declaration + */ +private fun KtFile.hasConflictingImportBinding(name: String, newFqName: FqName, oldFqName: FqName): Boolean = + importDirectives.any { directive -> + if (directive.isAllUnder) return@any false + val imported = directive.importedFqName ?: return@any false + if (imported == newFqName || imported == oldFqName) return@any false + val alias = directive.aliasName + if (alias != null) alias == name else imported.shortName().asString() == name + } + /** * Finds a same-package caller whose existing reference to the new name would rebind. * @@ -660,25 +687,6 @@ internal fun findSamePackageOverloadConflict( } } -/** - * Tells whether this top-level function has the same call signature as a primary constructor. - * - * Matching parameter count and type spellings is what makes Kotlin report conflicting overloads. - * An extension function has an implicit receiver and therefore cannot conflict with a constructor. - * - * @param constructorParameterTypes type spellings of the renamed primary constructor parameters - * @return `true` when a call to either declaration could not be told apart - */ -private fun KtNamedFunction.conflictsWithConstructor(constructorParameterTypes: List): Boolean { - if (receiverTypeReference != null) return false - if (valueParameters.size != constructorParameterTypes.size) return false - return valueParameters.indices.all { index -> - val functionType = valueParameters[index].typeReference?.text - val constructorType = constructorParameterTypes[index] - functionType == null || constructorType == null || functionType == constructorType - } -} - /** Tells whether this declaration is [primaryClass], tolerating distinct PSI instances of it. */ private fun KtNamedDeclaration.isSameDeclaration(primaryClass: KtClass): Boolean = this === primaryClass || diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 15bb92de3..540497bba 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3803,6 +3803,103 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a rewritten import cannot drop an overload a convention reference keeps using. + * + * The caller imports `old.get`, which binds both the moved `Number.get` and the staying + * `Int.get`. Rewriting the directive to `dest.get` keeps only the moved overload, so `n[0]` + * would silently switch from `Int.get` to `Number.get`. + */ + fun testApply_realSession_conflictsWhenRewrittenImportDropsUnmovedConventionOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-rewritten-convention") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString( + sourcePath, + "package old\n\noperator fun Number.get(index: Int) = index\n", + ) + Files.writeString( + temporaryRoot.resolve("old/Int.kt"), + "package old\n\noperator fun Int.get(index: Int) = -index\n", + ) + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.get\n\nfun use(n: Int) = n[0] + (1.5).get(0)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-rewritten-convention-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose convention reference loses an overload: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a delegated property keeps the accessor operator left in the old package. + * + * `val value by Delegate()` calls the top-level `Delegate.getValue` extension implicitly, and + * neither reference is spelled as a simple name, so the moved file has to import it. + */ + fun testApply_realSession_importsDelegateAccessorOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-delegate") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString( + temporaryRoot.resolve("old/Delegate.kt"), + "package old\n\nclass Delegate\n\n" + + "operator fun Delegate.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString(sourcePath, "package old\n\nval value by Delegate()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-delegate-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the delegate accessor: $text", + text.contains("import old.getValue"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt new file mode 100644 index 000000000..76eb49db1 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -0,0 +1,145 @@ +/******************************************************************************* + * 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.KotlinConstructorSignatureCheck +import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNamedFunction +import utils.KotlinTestCase +import java.nio.file.Files +import java.nio.file.Path + +/** Integration tests for the constructor-signature comparison used by generic file Rename. */ +class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSignatureCheckTest", "moveFile") { + + /** + * Verifies a fully qualified parameter type is recognized as the constructor's type. + * + * `kotlin.Int` and `Int` denote the same type, so the function would collide with the renamed + * constructor even though the source spellings differ. + */ + fun testConflictsWithPrimaryConstructor_reportsEquivalentQualifiedType() { + withSession( + name = "qualified", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: kotlin.Int) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertTrue( + "Int and kotlin.Int are the same parameter type", + KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** Verifies a function with a different parameter type does not conflict. */ + fun testConflictsWithPrimaryConstructor_ignoresDifferentType() { + withSession( + name = "different", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: String) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "String cannot collide with an Int parameter", + KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** Verifies an extension function is not reported despite matching parameter types. */ + fun testConflictsWithPrimaryConstructor_ignoresExtensionFunction() { + withSession( + name = "extension", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Foo.Bar(n: Int) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "an extension function has an implicit receiver", + KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** @return the single top-level class of [file]. */ + private fun primaryClass(file: KtFile): KtClass = file.declarations.filterIsInstance().single() + + /** @return the session source for [path], failing the test when it is absent. */ + private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = + getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") + + /** + * Writes files through [prepare], then runs [check] against a session created afterwards. + * + * The session snapshots its source roots at creation, so files must exist before it is built. + */ + private fun withSession( + name: String, + prepare: (Path) -> Unit, + check: (KotlinAnalysisAPISession, Path) -> Unit, + ) { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping constructor signature integration test") + return + } + val root = Files.createTempDirectory("nbkotlin-constructor-signature-$name") + try { + prepare(root) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "constructor-signature-$name-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + check(session, root) + } finally { + root.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/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index b12d27328..f1018982b 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -21,6 +21,7 @@ import io.github.nbplugins.kotlin.refactoring.KotlinImportBindingCheck import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtProperty import utils.KotlinTestCase import java.nio.file.Files import java.nio.file.Path @@ -152,7 +153,7 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes }, ) { session, root -> val make = session.ktFile(root.resolve("usage/Usage.kt")) - .declarations.filterIsInstance().single() + .declarations.filterIsInstance().single { type -> type.name == "Caller" } .declarations.filterIsInstance().single { function -> function.name == "make" } assertTrue( @@ -205,6 +206,59 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies a primary-constructor parameter is reported inside a property initializer. + * + * A non-property constructor parameter is visible in initializers without being a member, so + * rewriting a name into the initializer would call the parameter instead of the renamed class. + */ + fun testIsNameBoundInScope_reportsPrimaryConstructorParameterInInitializer() { + withSession( + name = "constructor-initializer", + prepare = { root -> + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nclass Caller(After: () -> String) {\n" + + " val value = Before().toString()\n}\n\nclass Before\n", + ) + }, + ) { session, root -> + val property = session.ktFile(root.resolve("usage/Usage.kt")) + .declarations.filterIsInstance().single { type -> type.name == "Caller" } + .declarations.filterIsInstance().single() + + assertTrue( + "the constructor parameter is visible in the initializer", + KotlinImportBindingCheck.isNameBoundInScope(property, "After"), + ) + } + } + + /** Verifies a primary-constructor parameter is not visible inside a member function body. */ + fun testIsNameBoundInScope_ignoresPrimaryConstructorParameterInMemberFunction() { + withSession( + name = "constructor-member", + prepare = { root -> + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nclass Caller(After: () -> String) {\n" + + " fun make() = 1\n}\n\nclass Before\n", + ) + }, + ) { session, root -> + val make = session.ktFile(root.resolve("usage/Usage.kt")) + .declarations.filterIsInstance().single { type -> type.name == "Caller" } + .declarations.filterIsInstance().single { function -> function.name == "make" } + + assertFalse( + "a member function does not see the primary-constructor parameters", + KotlinImportBindingCheck.isNameBoundInScope(make, "After"), + ) + } + } + /** @return the session source for [path], failing the test when it is absent. */ private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt index a304e2f1d..be6da1944 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -329,6 +329,67 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie } } + /** + * Verifies a top-level function with an equivalently spelled parameter type blocks the rename. + * + * `Int` and `kotlin.Int` are the same type, so `fun After(n: kotlin.Int)` collides with the + * renamed `class Before(n: Int)` even though the spellings differ. + */ + fun testFindRenameConflict_reportsFunctionWithEquivalentQualifiedSignature() { + withSession( + name = "qualified-function-signature", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: kotlin.Int) = n\n") + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + val conflict = findRenameConflict(source, primaryClass(source), "Bar", listOf(source, functions)) + + assertEquals("Bar", conflict?.name) + } + } + + /** + * Verifies an existing import that would conflict with the rewritten one blocks the rename. + * + * The caller imports `p.Foo` and `q.Bar` without using either. After the rename the first + * directive becomes `import p.Bar`, and Kotlin rejects two imports binding the same name even + * when neither has a call site. + */ + fun testFindCallerScopeConflict_reportsExistingConflictingImport() { + withSession( + name = "caller-import-conflict", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("other")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo\n") + Files.writeString(root.resolve("other/Types.kt"), "package q\n\nclass Bar\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport p.Foo\nimport q.Bar\n\nfun use() = 1\n", + ) + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val types = session.ktFile(root.resolve("other/Types.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findCallerScopeConflict( + source, + primaryClass(source), + "Bar", + listOf(source, types, usage), + ) + + assertEquals("Usage.kt", conflict?.virtualFile?.name) + } + } + /** @return the single top-level class of [source]. */ private fun primaryClass(source: KtFile): KtClass = source.declarations.filterIsInstance().single() diff --git a/pom.xml b/pom.xml index aaf38f57e..7c08fa1f6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.43-SNAPSHOT + 0.12.44-SNAPSHOT pom Netbeans Plugin Kotlin From 4d967be6bceea1e74e3bde12ec99232d64d62e2f Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 15:55:58 +0300 Subject: [PATCH 34/55] Fixed outgoing convention, provided delegate, secondary constructor and implicit it edges - Outgoing imports now check convention references (`n[0]`, destructuring, for loops) for declarations moving with the file or bound through another package. - A delegated property also resolves a same-package `provideDelegate` operator and selects the accessors on the type it returns. - Rename signature conflicts compare every constructor, including secondary ones. - A lambda without declared parameters introduces the implicit `it` name. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 75 +++++++---- .../KotlinConstructorSignatureCheck.kt | 59 ++++++--- .../refactoring/KotlinImportBindingCheck.kt | 62 ++++++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KotlinRenamePlugin.kt | 7 +- .../nbm/refactoring/KaChangePackageTest.kt | 49 +++++++ .../KotlinConstructorSignatureCheckTest.kt | 63 ++++++++- .../KotlinImportBindingCheckTest.kt | 121 ++++++++++++++++++ pom.xml | 2 +- 19 files changed, 392 insertions(+), 68 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index dcea27694..fc27709ac 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 7be7c13a4..ca2b53811 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 163170789..5c2de720a 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index f062868a2..deac040ad 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 0d81c53a9..8f3cddc16 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index cd2dea429..99c38336f 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index fb21623a1..d6bc9f629 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index d43669e4e..9af884985 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 0c2ad5294..80c0d5189 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index f258a4463..803008232 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 2d83d17db..ce861d634 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -834,22 +834,41 @@ class KaChangePackageComputer( } // A delegated property spells neither accessor: `val value by Delegate()` - // calls a top-level `Delegate.getValue` (and `setValue` for a `var`) - // through overload resolution, so that operator has to follow the file - // when it leaves the package that made it visible. + // calls a top-level `provideDelegate` and then `getValue`/`setValue` + // through overload resolution, so those operators have to follow the file + // when it leaves the package that made them visible. override fun visitProperty(property: KtProperty) { super.visitProperty(property) val delegateType = property.delegateExpression?.expressionType ?: return - val accessorNames = - if (property.isVar) listOf("getValue", "setValue") else listOf("getValue") - accessorNames.asSequence() - .mapNotNull { accessorName -> - samePackageDelegateAccessor( - delegateType, - accessorName, + val provider = samePackageDelegateOperator( + delegateType, + "provideDelegate", + originalPackage, + movedPaths, + ) + // `provideDelegate` runs before the accessors and its return type is the + // type the accessors are selected on, so it replaces the expression type. + val accessorType = provider?.returnType ?: delegateType + val operators = buildList { + if (provider != null) add(provider) + samePackageDelegateOperator( + accessorType, + "getValue", + originalPackage, + movedPaths, + )?.let { symbol -> add(symbol) } + if (property.isVar) { + samePackageDelegateOperator( + accessorType, + "setValue", originalPackage, movedPaths, - ) + )?.let { symbol -> add(symbol) } + } + } + operators.asSequence() + .mapNotNull { symbol -> + samePackageTopLevelName(symbol, originalPackage, movedPaths) } .filter { importFqName -> importFqName !in importedFqNames && originalPackage !in starPackages @@ -966,34 +985,34 @@ class KaChangePackageComputer( } /** - * Finds a delegate accessor operator a moved file still needs from the original package. + * Finds a delegate operator a moved file still needs from the original package. * - * K2 does not expose the implicit `getValue`/`setValue` call of a delegated property, so the - * operator is looked up by name and receiver applicability on the delegate expression's type. - * Only a declaration that stays behind qualifies, mirroring the indexed-operator lookup. + * K2 does not expose the implicit `provideDelegate`/`getValue`/`setValue` calls of a delegated + * property, so the operator is looked up by name and receiver applicability on the type it is + * selected on. Only a declaration that stays behind qualifies, mirroring the indexed-operator + * lookup. * - * @param delegateType type of the property's delegate expression - * @param accessorName `getValue` for a read or `setValue` for a write + * @param type type of the delegate, or of the value a `provideDelegate` returns + * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @param originalPackage package the moved file declares before the change * @param movedPaths paths of the files whose package changes - * @return FQN of the remaining accessor extension, or `null` when none applies + * @return the remaining operator, or `null` when none applies */ context(_: KaSession) - private fun samePackageDelegateAccessor( - delegateType: KaType, - accessorName: String, + private fun samePackageDelegateOperator( + type: KaType, + operatorName: String, originalPackage: FqName, movedPaths: Set, - ): FqName? { + ): KaCallableSymbol? { return findPackage(originalPackage)?.packageScope?.declarations ?.filterIsInstance() - ?.firstNotNullOfOrNull { symbol -> + ?.firstOrNull { symbol -> val receiver = symbol.receiverParameter?.returnType - if ((symbol as? KaNamedSymbol)?.name?.asString() != accessorName || receiver == null) { - return@firstNotNullOfOrNull null - } - if (!delegateType.isSubtypeOf(receiver)) return@firstNotNullOfOrNull null - samePackageTopLevelName(symbol, originalPackage, movedPaths) + (symbol as? KaNamedSymbol)?.name?.asString() == operatorName && + receiver != null && + type.isSubtypeOf(receiver) && + samePackageTopLevelName(symbol, originalPackage, movedPaths) != null } } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt index 26951f6bd..a94245d5f 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -21,39 +21,66 @@ import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals import org.jetbrains.kotlin.analysis.api.components.type import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtParameter /** - * Decides whether a top-level function would collide with a class's primary constructor. + * Decides whether a top-level function would collide with a constructor of a renamed class. * * Renaming a class writes its new name verbatim, so a same-package function whose call signature - * matches the constructor becomes a conflicting overload that Kotlin rejects. Parameter types are + * matches a constructor becomes a conflicting overload that Kotlin rejects. Parameter types are * compared as resolved types, because `Int` and a fully qualified `kotlin.Int` are the same Kotlin * type even though their source spellings differ. */ object KotlinConstructorSignatureCheck { /** - * Tells whether [function] has the same call signature as the primary constructor of [primaryClass]. + * Tells whether [function] has the same call signature as a constructor of [primaryClass]. * - * An extension function has an implicit receiver and therefore cannot conflict with a - * constructor. A parameter whose type cannot be resolved makes the comparison inconclusive, - * which is reported as a conflict so the rename is rejected instead of producing uncompilable - * code. + * Every constructor counts, not only the primary one: a class that declares only secondary + * constructors has no primary constructor at all, while a class that declares none gets an + * implicit no-argument primary constructor. An extension function has an implicit receiver and + * therefore cannot conflict with a constructor. A parameter whose type cannot be resolved makes + * the comparison inconclusive, which is reported as a conflict so the rename is rejected + * instead of producing uncompilable code. * - * @param primaryClass class whose constructor the rename would introduce + * @param primaryClass class whose constructors the rename would introduce * @param function same-package function that would share the class's new name - * @return `true` when a call could not tell the constructor and the function apart + * @return `true` when a call could not tell a constructor and the function apart */ - fun conflictsWithPrimaryConstructor(primaryClass: KtClass, function: KtNamedFunction): Boolean { + fun conflictsWithRenamedConstructor(primaryClass: KtClass, function: KtNamedFunction): Boolean { if (function.receiverTypeReference != null) return false - val constructorParameters = primaryClass.primaryConstructorParameters - if (function.valueParameters.size != constructorParameters.size) return false + val matching = constructorSignatures(primaryClass) + .filter { parameters -> parameters.size == function.valueParameters.size } + if (matching.isEmpty()) return false return analyze(function) { - constructorParameters.indices.all { index -> - val constructorType = runCatching { constructorParameters[index].typeReference?.type }.getOrNull() - val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() - constructorType == null || functionType == null || functionType.semanticallyEquals(constructorType) + matching.any { parameters -> + parameters.indices.all { index -> + val constructorType = runCatching { parameters[index].typeReference?.type }.getOrNull() + val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() + constructorType == null || functionType == null || functionType.semanticallyEquals(constructorType) + } } } } + + /** + * Returns the parameter list of every constructor the renamed class would expose. + * + * A class with a primary constructor uses its parameters; one that declares only secondary + * constructors has no primary constructor, and one that declares none gets the implicit + * no-argument constructor. + * + * @param classOrObject class whose constructors are being compared + * @return one parameter list per constructor + */ + private fun constructorSignatures(classOrObject: KtClass): List> { + val signatures = mutableListOf>() + val primaryConstructor = classOrObject.primaryConstructor + when { + primaryConstructor != null -> signatures += primaryConstructor.valueParameters + classOrObject.secondaryConstructors.isEmpty() -> signatures.add(emptyList()) + } + classOrObject.secondaryConstructors.forEach { constructor -> signatures.add(constructor.valueParameters) } + return signatures + } } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 3443da585..fcbdba775 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -54,6 +54,7 @@ import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtForExpression import org.jetbrains.kotlin.psi.KtFunction import org.jetbrains.kotlin.psi.KtImportDirective +import org.jetbrains.kotlin.psi.KtLambdaExpression import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtOperationReferenceExpression import org.jetbrains.kotlin.psi.KtParameter @@ -267,11 +268,11 @@ object KotlinImportBindingCheck { ): Boolean = runCatching { analyze(file) { var conflicting = false + val importedName = imported.shortName().asString() file.accept(object : KtTreeVisitorVoid() { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) if (conflicting) return - val importedName = imported.shortName().asString() val referencedName = expression.getReferencedName() if (referencedName != importedName && expression !is KtOperationReferenceExpression) return if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return @@ -280,12 +281,39 @@ object KotlinImportBindingCheck { ?.resolveToSymbol() ?: return if (referencedName != importedName && symbol.declaredName() != importedName) return if (!symbol.isTopLevelBinding()) return - val path = symbol.psi?.containingFile?.virtualFile?.path - if (path != null && path in movingPaths) { - conflicting = true - return + if (symbol.isCapturedByOutgoingImport(imported, movingPaths)) conflicting = true + } + + // A convention call spells no name, so `n[0]` (`get`) keeps binding the same-package + // overload while the file moves; the added import would outrank it instead. + override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { + super.visitArrayAccessExpression(expression) + if (!conflicting) { + expression.capturesTopLevelName( + importedName, + { symbol -> symbol.isCapturedByOutgoingImport(imported, movingPaths) }, + ) { conflicting = true } + } + } + + override fun visitDestructuringDeclarationEntry(entry: KtDestructuringDeclarationEntry) { + super.visitDestructuringDeclarationEntry(entry) + if (!conflicting) { + entry.capturesTopLevelName( + importedName, + { symbol -> symbol.isCapturedByOutgoingImport(imported, movingPaths) }, + ) { conflicting = true } + } + } + + override fun visitForExpression(expression: KtForExpression) { + super.visitForExpression(expression) + if (!conflicting) { + expression.capturesTopLevelName( + importedName, + { symbol -> symbol.isCapturedByOutgoingImport(imported, movingPaths) }, + ) { conflicting = true } } - if (symbol.declaredPackage() != imported.parent()) conflicting = true } }) conflicting @@ -421,6 +449,11 @@ object KotlinImportBindingCheck { is KtClass -> element.isInsideClassInitializerScope(current) && current.primaryConstructorParameters.any { parameter -> parameter.bindsName(name) } is KtFunction -> current.valueParameters.any { parameter -> parameter.bindsName(name) } + // A lambda that declares no parameters receives the implicit `it` parameter, which + // the rewritten text would then call. Its function literal is a `KtFunction` with an + // empty parameter list, so the enclosing lambda expression carries the rule. + is KtLambdaExpression -> + name == "it" && current.functionLiteral.valueParameters.isEmpty() is KtPropertyAccessor -> current.parameterList?.parameters?.any { parameter -> parameter.bindsName(name) } == true is KtForExpression -> current.loopParameter?.bindsName(name) == true @@ -696,6 +729,23 @@ object KotlinImportBindingCheck { return declaredPackage() == imported.parent() } + /** + * Tells whether adding an import of [imported] to a moved file would capture this symbol. + * + * An explicit import outranks both the same-package declarations of the moved file's new + * package and any wildcard or default import that currently provides the symbol. A declaration + * already bound through [imported] itself keeps its target. + * + * @param imported FQN of the declaration that stays behind + * @param movingPaths paths of the files whose package changes + * @return `true` when the added import would rebind the reference + */ + private fun KaSymbol.isCapturedByOutgoingImport(imported: FqName, movingPaths: Set): Boolean { + val path = psi?.containingFile?.virtualFile?.path + if (path != null && path in movingPaths) return true + return declaredPackage() != imported.parent() + } + /** * Returns the innermost class or object containing this element, or `null` at file level. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index e229d3b20..bc80d139b 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index 8a0184bc1..bfbc47f36 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -565,8 +565,9 @@ private fun validateClassName(name: String, updatePrimaryClass: Boolean): Proble * * Renaming a file together with its primary class writes the new name verbatim, so a class, * object, or type alias that already has that name in the same package would be redeclared. A - * top-level function with the same call signature as the renamed primary constructor conflicts - * with it as well: `class Before` plus `fun After()` in one package cannot both become `After`. + * top-level function with the same call signature as any constructor of the renamed class + * conflicts with it as well: `class Before` plus `fun After()` in one package cannot both become + * `After`. * * @param source file whose matching class is renamed * @param primaryClass top-level class renamed together with the file @@ -591,7 +592,7 @@ internal fun findRenameConflict( when (declaration) { is KtClassOrObject, is KtTypeAlias -> true is KtNamedFunction -> - KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor(primaryClass, declaration) + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor(primaryClass, declaration) else -> false } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 540497bba..d2f7e441d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3900,6 +3900,55 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a delegated property keeps the `provideDelegate` operator left in the old package. + * + * `provideDelegate` runs before the accessors and can return a delegate of another type, so the + * moved file has to import it even when the accessors themselves are members of the returned + * type rather than same-package extensions. + */ + fun testApply_realSession_importsProvidedDelegateOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-provided-delegate") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString( + temporaryRoot.resolve("old/Delegate.kt"), + "package old\n\nclass Delegate\n\n" + + "class Provided {\n" + + " operator fun getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n}\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n", + ) + Files.writeString(sourcePath, "package old\n\nval value by Delegate()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-provided-delegate-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the delegate provider: $text", + text.contains("import old.provideDelegate"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt index 76eb49db1..7b4c2ae82 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -28,6 +28,63 @@ import java.nio.file.Path /** Integration tests for the constructor-signature comparison used by generic file Rename. */ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSignatureCheckTest", "moveFile") { + /** + * Verifies a secondary constructor's signature is compared as well. + * + * `class Foo { constructor(n: Int) }` has no primary constructor, so checking only the primary + * parameter list would miss that the renamed class still offers a colliding `(Int)` overload. + */ + fun testConflictsWithRenamedConstructor_reportsSecondaryConstructor() { + withSession( + name = "secondary", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString( + root.resolve("source/Types.kt"), + "package p\n\nclass Foo {\n constructor(n: Int) {}\n}\n", + ) + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: Int) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertTrue( + "the secondary constructor takes the same parameter", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** Verifies a secondary constructor with a different parameter type does not conflict. */ + fun testConflictsWithRenamedConstructor_ignoresDifferentSecondaryConstructor() { + withSession( + name = "secondary-different", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString( + root.resolve("source/Types.kt"), + "package p\n\nclass Foo {\n constructor(n: String) {}\n}\n", + ) + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: Int) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "String cannot collide with an Int parameter", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + /** * Verifies a fully qualified parameter type is recognized as the constructor's type. * @@ -48,7 +105,7 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig assertTrue( "Int and kotlin.Int are the same parameter type", - KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor( + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( primaryClass(types), functions.declarations.filterIsInstance().single(), ), @@ -71,7 +128,7 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig assertFalse( "String cannot collide with an Int parameter", - KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor( + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( primaryClass(types), functions.declarations.filterIsInstance().single(), ), @@ -94,7 +151,7 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig assertFalse( "an extension function has an implicit receiver", - KotlinConstructorSignatureCheck.conflictsWithPrimaryConstructor( + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( primaryClass(types), functions.declarations.filterIsInstance().single(), ), diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index f1018982b..c1f4b59fa 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -18,8 +18,12 @@ package io.github.nbplugins.kotlin.nbm.refactoring import io.github.nbplugins.kotlin.nbm.resolve.KotlinAnalysisAPISession import io.github.nbplugins.kotlin.refactoring.KotlinImportBindingCheck +import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtFile +import org.jetbrains.kotlin.psi.KtNameReferenceExpression import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty import utils.KotlinTestCase @@ -140,6 +144,66 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies a convention reference to a moving declaration is reported for an outgoing import. + * + * `n[0]` calls the `Int.get` extension that moves with the file without spelling its name. + * Adding `import old.get` would make the import outrank the same-package binding and select + * the staying `Number.get` overload instead. + */ + fun testHasConflictingOutgoingImport_reportsConventionReferenceToMovingDeclaration() { + withSession( + name = "outgoing-convention", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.writeString( + root.resolve("old/Operators.kt"), + "package old\n\noperator fun Number.get(index: Int) = index\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\noperator fun Int.get(index: Int) = -index\n", + ) + Files.writeString(root.resolve("old/Usage.kt"), "package old\n\nfun use(n: Int) = n[0]\n") + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("old/Usage.kt")) + val movingPaths = setOf(root.resolve("old/Moving.kt").toString()) + + assertTrue( + "importing old.get would reselect the staying overload for n[0]", + KotlinImportBindingCheck.hasConflictingOutgoingImport(usage, FqName("old.get"), movingPaths), + ) + } + } + + /** Verifies a convention reference already bound to the staying declaration is not reported. */ + fun testHasConflictingOutgoingImport_ignoresConventionReferenceToStayingDeclaration() { + withSession( + name = "outgoing-convention-staying", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.writeString( + root.resolve("old/Operators.kt"), + "package old\n\noperator fun Number.get(index: Int) = index\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\noperator fun Int.get(index: Int) = -index\n", + ) + Files.writeString(root.resolve("old/Usage.kt"), "package old\n\nfun use(n: Number) = n[0]\n") + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("old/Usage.kt")) + val movingPaths = setOf(root.resolve("old/Moving.kt").toString()) + + assertFalse( + "the accessor already binds the declaration the import would provide", + KotlinImportBindingCheck.hasConflictingOutgoingImport(usage, FqName("old.get"), movingPaths), + ) + } + } + /** Verifies a member function of an enclosing class binds the name lexically. */ fun testIsNameBoundInScope_reportsMemberFunction() { withSession( @@ -259,6 +323,63 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies an implicit lambda parameter binds the name `it`. + * + * A lambda without declared parameters receives `it` implicitly, so rewriting a reference + * inside it to `it` would call the lambda parameter rather than the renamed declaration. + */ + fun testIsNameBoundInScope_reportsImplicitLambdaParameter() { + withSession( + name = "implicit-it", + prepare = { root -> + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nfun use() = listOf({ \"other\" }).map { Before().toString() }\n\n" + + "class Before\n", + ) + }, + ) { session, root -> + val file = session.ktFile(root.resolve("usage/Usage.kt")) + val reference = PsiTreeUtil.findChildrenOfType(file, KtNameReferenceExpression::class.java) + .single { expression -> + expression.getReferencedName() == "Before" && expression.parent is KtCallExpression + } + + assertTrue( + "the lambda's implicit it parameter is in scope", + KotlinImportBindingCheck.isNameBoundInScope(reference, "it"), + ) + } + } + + /** Verifies a lambda with declared parameters does not introduce `it`. */ + fun testIsNameBoundInScope_ignoresLambdaWithDeclaredParameter() { + withSession( + name = "declared-parameter", + prepare = { root -> + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nfun use() = listOf(1).map { value -> Before().toString() }\n\n" + + "class Before\n", + ) + }, + ) { session, root -> + val file = session.ktFile(root.resolve("usage/Usage.kt")) + val reference = PsiTreeUtil.findChildrenOfType(file, KtNameReferenceExpression::class.java) + .single { expression -> + expression.getReferencedName() == "Before" && expression.parent is KtCallExpression + } + + assertFalse( + "a lambda with a declared parameter has no implicit it", + KotlinImportBindingCheck.isNameBoundInScope(reference, "it"), + ) + } + } + /** @return the session source for [path], failing the test when it is absent. */ private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") diff --git a/pom.xml b/pom.xml index 7c08fa1f6..e1b8d2fe5 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.44-SNAPSHOT + 0.12.45-SNAPSHOT pom Netbeans Plugin Kotlin From b632a7c0b9a3d34709e4ac7d647abebb8bed6994 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 16:32:04 +0300 Subject: [PATCH 35/55] Fixed wildcard-head, delegate-provider and type-parameter edges Change Package now checks wildcard/default imports before shortening a package qualifier, keeps an indexed setter whose getter is a member, and follows a member provideDelegate to the type its accessors are selected on. Import conflict detection treats delegated-property accessors as convention calls. Constructor signature comparison substitutes the function's type parameters with the class's before comparing types and bounds, so equivalent generic overloads are detected. Co-Authored-By: Claude Code --- .../refactoring/KaChangePackageComputer.kt | 67 +++++- .../KotlinConstructorSignatureCheck.kt | 73 +++++- .../refactoring/KotlinImportBindingCheck.kt | 178 +++++++++++++++ .../nbm/refactoring/KaChangePackageTest.kt | 209 ++++++++++++++++++ .../KotlinConstructorSignatureCheckTest.kt | 52 +++++ 5 files changed, 565 insertions(+), 14 deletions(-) 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 index ce861d634..0f3305c93 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -22,8 +22,10 @@ import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.expressionType import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf +import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols @@ -822,9 +824,10 @@ class KaChangePackageComputer( importFqName !in importedFqNames && originalPackage !in starPackages } .forEach { importFqName -> missingImports += importFqName } - // Only a convention that stays behind needs the companion setter, so a - // member operator keeps its resolution untouched. - if (expression.isCompoundWrite() && retainedOperators.isNotEmpty()) { + // A compound indexed write needs the companion setter even when the + // getter is a member: the array reference exposes only `get`, so a + // staying top-level `set` would otherwise be lost without an import. + if (expression.isCompoundWrite()) { samePackageIndexedSetter(expression, originalPackage, movedPaths) ?.takeIf { importFqName -> importFqName !in importedFqNames && originalPackage !in starPackages @@ -840,25 +843,28 @@ class KaChangePackageComputer( override fun visitProperty(property: KtProperty) { super.visitProperty(property) val delegateType = property.delegateExpression?.expressionType ?: return - val provider = samePackageDelegateOperator( - delegateType, - "provideDelegate", - originalPackage, - movedPaths, - ) + // A member provider wins over extensions and needs no import, but its + // return type still decides which accessor the property selects. + val provider = delegateType.memberDelegateOperator("provideDelegate") + ?: samePackageDelegateOperator( + delegateType, + "provideDelegate", + originalPackage, + movedPaths, + ) // `provideDelegate` runs before the accessors and its return type is the // type the accessors are selected on, so it replaces the expression type. val accessorType = provider?.returnType ?: delegateType val operators = buildList { if (provider != null) add(provider) - samePackageDelegateOperator( + samePackageDelegateAccessor( accessorType, "getValue", originalPackage, movedPaths, )?.let { symbol -> add(symbol) } if (property.isVar) { - samePackageDelegateOperator( + samePackageDelegateAccessor( accessorType, "setValue", originalPackage, @@ -984,6 +990,45 @@ class KaChangePackageComputer( } } + /** + * Returns the member declaration of [name] on this type, or `null` when it has none. + * + * A member always wins over an extension, so the compiler never falls back to a same-package + * operator when the type declares one of that name. + * + * @param name member name + * @return the declared member, regardless of its applicability + */ + context(_: KaSession) + private fun KaType.memberDelegateOperator(name: String): KaCallableSymbol? = runCatching { + expandedSymbol?.memberScope?.declarations + ?.filterIsInstance() + ?.firstOrNull { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name } + }.getOrNull() + + /** + * Finds the delegate accessor a moved file still needs from the original package. + * + * A member accessor wins over every extension, so the same-package lookup only applies when the + * receiver type declares no member of that name. + * + * @param accessorType type the accessor is selected on + * @param operatorName `getValue` or `setValue` + * @param originalPackage package the moved file declares before the change + * @param movedPaths paths of the files whose package changes + * @return the remaining accessor extension, or `null` when none applies + */ + context(_: KaSession) + private fun samePackageDelegateAccessor( + accessorType: KaType, + operatorName: String, + originalPackage: FqName, + movedPaths: Set, + ): KaCallableSymbol? { + if (accessorType.memberDelegateOperator(operatorName) != null) return null + return samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths) + } + /** * Finds a delegate operator a moved file still needs from the original package. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt index a94245d5f..5acb33666 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -16,9 +16,14 @@ *******************************************************************************/ package io.github.nbplugins.kotlin.refactoring +import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.buildTypeParameterType +import org.jetbrains.kotlin.analysis.api.components.createSubstitutor import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals import org.jetbrains.kotlin.analysis.api.components.type +import org.jetbrains.kotlin.analysis.api.symbols.symbol +import org.jetbrains.kotlin.analysis.api.types.KaSubstitutor import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtParameter @@ -29,7 +34,8 @@ import org.jetbrains.kotlin.psi.KtParameter * Renaming a class writes its new name verbatim, so a same-package function whose call signature * matches a constructor becomes a conflicting overload that Kotlin rejects. Parameter types are * compared as resolved types, because `Int` and a fully qualified `kotlin.Int` are the same Kotlin - * type even though their source spellings differ. + * type even though their source spellings differ. Type parameters are compared by position after + * substituting the function's parameters with the class's, and their bounds are compared as well. */ object KotlinConstructorSignatureCheck { @@ -53,16 +59,77 @@ object KotlinConstructorSignatureCheck { .filter { parameters -> parameters.size == function.valueParameters.size } if (matching.isEmpty()) return false return analyze(function) { - matching.any { parameters -> + val substitutor = typeParameterSubstitutor(primaryClass, function) + typeParametersMatch(primaryClass, function, substitutor) && matching.any { parameters -> parameters.indices.all { index -> val constructorType = runCatching { parameters[index].typeReference?.type }.getOrNull() val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() - constructorType == null || functionType == null || functionType.semanticallyEquals(constructorType) + constructorType == null || functionType == null || runCatching { + substitutor.substitute(functionType).semanticallyEquals(constructorType) + }.getOrDefault(false) } } } } + /** + * Maps the function's type parameters to the class's parameters at the same position. + * + * After the rename the function spells the class's parameters, so a parameter type using the + * function's first type parameter corresponds to one using the class's first type parameter. + * Their resolved type symbols differ, which would make semantically equivalent signatures + * compare as different. Function parameters without a counterpart stay unmapped and therefore + * keep a parameter type that uses them different. + * + * @param classOrObject class whose type parameters the function would spell + * @param function function whose type parameters are substituted + * @return substitutor replacing the function's type parameters with the class's + */ + context(_: KaSession) + private fun typeParameterSubstitutor( + classOrObject: KtClass, + function: KtNamedFunction, + ): KaSubstitutor { + val classParameters = classOrObject.typeParameters + val mapping = function.typeParameters.mapIndexedNotNull { index, parameter -> + val classParameter = classParameters.getOrNull(index) ?: return@mapIndexedNotNull null + parameter.symbol to buildTypeParameterType(classParameter.symbol) + }.toMap() + return createSubstitutor(mapping) + } + + /** + * Tells whether corresponding type parameters have equivalent bounds. + * + * Two signatures are conflicting only when their type parameters also allow the same type + * arguments; a function accepting any `T` would not collide with a constructor restricted to + * `T : Number`. + * + * @param classOrObject class whose type parameter bounds are compared + * @param function function whose type parameter bounds are compared + * @param substitutor maps the function's type parameters to the class's + * @return `true` when every corresponding bound is equivalent + */ + context(_: KaSession) + private fun typeParametersMatch( + classOrObject: KtClass, + function: KtNamedFunction, + substitutor: KaSubstitutor, + ): Boolean { + val classParameters = classOrObject.typeParameters + return function.typeParameters.indices.all { index -> + val classParameter = classParameters.getOrNull(index) ?: return@all true + val functionBounds = function.typeParameters[index].symbol.upperBounds + val classBounds = classParameter.symbol.upperBounds + functionBounds.size == classBounds.size && + functionBounds.zip(classBounds).all { (functionBound, classBound) -> + runCatching { + substitutor.substitute(functionBound).semanticallyEquals(classBound) + }.getOrDefault(false) + } + } + } + /** * Returns the parameter list of every constructor the renamed class would expose. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index fcbdba775..2296c9555 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -23,10 +23,13 @@ import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol import org.jetbrains.kotlin.analysis.api.components.KaScopeImplicitReceiverValue import org.jetbrains.kotlin.analysis.api.components.expandedSymbol +import org.jetbrains.kotlin.analysis.api.components.expressionType +import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols +import org.jetbrains.kotlin.analysis.api.components.scopeContext import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol @@ -35,9 +38,12 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol import org.jetbrains.kotlin.analysis.api.symbols.findPackage +import org.jetbrains.kotlin.analysis.api.symbols.findTopLevelCallables import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression @@ -98,6 +104,12 @@ object KotlinImportBindingCheck { ): Boolean = runCatching { analyze(file) { var conflicting = false + // Only an extension can act as a delegate accessor, so importing a class or property + // of that name cannot change which accessor a delegated property selects. + val importsAccessor = excludedDeclarations.any { declaration -> + declaration is KtNamedFunction && declaration.name == name && + declaration.receiverTypeReference != null + } file.accept(object : KtTreeVisitorVoid() { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) @@ -119,6 +131,18 @@ object KotlinImportBindingCheck { conflicting = true } + // A delegated property spells none of its operators either: `val value by 1` + // calls a top-level `getValue` that an added explicit import outranks. + override fun visitProperty(property: KtProperty) { + super.visitProperty(property) + if (conflicting || !importsAccessor) return + runCatching { property.delegateAccessorCandidates(name) }.getOrNull()?.forEach { symbol -> + if (!symbol.belongsTo(excludedDeclarations) && symbol.isTopLevelBinding()) { + conflicting = true + } + } + } + // Convention references spell no name for the callable they invoke: `n[0]` calls // `get`, `val (a) = box` calls `componentN`, and `for (x in box)` calls // `iterator`. An import of that name can retarget them exactly like a spelled @@ -189,6 +213,9 @@ object KotlinImportBindingCheck { analyze(file) { var conflicting = false val importedName = imported.shortName().asString() + // Only an extension can act as a delegate accessor, and an aliased directive provides + // no operator under the declaration's own name. + val rewritesAccessor = !aliased && isExtensionCallable(imported) file.accept(object : KtTreeVisitorVoid() { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) @@ -210,6 +237,18 @@ object KotlinImportBindingCheck { conflicting = true } + // A delegated property spells no accessor either, so `val value by 1` keeps + // binding the overload the rewritten directive stops importing. + override fun visitProperty(property: KtProperty) { + super.visitProperty(property) + if (conflicting || !rewritesAccessor) return + runCatching { property.delegateAccessorCandidates(importedName) }.getOrNull()?.forEach { symbol -> + if (symbol.isTopLevelBinding() && symbol.staysInOriginalPackage(imported, movingPaths)) { + conflicting = true + } + } + } + // A convention call spells no name, so `n[0]` (`get`) keeps binding an overload // the rewritten directive stops importing even though no reference names it. // An aliased directive binds only its alias, so it provides no convention operator. @@ -269,6 +308,9 @@ object KotlinImportBindingCheck { analyze(file) { var conflicting = false val importedName = imported.shortName().asString() + // Only an extension can act as a delegate accessor, so importing another kind of + // declaration of that name leaves the property's resolution untouched. + val importsAccessor = isExtensionCallable(imported) file.accept(object : KtTreeVisitorVoid() { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) @@ -284,6 +326,20 @@ object KotlinImportBindingCheck { if (symbol.isCapturedByOutgoingImport(imported, movingPaths)) conflicting = true } + // A delegated property spells no accessor, so the added import can retarget the + // operator its delegate expression selects. + override fun visitProperty(property: KtProperty) { + super.visitProperty(property) + if (conflicting || !importsAccessor) return + runCatching { property.delegateAccessorCandidates(importedName) }.getOrNull()?.forEach { symbol -> + if (symbol.isTopLevelBinding() && + symbol.isCapturedByOutgoingImport(imported, movingPaths) + ) { + conflicting = true + } + } + } + // A convention call spells no name, so `n[0]` (`get`) keeps binding the same-package // overload while the file moves; the added import would outrank it instead. override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { @@ -436,6 +492,10 @@ object KotlinImportBindingCheck { if (file.declarations.any { declaration -> declaration !in ignoredDeclarations && declaration.declaresName(name) }) return true + // A wildcard or default import binds names without spelling any directive of this file, so + // a classifier or callable it provides hides a same-named package segment or declaration + // exactly like an explicit import does. + if (isNameBoundByWildcardImport(element, name)) return true var child: PsiElement = element var current: PsiElement? = element.parent while (current != null) { @@ -487,6 +547,28 @@ object KotlinImportBindingCheck { } }.getOrDefault(false) + /** + * Tells whether a wildcard or default import of [file] binds [name] at [element]. + * + * `import lib.*` and Kotlin's default imports introduce declarations without listing them in + * the file's own directives. Scope resolution is therefore consulted instead of directive + * text: the position's composite scope already knows which star imports apply and which + * declarations they contribute. + * + * @param element position whose scope is inspected + * @param name identifier to look for + * @return `true` when a wildcard or default import provides a classifier or callable of that name + */ + private fun isNameBoundByWildcardImport(element: KtElement, name: String): Boolean = runCatching { + analyze(element) { + val identifier = Name.identifier(name) + element.containingKtFile.scopeContext(element).scopes.any { scopeWithKind -> + scopeWithKind.scope.classifiers(identifier).any() || + scopeWithKind.scope.callables(identifier).any() + } + } + }.getOrDefault(false) + /** * Tells whether a member of an enclosing or implicit receiver binds [name]. * @@ -746,6 +828,102 @@ object KotlinImportBindingCheck { return declaredPackage() != imported.parent() } + /** + * Tells whether [fqName] names a top-level extension callable. + * + * Only an extension can act as a convention operator, so an import of a class, property, or + * member callable of the same name leaves convention references and delegated properties + * untouched. + * + * @param fqName FQN the import binds + * @return `true` when the FQN denotes a top-level extension callable + */ + context(_: KaSession) + private fun isExtensionCallable(fqName: FqName): Boolean = runCatching { + findTopLevelCallables(fqName.parent(), fqName.shortName()).any { symbol -> + symbol.isExtension && symbol.callableId?.asSingleFqName() == fqName + } + }.getOrDefault(false) + + /** + * Returns the extension operators a delegated property implicitly selects. + * + * A delegated property spells neither `provideDelegate` nor `getValue`/`setValue`; K2 does not + * expose the implicit calls either, so the applicable extensions are looked up by name and + * receiver applicability in the property's scope. `provideDelegate` runs first and its return + * type is the type the accessors are selected on. A member accessor always wins over + * extensions, so it cannot be captured by an import and is not reported. + * + * @param name accessor name the import introduces + * @return applicable extension operators of that name, empty when the property does not use it + */ + context(_: KaSession) + private fun KtProperty.delegateAccessorCandidates(name: String): List { + val accessorNames = if (isVar) listOf("getValue", "setValue") else listOf("getValue") + if (name !in accessorNames) return emptyList() + val delegateType = runCatching { delegateExpression?.expressionType }.getOrNull() ?: return emptyList() + val provider = delegateOperator(delegateType, "provideDelegate") + val accessorTypes = provider + ?.let { symbol -> runCatching { symbol.returnType }.getOrNull() } + ?.let { providedType -> listOf(providedType) } + ?: listOf(delegateType) + return accessorTypes.flatMap { accessorType -> + if (accessorType.memberOperator(name) != null) { + emptyList() + } else { + applicableOperators(accessorType, name) + } + } + } + + /** + * Finds the operator of [name] the compiler would select on [type]. + * + * A member declaration always outranks an extension, so it is returned first; otherwise the + * first applicable extension visible at this property is used. + * + * @param type receiver type the operator is selected on + * @param name operator name + * @return the selected operator, or `null` when none applies + */ + context(_: KaSession) + private fun KtProperty.delegateOperator(type: KaType, name: String): KaCallableSymbol? = + type.memberOperator(name) ?: applicableOperators(type, name).firstOrNull() + + /** + * Returns the member declaration of [name] on this type, or `null` when it has none. + * + * @param name member name + * @return the declared member, regardless of its applicability + */ + context(_: KaSession) + private fun KaType.memberOperator(name: String): KaCallableSymbol? = runCatching { + expandedSymbol?.memberScope?.declarations + ?.filterIsInstance() + ?.firstOrNull { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name } + }.getOrNull() + + /** + * Returns the top-level extension operators of [name] applicable to [type] at this property. + * + * @param type receiver type the operator is selected on + * @param name operator name + * @return applicable extensions visible in the property's scope + */ + context(_: KaSession) + private fun KtProperty.applicableOperators(type: KaType, name: String): List = runCatching { + val identifier = Name.identifier(name) + containingKtFile.scopeContext(this).scopes.asSequence() + .flatMap { scopeWithKind -> scopeWithKind.scope.callables(identifier) } + .filter { symbol -> symbol.isExtension } + .filter { symbol -> + val receiverType = symbol.receiverParameter?.returnType ?: return@filter false + type.isSubtypeOf(receiverType) + } + .distinct() + .toList() + }.getOrElse { emptyList() } + /** * Returns the innermost class or object containing this element, or `null` at file level. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index d2f7e441d..85c653620 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3949,6 +3949,215 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a package head bound by a wildcard import falls back to the shortened call. + * + * The caller imports `lib.*`, and `lib.dest` is an object. Rewriting `old.moved()` to + * `dest.moved()` would resolve the qualifier to that object's member instead of the package + * the declaration moved to, so the reference must become a bare name with an explicit import. + */ + fun testApply_realSession_shortensPackageHeadCapturedByWildcardImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-head") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString( + temporaryRoot.resolve("lib/Lib.kt"), + "package lib\n\nobject dest {\n fun moved() = \"other\"\n}\n", + ) + Files.writeString(sourcePath, "package old\n\nfun moved() = \"old\"\n") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + usagePath, + "package usage\n\nimport lib.*\n\nfun use() = old.moved()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-head-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The qualifier is captured by lib.dest, so the call must be shortened: $text", + text.contains("fun use() = moved()"), + ) + assertTrue( + "The shortened call needs an explicit import: $text", + text.contains("import dest.moved"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies adding an import for an extension does not capture a delegated property's accessor. + * + * The caller imports `old.*` and declares `val value by 1`, which selects the staying + * `Int.getValue`. The moved `Number.getValue` needs a new explicit import, and an explicit + * import outranks the wildcard, so the delegated property would silently use the Number + * accessor instead. The change must be rejected. + */ + fun testApply_realSession_rejectsImportCapturingDelegateAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-delegate-capture") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Number.kt") + Files.writeString( + sourcePath, + "package old\n\noperator fun Number.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + temporaryRoot.resolve("old/Int.kt"), + "package old\n\noperator fun Int.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value by 1\n\n" + + "fun probe(property: kotlin.reflect.KProperty<*>) = (1.5).getValue(null, property)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-delegate-capture-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected conflicts, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Conflicts) + val conflicts = outcome as KaChangePackageComputer.ApplyOutcome.Conflicts + assertTrue( + "The conflict must name the caller whose delegated property is captured: ${conflicts.messages}", + conflicts.messages.any { message -> message.contains("Usage.kt") }, + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a compound indexed write discovers the setter even when the getter is a member. + * + * `Box` declares a member `get`, so no operator import is needed for it, but the top-level + * `set` extension stays in the old package and is still called by `box[0]++`. + */ + fun testApply_realSession_importsIndexedSetterForMemberGetter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-member-get-indexed-set") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box {\n operator fun get(i: Int): Int = i\n}\n\n" + + "operator fun Box.set(i: Int, value: Int) {}\n", + ) + Files.writeString(sourcePath, "package old\n\nfun bump(box: Box) {\n box[0]++\n}\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-member-get-indexed-set-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The compound write still calls the staying setter: $text", + text.contains("import old.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a member `provideDelegate` selects the accessor type used for import discovery. + * + * The provider is a member of `Delegate` and returns `Provided`, whose `getValue` is a top-level + * extension staying in the old package. Looking only at top-level providers would miss the + * provider and look for the accessor on `Delegate` instead, leaving the property uncompilable. + */ + fun testApply_realSession_followsMemberProviderForDelegateAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-member-provider") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString( + temporaryRoot.resolve("old/Delegate.kt"), + "package old\n\n" + + "class Delegate {\n" + + " operator fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n}\n\n" + + "class Provided\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString(sourcePath, "package old\n\nval value by Delegate()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-member-provider-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The accessor is selected on the provided type: $text", + text.contains("import old.getValue"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt index 7b4c2ae82..6edfb3291 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -85,6 +85,58 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig } } + /** + * Verifies type parameters are compared by position rather than by symbol. + * + * The constructor's `T` is a parameter of the class while the function's `T` is its own type + * parameter, so the resolved types refer to different symbols even though both signatures are + * the same after renaming. + */ + fun testConflictsWithRenamedConstructor_matchesCorrespondingTypeParameter() { + withSession( + name = "type-parameter", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Foo(val n: T)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: T) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertTrue( + "both signatures take one type parameter in the same position", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** Verifies a type parameter nested in another type is normalized as well. */ + fun testConflictsWithRenamedConstructor_ignoresDifferentTypeParameterUse() { + withSession( + name = "type-parameter-different", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Foo(val n: List)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun Bar(n: T) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "List is not the same type as T", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + /** * Verifies a fully qualified parameter type is recognized as the constructor's type. * From 0e203ff8d848d5f6994338100d82a346233f6787 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 19:05:09 +0300 Subject: [PATCH 36/55] Fixed implicit delegate and indexed-setter edges in rename and package change Imports can now capture the implicit `provideDelegate` of a delegated property and the implicit `set` of a compound indexed write, so both are checked by all three import-binding guards. Member accessors are matched by the delegation protocol shape instead of by name alone, and a `provideDelegate` moving with the file still decides which accessor the moved file must import. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 37 +++-- .../refactoring/KaDelegateOperatorShape.kt | 49 +++++++ .../refactoring/KotlinImportBindingCheck.kt | 134 ++++++++++++++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 101 +++++++++++++ .../KotlinImportBindingCheckTest.kt | 91 ++++++++++++ pom.xml | 2 +- 17 files changed, 395 insertions(+), 41 deletions(-) create mode 100644 KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index fc27709ac..f8a733a3b 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index ca2b53811..8cf71ce21 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 5c2de720a..f2b06764b 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index deac040ad..cd85468c6 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 8f3cddc16..95d82c0e8 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 99c38336f..874c6ea23 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index d6bc9f629..813fbe2a8 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 9af884985..e940d4bd1 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 80c0d5189..7926bef99 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 803008232..36ef43e32 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 0f3305c93..4c1584d53 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -845,12 +845,15 @@ class KaChangePackageComputer( val delegateType = property.delegateExpression?.expressionType ?: return // A member provider wins over extensions and needs no import, but its // return type still decides which accessor the property selects. + // A provider moving with the file is still selected by the property + // after the move, so it decides the accessor type too; only its + // import is skipped below. val provider = delegateType.memberDelegateOperator("provideDelegate") ?: samePackageDelegateOperator( delegateType, "provideDelegate", originalPackage, - movedPaths, + movedPaths = null, ) // `provideDelegate` runs before the accessors and its return type is the // type the accessors are selected on, so it replaces the expression type. @@ -991,19 +994,22 @@ class KaChangePackageComputer( } /** - * Returns the member declaration of [name] on this type, or `null` when it has none. + * Returns the member delegate operator of [name] on this type, or `null` when it has none. * * A member always wins over an extension, so the compiler never falls back to a same-package - * operator when the type declares one of that name. + * operator when the type declares a fitting one. Matching by name alone would treat an + * ordinary member whose signature does not fit the delegation protocol as an accessor. * - * @param name member name - * @return the declared member, regardless of its applicability + * @param name operator name + * @return the applicable member operator, or `null` when the type declares none */ context(_: KaSession) private fun KaType.memberDelegateOperator(name: String): KaCallableSymbol? = runCatching { expandedSymbol?.memberScope?.declarations ?.filterIsInstance() - ?.firstOrNull { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name } + ?.firstOrNull { symbol -> + (symbol as? KaNamedSymbol)?.name?.asString() == name && symbol.isDelegateOperatorShape(name) + } }.getOrNull() /** @@ -1030,34 +1036,37 @@ class KaChangePackageComputer( } /** - * Finds a delegate operator a moved file still needs from the original package. + * Finds a delegate operator of the original package selected on [type]. * * K2 does not expose the implicit `provideDelegate`/`getValue`/`setValue` calls of a delegated - * property, so the operator is looked up by name and receiver applicability on the type it is - * selected on. Only a declaration that stays behind qualifies, mirroring the indexed-operator - * lookup. + * property, so the operator is looked up by name, signature shape, and receiver applicability + * on the type it is selected on. When [movedPaths] is `null`, every declaration of the + * original package qualifies — a provider moving with the file still decides which accessor + * the property selects. Otherwise only a declaration that stays behind qualifies, mirroring + * the indexed-operator lookup. * * @param type type of the delegate, or of the value a `provideDelegate` returns * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @param originalPackage package the moved file declares before the change - * @param movedPaths paths of the files whose package changes - * @return the remaining operator, or `null` when none applies + * @param movedPaths paths of the files whose package changes, or `null` to accept moving ones + * @return the selected operator, or `null` when none applies */ context(_: KaSession) private fun samePackageDelegateOperator( type: KaType, operatorName: String, originalPackage: FqName, - movedPaths: Set, + movedPaths: Set?, ): KaCallableSymbol? { return findPackage(originalPackage)?.packageScope?.declarations ?.filterIsInstance() ?.firstOrNull { symbol -> val receiver = symbol.receiverParameter?.returnType (symbol as? KaNamedSymbol)?.name?.asString() == operatorName && + symbol.isDelegateOperatorShape(operatorName) && receiver != null && type.isSubtypeOf(receiver) && - samePackageTopLevelName(symbol, originalPackage, movedPaths) != null + (movedPaths == null || samePackageTopLevelName(symbol, originalPackage, movedPaths) != null) } } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt new file mode 100644 index 000000000..9e3a2d992 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt @@ -0,0 +1,49 @@ +/******************************************************************************* + * Copyright 2026 nbplugins contributors + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + * + *******************************************************************************/ +package io.github.nbplugins.kotlin.refactoring + +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.components.expandedSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.name.ClassId +import org.jetbrains.kotlin.name.FqName + +/** + * Tells whether this callable has the call shape Kotlin requires of a delegate operator. + * + * Delegated properties select their operators by name, but a same-named member is only an + * accessor when its signature fits the delegation protocol: `provideDelegate` and `getValue` take + * the delegating receiver and the property, while `setValue` adds the assigned value. A + * parameterless `fun getValue()` is therefore an ordinary member and must not hide the extension + * the property actually uses. + * + * @param operatorName `provideDelegate`, `getValue`, or `setValue` + * @return `true` when this callable can be selected as that delegate operator + */ +context(_: KaSession) +internal fun KaCallableSymbol.isDelegateOperatorShape(operatorName: String): Boolean { + val function = this as? KaFunctionSymbol ?: return false + val expectedParameters = if (operatorName == "setValue") 3 else 2 + if (function.valueParameters.size != expectedParameters) return false + return runCatching { + function.valueParameters[1].returnType.expandedSymbol?.classId == K_PROPERTY_CLASS_ID + }.getOrDefault(false) +} + +/** Class id of `kotlin.reflect.KProperty`, the property parameter every accessor declares. */ +private val K_PROPERTY_CLASS_ID = ClassId.topLevel(FqName("kotlin.reflect.KProperty")) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 2296c9555..cc04fd4d3 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -42,9 +42,11 @@ import org.jetbrains.kotlin.analysis.api.symbols.findTopLevelCallables import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.idea.references.KtReference +import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtBinaryExpression import org.jetbrains.kotlin.psi.KtBlockExpression import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtCatchClause @@ -70,6 +72,7 @@ import org.jetbrains.kotlin.psi.KtSimpleNameExpression import org.jetbrains.kotlin.psi.KtTreeVisitorVoid import org.jetbrains.kotlin.psi.KtTypeAlias import org.jetbrains.kotlin.psi.KtTypeParameterListOwner +import org.jetbrains.kotlin.psi.KtUnaryExpression import org.jetbrains.kotlin.psi.KtUserType import org.jetbrains.kotlin.psi.KtWhenExpression @@ -155,6 +158,15 @@ object KotlinImportBindingCheck { { symbol -> !symbol.belongsTo(excludedDeclarations) }, ) { conflicting = true } } + // A compound indexed write spells only the getter; its implicit setter goes + // through the same check because an import of `set` outranks the wildcard. + if (!conflicting && name == "set") { + runCatching { expression.implicitSetterCandidates() }.getOrNull()?.forEach { symbol -> + if (!symbol.belongsTo(excludedDeclarations) && symbol.isTopLevelBinding()) { + conflicting = true + } + } + } } override fun visitDestructuringDeclarationEntry(entry: KtDestructuringDeclarationEntry) { @@ -259,6 +271,17 @@ object KotlinImportBindingCheck { importedName, { symbol -> symbol.staysInOriginalPackage(imported, movingPaths) }, ) { conflicting = true } + // The setter half of a compound write is implicit; it loses the overload + // the rewritten directive stops importing just like the getter does. + if (!conflicting && importedName == "set") { + runCatching { expression.implicitSetterCandidates() }.getOrNull()?.forEach { symbol -> + if (symbol.isTopLevelBinding() && + symbol.staysInOriginalPackage(imported, movingPaths) + ) { + conflicting = true + } + } + } } } @@ -349,6 +372,17 @@ object KotlinImportBindingCheck { importedName, { symbol -> symbol.isCapturedByOutgoingImport(imported, movingPaths) }, ) { conflicting = true } + // The implicit setter of a compound write keeps binding through the + // wildcard while the file moves; the added import would outrank it. + if (!conflicting && importedName == "set") { + runCatching { expression.implicitSetterCandidates() }.getOrNull()?.forEach { symbol -> + if (symbol.isTopLevelBinding() && + symbol.isCapturedByOutgoingImport(imported, movingPaths) + ) { + conflicting = true + } + } + } } } @@ -846,29 +880,38 @@ object KotlinImportBindingCheck { }.getOrDefault(false) /** - * Returns the extension operators a delegated property implicitly selects. + * Returns the extension delegate operators a delegated property implicitly selects. * * A delegated property spells neither `provideDelegate` nor `getValue`/`setValue`; K2 does not * expose the implicit calls either, so the applicable extensions are looked up by name and * receiver applicability in the property's scope. `provideDelegate` runs first and its return - * type is the type the accessors are selected on. A member accessor always wins over - * extensions, so it cannot be captured by an import and is not reported. + * type is the type the accessors are selected on, so the provider itself is a candidate when + * the import introduces a provider. A member operator always wins over extensions, so it + * cannot be captured by an import and is not reported. * - * @param name accessor name the import introduces + * @param name operator name the import introduces * @return applicable extension operators of that name, empty when the property does not use it */ context(_: KaSession) private fun KtProperty.delegateAccessorCandidates(name: String): List { + val delegateType = runCatching { delegateExpression?.expressionType }.getOrNull() ?: return emptyList() + if (name == "provideDelegate") { + // The provider is selected on the delegate expression's own type, before any accessor. + return if (delegateType.memberDelegateOperator(name) != null) { + emptyList() + } else { + applicableOperators(delegateType, name) + } + } val accessorNames = if (isVar) listOf("getValue", "setValue") else listOf("getValue") if (name !in accessorNames) return emptyList() - val delegateType = runCatching { delegateExpression?.expressionType }.getOrNull() ?: return emptyList() val provider = delegateOperator(delegateType, "provideDelegate") val accessorTypes = provider ?.let { symbol -> runCatching { symbol.returnType }.getOrNull() } ?.let { providedType -> listOf(providedType) } ?: listOf(delegateType) return accessorTypes.flatMap { accessorType -> - if (accessorType.memberOperator(name) != null) { + if (accessorType.memberDelegateOperator(name) != null) { emptyList() } else { applicableOperators(accessorType, name) @@ -876,6 +919,43 @@ object KotlinImportBindingCheck { } } + /** + * Returns the top-level `set` extensions a compound indexed write implicitly selects. + * + * `sub[0]++` and `sub[0] += 1` spell only the getter, so K2 does not expose the setter half. + * The applicable `set` extensions are looked up by name and receiver applicability at the + * access site, mirroring the delegate accessor lookup. A member setter always wins over + * extensions and cannot be captured by an import. + * + * @return applicable `set` extensions, empty for a plain read or a member setter + */ + context(_: KaSession) + private fun KtArrayAccessExpression.implicitSetterCandidates(): List { + if (!isCompoundWrite()) return emptyList() + val receiverType = runCatching { arrayExpression?.expressionType }.getOrNull() ?: return emptyList() + if (receiverType.memberIndexedSetter() != null) return emptyList() + return applicableOperators(receiverType, "set") + } + + /** + * Tells whether this indexed access also writes its element. + * + * K2 exposes only the `get` convention of an indexed access; a compound assignment or + * increment is what makes the compiler select a `set` as well. + * + * @return `true` when the access is assigned through rather than merely read + */ + private fun KtArrayAccessExpression.isCompoundWrite(): Boolean { + val parent = parent + return when (parent) { + is KtBinaryExpression -> parent.left == this && + parent.operationToken in KtTokens.ALL_ASSIGNMENTS && parent.operationToken != KtTokens.EQ + is KtUnaryExpression -> parent.baseExpression == this && + parent.operationToken in setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) + else -> false + } + } + /** * Finds the operator of [name] the compiler would select on [type]. * @@ -888,30 +968,54 @@ object KotlinImportBindingCheck { */ context(_: KaSession) private fun KtProperty.delegateOperator(type: KaType, name: String): KaCallableSymbol? = - type.memberOperator(name) ?: applicableOperators(type, name).firstOrNull() + type.memberDelegateOperator(name) ?: applicableOperators(type, name).firstOrNull() + + /** + * Returns the member delegate operator of [name] on this type, or `null` when it has none. + * + * Matching by name alone would treat an ordinary member whose signature does not fit the + * delegation protocol as an accessor and hide the extension the property actually uses. + * + * @param name operator name + * @return the applicable member operator, or `null` when the type declares none + */ + context(_: KaSession) + private fun KaType.memberDelegateOperator(name: String): KaCallableSymbol? = runCatching { + expandedSymbol?.memberScope?.declarations + ?.filterIsInstance() + ?.firstOrNull { symbol -> + (symbol as? KaNamedSymbol)?.name?.asString() == name && symbol.isDelegateOperatorShape(name) + } + }.getOrNull() /** - * Returns the member declaration of [name] on this type, or `null` when it has none. + * Returns the member `set` operator of an indexed access on this type, or `null` when absent. * - * @param name member name - * @return the declared member, regardless of its applicability + * A member setter takes the index and the assigned value; extensions declare the receiver + * separately and therefore have one more value parameter. + * + * @return the member indexed setter, or `null` when there is none */ context(_: KaSession) - private fun KaType.memberOperator(name: String): KaCallableSymbol? = runCatching { + private fun KaType.memberIndexedSetter(): KaCallableSymbol? = runCatching { expandedSymbol?.memberScope?.declarations ?.filterIsInstance() - ?.firstOrNull { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name } + ?.firstOrNull { symbol -> + (symbol as? KaNamedSymbol)?.name?.asString() == "set" && + symbol is org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol && + symbol.valueParameters.size == 2 + } }.getOrNull() /** - * Returns the top-level extension operators of [name] applicable to [type] at this property. + * Returns the top-level extension operators of [name] applicable to [type] at this element. * * @param type receiver type the operator is selected on * @param name operator name - * @return applicable extensions visible in the property's scope + * @return applicable extensions visible in the element's scope */ context(_: KaSession) - private fun KtProperty.applicableOperators(type: KaType, name: String): List = runCatching { + private fun KtElement.applicableOperators(type: KaType, name: String): List = runCatching { val identifier = Name.identifier(name) containingKtFile.scopeContext(this).scopes.asSequence() .flatMap { scopeWithKind -> scopeWithKind.scope.callables(identifier) } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index bc80d139b..2c5ecc0a3 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 85c653620..43b1baaff 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -4158,6 +4158,107 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an inapplicable member of the accessor name does not hide the same-package accessor. + * + * `Delegate` declares a parameterless `getValue` that cannot act as a delegate accessor. Matching + * the member by name alone would suppress the import of the `Delegate.getValue` extension the + * delegated property actually uses, leaving it uncompilable after the move. + */ + fun testApply_realSession_ignoresInapplicableMemberAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-inapplicable-member") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString( + temporaryRoot.resolve("old/Delegate.kt"), + "package old\n\n" + + "class Delegate {\n" + + " fun getValue() = 0\n}\n\n" + + "operator fun Delegate.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString(sourcePath, "package old\n\nval value by Delegate()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-inapplicable-member-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The extension is the real accessor, not the parameterless member: $text", + text.contains("import old.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a moving provider still decides which accessor the moved file needs to import. + * + * `provideDelegate` moves with the file, but the `Provided.getValue` accessor it selects stays + * in the old package. Ignoring a moving provider would look for the accessor on `Delegate` + * instead and leave the property uncompilable. + */ + fun testApply_realSession_followsMovingProviderForAccessorImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-moving-provider") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + Files.writeString( + temporaryRoot.resolve("old/Accessors.kt"), + "package old\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\nclass Delegate\n\nclass Provided\n\nval value by Delegate()\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-moving-provider-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The accessor type follows the moving provider: $text", + text.contains("import old.getValue"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index c1f4b59fa..38ebfa34e 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -204,6 +204,97 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies an implicit `provideDelegate` of a delegated property is reported. + * + * `val value by 1` calls the `Int.provideDelegate` extension bound through the wildcard + * import without spelling its name. Adding an explicit import of the moving `Long.provideDelegate` + * overload outranks the wildcard, so the property would select the imported overload instead. + */ + fun testHasConflictingReference_reportsImplicitDelegateProvider() { + withSession( + name = "implicit-provider", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\n" + + "operator fun Int.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = this\n\n" + + "operator fun Int.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = this\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\n" + + "operator fun Long.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Long = this\n\n" + + "operator fun Long.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Long = this\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\n" + + "fun use(property: kotlin.reflect.KProperty<*>) {\n" + + " val value by 1\n" + + " (1L).provideDelegate(null, property)\n}\n", + ) + }, + ) { session, root -> + val moving = session.ktFile(root.resolve("old/Moving.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val imported = moving.declarations.filterIsInstance().single { it.name == "provideDelegate" } + + assertTrue( + "the imported provider would outrank the wildcard provider of val value by 1", + KotlinImportBindingCheck.hasConflictingReference(usage, "provideDelegate", setOf(imported)), + ) + } + } + + /** + * Verifies the implicit setter of a compound indexed write is reported. + * + * `sub[0]++` spells only the getter, and K2 does not expose the setter half. Adding an explicit + * import of the moving `Box.set` extension outranks the wildcard import that currently selects + * the staying `SubBox.set`, so the write would silently switch to the imported overload. + */ + fun testHasConflictingReference_reportsImplicitIndexedSetter() { + withSession( + name = "implicit-setter", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\n" + + "open class SubBox\n\n" + + "operator fun SubBox.get(index: Int): Int = 0\n\n" + + "operator fun SubBox.set(index: Int, value: Int) {}\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\nclass Box\n\n" + + "operator fun Box.set(index: Int, value: Int) {}\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nfun use(sub: SubBox) {\n sub[0]++\n}\n", + ) + }, + ) { session, root -> + val moving = session.ktFile(root.resolve("old/Moving.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val imported = moving.declarations.filterIsInstance().single { it.name == "set" } + + assertTrue( + "the implicit setter of sub[0]++ would follow the imported set", + KotlinImportBindingCheck.hasConflictingReference(usage, "set", setOf(imported)), + ) + } + } + /** Verifies a member function of an enclosing class binds the name lexically. */ fun testIsNameBoundInScope_reportsMemberFunction() { withSession( diff --git a/pom.xml b/pom.xml index e1b8d2fe5..54efaeba5 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.45-SNAPSHOT + 0.12.46-SNAPSHOT pom Netbeans Plugin Kotlin From 6e2b54d7aea2be3560a755a1eecc10eead328bbc Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 19:26:28 +0300 Subject: [PATCH 37/55] Fixed qualified package overloads, object receivers and implicit invoke imports Qualified renamed calls are now validated against the package their receiver names, so a same-package function of the new name cannot silently replace the renamed constructor. An extension called on a named object is treated as a value receiver even when it is declared on the object's supertypes, and a moved file now imports the implicit `invoke` operator of a convention call. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 21 +++++ .../kotlin/refactoring/KaRenameComputer.kt | 14 +-- .../refactoring/KotlinImportBindingCheck.kt | 87 ++++++++++++++++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 45 ++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 45 ++++++++++ .../KotlinImportBindingCheckTest.kt | 47 ++++++++++ pom.xml | 2 +- 18 files changed, 265 insertions(+), 18 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index f8a733a3b..6687c5c87 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 8cf71ce21..4076f9f16 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index f2b06764b..672333609 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index cd85468c6..86716aadc 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 95d82c0e8..dee4aa32d 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 874c6ea23..39b5c8f7b 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 813fbe2a8..c8d37df3f 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index e940d4bd1..a7e84a7c3 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 7926bef99..c46fdc46d 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 36ef43e32..f92073ad2 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 4c1584d53..b1ffd08fe 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -763,6 +763,27 @@ class KaChangePackageComputer( missingImports += importFqName } + // A call on a value spells no name for the convention it invokes: + // `(box)()` depends on a top-level `operator fun Box.invoke` that only the + // original package's visibility provided, and the implicit invocation + // reference belongs to the call expression rather than to any spelled + // name. Spelled callees are handled by visitSimpleNameExpression. + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + if (expression.calleeExpression is KtSimpleNameExpression) return + expression.references.filterIsInstance() + .flatMap { reference -> + runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() + } + .mapNotNull { symbol -> + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } + .filter { importFqName -> + importFqName !in importedFqNames && originalPackage !in starPackages + } + .forEach { importFqName -> missingImports += importFqName } + } + // A destructuring declaration spells no name for the operator it calls: // `val (x) = box` depends on a top-level `operator fun Box.component1` // that only the original package's visibility provided, so the convention diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 2db2240ee..1521c42a4 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -392,13 +392,17 @@ class KaRenameComputer( // declaration, so a local or member of that name cannot capture it. if (element is KtLabelReferenceExpression) continue if (PsiTreeUtil.getParentOfType(element, KtImportDirective::class.java) != null) continue - // A qualified selector keeps resolving through its receiver: `p.Before()` - // becomes `p.After()` and still names the constructor, so no lexical binding - // of the new name can capture it. - if (KotlinImportBindingCheck.isQualifiedReference(element)) continue // Kotlin resolves types and values in separate namespaces: a type position is // only captured by another classifier, a value position only by another value. - val captured = if (element.isTypeReference()) { + val typePosition = element.isTypeReference() + val captured = if (KotlinImportBindingCheck.isQualifiedReference(element)) { + // A qualified selector keeps resolving through its receiver: `p.Before()` + // becomes `p.After()`, so no lexical binding can capture it. A package + // receiver is different: the rewritten selector is resolved inside that + // package, where a sibling declaration of the new name takes part in + // overload resolution and can replace the renamed declaration. + KotlinImportBindingCheck.isDeclaredInQualifiedPackage(element, newName, typePosition) + } else if (typePosition) { KotlinImportBindingCheck.isClassifierBoundInScope(element, newName) } else { KotlinImportBindingCheck.isNameBoundInScope(element, newName) || diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index cc04fd4d3..6820ca4ea 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -33,6 +33,7 @@ import org.jetbrains.kotlin.analysis.api.components.scopeContext import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol @@ -58,6 +59,7 @@ import org.jetbrains.kotlin.psi.KtDestructuringDeclaration import org.jetbrains.kotlin.psi.KtDestructuringDeclarationEntry import org.jetbrains.kotlin.psi.KtDotQualifiedExpression import org.jetbrains.kotlin.psi.KtElement +import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtForExpression import org.jetbrains.kotlin.psi.KtFunction @@ -581,6 +583,84 @@ object KotlinImportBindingCheck { } }.getOrDefault(false) + /** + * Tells whether the package a qualified reference points into declares [name]. + * + * A qualified selector keeps resolving through its receiver, so a lexical binding of the new + * name cannot capture it. A package receiver is different: the rewritten selector is looked up + * in that package's member scope, where a sibling declaration of the new name takes part in + * overload resolution and can silently replace the referenced declaration. + * + * @param expression qualified reference about to be rewritten + * @param name identifier the rewritten selector will spell + * @param classifier `true` for a type position, `false` for a value position + * @return `true` when the referenced package declares a conflicting declaration + */ + fun isDeclaredInQualifiedPackage( + expression: KtSimpleNameExpression, + name: String, + classifier: Boolean, + ): Boolean = runCatching { + analyze(expression) { + val packageFqName = expression.qualifiedPackageFqName() ?: return@analyze false + val scope = findPackage(packageFqName)?.packageScope ?: return@analyze false + if (classifier) { + scope.classifiers.any { symbol -> symbol.name?.asString() == name } + } else { + scope.callables(Name.identifier(name)).any() + } + } + }.getOrDefault(false) + + /** + * Resolves the package this qualified reference is resolved in, or `null` for a value receiver. + * + * A dot-qualified call resolves its receiver symbol first; the source spelling is used as a + * fallback because K2 does not resolve every package segment. A qualified type spells its + * qualifier as a nested [KtUserType] instead. + * + * @return FQN of the package named by the qualifier, or `null` when it is not a package + */ + context(_: KaSession) + private fun KtSimpleNameExpression.qualifiedPackageFqName(): FqName? { + qualifiedAccess()?.let { qualified -> + val receiver = qualified.receiverExpression + val symbol = receiver.dottedSelectorReference()?.resolveToSymbol() + val fromSymbol = (symbol as? KaPackageSymbol)?.fqName + if (fromSymbol != null) return fromSymbol + return receiver.text.asPackageFqName() + } + val userType = parent as? KtUserType ?: return null + if (userType.referenceExpression != this) return null + return userType.qualifier?.text?.asPackageFqName() + } + + /** + * Returns the reference whose resolution names the last segment of this qualifier chain. + * + * @return the selector reference of a dot access, the name reference itself, or `null` + */ + private fun KtExpression.dottedSelectorReference(): KtReference? = when (this) { + is KtDotQualifiedExpression -> selectorExpression?.references + ?.filterIsInstance()?.firstOrNull() + is KtSimpleNameExpression -> references.filterIsInstance().firstOrNull() + else -> null + } + + /** + * Converts a dotted source spelling to a package FQN when that package exists. + * + * @return the package FQN, or `null` when the text is not a dotted identifier chain or the + * package is unknown + */ + context(_: KaSession) + private fun String.asPackageFqName(): FqName? { + val segments = split('.') + if (segments.isEmpty() || !segments.all { segment -> Name.isValidIdentifier(segment) }) return null + val fqName = FqName(this) + return fqName.takeIf { packageName -> findPackage(packageName) != null } + } + /** * Tells whether a wildcard or default import of [file] binds [name] at [element]. * @@ -1106,7 +1186,12 @@ object KotlinImportBindingCheck { val receiverSymbol = runCatching { receiverRoot.references.filterIsInstance().firstOrNull()?.resolveToSymbol() }.getOrNull() - return receiverSymbol !is KaPackageSymbol && receiverSymbol !is KaClassLikeSymbol + // A named object is a value even though K2 reports it as a class-like symbol: extensions + // declared on the object's supertypes are selected by overload resolution for + // `singleton.choose()`, so an import can still rebind the call. A class or package name in + // the same position is a qualifier, which an import cannot affect. + if (receiverSymbol is KaClassSymbol) return receiverSymbol.classKind == KaClassKind.OBJECT + return receiverSymbol !is KaPackageSymbol } /** diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 2c5ecc0a3..0b4b87879 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 43b1baaff..97f9a8440 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -4259,6 +4259,51 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a moved file keeps the implicit `invoke` operator it left behind. + * + * `(box)()` spells no name for the convention it calls; the implicit invocation reference + * belongs to the call expression rather than to the `box` reference. The moved file still needs + * `import p.invoke`, otherwise the call becomes unresolved after the package change. + */ + fun testApply_realSession_importsInvokeOperatorForMovedFile() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-invoke") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\noperator fun Box.invoke() = 1\n", + ) + Files.writeString(sourcePath, "package p\n\nfun use(box: Box) = (box)()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-invoke-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the implicit invoke operator: $text", + text.contains("import p.invoke"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 7090ebba7..d6669fa1f 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -515,6 +515,51 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a qualified constructor call is validated against the referenced package. + * + * `p.Before(1)` is rewritten to `p.After(1)`, and package `p` also declares + * `fun After(n: Int)`. A qualified selector cannot be captured lexically, but the rewritten + * name is still resolved inside package `p`, where the function is more specific than the + * renamed constructor and would silently replace it. + */ + fun testFindCaptureConflicts_reportsQualifiedPackageOverload() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-qualified") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("p/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before(val n: Number)\n") + Files.writeString( + callerPath, + "package p\n\nfun After(n: Int) = \"other\"\n\nfun use() = p.Before(1).toString()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-qualified-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "After", + ).findCaptureConflicts() + + assertEquals( + "The same-package function is more specific than the renamed constructor", + caller, + conflict, + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a rewrite with no competing binding reports no capture conflict. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 38ebfa34e..ee02b4d79 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -295,6 +295,53 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies an extension called on a named object is treated as a value receiver. + * + * `singleton.choose()` resolves the extension on `Derived`, the object's supertype, not on the + * object declaration itself. K2 reports the receiver name as a class-like symbol, so the + * class-qualifier shortcut would skip it; an import of a `Base.choose` overload would then be + * approved even though extension overload resolution would reselect it for `singleton`. + */ + fun testHasConflictingImportRewrite_reportsObjectValueReceiver() { + withSession( + name = "object-receiver", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\n" + + "open class Base\n\n" + + "open class Derived : Base()\n\n" + + "object singleton : Derived()\n\n" + + "fun Derived.choose() = 2\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\nfun Base.choose() = 1\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.choose\nimport old.singleton\n\n" + + "fun use() = singleton.choose()\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val movingPaths = setOf(root.resolve("old/Moving.kt").toString()) + + assertTrue( + "an explicit import of the moved overload would reselect it for singleton.choose()", + KotlinImportBindingCheck.hasConflictingImportRewrite( + usage, + FqName("old.choose"), + movingPaths, + ), + ) + } + } + /** Verifies a member function of an enclosing class binds the name lexically. */ fun testIsNameBoundInScope_reportsMemberFunction() { withSession( diff --git a/pom.xml b/pom.xml index 54efaeba5..9c3a69e69 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.46-SNAPSHOT + 0.12.47-SNAPSHOT pom Netbeans Plugin Kotlin From a129a2694daa232ce6f4d14113407d3a7b090fb9 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 19:49:01 +0300 Subject: [PATCH 38/55] Fixed alias-name function rename, implicit invoke preflight and loop conventions Restricted the type-alias exemption to constructor/class references so a function rename still rewrites its own call sites. Validated implicit invoke calls in all three import-binding checks, collected every loop-convention dependency, and honored the caller's ignore sets in the composite-scope lookup. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 28 ++-- .../kotlin/refactoring/KaRenameComputer.kt | 17 +- .../refactoring/KotlinImportBindingCheck.kt | 96 ++++++++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 91 +++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 45 ++++++ .../KotlinImportBindingCheckTest.kt | 153 ++++++++++++++++++ pom.xml | 2 +- 18 files changed, 418 insertions(+), 36 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 6687c5c87..c0504c920 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 4076f9f16..27954b354 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 672333609..28eb242bd 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 86716aadc..a05ccf14b 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index dee4aa32d..27f324fe4 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 39b5c8f7b..b37984722 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index c8d37df3f..9562d7de9 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index a7e84a7c3..5375f3d98 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index c46fdc46d..2b569cac4 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index f92073ad2..384fe04e9 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index b1ffd08fe..0b1f7f0cb 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -806,23 +806,25 @@ class KaChangePackageComputer( } } - // A for loop spells no name for the convention it calls: `for (x in box)` - // depends on a top-level `operator fun Box.iterator` that only the - // original package's visibility provided. Its reference resolves to both - // the loop variable's property and the convention function. + // A for loop spells no name for the conventions it calls: `for (x in box)` + // depends on top-level `operator fun Box.iterator`, `Cursor.hasNext`, and + // `Cursor.next` that only the original package's visibility provided. Its + // reference resolves to every one of them — and to the loop variable's + // own property — so all retained callables are collected. override fun visitForExpression(expression: KtForExpression) { super.visitForExpression(expression) - expression.references.filterIsInstance().forEach { reference -> - val symbols = runCatching { reference.resolveToSymbols() }.getOrNull() - ?: return@forEach - val importFqName = symbols.firstNotNullOfOrNull { symbol -> + expression.references + .filterIsInstance() + .flatMap { reference -> + runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() + } + .mapNotNull { symbol -> samePackageTopLevelName(symbol, originalPackage, movedPaths) - } ?: return@forEach - if (importFqName in importedFqNames || originalPackage in starPackages) { - return@forEach } - missingImports += importFqName - } + .filter { importFqName -> + importFqName !in importedFqNames && originalPackage !in starPackages + } + .forEach { importFqName -> missingImports += importFqName } } // Convention operators are not spelled as simple names: `box[0]` depends diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 1521c42a4..cd7bf7701 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -501,18 +501,23 @@ class KaRenameComputer( val simpleName = expression as? KtSimpleNameExpression ?: return val targetName = (targetPsi as? KtNamedDeclaration)?.name if (targetName != null && simpleName.getReferencedName() != targetName) return - if (simpleName.isAliasUsage()) { - val referencedName = simpleName.getReferencedName() - if (referencedName in aliasNames || simpleName.spellsTypeAlias(referencedName)) return - } val sym = runCatching { expression.mainReference?.resolveToSymbol() }.getOrNull() ?: return val constructsTarget = targetClassId != null && (sym as? KaConstructorSymbol)?.containingClassId == targetClassId - if (sym.psi == targetPsi || constructsTarget) { - elements.add(simpleName) + val refersDirectlyToTarget = sym.psi == targetPsi + if (!refersDirectlyToTarget && !constructsTarget) return + // A constructor call through a same-spelled type alias resolves to the + // expanded class's constructor, so the class match alone cannot tell + // `Before()` from `typealias Before = p.Before`. Only such an alias + // expansion is exempt: a reference resolving to the renamed declaration + // itself (a function or the type alias) must still be rewritten. + if (!refersDirectlyToTarget && simpleName.isAliasUsage()) { + val referencedName = simpleName.getReferencedName() + if (referencedName in aliasNames || simpleName.spellsTypeAlias(referencedName)) return } + elements.add(simpleName) } }) } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 6820ca4ea..1c33b75b8 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -190,6 +190,19 @@ object KotlinImportBindingCheck { ) { conflicting = true } } } + + // An implicit invocation `(n)()` spells no name for the `invoke` operator it + // calls: the invocation reference belongs to the call expression rather than to + // any spelled name. An explicit callee is already handled above. + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + if (!conflicting && name == "invoke") { + expression.capturesTopLevelName( + name, + { symbol -> !symbol.belongsTo(excludedDeclarations) }, + ) { conflicting = true } + } + } }) conflicting } @@ -306,6 +319,20 @@ object KotlinImportBindingCheck { ) { conflicting = true } } } + + // An implicit invocation `(n)()` spells no name for the `invoke` operator it + // calls: the invocation reference belongs to the call expression rather than to + // any spelled name. An aliased directive binds only its alias, so it provides no + // convention operator. + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + if (!conflicting && !aliased && importedName == "invoke") { + expression.capturesTopLevelName( + importedName, + { symbol -> symbol.staysInOriginalPackage(imported, movingPaths) }, + ) { conflicting = true } + } + } }) conflicting } @@ -407,6 +434,19 @@ object KotlinImportBindingCheck { ) { conflicting = true } } } + + // An implicit invocation `(n)()` spells no name for the `invoke` operator it + // calls: the invocation reference belongs to the call expression rather than to + // any spelled name. An explicit callee is already handled above. + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + if (!conflicting && importedName == "invoke") { + expression.capturesTopLevelName( + importedName, + { symbol -> symbol.isCapturedByOutgoingImport(imported, movingPaths) }, + ) { conflicting = true } + } + } }) conflicting } @@ -530,8 +570,9 @@ object KotlinImportBindingCheck { }) return true // A wildcard or default import binds names without spelling any directive of this file, so // a classifier or callable it provides hides a same-named package segment or declaration - // exactly like an explicit import does. - if (isNameBoundByWildcardImport(element, name)) return true + // exactly like an explicit import does. The composite scope also contains the explicit + // import this check deliberately ignores, so the ignore sets have to follow along. + if (isNameBoundByWildcardImport(element, name, ignoredImports, ignoredDeclarations)) return true var child: PsiElement = element var current: PsiElement? = element.parent while (current != null) { @@ -669,20 +710,65 @@ object KotlinImportBindingCheck { * text: the position's composite scope already knows which star imports apply and which * declarations they contribute. * + * The composite scope also exposes the explicit imports of the file, so [ignoredImports] and + * [ignoredDeclarations] are filtered out here: a binding the caller deliberately excludes — + * the moved declaration's own import, or the moved declaration itself — is not a competing + * declaration even though scope resolution reports it. + * * @param element position whose scope is inspected * @param name identifier to look for + * @param ignoredImports imported FQNs that already bind the declaration being renamed + * @param ignoredDeclarations declarations that are the intended target themselves * @return `true` when a wildcard or default import provides a classifier or callable of that name */ - private fun isNameBoundByWildcardImport(element: KtElement, name: String): Boolean = runCatching { + private fun isNameBoundByWildcardImport( + element: KtElement, + name: String, + ignoredImports: Set = emptySet(), + ignoredDeclarations: Set = emptySet(), + ): Boolean = runCatching { analyze(element) { val identifier = Name.identifier(name) element.containingKtFile.scopeContext(element).scopes.any { scopeWithKind -> - scopeWithKind.scope.classifiers(identifier).any() || - scopeWithKind.scope.callables(identifier).any() + scopeWithKind.scope.classifiers(identifier) + .any { symbol -> !symbol.isIgnoredBinding(ignoredImports, ignoredDeclarations) } || + scopeWithKind.scope.callables(identifier) + .any { symbol -> !symbol.isIgnoredBinding(ignoredImports, ignoredDeclarations) } } } }.getOrDefault(false) + /** + * Tells whether this resolved symbol is one the caller deliberately excludes from a check. + * + * A scope reports every binding it holds, including the explicit import that is being + * rewritten and the declaration being moved. The declaration is matched by PSI identity + * (including a constructor's enclosing class), the import by the symbol's FQN. + * + * @param ignoredImports imported FQNs that already bind the declaration being renamed + * @param ignoredDeclarations declarations that are the intended target themselves + * @return `true` when the symbol is one of the excluded bindings + */ + private fun KaSymbol.isIgnoredBinding( + ignoredImports: Set, + ignoredDeclarations: Set, + ): Boolean { + val element = psi + if (element != null) { + if (element in ignoredDeclarations) return true + val enclosingClass = element.enclosingClassOrObject() + if (enclosingClass != null && enclosingClass in ignoredDeclarations) return true + } + if (ignoredImports.isEmpty()) return false + val fqName = when (this) { + is KaConstructorSymbol -> containingClassId?.asSingleFqName() + is KaClassLikeSymbol -> classId?.asSingleFqName() + is KaCallableSymbol -> callableId?.asSingleFqName() + else -> null + } + return fqName != null && fqName in ignoredImports + } + /** * Tells whether a member of an enclosing or implicit receiver binds [name]. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 0b4b87879..80ef279f3 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 97f9a8440..1d7758414 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3583,6 +3583,97 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies every convention operator of a for loop is imported, not just the first one. + * + * `for (x in box)` spells none of `iterator`, `hasNext`, or `next`; all three are top-level + * extensions that same-package visibility provided. The loop reference resolves to more than + * one of them, so keeping only the first would leave the loop unresolved after the move. + */ + fun testApply_realSession_importsEveryForLoopConvention() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-for-loop-all") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString(sourcePath, "package p\n\nfun use(box: Box) {\n for (x in box) println(x)\n}\n") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\nclass Cursor\n\n" + + "operator fun Box.iterator(): Cursor = Cursor()\n\n" + + "operator fun Cursor.hasNext(): Boolean = false\n\n" + + "operator fun Cursor.next(): Int = 0\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-for-loop-all-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("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue("The for loop must import iterator: $text", text.contains("import p.iterator")) + assertTrue("The for loop must import hasNext: $text", text.contains("import p.hasNext")) + assertTrue("The for loop must import next: $text", text.contains("import p.next")) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a qualified call whose declaration is already imported can still be shortened. + * + * The caller already has `import old.moved` and spells `old.moved()`; moving the declaration + * to the root package needs the import rewritten to `import moved` and the call shortened. + * The existing import binds the very name being shortened, so it is not a shadowing conflict. + */ + fun testApply_realSession_shortensQualifiedCallWithExistingImportForRootTarget() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-root-target-imported") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\nfun moved() = 1\n") + Files.writeString(usagePath, "package usage\n\nimport old.moved\n\nfun use() = old.moved()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-root-target-imported-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.ROOT), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + assertEquals( + "The existing import follows the declaration and the call is shortened", + "package usage\n\nimport moved\n\nfun use() = moved()\n", + success.changedFiles[usagePath.toString()], + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a type alias whose name equals the expanded class is still imported. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index d6669fa1f..070cdf3d4 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -929,6 +929,51 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a call of a renamed function that shares a type alias's name is still rewritten. + * + * The file declares `typealias Before = Impl` and `fun Before(n: Int)`, and `Before(1)` calls + * the function. The type-alias exemption only protects constructor calls spelled through the + * alias; applying it to the function's own call would leave the reference untouched, and it + * would silently become an alias-constructor call after the rename. + */ + fun testCompute_renamesFunctionCallSharingTypeAliasName() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping rename-compute test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-function-alias-name") + try { + Files.createDirectories(root.resolve("p")) + val sourcePath = root.resolve("p/Use.kt") + Files.writeString( + sourcePath, + "package p\n\nclass Impl(val n: Number)\n\ntypealias Before = Impl\n\n" + + "fun Before(n: Int) = \"other\"\n\nfun use() = Before(1).toString()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-function-alias-name-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + + val changes = KaRenameComputer( + source, source.text.indexOf("fun Before") + "fun ".length, setOf(source), "After", + ).compute() + + assertNotNull("compute() must return non-null for a function rename", changes) + assertEquals( + "The function declaration and its call follow the rename", + "package p\n\nclass Impl(val n: Number)\n\ntypealias Before = Impl\n\n" + + "fun After(n: Int) = \"other\"\n\nfun use() = After(1).toString()\n", + applyChanges(source.text, changes!![sourcePath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a fully qualified reference is not checked against lexical bindings. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index ee02b4d79..4a447b14d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -295,6 +295,159 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies an implicit `invoke` call is reported. + * + * `(n)()` spells no name for the convention it calls, so the call-site reference cannot be + * matched by text. Adding an explicit import of the moving `Number.invoke` extension outranks + * the wildcard import that currently selects the staying `Int.invoke`, and the call would + * silently switch overloads. + */ + fun testHasConflictingReference_reportsImplicitInvoke() { + withSession( + name = "implicit-invoke", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\noperator fun Int.invoke(): Int = 1\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\noperator fun Number.invoke(): Int = 2\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nfun use(n: Int) = (n)()\n", + ) + }, + ) { session, root -> + val moving = session.ktFile(root.resolve("old/Moving.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val imported = moving.declarations.filterIsInstance().single { it.name == "invoke" } + + assertTrue( + "the imported Number.invoke would outrank the wildcard Int.invoke of (n)()", + KotlinImportBindingCheck.hasConflictingReference(usage, "invoke", setOf(imported)), + ) + } + } + + /** + * Verifies an implicit `invoke` call keeps an overload the rewritten import stops binding. + * + * `(box)()` resolves to the staying `Box.invoke`; rewriting `import old.invoke` to the moved + * `Number.invoke` drops that overload, so the call would change its target while still + * compiling. + */ + fun testHasConflictingImportRewrite_reportsImplicitInvoke() { + withSession( + name = "implicit-invoke-rewrite", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\nclass Box\n\noperator fun Box.invoke(): Int = 1\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\noperator fun Number.invoke(): Int = 2\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.invoke\n\nfun use(box: Box) = (box)()\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val movingPaths = setOf(root.resolve("old/Moving.kt").toString()) + + assertTrue( + "the rewritten import stops binding the Box.invoke of (box)()", + KotlinImportBindingCheck.hasConflictingImportRewrite( + usage, + FqName("old.invoke"), + movingPaths, + ), + ) + } + } + + /** + * Verifies an implicit `invoke` call would be captured by an outgoing import. + * + * The moved file's `(box)()` resolves to the `Box.invoke` that moves with it; adding an import + * of the staying `old.invoke` outranks it and silently retargets the call. + */ + fun testHasConflictingOutgoingImport_reportsImplicitInvoke() { + withSession( + name = "implicit-invoke-outgoing", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\noperator fun Number.invoke(): Int = 2\n", + ) + Files.writeString( + root.resolve("old/Movers.kt"), + "package old\n\nclass Box\n\noperator fun Box.invoke(): Int = 1\n\n" + + "fun use(box: Box) = (box)()\n", + ) + }, + ) { session, root -> + val movers = session.ktFile(root.resolve("old/Movers.kt")) + val movingPaths = setOf(root.resolve("old/Movers.kt").toString()) + + assertTrue( + "the outgoing import would retarget the Box.invoke of (box)()", + KotlinImportBindingCheck.hasConflictingOutgoingImport( + movers, + FqName("old.invoke"), + movingPaths, + ), + ) + } + } + + /** + * Verifies the composite-scope lookup honors the caller's ignore sets. + * + * The file's own `import old.moved` is reported by scope resolution like any other binding, + * but it is exactly the import being rewritten, so `moved` is not a competing declaration. + */ + fun testIsNameBoundInScope_ignoresListedImport() { + withSession( + name = "ignored-import", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("old/Moved.kt"), "package old\n\nfun moved() = 1\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.moved\n\nfun use() = old.moved()\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val function = usage.declarations.filterIsInstance().single { it.name == "use" } + + assertTrue( + "without exclusions the import is a binding", + KotlinImportBindingCheck.isNameBoundInScope(function, "moved"), + ) + assertFalse( + "the import being rewritten is not a competing binding", + KotlinImportBindingCheck.isNameBoundInScope( + function, + "moved", + ignoredImports = setOf(FqName("old.moved")), + ), + ) + } + } + /** * Verifies an extension called on a named object is treated as a value receiver. * diff --git a/pom.xml b/pom.xml index 9c3a69e69..64aafaf75 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.47-SNAPSHOT + 0.12.48-SNAPSHOT pom Netbeans Plugin Kotlin From 7208b9c29684bc1c44fd74df7af55f4e59bd7e47 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 20:21:51 +0300 Subject: [PATCH 39/55] Fixed simple-name invoke imports and imported delegate providers in package change Removed the simple-name callee early return from outgoing dependency discovery so the implicit invocation reference of `box()` contributes its `invoke` import. Resolved the delegated property's provider through its effective scope, so a provider imported from another package still decides which accessor type the property uses. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 43 ++++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 101 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 57 ++++++++++ pom.xml | 2 +- 15 files changed, 207 insertions(+), 18 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index c0504c920..e8a39862c 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 27954b354..056de3220 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 28eb242bd..2bfe026b0 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index a05ccf14b..9bea2f717 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 27f324fe4..f790d8163 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index b37984722..26cbbea67 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 9562d7de9..dfe37a179 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 5375f3d98..5d8db6382 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 2b569cac4..f6c23d807 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 384fe04e9..266852f11 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 0b1f7f0cb..d68be34cb 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -29,6 +29,7 @@ import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols +import org.jetbrains.kotlin.analysis.api.components.scopeContext import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol @@ -764,13 +765,15 @@ class KaChangePackageComputer( } // A call on a value spells no name for the convention it invokes: - // `(box)()` depends on a top-level `operator fun Box.invoke` that only the - // original package's visibility provided, and the implicit invocation - // reference belongs to the call expression rather than to any spelled - // name. Spelled callees are handled by visitSimpleNameExpression. + // `(box)()` and `box()` both depend on a top-level `operator fun Box.invoke` + // that only the original package's visibility provided. The implicit + // invocation reference belongs to the call expression rather than to any + // spelled name, so it is collected here for simple-name callees too: + // `box` resolves to the parameter, never to the operator. Spelled + // declarations are still handled by visitSimpleNameExpression, and the + // accumulator deduplicates the overlap. override fun visitCallExpression(expression: KtCallExpression) { super.visitCallExpression(expression) - if (expression.calleeExpression is KtSimpleNameExpression) return expression.references.filterIsInstance() .flatMap { reference -> runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() @@ -870,8 +873,11 @@ class KaChangePackageComputer( // return type still decides which accessor the property selects. // A provider moving with the file is still selected by the property // after the move, so it decides the accessor type too; only its - // import is skipped below. + // import is skipped below. A provider imported from another package + // survives the move unchanged and is found through the property's + // scope, not through the old package. val provider = delegateType.memberDelegateOperator("provideDelegate") + ?: property.scopeDelegateProvider(delegateType) ?: samePackageDelegateOperator( delegateType, "provideDelegate", @@ -1058,6 +1064,31 @@ class KaChangePackageComputer( return samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths) } + /** + * Finds the `provideDelegate` extension the property's scope selects on [type]. + * + * Unlike [samePackageDelegateOperator], this lookup follows the property's real visibility: + * a provider imported from another package stays bound to the property after the move, and + * its return type still decides which `getValue`/`setValue` accessor applies. Only the + * provider's own import is unaffected; ignoring it would search the accessors on the + * delegate expression's type instead. + * + * @param type type of the property's delegate expression + * @return the applicable extension provider, or `null` when none is in scope + */ + context(_: KaSession) + private fun KtProperty.scopeDelegateProvider(type: KaType): KaCallableSymbol? = runCatching { + val identifier = Name.identifier("provideDelegate") + containingKtFile.scopeContext(this).scopes.asSequence() + .flatMap { scopeWithKind -> scopeWithKind.scope.callables(identifier) } + .filter { symbol -> symbol.isExtension } + .filter { symbol -> symbol.isDelegateOperatorShape("provideDelegate") } + .firstOrNull { symbol -> + val receiverType = symbol.receiverParameter?.returnType + receiverType != null && type.isSubtypeOf(receiverType) + } + }.getOrNull() + /** * Finds a delegate operator of the original package selected on [type]. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 80ef279f3..ec01c049a 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 1d7758414..cb692c10c 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -3674,6 +3674,107 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a simple-name callee still contributes its implicit `invoke` dependency. + * + * `box()` where `box: Box` spells the variable, which resolves to the parameter, while the + * invocation reference belongs to the call expression. The moved file still needs + * `import p.invoke` in addition to `import p.Box`. + */ + fun testApply_realSession_importsInvokeOperatorForSimpleNameCallee() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-invoke-simple") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString( + temporaryRoot.resolve("p/Box.kt"), + "package p\n\nclass Box\n\noperator fun Box.invoke() = 1\n", + ) + Files.writeString(sourcePath, "package p\n\nfun use(box: Box) = box()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-invoke-simple-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("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue("The moved file must import the box type: $text", text.contains("import p.Box")) + assertTrue("The moved file must import the implicit invoke operator: $text", text.contains("import p.invoke")) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies the accessor type follows a provider imported from another package. + * + * The moved file keeps `import lib.provideDelegate`; that provider returns `Provided`, whose + * `getValue` extension stays in the old package. Searching only the old package for the + * provider would look for the accessor on `Delegate` and omit `import p.getValue`. + */ + fun testApply_realSession_followsImportedProviderForAccessorImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-imported-provider") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString( + temporaryRoot.resolve("lib/Provider.kt"), + "package lib\n\nclass Delegate\n\nclass Provided\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n", + ) + Files.writeString( + temporaryRoot.resolve("p/Accessors.kt"), + "package p\n\nimport lib.Provided\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package p\n\nimport lib.Delegate\nimport lib.provideDelegate\n\n" + + "fun make(): Int {\n val value by Delegate()\n return value\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-imported-provider-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("q")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The accessor type follows the imported provider: $text", + text.contains("import p.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a type alias whose name equals the expanded class is still imported. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 070cdf3d4..aa56c34d6 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -929,6 +929,62 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a qualified call through a same-spelled type alias keeps the alias's name. + * + * Package `q` declares `typealias Before = p.Before`, and the caller spells `q.Before(1)`. + * The alias stays named `Before`, so only its right-hand side follows the class rename; the + * qualified call site must keep spelling `q.Before`. + */ + fun testCompute_keepsQualifiedSameSpelledTypeAliasCall() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping rename-compute test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-qualified-alias") + try { + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + val sourcePath = root.resolve("p/Before.kt") + val aliasesPath = root.resolve("q/Aliases.kt") + val usagePath = root.resolve("q/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before(val n: Int)\n") + Files.writeString(aliasesPath, "package q\n\ntypealias Before = p.Before\n") + Files.writeString(usagePath, "package q\n\nfun make(other: q.Before): q.Before = q.Before(1)\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-qualified-alias-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val aliases = session.getKtFileForPath(aliasesPath.toString()) ?: error("Could not obtain aliases PSI") + val usage = session.getKtFileForPath(usagePath.toString()) ?: error("Could not obtain usage PSI") + + val changes = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, aliases, usage), "After", + ).compute() + + assertNotNull("compute() must return non-null for a class rename", changes) + assertEquals( + "The class declaration follows the rename", + "package p\n\nclass After(val n: Int)\n", + applyChanges(source.text, changes!![sourcePath.toString()].orEmpty()), + ) + assertEquals( + "Only the alias's right-hand side follows the rename", + "package q\n\ntypealias Before = p.After\n", + applyChanges(aliases.text, changes[aliasesPath.toString()].orEmpty()), + ) + assertEquals( + "The qualified alias call and type keep spelling the alias", + "package q\n\nfun make(other: q.Before): q.Before = q.Before(1)\n", + applyChanges(usage.text, changes[usagePath.toString()].orEmpty()), + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a call of a renamed function that shares a type alias's name is still rewritten. * @@ -974,6 +1030,7 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** * Verifies a fully qualified reference is not checked against lexical bindings. * diff --git a/pom.xml b/pom.xml index 64aafaf75..2a5e30a92 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.48-SNAPSHOT + 0.12.49-SNAPSHOT pom Netbeans Plugin Kotlin From 3aee3619132be42a648cd3a1bb1a68a5055471e9 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 21:08:31 +0300 Subject: [PATCH 40/55] Fixed convention usages, generic receivers and constructor bounds in package change Indexed access, implicit invocation, destructuring, loops and delegated properties in a caller outside the old package now receive the explicit import the move requires. Operator applicability uses the analysis API's extension check so generic receivers are inferred, delegate signatures accept KProperty supertypes, and constructor conflicts compare the bounds of the type parameters the matched value parameters actually use. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 234 ++++++++++++-- .../refactoring/KaDelegateOperatorShape.kt | 13 +- .../KotlinConstructorSignatureCheck.kt | 148 ++++++++- .../refactoring/KotlinImportBindingCheck.kt | 13 +- .../move/KotlinMoveUsageSearchService.kt | 12 + Nbm/pom.xml | 2 +- .../KotlinMoveUsageSearchServiceImpl.kt | 11 + .../nbm/refactoring/KaChangePackageTest.kt | 296 ++++++++++++++++++ .../KotlinConstructorSignatureCheckTest.kt | 90 ++++++ .../KotlinImportBindingCheckTest.kt | 88 ++++++ pom.xml | 2 +- 21 files changed, 869 insertions(+), 60 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index e8a39862c..59892335a 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 056de3220..3a80260a3 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 2bfe026b0..b35aab965 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 9bea2f717..eb7b1e9f2 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index f790d8163..76ddf6c6e 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 26cbbea67..4aaa4afe9 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index dfe37a179..6d225dfdf 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 5d8db6382..b29490c1c 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index f6c23d807..7155aae1b 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 266852f11..475a9d1eb 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index d68be34cb..54e1f7e7c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -20,11 +20,12 @@ import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil import com.intellij.refactoring.util.MoveRenameUsageInfo import com.intellij.usageView.UsageInfo +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze +import org.jetbrains.kotlin.analysis.api.components.canBeCalledAsExtensionOn import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.expressionType -import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol @@ -354,6 +355,7 @@ class KaChangePackageComputer( imports.getOrPut(file, ::linkedSetOf) += newFqName } } + collectIncomingConventionImports(session, movedDeclarations, targets, imports) // An explicit import takes precedence over wildcard imports and same-package // declarations, so it may capture references that point elsewhere today. val movedPsi = movedDeclarations.keys @@ -402,6 +404,194 @@ class KaChangePackageComputer( ) } + /** + * Adds imports for callers whose reference to a moved declaration is a convention usage. + * + * Usage search resolves simple-name references only, but an indexed access (`n[0]`), an + * implicit invocation (`(box)()`), a destructuring declaration, a loop, and a delegated + * property all bind top-level operators without spelling their names. A caller that shared + * the old package or wildcard-imported it loses those bindings when the operator moves, so + * the operator has to be imported explicitly. + * + * Only files that can currently see one of the old packages are analyzed: an explicit import + * of the moved declaration is retargeted with its directive instead, and a caller outside + * that visibility could not have bound the operator in the first place. + * + * @param session service enumerating the files of the analysis session + * @param movedDeclarations moved declarations with their old and new fully qualified names + * @param targets files whose declared package changes mapped to their final packages + * @param imports accumulator receiving the imports each caller needs + */ + private fun collectIncomingConventionImports( + session: KotlinMoveUsageSearchService, + movedDeclarations: Map>, + targets: Map, + imports: MutableMap>, + ) { + val movedPackages = movedDeclarations.values.mapTo(linkedSetOf()) { (oldFqName, _) -> oldFqName.parent() } + if (movedPackages.isEmpty()) return + val finalPackages = targets.entries.mapNotNull { (file, packageFqName) -> + file.virtualFile?.path?.let { path -> path to packageFqName } + }.toMap() + val moved = movedDeclarations.keys + session.findSourceFiles(ktFile.project).forEach { file -> + val visiblePackages = movedPackages.filter { packageFqName -> + packageFqName == file.packageFqName || file.importDirectives.any { directive -> + directive.isAllUnder && directive.importedFqName == packageFqName + } + } + if (visiblePackages.isEmpty()) return@forEach + val used = runCatching { analyze(file) { file.movedConventionUsages(visiblePackages, moved) } } + .getOrDefault(emptySet()) + used.forEach { declaration -> + val (oldFqName, newFqName) = movedDeclarations.getValue(declaration) + if (!file.needsIncomingImport(declaration, oldFqName, newFqName, finalPackages)) return@forEach + imports.getOrPut(file, ::linkedSetOf) += newFqName + } + } + } + + /** + * Collects moved declarations this file uses through conventions that spell no name. + * + * An operator can only have been bound through a visible old package, so the delegate + * lookups search the caller's own scope. The setter half of a compound indexed write is not + * exposed by K2 either; it is selected on the receiver type and skipped when a member setter + * wins. + * + * @param visiblePackages old packages this file can currently see + * @param moved declarations whose move the operation performs + * @return moved declarations this file selects implicitly + */ + @OptIn(KaExperimentalApi::class) + context(_: KaSession) + private fun KtFile.movedConventionUsages( + visiblePackages: List, + moved: Set, + ): Set { + val found = linkedSetOf() + fun record(symbol: KaSymbol?) { + val declaration = symbol?.psi as? KtNamedDeclaration ?: return + if (declaration in moved) found += declaration + } + fun movedOperator(type: KaType, name: String): KaCallableSymbol? = visiblePackages.asSequence() + .mapNotNull { packageFqName -> findPackage(packageFqName)?.packageScope } + .flatMap { scope -> scope.declarations.asSequence() } + .filterIsInstance() + .filter { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name && symbol.isExtension } + .firstOrNull { symbol -> symbol.canBeCalledAsExtensionOn(type) } + accept(object : KtTreeVisitorVoid() { + override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { + super.visitArrayAccessExpression(expression) + expression.conventionSymbols().forEach(::record) + if (!expression.isCompoundWrite()) return + val receiverType = expression.arrayExpression?.expressionType ?: return + val hasMemberSetter = receiverType.expandedSymbol?.memberScope?.declarations + .orEmpty() + .filterIsInstance() + .any { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "set" } + if (!hasMemberSetter) record(movedOperator(receiverType, "set")) + } + + // The implicit invocation reference belongs to the call expression, not to the + // spelled callee, so `(box)()` is collected here for simple-name callees too. + override fun visitCallExpression(expression: KtCallExpression) { + super.visitCallExpression(expression) + expression.conventionSymbols().forEach(::record) + } + + override fun visitDestructuringDeclarationEntry(multiDeclarationEntry: KtDestructuringDeclarationEntry) { + super.visitDestructuringDeclarationEntry(multiDeclarationEntry) + multiDeclarationEntry.conventionSymbols().forEach(::record) + } + + override fun visitForExpression(expression: KtForExpression) { + super.visitForExpression(expression) + expression.conventionSymbols().forEach(::record) + } + + override fun visitProperty(property: KtProperty) { + super.visitProperty(property) + property.movedDelegateOperators(moved).forEach { declaration -> found += declaration } + } + }) + return found + } + + /** + * Returns the symbols the convention references of this element resolve to. + * + * @return every resolved symbol, including the operator a convention invokes + */ + context(_: KaSession) + private fun KtElement.conventionSymbols(): List = + references.filterIsInstance() + .flatMap { reference -> runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() } + + /** + * Collects the moved delegate operators this property selects implicitly. + * + * `provideDelegate` runs first and its return type decides which accessor is selected, so the + * provider and both accessors of a `var` are looked up. A member operator always wins over + * an extension and cannot be captured by an import, so the member is checked first. + * + * @param moved declarations whose move the operation performs + * @return moved operators this property selects + */ + @OptIn(KaExperimentalApi::class) + context(_: KaSession) + private fun KtProperty.movedDelegateOperators(moved: Set): Set { + val delegateType = runCatching { delegateExpression?.expressionType }.getOrNull() ?: return emptySet() + val operators = linkedSetOf() + fun record(symbol: KaCallableSymbol?) { + val declaration = symbol?.psi as? KtNamedDeclaration ?: return + if (declaration in moved) operators += declaration + } + fun selectedOperator(type: KaType, name: String): KaCallableSymbol? = + type.memberDelegateOperator(name) ?: scopeDelegateOperator(type, name) + val provider = selectedOperator(delegateType, "provideDelegate") + record(provider) + val accessorType = provider?.returnType ?: delegateType + listOf("getValue", "setValue").forEach { name -> + if (name == "setValue" && !isVar) return@forEach + record(selectedOperator(accessorType, name)) + } + return operators + } + + /** + * Tells whether [file] loses its implicit or wildcard binding to a moved declaration. + * + * A direct import is retargeted with its own directive, and a file ending in the declaration's + * new package still sees it without an import. Everything else that shared the old package or + * wildcard-imported it needs an explicit import once the declaration moves. + * + * @param declaration moved declaration the convention usage resolves to + * @param oldFqName declaration's fully qualified name before the change + * @param newFqName declaration's fully qualified name after the change + * @param finalPackages paths of the moving files mapped to their final packages + * @return `true` when the caller has to import [newFqName] + */ + private fun KtFile.needsIncomingImport( + declaration: KtNamedDeclaration, + oldFqName: FqName, + newFqName: FqName, + finalPackages: Map, + ): Boolean { + val path = virtualFile?.path + if (path != null && path == declaration.containingKtFile.virtualFile?.path) return false + val finalPackage = path?.let(finalPackages::get) ?: packageFqName + if (finalPackage == newFqName.parent()) return false + val importsDirectly = importDirectives.any { directive -> + !directive.isAllUnder && directive.aliasName == null && directive.importedFqName == oldFqName + } + if (importsDirectly) return false + if (packageFqName == oldFqName.parent()) return true + return importDirectives.any { directive -> + directive.isAllUnder && directive.importedFqName == oldFqName.parent() + } + } + /** * Reports outgoing imports that cannot be added without changing an existing reference. * @@ -877,7 +1067,7 @@ class KaChangePackageComputer( // survives the move unchanged and is found through the property's // scope, not through the old package. val provider = delegateType.memberDelegateOperator("provideDelegate") - ?: property.scopeDelegateProvider(delegateType) + ?: property.scopeDelegateOperator(delegateType, "provideDelegate") ?: samePackageDelegateOperator( delegateType, "provideDelegate", @@ -1003,6 +1193,7 @@ class KaChangePackageComputer( * @param movedPaths paths of the files whose package changes * @return FQN of the remaining `set` extension, or `null` when none applies */ + @OptIn(KaExperimentalApi::class) context(_: KaSession) private fun samePackageIndexedSetter( expression: KtArrayAccessExpression, @@ -1013,11 +1204,10 @@ class KaChangePackageComputer( return findPackage(originalPackage)?.packageScope?.declarations ?.filterIsInstance() ?.firstNotNullOfOrNull { symbol -> - val receiver = symbol.receiverParameter?.returnType - if ((symbol as? KaNamedSymbol)?.name?.asString() != "set" || receiver == null) { + if ((symbol as? KaNamedSymbol)?.name?.asString() != "set") { return@firstNotNullOfOrNull null } - if (!receiverType.isSubtypeOf(receiver)) return@firstNotNullOfOrNull null + if (!symbol.canBeCalledAsExtensionOn(receiverType)) return@firstNotNullOfOrNull null samePackageTopLevelName(symbol, originalPackage, movedPaths) } } @@ -1065,28 +1255,25 @@ class KaChangePackageComputer( } /** - * Finds the `provideDelegate` extension the property's scope selects on [type]. + * Finds the delegate operator of [name] the file's scope selects on [type]. * - * Unlike [samePackageDelegateOperator], this lookup follows the property's real visibility: - * a provider imported from another package stays bound to the property after the move, and - * its return type still decides which `getValue`/`setValue` accessor applies. Only the - * provider's own import is unaffected; ignoring it would search the accessors on the - * delegate expression's type instead. + * Unlike [samePackageDelegateOperator], this lookup follows the element's real visibility: an + * operator imported from any package stays bound after the move, and a provider's return type + * still decides which `getValue`/`setValue` accessor applies. Receiver applicability goes + * through the analysis API's extension check so a generic receiver is inferred. * - * @param type type of the property's delegate expression - * @return the applicable extension provider, or `null` when none is in scope + * @param type type the operator is selected on + * @param operatorName `provideDelegate`, `getValue`, or `setValue` + * @return the selected extension, or `null` when none is in scope */ + @OptIn(KaExperimentalApi::class) context(_: KaSession) - private fun KtProperty.scopeDelegateProvider(type: KaType): KaCallableSymbol? = runCatching { - val identifier = Name.identifier("provideDelegate") + private fun KtElement.scopeDelegateOperator(type: KaType, operatorName: String): KaCallableSymbol? = runCatching { + val identifier = Name.identifier(operatorName) containingKtFile.scopeContext(this).scopes.asSequence() .flatMap { scopeWithKind -> scopeWithKind.scope.callables(identifier) } - .filter { symbol -> symbol.isExtension } - .filter { symbol -> symbol.isDelegateOperatorShape("provideDelegate") } - .firstOrNull { symbol -> - val receiverType = symbol.receiverParameter?.returnType - receiverType != null && type.isSubtypeOf(receiverType) - } + .filter { symbol -> symbol.isExtension && symbol.isDelegateOperatorShape(operatorName) } + .firstOrNull { symbol -> symbol.canBeCalledAsExtensionOn(type) } }.getOrNull() /** @@ -1105,6 +1292,7 @@ class KaChangePackageComputer( * @param movedPaths paths of the files whose package changes, or `null` to accept moving ones * @return the selected operator, or `null` when none applies */ + @OptIn(KaExperimentalApi::class) context(_: KaSession) private fun samePackageDelegateOperator( type: KaType, @@ -1115,11 +1303,9 @@ class KaChangePackageComputer( return findPackage(originalPackage)?.packageScope?.declarations ?.filterIsInstance() ?.firstOrNull { symbol -> - val receiver = symbol.receiverParameter?.returnType (symbol as? KaNamedSymbol)?.name?.asString() == operatorName && symbol.isDelegateOperatorShape(operatorName) && - receiver != null && - type.isSubtypeOf(receiver) && + symbol.canBeCalledAsExtensionOn(type) && (movedPaths == null || samePackageTopLevelName(symbol, originalPackage, movedPaths) != null) } } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt index 9e3a2d992..7cd09b212 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt @@ -16,10 +16,13 @@ *******************************************************************************/ package io.github.nbplugins.kotlin.refactoring +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession -import org.jetbrains.kotlin.analysis.api.components.expandedSymbol +import org.jetbrains.kotlin.analysis.api.components.defaultTypeWithStarProjections +import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.findClass import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName @@ -32,16 +35,22 @@ import org.jetbrains.kotlin.name.FqName * parameterless `fun getValue()` is therefore an ordinary member and must not hide the extension * the property actually uses. * + * Kotlin passes a `KProperty` instance to the property parameter, which may be declared with any + * supertype of it (`Any?`, for example), so the parameter is matched by assignability rather than + * by its classifier. + * * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @return `true` when this callable can be selected as that delegate operator */ +@OptIn(KaExperimentalApi::class) context(_: KaSession) internal fun KaCallableSymbol.isDelegateOperatorShape(operatorName: String): Boolean { val function = this as? KaFunctionSymbol ?: return false val expectedParameters = if (operatorName == "setValue") 3 else 2 if (function.valueParameters.size != expectedParameters) return false return runCatching { - function.valueParameters[1].returnType.expandedSymbol?.classId == K_PROPERTY_CLASS_ID + val propertyType = findClass(K_PROPERTY_CLASS_ID)?.defaultTypeWithStarProjections ?: return@runCatching false + propertyType.isSubtypeOf(function.valueParameters[1].returnType) }.getOrDefault(false) } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt index 5acb33666..091296120 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -22,11 +22,21 @@ import org.jetbrains.kotlin.analysis.api.components.buildTypeParameterType import org.jetbrains.kotlin.analysis.api.components.createSubstitutor import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals import org.jetbrains.kotlin.analysis.api.components.type +import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.symbol +import org.jetbrains.kotlin.analysis.api.types.KaCapturedType +import org.jetbrains.kotlin.analysis.api.types.KaClassType +import org.jetbrains.kotlin.analysis.api.types.KaDefinitelyNotNullType +import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaFunctionType +import org.jetbrains.kotlin.analysis.api.types.KaIntersectionType import org.jetbrains.kotlin.analysis.api.types.KaSubstitutor +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtParameter +import org.jetbrains.kotlin.psi.KtTypeParameter /** * Decides whether a top-level function would collide with a constructor of a renamed class. @@ -35,7 +45,8 @@ import org.jetbrains.kotlin.psi.KtParameter * matches a constructor becomes a conflicting overload that Kotlin rejects. Parameter types are * compared as resolved types, because `Int` and a fully qualified `kotlin.Int` are the same Kotlin * type even though their source spellings differ. Type parameters are compared by position after - * substituting the function's parameters with the class's, and their bounds are compared as well. + * substituting the function's parameters with the class's, and the bounds of those the value + * parameters actually use are compared as well. */ object KotlinConstructorSignatureCheck { @@ -60,14 +71,18 @@ object KotlinConstructorSignatureCheck { if (matching.isEmpty()) return false return analyze(function) { val substitutor = typeParameterSubstitutor(primaryClass, function) - typeParametersMatch(primaryClass, function, substitutor) && matching.any { parameters -> - parameters.indices.all { index -> + matching.any { parameters -> + val typesMatch = parameters.indices.all { index -> val constructorType = runCatching { parameters[index].typeReference?.type }.getOrNull() val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() constructorType == null || functionType == null || runCatching { substitutor.substitute(functionType).semanticallyEquals(constructorType) }.getOrDefault(false) } + // Bounds are part of the signature only for the type parameters the matched value + // parameters actually use; an unused parameter cannot tell two calls apart even + // when its bounds differ, so it must not hide a real conflict. + typesMatch && typeParametersMatch(primaryClass, function, substitutor, parameters) } } } @@ -99,35 +114,134 @@ object KotlinConstructorSignatureCheck { } /** - * Tells whether corresponding type parameters have equivalent bounds. + * Tells whether corresponding type parameters of a matched signature have equivalent bounds. * - * Two signatures are conflicting only when their type parameters also allow the same type - * arguments; a function accepting any `T` would not collide with a constructor restricted to - * `T : Number`. + * Two signatures conflict only when their type parameters also allow the same type arguments; + * a function accepting any `T` would not collide with a constructor restricted to + * `T : Number`. Only the type parameters the matched value parameters use take part in that + * comparison: a parameter that never appears in a value parameter cannot distinguish calls, + * so its bounds must not hide a conflict. A type parameter reached only through another + * parameter's bound still counts, because a differing bound there narrows the accepted type + * arguments as well. * * @param classOrObject class whose type parameter bounds are compared * @param function function whose type parameter bounds are compared * @param substitutor maps the function's type parameters to the class's - * @return `true` when every corresponding bound is equivalent + * @param parameters matched value parameters of the compared signature + * @return `true` when every relevant corresponding bound is equivalent */ context(_: KaSession) private fun typeParametersMatch( classOrObject: KtClass, function: KtNamedFunction, substitutor: KaSubstitutor, + parameters: List, ): Boolean { val classParameters = classOrObject.typeParameters - return function.typeParameters.indices.all { index -> - val classParameter = classParameters.getOrNull(index) ?: return@all true - val functionBounds = function.typeParameters[index].symbol.upperBounds - val classBounds = classParameter.symbol.upperBounds - functionBounds.size == classBounds.size && - functionBounds.zip(classBounds).all { (functionBound, classBound) -> - runCatching { - substitutor.substitute(functionBound).semanticallyEquals(classBound) - }.getOrDefault(false) + val indexBySymbol = classParameters.mapIndexedNotNull { index, parameter -> + runCatching { parameter.symbol }.getOrNull()?.let { symbol -> symbol to index } + }.toMap() + val usedIndices = usedTypeParameterIndices(parameters, indexBySymbol) + ?: return classParameters.indices.all { index -> + boundsMatch(classParameters[index], function.typeParameters.getOrNull(index), substitutor) + } + val pending = ArrayDeque(usedIndices) + while (pending.isNotEmpty()) { + val index = pending.removeFirst() + val bounds = runCatching { classParameters[index].symbol.upperBounds }.getOrNull().orEmpty() + bounds.flatMap { bound -> typeParameterIndices(bound, indexBySymbol) } + .filter { discovered -> usedIndices.add(discovered) } + .forEach(pending::addLast) + } + return usedIndices.all { index -> + boundsMatch(classParameters[index], function.typeParameters.getOrNull(index), substitutor) + } + } + + /** + * Collects the type parameters the value parameters' types mention directly. + * + * @param parameters value parameters of the compared signature + * @param indexBySymbol class type parameter symbols mapped to their positional index + * @return mentioned indices, or `null` when a parameter type cannot be resolved + */ + context(_: KaSession) + private fun usedTypeParameterIndices( + parameters: List, + indexBySymbol: Map, + ): MutableSet? { + val used = mutableSetOf() + parameters.forEach { parameter -> + val type = runCatching { parameter.typeReference?.type }.getOrNull() ?: return null + used.addAll(typeParameterIndices(type, indexBySymbol)) + } + return used + } + + /** + * Collects the class type parameters a type mentions anywhere in its structure. + * + * @param type type to inspect, including nested arguments and function signatures + * @param indexBySymbol class type parameter symbols mapped to their positional index + * @return indices of the mentioned class type parameters + */ + context(_: KaSession) + private fun typeParameterIndices( + type: KaType, + indexBySymbol: Map, + ): Set { + val indices = mutableSetOf() + fun visit(current: KaType) { + when (current) { + is KaFunctionType -> { + current.receiverType?.let(::visit) + current.parameterTypes.forEach(::visit) + visit(current.returnType) + } + is KaClassType -> current.typeArguments.forEach { projection -> + projection.type?.let(::visit) + } + is KaTypeParameterType -> indexBySymbol[current.symbol]?.let(indices::add) + is KaCapturedType -> current.projection.type?.let(::visit) + is KaDefinitelyNotNullType -> visit(current.original) + is KaFlexibleType -> { + visit(current.lowerBound) + visit(current.upperBound) } + is KaIntersectionType -> current.conjuncts.forEach(::visit) + else -> {} + } } + visit(type) + return indices + } + + /** + * Tells whether a class type parameter and its function counterpart allow the same arguments. + * + * A missing function counterpart or unreadable bounds leave the comparison inconclusive, which + * keeps the conflict decision conservative instead of hiding a real collision. + * + * @param classParameter type parameter of the class being renamed + * @param functionParameter corresponding type parameter of the function, if any + * @param substitutor maps the function's type parameters to the class's + * @return `true` when the bounds are equivalent or cannot be compared + */ + context(_: KaSession) + private fun boundsMatch( + classParameter: KtTypeParameter, + functionParameter: KtTypeParameter?, + substitutor: KaSubstitutor, + ): Boolean { + functionParameter ?: return true + val functionBounds = runCatching { functionParameter.symbol.upperBounds }.getOrNull() ?: return true + val classBounds = runCatching { classParameter.symbol.upperBounds }.getOrNull() ?: return true + return functionBounds.size == classBounds.size && + functionBounds.zip(classBounds).all { (functionBound, classBound) -> + runCatching { + substitutor.substitute(functionBound).semanticallyEquals(classBound) + }.getOrDefault(false) + } } /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 1c33b75b8..a9ff68e4b 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -18,13 +18,14 @@ package io.github.nbplugins.kotlin.refactoring import com.intellij.psi.PsiElement import com.intellij.psi.util.PsiTreeUtil +import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol import org.jetbrains.kotlin.analysis.api.components.KaScopeImplicitReceiverValue +import org.jetbrains.kotlin.analysis.api.components.canBeCalledAsExtensionOn import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.expressionType -import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.packageScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbol @@ -1176,20 +1177,22 @@ object KotlinImportBindingCheck { /** * Returns the top-level extension operators of [name] applicable to [type] at this element. * + * Applicability goes through the analysis API's extension check rather than a raw subtype + * test: a declared receiver may be generic (`Box.getValue`), and only constraint inference + * can tell that `Box` satisfies it. + * * @param type receiver type the operator is selected on * @param name operator name * @return applicable extensions visible in the element's scope */ + @OptIn(KaExperimentalApi::class) context(_: KaSession) private fun KtElement.applicableOperators(type: KaType, name: String): List = runCatching { val identifier = Name.identifier(name) containingKtFile.scopeContext(this).scopes.asSequence() .flatMap { scopeWithKind -> scopeWithKind.scope.callables(identifier) } .filter { symbol -> symbol.isExtension } - .filter { symbol -> - val receiverType = symbol.receiverParameter?.returnType ?: return@filter false - type.isSubtypeOf(receiverType) - } + .filter { symbol -> symbol.canBeCalledAsExtensionOn(type) } .distinct() .toList() }.getOrElse { emptyList() } 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 0723d0a67..59cf40272 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 @@ -39,6 +39,18 @@ data class MoveImport(val packageName: FqName, val declarationName: String) interface KotlinMoveUsageSearchService { fun findUsages(declaration: KtNamedDeclaration): List + /** + * Enumerates the Kotlin source files the project's standalone session knows about. + * + * Convention usages—indexed access, implicit invocation, destructuring, loops, and delegated + * properties—bind a top-level operator without spelling its name, so [findUsages] cannot + * report them. Callers that need those usages scan the session files themselves. + * + * @param project IntelliJ project whose standalone K2 session supplies the files + * @return session source files in an unspecified order + */ + fun findSourceFiles(project: com.intellij.openapi.project.Project): List = emptyList() + /** * Finds session files that directly import one of [movedImports]' old declaration paths. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index ec01c049a..827ae05a5 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm 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 25cec3580..c3c2d9cfc 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 @@ -93,6 +93,17 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { return references } + /** + * Enumerates every Kotlin source file of the standalone session. + * + * @param project IntelliJ project owning the standalone K2 session + * @return session source files, or an empty list when no session exists + */ + override fun findSourceFiles(project: com.intellij.openapi.project.Project): List { + val session = KotlinAnalysisAPISession.forProject(project) ?: return emptyList() + return session.fileMap.values.mapNotNull { lightFile -> session.getKtFileForPath(lightFile.path) } + } + /** * Finds Kotlin files that directly import a moved declaration or one nested inside it. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index cb692c10c..54248bd7c 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -4496,6 +4496,302 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an incoming caller imports an indexed-access operator it never spelled. + * + * `n[0]` binds `Int.get` through the wildcard import of the old package while the operator + * lives there. Once it moves, the caller would fall back to the staying `Number.get` overload, + * so the moved declaration has to be imported explicitly even though usage search reports no + * simple-name reference for it. + */ + fun testApply_realSession_addsIncomingImportForArrayAccessOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-incoming-array") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Int.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(sourcePath, "package old\n\noperator fun Int.get(index: Int) = 1\n") + Files.writeString( + temporaryRoot.resolve("old/Number.kt"), + "package old\n\noperator fun Number.get(index: Int) = 2\n", + ) + Files.writeString(usagePath, "package usage\n\nimport old.*\n\nfun use(n: Int) = n[0]\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-incoming-array-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved indexed-access operator: $text", + text.contains("import dest.get"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an incoming caller imports an implicitly invoked operator. + * + * `(box)()` spells no name for the `Box.invoke` it calls, so only convention discovery can + * notice that the caller loses the operator when it moves. + */ + fun testApply_realSession_addsIncomingImportForInvokeOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-incoming-invoke") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Box.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + sourcePath, + "package old\n\nclass Box\n\noperator fun Box.invoke() = 1\n", + ) + Files.writeString(usagePath, "package usage\n\nimport old.*\n\nfun use(box: Box) = (box)()\n") + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-incoming-invoke-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved invoke operator: $text", + text.contains("import dest.invoke"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an incoming caller imports a destructuring operator. + * + * `val (x) = box` spells neither the entry nor `component1`; the reference belongs to the + * destructuring entry, so usage search cannot report the call site. + */ + fun testApply_realSession_addsIncomingImportForDestructuringOperator() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-incoming-destructuring") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Ops.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(temporaryRoot.resolve("old/Box.kt"), "package old\n\nclass Box\n") + Files.writeString(sourcePath, "package old\n\noperator fun Box.component1() = 1\n") + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n val (x) = box\n x\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-incoming-destructuring-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved destructuring operator: $text", + text.contains("import dest.component1"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** Verifies an incoming caller imports every loop convention the moved file declares. */ + fun testApply_realSession_addsIncomingImportsForForLoopConventions() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-incoming-for") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Ops.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box\n\nclass Cursor\n", + ) + Files.writeString( + sourcePath, + "package old\n\n" + + "operator fun Box.iterator() = Cursor()\n\n" + + "operator fun Cursor.hasNext() = false\n\n" + + "operator fun Cursor.next() = 1\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n for (x in box) x\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-incoming-for-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + listOf("iterator", "hasNext", "next").forEach { operatorName -> + assertTrue( + "The caller must import the moved $operatorName operator: $text", + text.contains("import dest.$operatorName"), + ) + } + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an incoming caller imports the accessor of a delegated property. + * + * `val value by Delegate()` spells neither `getValue` nor `provideDelegate`; the accessor is + * selected by overload resolution, so only convention discovery can notice that the caller + * loses the operator when it moves. + */ + fun testApply_realSession_addsIncomingImportForDelegateAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-incoming-delegate") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Ops.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString(temporaryRoot.resolve("old/Delegate.kt"), "package old\n\nclass Delegate\n") + Files.writeString( + sourcePath, + "package old\n\noperator fun Delegate.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 7\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use() {\n val value by Delegate()\n value\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-incoming-delegate-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved delegate accessor: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a delegate operator accepting a `KProperty` supertype is recognized. + * + * Kotlin passes a `KProperty` value to the operator's property parameter, which may be + * declared with any supertype. A `Any?` parameter therefore still selects the operator, and a + * moved file leaving its package must import it. + */ + fun testApply_realSession_importsDelegateOperatorWithPropertySupertype() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-property-supertype") + try { + Files.createDirectories(temporaryRoot.resolve("p")) + val sourcePath = temporaryRoot.resolve("p/Moved.kt") + Files.writeString( + temporaryRoot.resolve("p/Delegate.kt"), + "package p\n\nclass Delegate\n\n" + + "operator fun Delegate.getValue(thisRef: Any?, property: Any?): Int = 7\n", + ) + Files.writeString( + sourcePath, + "package p\n\nfun use() {\n val value by Delegate()\n value\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-property-supertype-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the accessor accepting a property supertype: $text", + text.contains("import p.getValue"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt index 6edfb3291..c6529724b 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -211,6 +211,96 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig } } + /** + * Verifies differing bounds of type parameters unused by the parameters do not differentiate. + * + * Neither signature uses its type parameter, so both are callable as `(Int)`. Kotlin rejects + * the resulting overloads as conflicting even though their bounds differ. + */ + fun testConflictsWithRenamedConstructor_reportsUnusedTypeParameterWithDifferentBounds() { + withSession( + name = "unused-type-parameter-bounds", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Before(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After(n: Int) = \"other\"\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertTrue( + "unused type parameters cannot distinguish the (Int) signatures", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** + * Verifies differing bounds of a type parameter used by the parameters do differentiate. + * + * The constructor accepts only `T : Number` while the function accepts `T : CharSequence`, so + * no call could be ambiguous and the rename must stay allowed. + */ + fun testConflictsWithRenamedConstructor_ignoresDifferentBoundsOnUsedTypeParameter() { + withSession( + name = "used-type-parameter-bounds", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\nclass Before(val n: T)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After(n: T) = n\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "different bounds on a used type parameter keep the signatures apart", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** + * Verifies a type parameter reached only through another bound still counts as used. + * + * `R : List` uses `T` inside its bound, so a differing bound on `T` keeps the signatures + * apart even though no value parameter spells `T` directly. + */ + fun testConflictsWithRenamedConstructor_ignoresDifferentBoundOfTransitivelyUsedTypeParameter() { + withSession( + name = "transitive-type-parameter-bounds", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString( + root.resolve("source/Types.kt"), + "package p\n\nclass Before>(val n: R)\n", + ) + Files.writeString( + root.resolve("source/Functions.kt"), + "package p\n\nfun > After(n: R) = n\n", + ) + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "a bound reached through another bound still distinguishes the signatures", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + /** @return the single top-level class of [file]. */ private fun primaryClass(file: KtFile): KtClass = file.declarations.filterIsInstance().single() diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 4a447b14d..7ff8a0fcc 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -671,6 +671,94 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies a retained delegate accessor with a generic receiver is reported. + * + * The staying `Box.getValue` accepts `Box` only after inferring `T`. A raw subtype + * test rejects it, so the added import of the moving `Any.getValue` would look harmless even + * though an explicit import outranks the wildcard binding the property uses today. + */ + fun testHasConflictingReference_reportsGenericDelegateAccessor() { + withSession( + name = "generic-delegate-accessor", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\nclass Box\n\n" + + "operator fun Box.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\n" + + "operator fun Any.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\n" + + "fun use() {\n val value by Box()\n value\n}\n", + ) + }, + ) { session, root -> + val moving = session.ktFile(root.resolve("old/Moving.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + val imported = moving.declarations.filterIsInstance().single { it.name == "getValue" } + + assertTrue( + "the imported accessor would outrank the generic wildcard accessor", + KotlinImportBindingCheck.hasConflictingReference(usage, "getValue", setOf(imported)), + ) + } + } + + /** + * Verifies a generic retained accessor blocks rewriting a direct import. + * + * `import old.getValue` binds both the staying `Box.getValue` and the moving + * `Any.getValue`. Rewriting the directive to the new package keeps only the latter, so the + * property would silently select the moved declaration. + */ + fun testHasConflictingImportRewrite_reportsGenericDelegateAccessor() { + withSession( + name = "generic-delegate-rewrite", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\nclass Box\n\n" + + "operator fun Box.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\n" + + "operator fun Any.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.Box\nimport old.getValue\n\n" + + "fun use() {\n val value by Box()\n value\n}\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + assertTrue( + "rewriting the import would drop the generic accessor the property uses", + KotlinImportBindingCheck.hasConflictingImportRewrite( + usage, + FqName("old.getValue"), + setOf(root.resolve("old/Moving.kt").toString()), + ), + ) + } + } + /** @return the session source for [path], failing the test when it is absent. */ private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") diff --git a/pom.xml b/pom.xml index 2a5e30a92..52618ac79 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.49-SNAPSHOT + 0.12.50-SNAPSHOT pom Netbeans Plugin Kotlin From fbddb401bac82a4912e158ac3eac6ea47ac3852b Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 22:11:59 +0300 Subject: [PATCH 41/55] Fixed setter applicability, provider type inference and shadowed bindings in package change Selected the indexed setter among the overloads that accept the actual indices and value instead of assuming any member setter wins, and inferred accessor return types from the delegate expression so a generic provider picks the instantiated operator. Preserved same-package and wildcard bindings that a declaration of the destination package would otherwise shadow, and treated classifiers bound by the surrounding scope as bound. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 155 +++++++++++-- .../refactoring/KaOperatorApplicability.kt | 208 ++++++++++++++++++ .../refactoring/KotlinImportBindingCheck.kt | 49 +++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 157 +++++++++++++ .../KotlinImportBindingCheckTest.kt | 89 ++++++++ pom.xml | 2 +- 17 files changed, 632 insertions(+), 50 deletions(-) create mode 100644 KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 59892335a..253868d95 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 3a80260a3..6f32f99d3 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index b35aab965..14636e9f3 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index eb7b1e9f2..722edb06a 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 76ddf6c6e..680d59ab6 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 4aaa4afe9..d910a7e56 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 6d225dfdf..f4ebf828b 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index b29490c1c..d120edce4 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 7155aae1b..4669a9910 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 475a9d1eb..8f33791ce 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 54e1f7e7c..2aab7f298 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -486,11 +486,12 @@ class KaChangePackageComputer( expression.conventionSymbols().forEach(::record) if (!expression.isCompoundWrite()) return val receiverType = expression.arrayExpression?.expressionType ?: return - val hasMemberSetter = receiverType.expandedSymbol?.memberScope?.declarations - .orEmpty() - .filterIsInstance() - .any { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "set" } - if (!hasMemberSetter) record(movedOperator(receiverType, "set")) + // A member setter wins only when it accepts the actual indices and value. A + // misfitting overload (`set(String, Int)` for `box[0]++`) leaves the write to + // the applicable extension, which the caller then has to import. + if (!receiverType.hasApplicableMemberIndexedSetter(expression.setterArgumentTypes())) { + record(movedOperator(receiverType, "set")) + } } // The implicit invocation reference belongs to the call expression, not to the @@ -551,7 +552,9 @@ class KaChangePackageComputer( type.memberDelegateOperator(name) ?: scopeDelegateOperator(type, name) val provider = selectedOperator(delegateType, "provideDelegate") record(provider) - val accessorType = provider?.returnType ?: delegateType + // The provider's return type is declared in terms of its own type parameters; only the + // instantiation inferred from the delegate receiver selects the real accessor. + val accessorType = provider?.returnTypeInferredFrom(delegateType) ?: delegateType listOf("getValue", "setValue").forEach { name -> if (name == "setValue" && !isVar) return@forEach record(selectedOperator(accessorType, name)) @@ -898,6 +901,13 @@ class KaChangePackageComputer( .filter { it.isAllUnder } .mapNotNull { it.importedFqName } .toSet() + val destination = targets.getValue(file) + // A same-package declaration outranks a wildcard or default import, so a reference + // that is bound through one of those has to become an explicit import when the + // destination package declares the same name. + val destinationNames = runCatching { + analyze(file) { destinationDeclarationNames(destination, movedFiles, targets) } + }.getOrDefault(emptySet()) val missingImports = linkedSetOf() runCatching { analyze(file) { @@ -908,6 +918,14 @@ class KaChangePackageComputer( val reference = expression.references.filterIsInstance().firstOrNull() ?: return val symbol = reference.resolveToSymbol() ?: return val referencedName = expression.getReferencedName() + file.shadowedWildcardImport( + expression, + symbol, + destination, + destinationNames, + importedFqNames, + movedPaths, + )?.let { importFqName -> missingImports += importFqName } val topLevelFqName = samePackageTopLevelName(symbol, originalPackage, movedPaths) // A call may spell a same-package type alias rather than the class it // expands to: K2 can report the constructor of the underlying class, @@ -1075,8 +1093,10 @@ class KaChangePackageComputer( movedPaths = null, ) // `provideDelegate` runs before the accessors and its return type is the - // type the accessors are selected on, so it replaces the expression type. - val accessorType = provider?.returnType ?: delegateType + // type the accessors are selected on, so it replaces the expression + // type. A generic provider returns `Provided`, so the type has to + // be instantiated from the delegate receiver first. + val accessorType = provider?.returnTypeInferredFrom(delegateType) ?: delegateType val operators = buildList { if (provider != null) add(provider) samePackageDelegateAccessor( @@ -1131,6 +1151,24 @@ class KaChangePackageComputer( originalPackage: FqName, movedPaths: Set, ): FqName? { + val fqName = topLevelDeclarationFqName(symbol) ?: return null + if (fqName.parent() != originalPackage) return null + // A declaration that moves with its file follows the file, so it needs no import. + if (symbol.psi?.containingFile?.virtualFile?.path in movedPaths) return null + return fqName + } + + /** + * Returns the fully qualified name of a top-level declaration, or `null` for members and locals. + * + * Class-like and constructor symbols are matched through their class id; callables through + * their callable id, which also covers dependency JARs without source PSI. Remaining Kotlin + * top-level declarations are recognized by having a [KtFile] as their parent. + * + * @param symbol symbol resolved for a reference + * @return the declaration's FQN, or `null` when it is not a top-level declaration + */ + private fun topLevelDeclarationFqName(symbol: KaSymbol): FqName? { val psi = symbol.psi val classId = when (symbol) { is KaConstructorSymbol -> symbol.containingClassId @@ -1139,26 +1177,109 @@ class KaChangePackageComputer( } if (classId != null) { if (classId.isLocal || classId.isNestedClass) return null - if (classId.packageFqName != originalPackage) return null - if (psi?.containingFile?.virtualFile?.path in movedPaths) return null return classId.asSingleFqName() } // A callable from a Kotlin dependency JAR has no source PSI; its id is the only way to - // tell that it is a top-level declaration of the package the moved file is leaving. + // tell that it is a top-level declaration. if (symbol is KaCallableSymbol) { val callableId = symbol.callableId ?: return null if (callableId.className != null) return null - if (callableId.packageName != originalPackage) return null - if (psi?.containingFile?.virtualFile?.path in movedPaths) return null return callableId.asSingleFqName() } if (psi !is KtNamedDeclaration) return null - val containingFile = psi.containingKtFile - if (containingFile.virtualFile?.path in movedPaths) return null - if (containingFile.packageFqName != originalPackage) return null if (psi.parent !is KtFile) return null val name = psi.name ?: return null - return originalPackage.child(Name.identifier(name)) + return psi.containingKtFile.packageFqName.child(Name.identifier(name)) + } + + /** + * Collects the top-level names the destination package declares after the move. + * + * A same-package declaration outranks a wildcard or default import, so a reference bound by + * one of those needs an explicit import once the file changes into that package. Declarations + * living in files that leave the destination in the same operation are excluded, while those + * arriving with the moved files are included. + * + * @param destination package the current file is changing to + * @param movedFiles Kotlin sources whose declared package changes + * @param targets mapped files with their final declared packages + * @return top-level simple names the destination package will contain + */ + @OptIn(KaExperimentalApi::class) + context(_: KaSession) + private fun destinationDeclarationNames( + destination: FqName, + movedFiles: Collection, + targets: Map, + ): Set { + val names = linkedSetOf() + val leavingPaths = movedFiles + .filter { movedFile -> targets[movedFile] != destination } + .mapNotNull { movedFile -> movedFile.virtualFile?.path } + .toSet() + findPackage(destination)?.packageScope?.declarations.orEmpty().forEach { symbol -> + val path = symbol.psi?.containingFile?.virtualFile?.path + if (path != null && path in leavingPaths) return@forEach + (symbol as? KaNamedSymbol)?.name?.asString()?.let(names::add) + } + movedFiles.forEach { movedFile -> + if (targets[movedFile] != destination) return@forEach + movedFile.declarations.forEach { declaration -> + (declaration as? KtNamedDeclaration)?.name?.let(names::add) + } + } + return names + } + + /** + * Returns the import a wildcard- or default-import binding needs after the move, if any. + * + * The reference currently resolves to a top-level declaration of another package through a + * star or default import. If the destination package declares the same simple name, that + * declaration outranks the import and would silently take over the reference. An explicit + * import of the resolved declaration preserves the meaning; when the file already binds the + * name explicitly, nothing changes and the rewrite is safe. + * + * @param expression simple-name reference inside the moved file + * @param symbol declaration the reference currently resolves to + * @param originalPackage package the moved file declares before the change + * @param destination package the moved file changes to + * @param destinationNames top-level names the destination package will contain + * @param importedFqNames unaliased imports already present in the file + * @return FQN to import, or `null` when the binding is unaffected + */ + private fun KtFile.shadowedWildcardImport( + expression: KtSimpleNameExpression, + symbol: KaSymbol, + destination: FqName, + destinationNames: Set, + importedFqNames: Set, + movedPaths: Set, + ): FqName? { + if (destinationNames.isEmpty()) return null + if (expression.qualifiedReceiver() != null) return null + val name = expression.getReferencedName() + if (name !in destinationNames) return null + val fqName = topLevelDeclarationFqName(symbol) ?: return null + // A type alias is spelled by its own name; a declaration of another package that only + // expands the alias cannot be imported under the spelled name. + if (fqName.shortName().asString() != name) return null + // A declaration moving with its file follows it and no import can express that. + if (symbol.psi?.containingFile?.virtualFile?.path in movedPaths) return null + // A declaration of the destination package is already bound through the package itself. + if (fqName.parent() == destination) return null + if (fqName in importedFqNames) return null + // An explicit import, aliased or not, keeps its priority over same-package declarations. + val explicitlyBound = importDirectives.any { directive -> + if (directive.isAllUnder) return@any false + val alias = directive.aliasName + if (alias != null) alias == name else directive.importedFqName?.shortName()?.asString() == name + } + if (explicitlyBound) return null + // Without an explicit import, a resolved top-level name is visible through the file's own + // package, a wildcard import, or Kotlin's default imports — all outranked by the + // destination's same-package declaration. + return fqName } /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt new file mode 100644 index 000000000..c61417ad8 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -0,0 +1,208 @@ +/******************************************************************************* + * 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 org.jetbrains.kotlin.analysis.api.KaExperimentalApi +import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.components.createSubstitutor +import org.jetbrains.kotlin.analysis.api.components.expandedSymbol +import org.jetbrains.kotlin.analysis.api.components.expressionType +import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf +import org.jetbrains.kotlin.analysis.api.components.memberScope +import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols +import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals +import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.types.KaCapturedType +import org.jetbrains.kotlin.analysis.api.types.KaClassType +import org.jetbrains.kotlin.analysis.api.types.KaDefinitelyNotNullType +import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType +import org.jetbrains.kotlin.idea.references.KtReference +import org.jetbrains.kotlin.psi.KtArrayAccessExpression + +/** + * Returns this callable's return type with its type arguments inferred from [receiverType]. + * + * A generic extension declares its return type in terms of its own type parameters + * (`operator fun Delegate.provideDelegate(...): Provided`), while the compiler + * instantiates them from the receiver. The delegated property's accessors are selected on + * `Provided` for a `Delegate` delegate expression, so the declared `Provided` cannot + * be used directly. Unifying the declared receiver with the actual one yields the mapping. + * + * A callable without a receiver keeps its declared return type; unifyable structures also fall + * back to it, because guessing an instantiation would select the wrong operator. + * + * @param receiverType actual type the callable is invoked on + * @return the return type with the inferred type arguments substituted + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun KaCallableSymbol.returnTypeInferredFrom(receiverType: KaType): KaType { + val declaredReceiverType = receiverParameter?.returnType ?: return returnType + return runCatching { + val mappings = linkedMapOf() + if (!collectTypeMappings(declaredReceiverType, receiverType, mappings)) return@runCatching returnType + if (mappings.isEmpty()) return@runCatching returnType + createSubstitutor(mappings).substitute(returnType) + }.getOrDefault(returnType) +} + +/** + * Collects the type-parameter instantiations that make [actual] match [declared]. + * + * Only type parameters and class types contribute mappings; any other declared structure is + * accepted without constraining the inference, since the receiver-applicability check has already + * proved that the call is possible. + * + * @param declared type spelled by the declaration + * @param actual type supplied at the call site + * @param mappings accumulator of type-parameter instantiations + * @return `false` when the same type parameter would need two different instantiations + */ +context(_: KaSession) +private fun collectTypeMappings( + declared: KaType, + actual: KaType, + mappings: MutableMap, +): Boolean { + val declaredType = unwrapType(declared) + val actualType = unwrapType(actual) + return when (declaredType) { + is KaTypeParameterType -> { + val previous = mappings[declaredType.symbol] + if (previous == null) { + mappings[declaredType.symbol] = actualType + true + } else { + previous.semanticallyEquals(actualType) + } + } + is KaClassType -> { + val actualClassType = actualType as? KaClassType ?: return true + if (declaredType.classId != actualClassType.classId) return true + val declaredArguments = declaredType.typeArguments + val actualArguments = actualClassType.typeArguments + if (declaredArguments.size != actualArguments.size) return true + declaredArguments.indices.all { index -> + val declaredArgument = declaredArguments[index].type ?: return@all true + val actualArgument = actualArguments[index].type ?: return@all true + collectTypeMappings(declaredArgument, actualArgument, mappings) + } + } + else -> true + } +} + +/** + * Removes the wrappers that hide a type's structure from inference. + * + * @param type type to inspect + * @return the same type without flexibility, capture, or definite-non-null wrappers + */ +private fun unwrapType(type: KaType): KaType = when (type) { + is KaDefinitelyNotNullType -> unwrapType(type.original) + is KaCapturedType -> type.projection.type?.let(::unwrapType) ?: type + is KaFlexibleType -> unwrapType(type.upperBound) + else -> type +} + +/** + * Returns the argument types a compound indexed write passes to `set`. + * + * The write passes its indices and then the value produced by the getter and the compound + * operation. The getter's return type stands in for that value: for the integer and boxed types + * the operators produce the same type, and the member-setter applicability check only needs + * enough precision to reject a signature whose index or value type cannot fit. + * + * @return index types followed by the getter's return type, or `null` when unresolved + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun KtArrayAccessExpression.setterArgumentTypes(): List? { + val indices = indexExpressions.map { index -> + runCatching { index.expressionType }.getOrNull() ?: return null + } + val getter = references.filterIsInstance() + .flatMap { reference -> runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() } + .filterIsInstance() + .firstOrNull { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "get" } + ?: return null + return indices + runCatching { getter.returnType }.getOrNull() +} + +/** + * Tells whether this callable accepts the arguments of an indexed write. + * + * An indexed write passes the indices and then the assigned value. A parameter whose type is a + * type parameter or cannot be compared is accepted, because the alternative — treating it as + * inapplicable — would import an extension the member already shadows. + * + * @param arguments argument types in call order; `null` entries are unresolved expressions + * @return `true` when the callable can be selected for those arguments + */ +context(_: KaSession) +internal fun KaCallableSymbol.acceptsIndexedSetterArguments(arguments: List): Boolean = runCatching { + val function = this as? KaFunctionSymbol ?: return@runCatching false + val parameters = function.valueParameters + if (parameters.isEmpty()) return@runCatching false + val lastParameter = parameters.last() + val fixedCount = if (lastParameter.isVararg) parameters.size - 1 else parameters.size + if (arguments.size < fixedCount) return@runCatching false + if (!lastParameter.isVararg && arguments.size != parameters.size) return@runCatching false + arguments.withIndex().all { (index, argument) -> + val parameter = parameters.getOrNull(index) ?: lastParameter + argument == null || acceptsArgument(parameter.returnType, argument) + } +}.getOrDefault(false) + +/** + * Tells whether a value parameter of [parameterType] takes [argument]. + * + * @param parameterType declared parameter type + * @param argument actual argument type + * @return `true` when assignability holds or cannot be decided + */ +context(_: KaSession) +private fun acceptsArgument(parameterType: KaType, argument: KaType): Boolean = runCatching { + parameterType is KaTypeParameterType || argument.isSubtypeOf(parameterType) +}.getOrDefault(true) + +/** + * Tells whether this type declares a member `set` that accepts the indexed-write arguments. + * + * A member setter always wins over an extension, but only when its signature actually fits: a + * `set(String, Int)` member does not shadow `box[0]++`, which needs `set(Int, Int)`. An unknown + * argument list keeps the member as applicable, preserving the previous conservative behavior. + * + * @param arguments argument types of the write, or `null` when they cannot be resolved + * @return `true` when a member setter of this type can be selected + */ +context(_: KaSession) +internal fun KaType.hasApplicableMemberIndexedSetter(arguments: List?): Boolean = runCatching { + if (arguments == null) return@runCatching true + expandedSymbol?.memberScope?.declarations + .orEmpty() + .filterIsInstance() + .any { symbol -> + (symbol as? KaNamedSymbol)?.name?.asString() == "set" && + symbol.acceptsIndexedSetterArguments(arguments) + } +}.getOrDefault(true) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index a9ff68e4b..1fb15191c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -520,6 +520,10 @@ object KotlinImportBindingCheck { val file = element.containingKtFile if (file.importDirectives.any { directive -> directive.bindsName(name, emptySet()) }) return true if (file.declarations.any { declaration -> declaration.declaresClassifierName(name) }) return true + // A sibling file of the caller's package, a wildcard import, and Kotlin's default imports + // bind a classifier without any directive of this file, so the composite scope has to be + // consulted as well: the rewritten type reference would resolve to that classifier. + if (isClassifierBoundByScope(element, name)) return true var child: PsiElement = element var current: PsiElement? = element.parent while (current != null) { @@ -542,6 +546,26 @@ object KotlinImportBindingCheck { return isNameBoundByInheritedMember(element, name, typesOnly = true) } + /** + * Tells whether a classifier of [name] is visible at [element] through its composite scope. + * + * Same-package declarations of sibling files, wildcard imports, and Kotlin's default imports + * bind names without any directive of this file and without a PSI ancestor, so directive text + * and the lexical walk miss them. + * + * @param element position whose classifier scope is inspected + * @param name identifier to look for + * @return `true` when some visible classifier has that name + */ + private fun isClassifierBoundByScope(element: KtElement, name: String): Boolean = runCatching { + analyze(element) { + val identifier = Name.identifier(name) + element.containingKtFile.scopeContext(element).scopes.any { scopeWithKind -> + scopeWithKind.scope.classifiers(identifier).any() + } + } + }.getOrDefault(false) + /** * Tells whether [name] is already bound to something else at [element]. * @@ -1074,7 +1098,7 @@ object KotlinImportBindingCheck { if (name !in accessorNames) return emptyList() val provider = delegateOperator(delegateType, "provideDelegate") val accessorTypes = provider - ?.let { symbol -> runCatching { symbol.returnType }.getOrNull() } + ?.let { symbol -> runCatching { symbol.returnTypeInferredFrom(delegateType) }.getOrNull() } ?.let { providedType -> listOf(providedType) } ?: listOf(delegateType) return accessorTypes.flatMap { accessorType -> @@ -1100,7 +1124,9 @@ object KotlinImportBindingCheck { private fun KtArrayAccessExpression.implicitSetterCandidates(): List { if (!isCompoundWrite()) return emptyList() val receiverType = runCatching { arrayExpression?.expressionType }.getOrNull() ?: return emptyList() - if (receiverType.memberIndexedSetter() != null) return emptyList() + // A member setter wins only when its signature accepts the actual indices and value; a + // misfitting overload leaves the write to an applicable extension. + if (receiverType.hasApplicableMemberIndexedSetter(setterArgumentTypes())) return emptyList() return applicableOperators(receiverType, "set") } @@ -1155,25 +1181,6 @@ object KotlinImportBindingCheck { } }.getOrNull() - /** - * Returns the member `set` operator of an indexed access on this type, or `null` when absent. - * - * A member setter takes the index and the assigned value; extensions declare the receiver - * separately and therefore have one more value parameter. - * - * @return the member indexed setter, or `null` when there is none - */ - context(_: KaSession) - private fun KaType.memberIndexedSetter(): KaCallableSymbol? = runCatching { - expandedSymbol?.memberScope?.declarations - ?.filterIsInstance() - ?.firstOrNull { symbol -> - (symbol as? KaNamedSymbol)?.name?.asString() == "set" && - symbol is org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol && - symbol.valueParameters.size == 2 - } - }.getOrNull() - /** * Returns the top-level extension operators of [name] applicable to [type] at this element. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 827ae05a5..e1532ffe3 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 54248bd7c..0a0c72aea 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -4792,6 +4792,163 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a compound indexed write keeps the extension setter when the member setter misfits. + * + * `Box` declares `get(Int)` and `set(String, Int)`, so `box[0]++` cannot use the member + * setter and selects the top-level `Box.set(Int, Int)` instead. A name-only member check + * suppresses that lookup and the caller loses `dest.set` when the extension moves. + */ + fun testApply_realSession_importsSetterApplicableToActualIndices() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-member-setter") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Ops.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box {\n" + + " fun get(i: Int): Int = i\n" + + " fun set(s: String, v: Int) {}\n" + + "}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(i: Int, v: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use() {\n val box = Box()\n box[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-member-setter-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved setter applicable to its indices: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a generic provider's return type is inferred before selecting the accessor. + * + * `provideDelegate` returns `Provided`; only with `T = Int` inferred from + * `Delegate` is the accessor `Provided.getValue` selected. Without substitution the + * moved file loses `old.getValue`. + */ + fun testApply_realSession_importsAccessorSelectedOnInferredProviderType() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-inferred-provider") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Delegate.kt"), + "package old\n\nclass Delegate\n\nclass Provided\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\nval value by Delegate()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-inferred-provider-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the accessor selected on Provided: $text", + text.contains("import old.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a wildcard-bound dependency is preserved against a destination declaration. + * + * The moved file reads `value` through `import lib.*`. After it joins `dest`, which declares + * its own `value`, that same-package declaration would outrank the wildcard, so the reference + * needs an explicit `lib.value` import. + */ + fun testApply_realSession_preservesWildcardBindingAgainstDestinationDeclaration() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-wildcard-shadow") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("dest")) + Files.createDirectories(temporaryRoot.resolve("source")) + val sourcePath = temporaryRoot.resolve("source/Usage.kt") + Files.writeString(temporaryRoot.resolve("lib/Lib.kt"), "package lib\n\nval value = 1\n") + Files.writeString(temporaryRoot.resolve("dest/Dest.kt"), "package dest\n\nval value = 2\n") + Files.writeString( + sourcePath, + "package source\n\nimport lib.*\n\nfun read() = value\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-wildcard-shadow-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must keep binding lib.value explicitly: $text", + text.contains("import lib.value"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 7ff8a0fcc..84e805ce7 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -759,6 +759,95 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies a classifier declared in a sibling file of the caller's package is reported. + * + * Rewriting a type reference to `After` in package `q` resolves to the sibling `q.After` + * rather than the renamed `p.After`, even though the caller file itself declares neither. + */ + fun testIsClassifierBoundInScope_reportsSiblingClassifierInCallerPackage() { + withSession( + name = "classifier-sibling-package", + prepare = { root -> + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before\n") + Files.writeString(root.resolve("q/After.kt"), "package q\n\nclass After\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport p.*\n\nfun consume(x: Before) = x.toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val typeElement = caller.declarations.filterIsInstance().single() + .valueParameters.single().typeReference?.typeElement ?: error("No type element") + + assertTrue( + "a sibling classifier of the caller's package would capture the rewritten type", + KotlinImportBindingCheck.isClassifierBoundInScope(typeElement, "After"), + ) + } + } + + /** + * Verifies a classifier supplied by a wildcard import is reported. + * + * Neither `lib.After` nor `p.*` is spelled by a directive that binds the name directly, but + * the composite scope resolves `After` through `import lib.*`, so the rewritten type would + * bind it instead of the renamed declaration. + */ + fun testIsClassifierBoundInScope_reportsClassifierFromWildcardImport() { + withSession( + name = "classifier-wildcard-package", + prepare = { root -> + for (directory in listOf("p", "lib", "r")) { + Files.createDirectories(root.resolve(directory)) + } + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before\n") + Files.writeString(root.resolve("lib/After.kt"), "package lib\n\nclass After\n") + Files.writeString( + root.resolve("r/Caller.kt"), + "package r\n\nimport lib.*\nimport p.*\n\nfun consume(x: Before) = x.toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("r/Caller.kt")) + val typeElement = caller.declarations.filterIsInstance().single() + .valueParameters.single().typeReference?.typeElement ?: error("No type element") + + assertTrue( + "a wildcard-imported classifier would capture the rewritten type", + KotlinImportBindingCheck.isClassifierBoundInScope(typeElement, "After"), + ) + } + } + + /** Verifies a name no scope binds is not reported as a capture. */ + fun testIsClassifierBoundInScope_ignoresUnboundName() { + withSession( + name = "classifier-unbound-package", + prepare = { root -> + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport p.*\n\nfun consume(x: Before) = x.toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val typeElement = caller.declarations.filterIsInstance().single() + .valueParameters.single().typeReference?.typeElement ?: error("No type element") + + assertFalse( + "no scope binds the new name, so the rename is safe", + KotlinImportBindingCheck.isClassifierBoundInScope(typeElement, "After"), + ) + } + } + /** @return the session source for [path], failing the test when it is absent. */ private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") diff --git a/pom.xml b/pom.xml index 52618ac79..6018e5def 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.50-SNAPSHOT + 0.12.51-SNAPSHOT pom Netbeans Plugin Kotlin From 56e761018dc70fe244a6c2b2c807662168a7e747 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 22:38:00 +0300 Subject: [PATCH 42/55] Fixed overload selection and external callers in rename and package change Selected convention operators by receiver specificity and actual arguments instead of the first applicable overload, so a moved subtype setter keeps its import and import conflicts compare the setter the compiler actually selects. Preserved wildcard-bound extension calls on value receivers against destination declarations, inferred provider type arguments through receiver supertypes, and rejected a rename when a caller of another package already binds the new name through an import or a package-qualified call. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 47 ++++-- .../refactoring/KaOperatorApplicability.kt | 63 ++++++- .../refactoring/KotlinImportBindingCheck.kt | 122 ++++++++++---- Nbm/pom.xml | 2 +- .../nbm/refactoring/KotlinRenamePlugin.kt | 24 +-- .../nbm/refactoring/KaChangePackageTest.kt | 155 ++++++++++++++++++ .../KotlinRenameClassifierConflictTest.kt | 107 ++++++++++++ pom.xml | 2 +- 18 files changed, 465 insertions(+), 77 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 253868d95..6dbb93373 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 6f32f99d3..96619ea76 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 14636e9f3..d9c1798e8 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 722edb06a..eb4bbdb7c 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 680d59ab6..05d96fbad 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index d910a7e56..7d171f8e4 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index f4ebf828b..bd1e9987c 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index d120edce4..3351ecc81 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 4669a9910..79f61e935 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 8f33791ce..42c7fa893 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 2aab7f298..e9f4fe865 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -474,12 +474,18 @@ class KaChangePackageComputer( val declaration = symbol?.psi as? KtNamedDeclaration ?: return if (declaration in moved) found += declaration } - fun movedOperator(type: KaType, name: String): KaCallableSymbol? = visiblePackages.asSequence() - .mapNotNull { packageFqName -> findPackage(packageFqName)?.packageScope } - .flatMap { scope -> scope.declarations.asSequence() } - .filterIsInstance() - .filter { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name && symbol.isExtension } - .firstOrNull { symbol -> symbol.canBeCalledAsExtensionOn(type) } + fun movedOperator(type: KaType, name: String, arguments: List? = null): KaCallableSymbol? { + val candidates = visiblePackages.asSequence() + .mapNotNull { packageFqName -> findPackage(packageFqName)?.packageScope } + .flatMap { scope -> scope.declarations.asSequence() } + .filterIsInstance() + .filter { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name && symbol.isExtension } + .toList() + // Both `Box.set` and the more specific `SubBox.set` can be visible at once; only the + // one overload resolution selects may be recorded, or the caller would import a + // setter it does not call. + return selectExtension(candidates, type, arguments) + } accept(object : KtTreeVisitorVoid() { override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { super.visitArrayAccessExpression(expression) @@ -489,8 +495,9 @@ class KaChangePackageComputer( // A member setter wins only when it accepts the actual indices and value. A // misfitting overload (`set(String, Int)` for `box[0]++`) leaves the write to // the applicable extension, which the caller then has to import. - if (!receiverType.hasApplicableMemberIndexedSetter(expression.setterArgumentTypes())) { - record(movedOperator(receiverType, "set")) + val arguments = expression.setterArgumentTypes() + if (!receiverType.hasApplicableMemberIndexedSetter(arguments)) { + record(movedOperator(receiverType, "set", arguments)) } } @@ -1257,7 +1264,12 @@ class KaChangePackageComputer( movedPaths: Set, ): FqName? { if (destinationNames.isEmpty()) return null - if (expression.qualifiedReceiver() != null) return null + // A qualified access resolves through its qualifier, which neither an import nor a + // same-package declaration can capture — with one exception: an extension call on a value + // receiver (`1.choose()`, `box.choose()`) selects its callee through the file's scope. + if (expression.qualifiedReceiver() != null && (symbol as? KaCallableSymbol)?.isExtension != true) { + return null + } val name = expression.getReferencedName() if (name !in destinationNames) return null val fqName = topLevelDeclarationFqName(symbol) ?: return null @@ -1322,15 +1334,18 @@ class KaChangePackageComputer( movedPaths: Set, ): FqName? { val receiverType = expression.arrayExpression?.expressionType ?: return null - return findPackage(originalPackage)?.packageScope?.declarations + val candidates = findPackage(originalPackage)?.packageScope?.declarations ?.filterIsInstance() - ?.firstNotNullOfOrNull { symbol -> - if ((symbol as? KaNamedSymbol)?.name?.asString() != "set") { - return@firstNotNullOfOrNull null - } - if (!symbol.canBeCalledAsExtensionOn(receiverType)) return@firstNotNullOfOrNull null - samePackageTopLevelName(symbol, originalPackage, movedPaths) + ?.filter { symbol -> + (symbol as? KaNamedSymbol)?.name?.asString() == "set" && symbol.isExtension } + .orEmpty() + .toList() + // The receiver alone cannot tell `Box.set` from the more specific `SubBox.set`; only the + // overload resolution result may be imported. + val selected = selectExtension(candidates, receiverType, expression.setterArgumentTypes()) + ?: return null + return samePackageTopLevelName(selected, originalPackage, movedPaths) } /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index c61417ad8..2e69e1f46 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -18,6 +18,8 @@ package io.github.nbplugins.kotlin.refactoring import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.components.allSupertypes +import org.jetbrains.kotlin.analysis.api.components.canBeCalledAsExtensionOn import org.jetbrains.kotlin.analysis.api.components.createSubstitutor import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.expressionType @@ -77,6 +79,7 @@ internal fun KaCallableSymbol.returnTypeInferredFrom(receiverType: KaType): KaTy * @param mappings accumulator of type-parameter instantiations * @return `false` when the same type parameter would need two different instantiations */ +@OptIn(KaExperimentalApi::class) context(_: KaSession) private fun collectTypeMappings( declared: KaType, @@ -97,9 +100,19 @@ private fun collectTypeMappings( } is KaClassType -> { val actualClassType = actualType as? KaClassType ?: return true - if (declaredType.classId != actualClassType.classId) return true + val matchingType = if (declaredType.classId == actualClassType.classId) { + actualClassType + } else { + // A subtype supplies the type arguments indirectly: `IntDelegate : Delegate()` + // is instantiated as `Provided` by a provider declared on `Delegate`, so the + // declared supertype has to be unified instead of giving up on the class mismatch. + actualClassType.allSupertypes + .mapNotNull { supertype -> supertype as? KaClassType } + .firstOrNull { supertype -> supertype.classId == declaredType.classId } + ?: return true + } val declaredArguments = declaredType.typeArguments - val actualArguments = actualClassType.typeArguments + val actualArguments = matchingType.typeArguments if (declaredArguments.size != actualArguments.size) return true declaredArguments.indices.all { index -> val declaredArgument = declaredArguments[index].type ?: return@all true @@ -185,6 +198,52 @@ private fun acceptsArgument(parameterType: KaType, argument: KaType): Boolean = parameterType is KaTypeParameterType || argument.isSubtypeOf(parameterType) }.getOrDefault(true) +/** + * Selects the extension the compiler binds for a convention call. + * + * Every candidate is visible through the same package scopes, so overload resolution ranks them + * by receiver specificity: `SubBox.set` wins over `Box.set` for a `SubBox` receiver. Candidates + * that cannot take [arguments] are dropped first, which separates overloads declared on the same + * receiver type. + * + * @param candidates extensions visible under the same simple name + * @param receiverType type of the value the operator is invoked on + * @param arguments argument types of the call, or `null` when they cannot be resolved + * @return the selected extension, or `null` when none applies + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun selectExtension( + candidates: List, + receiverType: KaType, + arguments: List? = null, +): KaCallableSymbol? { + val applicable = candidates.filter { candidate -> + runCatching { candidate.canBeCalledAsExtensionOn(receiverType) }.getOrDefault(false) && + (arguments == null || candidate.acceptsIndexedSetterArguments(arguments)) + } + return applicable.firstOrNull { candidate -> + applicable.none { other -> other !== candidate && other.receiverIsMoreSpecificThan(candidate) } + } ?: applicable.firstOrNull() +} + +/** + * Tells whether this callable's receiver is a proper subtype of [other]'s. + * + * The declared receiver types decide which extension overload wins when both are visible through + * the same scope. Equal types are not "more specific", so a tie keeps the declaration order. + * + * @param other competing callable + * @return `true` when this receiver fits [other]'s receiver but not the other way round + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.receiverIsMoreSpecificThan(other: KaCallableSymbol): Boolean = runCatching { + val receiver = receiverParameter?.returnType ?: return@runCatching false + val otherReceiver = other.receiverParameter?.returnType ?: return@runCatching false + receiver.isSubtypeOf(otherReceiver) && !otherReceiver.isSubtypeOf(receiver) +}.getOrDefault(false) + /** * Tells whether this type declares a member `set` that accepts the indexed-write arguments. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 1fb15191c..4fccd38a5 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -164,10 +164,12 @@ object KotlinImportBindingCheck { // A compound indexed write spells only the getter; its implicit setter goes // through the same check because an import of `set` outranks the wildcard. if (!conflicting && name == "set") { - runCatching { expression.implicitSetterCandidates() }.getOrNull()?.forEach { symbol -> - if (!symbol.belongsTo(excludedDeclarations) && symbol.isTopLevelBinding()) { - conflicting = true - } + val setter = runCatching { expression.implicitSetterCandidates() }.getOrNull() + if (setter != null && + !setter.belongsTo(excludedDeclarations) && + setter.isTopLevelBinding() + ) { + conflicting = true } } } @@ -290,12 +292,12 @@ object KotlinImportBindingCheck { // The setter half of a compound write is implicit; it loses the overload // the rewritten directive stops importing just like the getter does. if (!conflicting && importedName == "set") { - runCatching { expression.implicitSetterCandidates() }.getOrNull()?.forEach { symbol -> - if (symbol.isTopLevelBinding() && - symbol.staysInOriginalPackage(imported, movingPaths) - ) { - conflicting = true - } + val setter = runCatching { expression.implicitSetterCandidates() }.getOrNull() + if (setter != null && + setter.isTopLevelBinding() && + setter.staysInOriginalPackage(imported, movingPaths) + ) { + conflicting = true } } } @@ -405,12 +407,12 @@ object KotlinImportBindingCheck { // The implicit setter of a compound write keeps binding through the // wildcard while the file moves; the added import would outrank it. if (!conflicting && importedName == "set") { - runCatching { expression.implicitSetterCandidates() }.getOrNull()?.forEach { symbol -> - if (symbol.isTopLevelBinding() && - symbol.isCapturedByOutgoingImport(imported, movingPaths) - ) { - conflicting = true - } + val setter = runCatching { expression.implicitSetterCandidates() }.getOrNull() + if (setter != null && + setter.isTopLevelBinding() && + setter.isCapturedByOutgoingImport(imported, movingPaths) + ) { + conflicting = true } } } @@ -454,20 +456,20 @@ object KotlinImportBindingCheck { }.getOrDefault(false) /** - * Tells whether [file] already refers to a same-package declaration named [name]. + * Tells whether [file] refers to a declaration named [name] that an added declaration would + * outrank. * - * Adding a top-level declaration to a package changes overload resolution for unqualified - * references to that name, so a same-package reference that currently resolves to another - * top-level callable can silently bind to the added declaration. A reference bound through a - * star or default import is outranked by the same-package declaration in the same way. An - * explicit import of the name from elsewhere keeps winning over the package, so it is not a - * conflict. + * Adding a top-level declaration to a package changes overload resolution for references that + * can see that package: same-package references, package-qualified calls (`p.After(1)`), and + * names bound through a star or default import. A same-named top-level callable that currently + * wins can silently start binding to the added declaration. An explicit import of the name + * from another package keeps winning, so it is not a conflict. * * @param file caller that already names the declaration * @param name simple name of the declaration being added * @param packageName package the declaration is added to * @param excludedDeclarations declarations the added name is meant for - * @return `true` when a reference to another same-package declaration would be affected + * @return `true` when a reference to another declaration would be affected */ fun hasSamePackageReferenceToOtherDeclaration( file: KtFile, @@ -482,13 +484,19 @@ object KotlinImportBindingCheck { super.visitSimpleNameExpression(expression) if (conflicting) return if (expression.getReferencedName() != name) return - if (expression.isQualifiedSelector()) return if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return val symbol = expression.references.filterIsInstance().firstOrNull() ?.resolveToSymbol() ?: return if (symbol.belongsTo(excludedDeclarations)) return if (!symbol.isTopLevelBinding()) return - // A reference that already binds a same-package declaration changes overload + if (expression.isQualifiedSelector()) { + // The added declaration joins the package, so a call spelled with that + // package qualifier competes with it even without an import. + if (expression.isPackageQualifiedCall(packageName)) conflicting = true + return + } + if (!file.canSeeDeclarationOf(packageName, name)) return + // A reference that already binds a declaration of the package changes overload // resolution once the new declaration joins the package. A reference bound // through a star or default import is outranked by the package in the same // way, while an explicit import keeps winning, so only star-bound references @@ -504,6 +512,47 @@ object KotlinImportBindingCheck { } }.getOrDefault(false) + /** + * Tells whether this file can bind [name] to a declaration of [packageName]. + * + * The added declaration is visible through the file's own package, an explicit import of that + * declaration, or a wildcard import of its package. Any other import binds another declaration + * and keeps outranking the package. + * + * @param packageName package the declaration is added to + * @param name simple name of the declaration + * @return `true` when the file can see declarations of that package under that name + */ + private fun KtFile.canSeeDeclarationOf(packageName: FqName, name: String): Boolean = + packageFqName == packageName || importDirectives.any { directive -> + if (directive.isAllUnder) return@any directive.importedFqName == packageName + directive.importedFqName == packageName.child(Name.identifier(name)) && + (directive.aliasName == null || directive.aliasName == name) + } + + /** + * Tells whether this selector is a qualified call whose receiver spells [packageName]. + * + * Only a package qualifier resolves the call outside the file's scope, so a value or type + * named like the package has to be excluded by resolving the leftmost name. + * + * @param packageName package the qualified access may name in full + * @return `true` when the receiver is the package itself or a prefix of it + */ + context(_: KaSession) + private fun KtSimpleNameExpression.isPackageQualifiedCall(packageName: FqName): Boolean { + val qualified = qualifiedAccess() ?: return false + val receiverText = qualified.receiverExpression.text + val packageText = packageName.asString() + if (receiverText != packageText && !packageText.startsWith("$receiverText.")) return false + val root = qualified.receiverExpression.leadingReference() ?: return false + // K2 resolves a package reference to `null`; a value of the same name resolves to its symbol. + val rootSymbol = runCatching { + root.references.filterIsInstance().firstOrNull()?.resolveToSymbol() + }.getOrNull() + return rootSymbol == null || rootSymbol is KaPackageSymbol + } + /** * Tells whether [name] is already bound to a classifier at [element]. * @@ -1111,23 +1160,26 @@ object KotlinImportBindingCheck { } /** - * Returns the top-level `set` extensions a compound indexed write implicitly selects. + * Returns the top-level `set` extension a compound indexed write implicitly selects. * * `sub[0]++` and `sub[0] += 1` spell only the getter, so K2 does not expose the setter half. - * The applicable `set` extensions are looked up by name and receiver applicability at the - * access site, mirroring the delegate accessor lookup. A member setter always wins over + * The selected `set` extension is looked up by name, receiver applicability and the actual + * indices and value, mirroring the delegate accessor lookup. A member setter always wins over * extensions and cannot be captured by an import. * - * @return applicable `set` extensions, empty for a plain read or a member setter + * @return the selected `set` extension, `null` for a plain read or a member setter */ context(_: KaSession) - private fun KtArrayAccessExpression.implicitSetterCandidates(): List { - if (!isCompoundWrite()) return emptyList() - val receiverType = runCatching { arrayExpression?.expressionType }.getOrNull() ?: return emptyList() + private fun KtArrayAccessExpression.implicitSetterCandidates(): KaCallableSymbol? { + if (!isCompoundWrite()) return null + val receiverType = runCatching { arrayExpression?.expressionType }.getOrNull() ?: return null + val arguments = setterArgumentTypes() // A member setter wins only when its signature accepts the actual indices and value; a // misfitting overload leaves the write to an applicable extension. - if (receiverType.hasApplicableMemberIndexedSetter(setterArgumentTypes())) return emptyList() - return applicableOperators(receiverType, "set") + if (receiverType.hasApplicableMemberIndexedSetter(arguments)) return null + // The caller may see a less and a more specific setter at once; only the overload + // resolution winner can be rebound by an import. + return selectExtension(applicableOperators(receiverType, "set"), receiverType, arguments) } /** diff --git a/Nbm/pom.xml b/Nbm/pom.xml index e1532ffe3..60d087002 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt index bfbc47f36..4338be7bc 100644 --- a/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt +++ b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenamePlugin.kt @@ -657,12 +657,13 @@ private fun KtFile.hasConflictingImportBinding(name: String, newFqName: FqName, } /** - * Finds a same-package caller whose existing reference to the new name would rebind. + * Finds a caller whose existing reference to the new name would rebind to the renamed class. * - * The renamed class adds a constructor under the new name to its package, and a same-package - * caller needs no import for it. Overload resolution can then prefer the constructor over an - * existing function of that name — `After(1)` with `fun After(n: Number)` switches to the newly - * named `After(Int)` constructor — while the file still compiles. + * The renamed class adds a constructor under the new name to its package. Every file that can + * see that package — a same-package caller needs no import, while another package's caller + * imports `p.After` or spells `p.After(1)` — may then prefer the constructor over an existing + * function of that name: `After(1)` with `fun After(n: Number)` switches to the newly named + * `After(Int)` constructor while the file still compiles. * * @param source file whose primary class is renamed * @param primaryClass class renamed together with the file @@ -678,13 +679,12 @@ internal fun findSamePackageOverloadConflict( ): KtFile? { val packageName = source.packageFqName return files.firstOrNull { file -> - file.packageFqName == packageName && - KotlinImportBindingCheck.hasSamePackageReferenceToOtherDeclaration( - file, - newName, - packageName, - setOf(primaryClass), - ) + KotlinImportBindingCheck.hasSamePackageReferenceToOtherDeclaration( + file, + newName, + packageName, + setOf(primaryClass), + ) } } diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 0a0c72aea..ad5db7057 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -4949,6 +4949,161 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a wildcard-bound extension call keeps its binding against the destination package. + * + * The moved file calls `1.choose()` through `import lib.*`. After it joins `dest`, which + * declares its own `Number.choose`, that same-package extension outranks the wildcard, so the + * call needs an explicit `lib.choose` import. + */ + fun testApply_realSession_preservesWildcardBoundExtensionAgainstDestinationDeclaration() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-extension-shadow") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("dest")) + Files.createDirectories(temporaryRoot.resolve("source")) + val sourcePath = temporaryRoot.resolve("source/Usage.kt") + Files.writeString(temporaryRoot.resolve("lib/Lib.kt"), "package lib\n\nfun Int.choose() = 1\n") + Files.writeString(temporaryRoot.resolve("dest/Dest.kt"), "package dest\n\nfun Number.choose() = 2\n") + Files.writeString( + sourcePath, + "package source\n\nimport lib.*\n\nfun read() = 1.choose()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-extension-shadow-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must keep binding lib.choose explicitly: $text", + text.contains("import lib.choose"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies the moved usage discovers the most specific setter overload. + * + * `Box.set` stays in the old package while the more specific `SubBox.set` moves. Selecting the + * first applicable extension by receiver alone records the staying overload, so the caller + * silently switches to it; only the most specific applicable setter keeps the import. + */ + fun testApply_realSession_importsMostSpecificMovedSetter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-specific-setter") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nopen class Box {\n operator fun get(i: Int): Int = 0\n}\n\n" + + "operator fun Box.set(i: Int, v: Int) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\nclass SubBox : Box()\n\noperator fun SubBox.set(i: Int, v: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(sub: SubBox) {\n sub[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-specific-setter-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved setter selected for SubBox: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a provider's type arguments are inferred through the delegate's supertype. + * + * `IntDelegate : Delegate()` supplies the instantiation indirectly, so the accessor is + * selected on `Provided` only when the supertype is unified with the declared receiver. + */ + fun testApply_realSession_importsAccessorSelectedOnProviderSupertype() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-provider-supertype") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Delegate.kt"), + "package old\n\nopen class Delegate\n\nclass IntDelegate : Delegate()\n\n" + + "class Provided\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\nval value by IntDelegate()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-provider-supertype-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the accessor selected on Provided: $text", + text.contains("import old.getValue"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt index be6da1944..94d9a6e1f 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -329,6 +329,113 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie } } + /** + * Verifies a caller of another package that imports the new name blocks the rename. + * + * `import p.After` binds the whole overload set of `p.After`, so after `Before(Int)` becomes + * an `After(Int)` constructor the caller's untouched `After(1)` selects the constructor + * instead of the `fun After(n: Number)` it currently calls. + */ + fun testFindSamePackageOverloadConflict_reportsExplicitlyImportedNewName() { + withSession( + name = "external-explicit-import", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After(n: Number) = \"other\"\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package q\n\nimport p.After\n\nfun use() = After(1).toString()\n", + ) + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, functions, usage), + ) + + assertEquals("Usage.kt", conflict?.virtualFile?.name) + } + } + + /** + * Verifies a package-qualified call of another package blocks the rename. + * + * The caller spells `p.After(1)` without any import, so only the package-qualified form can + * reveal that the new constructor would win overload resolution. + */ + fun testFindSamePackageOverloadConflict_reportsPackageQualifiedNewName() { + withSession( + name = "external-package-qualified", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After(n: Number) = \"other\"\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package q\n\nfun use() = p.After(1).toString()\n", + ) + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, functions, usage), + ) + + assertEquals("Usage.kt", conflict?.virtualFile?.name) + } + } + + /** + * Verifies a caller that binds another package's `After` is left alone. + * + * `lib.After` is not visible from `p`, so adding `p.After` cannot change the caller's + * overload resolution. + */ + fun testFindSamePackageOverloadConflict_ignoresOtherPackageBinding() { + withSession( + name = "external-other-package", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.createDirectories(root.resolve("lib")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("lib/Library.kt"), "package lib\n\nfun After(n: Number) = \"other\"\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package q\n\nimport lib.After\n\nfun use() = After(1).toString()\n", + ) + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val library = session.ktFile(root.resolve("lib/Library.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, library, usage), + ) + + assertNull("another package's binding keeps winning over the added class", conflict) + } + } + /** * Verifies a top-level function with an equivalently spelled parameter type blocks the rename. * diff --git a/pom.xml b/pom.xml index 6018e5def..bf6971eb6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.51-SNAPSHOT + 0.12.52-SNAPSHOT pom Netbeans Plugin Kotlin From 1f2646eadfd1050b6c894ca1b04b59ce6cfc9fdb Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 23:05:16 +0300 Subject: [PATCH 43/55] Fixed convention shadowing, delegate accessor selection and vararg signatures A moved file now keeps its wildcard-bound convention operators: when the destination package declares an operator of the same name, the inline visitors reuse the shared shadowed-dependency check so an explicit import keeps binding the declaration that stays behind. Delegate accessors and implicit indexed setters are now chosen by overload resolution instead of by receiver applicability alone, so `Int.getValue` wins over `Number.getValue` and importing the moved accessor no longer reports a false conflict with the staying overload. The lookup logic is shared through KaOperatorApplicability. The constructor-signature check compares vararg status, because a vararg constructor and a scalar function are distinct Kotlin overloads. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 145 +++++++++++++----- .../refactoring/KaOperatorApplicability.kt | 54 +++++++ .../KotlinConstructorSignatureCheck.kt | 12 +- .../refactoring/KotlinImportBindingCheck.kt | 91 ++++------- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 110 +++++++++++++ .../KotlinConstructorSignatureCheckTest.kt | 60 ++++++++ pom.xml | 2 +- 18 files changed, 384 insertions(+), 112 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 6dbb93373..4be43fd66 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 96619ea76..02bc0c41a 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index d9c1798e8..ed18de391 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index eb4bbdb7c..d8d8b6645 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 05d96fbad..d0978413b 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 7d171f8e4..dbe40ec7c 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index bd1e9987c..22a44c1e0 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 3351ecc81..44ffb4cbf 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 79f61e935..648072ded 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 42c7fa893..8995855a5 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index e9f4fe865..73f4cd631 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -989,13 +989,24 @@ class KaChangePackageComputer( // accumulator deduplicates the overlap. override fun visitCallExpression(expression: KtCallExpression) { super.visitCallExpression(expression) - expression.references.filterIsInstance() + val symbols = expression.references.filterIsInstance() .flatMap { reference -> runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() } - .mapNotNull { symbol -> - samePackageTopLevelName(symbol, originalPackage, movedPaths) - } + // A destination declaration of the same name outranks the wildcard or + // default import that currently binds the operator. + symbols.forEach { symbol -> + file.shadowedDependency( + symbol, + destination, + destinationNames, + importedFqNames, + movedPaths, + )?.let { importFqName -> missingImports += importFqName } + } + symbols.mapNotNull { symbol -> + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } .filter { importFqName -> importFqName !in importedFqNames && originalPackage !in starPackages } @@ -1014,6 +1025,15 @@ class KaChangePackageComputer( multiDeclarationEntry.references.filterIsInstance().forEach { reference -> val symbols = runCatching { reference.resolveToSymbols() }.getOrNull() ?: return@forEach + symbols.forEach { symbol -> + file.shadowedDependency( + symbol, + destination, + destinationNames, + importedFqNames, + movedPaths, + )?.let { importFqName -> missingImports += importFqName } + } val importFqName = symbols.firstNotNullOfOrNull { symbol -> samePackageTopLevelName(symbol, originalPackage, movedPaths) } ?: return@forEach @@ -1031,14 +1051,23 @@ class KaChangePackageComputer( // own property — so all retained callables are collected. override fun visitForExpression(expression: KtForExpression) { super.visitForExpression(expression) - expression.references + val symbols = expression.references .filterIsInstance() .flatMap { reference -> runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() } - .mapNotNull { symbol -> - samePackageTopLevelName(symbol, originalPackage, movedPaths) - } + symbols.forEach { symbol -> + file.shadowedDependency( + symbol, + destination, + destinationNames, + importedFqNames, + movedPaths, + )?.let { importFqName -> missingImports += importFqName } + } + symbols.mapNotNull { symbol -> + samePackageTopLevelName(symbol, originalPackage, movedPaths) + } .filter { importFqName -> importFqName !in importedFqNames && originalPackage !in starPackages } @@ -1057,6 +1086,15 @@ class KaChangePackageComputer( .flatMap { reference -> runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() } + resolvedOperators.forEach { symbol -> + file.shadowedDependency( + symbol, + destination, + destinationNames, + importedFqNames, + movedPaths, + )?.let { importFqName -> missingImports += importFqName } + } val retainedOperators = resolvedOperators.mapNotNull { symbol -> samePackageTopLevelName(symbol, originalPackage, movedPaths) } @@ -1069,6 +1107,15 @@ class KaChangePackageComputer( // getter is a member: the array reference exposes only `get`, so a // staying top-level `set` would otherwise be lost without an import. if (expression.isCompoundWrite()) { + expression.selectedIndexedSetter()?.let { setter -> + file.shadowedDependency( + setter, + destination, + destinationNames, + importedFqNames, + movedPaths, + )?.let { importFqName -> missingImports += importFqName } + } samePackageIndexedSetter(expression, originalPackage, movedPaths) ?.takeIf { importFqName -> importFqName !in importedFqNames && originalPackage !in starPackages @@ -1121,6 +1168,15 @@ class KaChangePackageComputer( )?.let { symbol -> add(symbol) } } } + operators.forEach { symbol -> + file.shadowedDependency( + symbol, + destination, + destinationNames, + importedFqNames, + movedPaths, + )?.let { importFqName -> missingImports += importFqName } + } operators.asSequence() .mapNotNull { symbol -> samePackageTopLevelName(symbol, originalPackage, movedPaths) @@ -1272,10 +1328,38 @@ class KaChangePackageComputer( } val name = expression.getReferencedName() if (name !in destinationNames) return null - val fqName = topLevelDeclarationFqName(symbol) ?: return null // A type alias is spelled by its own name; a declaration of another package that only // expands the alias cannot be imported under the spelled name. + val fqName = topLevelDeclarationFqName(symbol) ?: return null if (fqName.shortName().asString() != name) return null + return shadowedDependency(symbol, destination, destinationNames, importedFqNames, movedPaths) + } + + /** + * Returns the import that keeps an implicitly selected declaration bound after the move. + * + * Convention operators (`component1`, `iterator`, `get`, `getValue`) spell no name, so a + * destination declaration of the same name would outrank the wildcard or default import that + * currently selects them. The resolved declaration's explicit import keeps its priority. + * + * @param symbol declaration the reference currently resolves to + * @param destination package the moved file changes to + * @param destinationNames top-level names the destination package will contain + * @param importedFqNames unaliased imports already present in the file + * @param movedPaths paths of the files whose package changes + * @return FQN to import, or `null` when the binding is unaffected + */ + private fun KtFile.shadowedDependency( + symbol: KaSymbol, + destination: FqName, + destinationNames: Set, + importedFqNames: Set, + movedPaths: Set, + ): FqName? { + if (destinationNames.isEmpty()) return null + val fqName = topLevelDeclarationFqName(symbol) ?: return null + val name = fqName.shortName().asString() + if (name !in destinationNames) return null // A declaration moving with its file follows it and no import can express that. if (symbol.psi?.containingFile?.virtualFile?.path in movedPaths) return null // A declaration of the destination package is already bound through the package itself. @@ -1294,26 +1378,6 @@ class KaChangePackageComputer( return fqName } - /** - * Tells whether this indexed access is updated implicitly, not merely assigned or read. - * - * A plain assignment (`box[0] = 1`) resolves its `set` through the array-access reference, but - * an increment or compound assignment (`box[0]++`, `box[0] += 1`) lowers to `get` then `set` - * and exposes only `get`. - * - * @return `true` when the compiler resolves a setter for this access implicitly - */ - private fun KtArrayAccessExpression.isCompoundWrite(): Boolean { - val parent = parent - return when (parent) { - is KtBinaryExpression -> parent.left == this && - parent.operationToken in KtTokens.ALL_ASSIGNMENTS && parent.operationToken != KtTokens.EQ - is KtUnaryExpression -> parent.baseExpression == this && - parent.operationToken in setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) - else -> false - } - } - /** * Finds the `set` extension a compound indexed write needs from the original package. * @@ -1406,10 +1470,16 @@ class KaChangePackageComputer( context(_: KaSession) private fun KtElement.scopeDelegateOperator(type: KaType, operatorName: String): KaCallableSymbol? = runCatching { val identifier = Name.identifier(operatorName) - containingKtFile.scopeContext(this).scopes.asSequence() - .flatMap { scopeWithKind -> scopeWithKind.scope.callables(identifier) } - .filter { symbol -> symbol.isExtension && symbol.isDelegateOperatorShape(operatorName) } - .firstOrNull { symbol -> symbol.canBeCalledAsExtensionOn(type) } + // Scopes follow import priority, so the first one that provides an applicable operator + // decides; inside it, the most specific receiver wins (`Int.getValue` over + // `Number.getValue`), exactly as overload resolution does. + containingKtFile.scopeContext(this).scopes.forEach { scopeWithKind -> + val candidates = scopeWithKind.scope.callables(identifier) + .filter { symbol -> symbol.isExtension && symbol.isDelegateOperatorShape(operatorName) } + .toList() + selectExtension(candidates, type)?.let { selected -> return@runCatching selected } + } + null }.getOrNull() /** @@ -1436,14 +1506,19 @@ class KaChangePackageComputer( originalPackage: FqName, movedPaths: Set?, ): KaCallableSymbol? { - return findPackage(originalPackage)?.packageScope?.declarations + val candidates = findPackage(originalPackage)?.packageScope?.declarations ?.filterIsInstance() - ?.firstOrNull { symbol -> + ?.filter { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == operatorName && symbol.isDelegateOperatorShape(operatorName) && symbol.canBeCalledAsExtensionOn(type) && (movedPaths == null || samePackageTopLevelName(symbol, originalPackage, movedPaths) != null) } + .orEmpty() + .toList() + // A package can declare several overloads of the operator; only the most specific + // applicable one is selected for this receiver. + return selectExtension(candidates, type) } /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 2e69e1f46..6ad208452 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -26,6 +26,7 @@ import org.jetbrains.kotlin.analysis.api.components.expressionType import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols +import org.jetbrains.kotlin.analysis.api.components.scopeContext import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol @@ -38,7 +39,11 @@ import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType import org.jetbrains.kotlin.idea.references.KtReference +import org.jetbrains.kotlin.lexer.KtTokens +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression +import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtUnaryExpression /** * Returns this callable's return type with its type arguments inferred from [receiverType]. @@ -244,6 +249,55 @@ private fun KaCallableSymbol.receiverIsMoreSpecificThan(other: KaCallableSymbol) receiver.isSubtypeOf(otherReceiver) && !otherReceiver.isSubtypeOf(receiver) }.getOrDefault(false) +/** + * Tells whether this indexed access is updated implicitly, not merely assigned or read. + * + * A plain assignment (`box[0] = 1`) resolves its `set` through the array-access reference, but + * an increment or compound assignment (`box[0]++`, `box[0] += 1`) lowers to `get` then `set` + * and exposes only `get`. + * + * @return `true` when the compiler resolves a setter for this access implicitly + */ +internal fun KtArrayAccessExpression.isCompoundWrite(): Boolean { + val parent = parent + return when (parent) { + is KtBinaryExpression -> parent.left == this && + parent.operationToken in KtTokens.ALL_ASSIGNMENTS && parent.operationToken != KtTokens.EQ + is KtUnaryExpression -> parent.baseExpression == this && + parent.operationToken in setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) + else -> false + } +} + +/** + * Returns the `set` extension a compound indexed write implicitly selects. + * + * The access exposes only `get`, so the setter is looked up through the file's real scope. Scopes + * are visited in priority order and the most specific applicable `set` wins inside each of them, + * mirroring overload resolution. A member setter always wins over extensions and cannot be + * captured by an import. + * + * @return the selected `set` extension, `null` for a plain read or a member setter + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun KtArrayAccessExpression.selectedIndexedSetter(): KaCallableSymbol? { + if (!isCompoundWrite()) return null + val receiverType = runCatching { arrayExpression?.expressionType }.getOrNull() ?: return null + val arguments = setterArgumentTypes() + // A member setter wins only when its signature accepts the actual indices and value; a + // misfitting overload leaves the write to an applicable extension. + if (receiverType.hasApplicableMemberIndexedSetter(arguments)) return null + val identifier = Name.identifier("set") + containingKtFile.scopeContext(this).scopes.forEach { scopeWithKind -> + val candidates = scopeWithKind.scope.callables(identifier) + .filter { symbol -> symbol.isExtension } + .toList() + selectExtension(candidates, receiverType, arguments)?.let { return it } + } + return null +} + /** * Tells whether this type declares a member `set` that accepts the indexed-write arguments. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt index 091296120..5ee3d34c8 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -33,6 +33,7 @@ import org.jetbrains.kotlin.analysis.api.types.KaIntersectionType import org.jetbrains.kotlin.analysis.api.types.KaSubstitutor import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType +import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.psi.KtClass import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtParameter @@ -58,7 +59,8 @@ object KotlinConstructorSignatureCheck { * implicit no-argument primary constructor. An extension function has an implicit receiver and * therefore cannot conflict with a constructor. A parameter whose type cannot be resolved makes * the comparison inconclusive, which is reported as a conflict so the rename is rejected - * instead of producing uncompilable code. + * instead of producing uncompilable code. The vararg status of each parameter is part of the + * signature, because Kotlin treats `(vararg Int)` and `(Int)` as distinct overloads. * * @param primaryClass class whose constructors the rename would introduce * @param function same-package function that would share the class's new name @@ -72,6 +74,14 @@ object KotlinConstructorSignatureCheck { return analyze(function) { val substitutor = typeParameterSubstitutor(primaryClass, function) matching.any { parameters -> + // A vararg and a fixed parameter of the same type are distinct overloads in + // Kotlin, so a constructor cannot collide with a function whose vararg status + // differs at that position. + val varargsMatch = parameters.indices.all { index -> + parameters[index].hasModifier(KtTokens.VARARG_KEYWORD) == + function.valueParameters[index].hasModifier(KtTokens.VARARG_KEYWORD) + } + if (!varargsMatch) return@any false val typesMatch = parameters.indices.all { index -> val constructorType = runCatching { parameters[index].typeReference?.type }.getOrNull() val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 4fccd38a5..b02a7494e 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -23,7 +23,6 @@ import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.analyze import org.jetbrains.kotlin.analysis.api.compat.getClassOrObjectSymbol import org.jetbrains.kotlin.analysis.api.components.KaScopeImplicitReceiverValue -import org.jetbrains.kotlin.analysis.api.components.canBeCalledAsExtensionOn import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.expressionType import org.jetbrains.kotlin.analysis.api.components.memberScope @@ -164,7 +163,7 @@ object KotlinImportBindingCheck { // A compound indexed write spells only the getter; its implicit setter goes // through the same check because an import of `set` outranks the wildcard. if (!conflicting && name == "set") { - val setter = runCatching { expression.implicitSetterCandidates() }.getOrNull() + val setter = runCatching { expression.selectedIndexedSetter() }.getOrNull() if (setter != null && !setter.belongsTo(excludedDeclarations) && setter.isTopLevelBinding() @@ -292,7 +291,7 @@ object KotlinImportBindingCheck { // The setter half of a compound write is implicit; it loses the overload // the rewritten directive stops importing just like the getter does. if (!conflicting && importedName == "set") { - val setter = runCatching { expression.implicitSetterCandidates() }.getOrNull() + val setter = runCatching { expression.selectedIndexedSetter() }.getOrNull() if (setter != null && setter.isTopLevelBinding() && setter.staysInOriginalPackage(imported, movingPaths) @@ -407,7 +406,7 @@ object KotlinImportBindingCheck { // The implicit setter of a compound write keeps binding through the // wildcard while the file moves; the added import would outrank it. if (!conflicting && importedName == "set") { - val setter = runCatching { expression.implicitSetterCandidates() }.getOrNull() + val setter = runCatching { expression.selectedIndexedSetter() }.getOrNull() if (setter != null && setter.isTopLevelBinding() && setter.isCapturedByOutgoingImport(imported, movingPaths) @@ -1140,7 +1139,7 @@ object KotlinImportBindingCheck { return if (delegateType.memberDelegateOperator(name) != null) { emptyList() } else { - applicableOperators(delegateType, name) + listOfNotNull(selectedOperator(delegateType, name)) } } val accessorNames = if (isVar) listOf("getValue", "setValue") else listOf("getValue") @@ -1150,62 +1149,23 @@ object KotlinImportBindingCheck { ?.let { symbol -> runCatching { symbol.returnTypeInferredFrom(delegateType) }.getOrNull() } ?.let { providedType -> listOf(providedType) } ?: listOf(delegateType) - return accessorTypes.flatMap { accessorType -> + return accessorTypes.mapNotNull { accessorType -> if (accessorType.memberDelegateOperator(name) != null) { - emptyList() + null } else { - applicableOperators(accessorType, name) + selectedOperator(accessorType, name) } } } - /** - * Returns the top-level `set` extension a compound indexed write implicitly selects. - * - * `sub[0]++` and `sub[0] += 1` spell only the getter, so K2 does not expose the setter half. - * The selected `set` extension is looked up by name, receiver applicability and the actual - * indices and value, mirroring the delegate accessor lookup. A member setter always wins over - * extensions and cannot be captured by an import. - * - * @return the selected `set` extension, `null` for a plain read or a member setter - */ - context(_: KaSession) - private fun KtArrayAccessExpression.implicitSetterCandidates(): KaCallableSymbol? { - if (!isCompoundWrite()) return null - val receiverType = runCatching { arrayExpression?.expressionType }.getOrNull() ?: return null - val arguments = setterArgumentTypes() - // A member setter wins only when its signature accepts the actual indices and value; a - // misfitting overload leaves the write to an applicable extension. - if (receiverType.hasApplicableMemberIndexedSetter(arguments)) return null - // The caller may see a less and a more specific setter at once; only the overload - // resolution winner can be rebound by an import. - return selectExtension(applicableOperators(receiverType, "set"), receiverType, arguments) - } - - /** - * Tells whether this indexed access also writes its element. - * - * K2 exposes only the `get` convention of an indexed access; a compound assignment or - * increment is what makes the compiler select a `set` as well. - * - * @return `true` when the access is assigned through rather than merely read - */ - private fun KtArrayAccessExpression.isCompoundWrite(): Boolean { - val parent = parent - return when (parent) { - is KtBinaryExpression -> parent.left == this && - parent.operationToken in KtTokens.ALL_ASSIGNMENTS && parent.operationToken != KtTokens.EQ - is KtUnaryExpression -> parent.baseExpression == this && - parent.operationToken in setOf(KtTokens.PLUSPLUS, KtTokens.MINUSMINUS) - else -> false - } - } - /** * Finds the operator of [name] the compiler would select on [type]. * - * A member declaration always outranks an extension, so it is returned first; otherwise the - * first applicable extension visible at this property is used. + * A member declaration always outranks an extension, so it wins; otherwise only the extension + * overload resolution selects counts. Reporting every applicable extension would make an + * import look conflicting even when the original selection is the very declaration it binds: + * with `Int.getValue` and `Number.getValue` visible, `val value by 1` selects the more specific + * `Int` one, so the staying `Number` overload cannot be rebound by that import. * * @param type receiver type the operator is selected on * @param name operator name @@ -1213,7 +1173,7 @@ object KotlinImportBindingCheck { */ context(_: KaSession) private fun KtProperty.delegateOperator(type: KaType, name: String): KaCallableSymbol? = - type.memberDelegateOperator(name) ?: applicableOperators(type, name).firstOrNull() + type.memberDelegateOperator(name) ?: selectedOperator(type, name) /** * Returns the member delegate operator of [name] on this type, or `null` when it has none. @@ -1234,27 +1194,30 @@ object KotlinImportBindingCheck { }.getOrNull() /** - * Returns the top-level extension operators of [name] applicable to [type] at this element. + * Returns the extension operator of [name] that overload resolution selects on [type]. * * Applicability goes through the analysis API's extension check rather than a raw subtype * test: a declared receiver may be generic (`Box.getValue`), and only constraint inference - * can tell that `Box` satisfies it. + * can tell that `Box` satisfies it. Scopes are visited in priority order, so the first + * one declaring an applicable overload decides; inside a scope the most specific receiver + * wins, exactly as the compiler resolves it. * * @param type receiver type the operator is selected on * @param name operator name - * @return applicable extensions visible in the element's scope + * @return the selected extension, or `null` when none is visible at this element */ @OptIn(KaExperimentalApi::class) context(_: KaSession) - private fun KtElement.applicableOperators(type: KaType, name: String): List = runCatching { + private fun KtElement.selectedOperator(type: KaType, name: String): KaCallableSymbol? = runCatching { val identifier = Name.identifier(name) - containingKtFile.scopeContext(this).scopes.asSequence() - .flatMap { scopeWithKind -> scopeWithKind.scope.callables(identifier) } - .filter { symbol -> symbol.isExtension } - .filter { symbol -> symbol.canBeCalledAsExtensionOn(type) } - .distinct() - .toList() - }.getOrElse { emptyList() } + containingKtFile.scopeContext(this).scopes.forEach { scopeWithKind -> + val candidates = scopeWithKind.scope.callables(identifier) + .filter { symbol -> symbol.isExtension } + .toList() + selectExtension(candidates, type)?.let { selected -> return@runCatching selected } + } + null + }.getOrNull() /** * Returns the innermost class or object containing this element, or `null` at file level. diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 60d087002..6612a2adb 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index ad5db7057..10ab25faa 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5104,6 +5104,116 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a wildcard-bound convention operator is preserved against the destination package. + * + * The moved file destructures `1` through `lib.component1`. After it joins `dest`, which + * declares its own `Number.component1`, that same-package operator outranks the wildcard, so + * the declaration needs an explicit `lib.component1` import even though no name is spelled. + */ + fun testApply_realSession_preservesWildcardBoundConventionAgainstDestinationDeclaration() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-convention-shadow") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("dest")) + Files.createDirectories(temporaryRoot.resolve("source")) + val sourcePath = temporaryRoot.resolve("source/Usage.kt") + Files.writeString( + temporaryRoot.resolve("lib/Lib.kt"), + "package lib\n\noperator fun Int.component1(): Int = 1\n", + ) + Files.writeString( + temporaryRoot.resolve("dest/Dest.kt"), + "package dest\n\noperator fun Number.component1(): Int = 2\n", + ) + Files.writeString( + sourcePath, + "package source\n\nimport lib.*\n\nfun read(): Int {\n val (value) = 1\n return value\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-convention-shadow-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must keep binding lib.component1 explicitly: $text", + text.contains("import lib.component1"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies the moved usage discovers the most specific delegate accessor overload. + * + * `Number.getValue` stays in the old package while the more specific `Int.getValue` moves. + * Selecting the first applicable accessor by receiver alone records the staying overload, so + * the caller silently switches to it; only the overload resolution winner keeps the import. + */ + fun testApply_realSession_importsMostSpecificMovedDelegateAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-specific-accessor") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Stays.kt"), + "package old\n\noperator fun Number.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Int.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value by 1\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-specific-accessor-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved accessor selected for Int: $text", + text.contains("import dest.getValue"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt index c6529724b..5713a7914 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -301,6 +301,66 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig } } + /** + * Verifies a vararg constructor does not collide with a scalar function. + * + * `class Foo(vararg val values: Int)` and `fun After(value: Int)` are distinct Kotlin + * overloads, so the rename must not be rejected for them. + */ + fun testConflictsWithRenamedConstructor_ignoresVarargMismatch() { + withSession( + name = "vararg-mismatch", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString( + root.resolve("source/Types.kt"), + "package p\n\nclass Foo(vararg val values: Int)\n", + ) + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After(value: Int) = \"factory\"\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "a vararg constructor and a scalar function are distinct overloads", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** Verifies two vararg signatures of the same element type still collide. */ + fun testConflictsWithRenamedConstructor_reportsMatchingVararg() { + withSession( + name = "vararg-match", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString( + root.resolve("source/Types.kt"), + "package p\n\nclass Foo(vararg val values: Int)\n", + ) + Files.writeString( + root.resolve("source/Functions.kt"), + "package p\n\nfun After(vararg value: Int) = \"factory\"\n", + ) + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertTrue( + "two vararg signatures of the same element type collide", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + /** @return the single top-level class of [file]. */ private fun primaryClass(file: KtFile): KtClass = file.declarations.filterIsInstance().single() diff --git a/pom.xml b/pom.xml index bf6971eb6..7032c7ed6 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.52-SNAPSHOT + 0.12.53-SNAPSHOT pom Netbeans Plugin Kotlin From edd18e3ba968557557f3fe3568d912caa7d1759c Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Tue, 3 Nov 2026 23:32:32 +0300 Subject: [PATCH 44/55] Fixed setter parameter ranking and delegate accessor discovery Overloads declared on the same receiver are now ranked by value-parameter specificity, so `Box.set(Int, Int)` wins over the applicable `Box.set(Int, Number)` instead of whichever the scope enumerates first. A moved file resolves its delegate accessors through the property's own scope before the same-package fallback, so a wildcard-imported accessor that the destination package would shadow keeps an explicit import. A member `provideDelegate` has no extension receiver, so its declared return type spelled the class's type parameters. The receiver class's type arguments are now substituted into it, which lets the accessors be selected on `Provided` rather than an uninstantiated `Provided`. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 20 ++- .../refactoring/KaOperatorApplicability.kt | 102 ++++++++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 165 ++++++++++++++++++ pom.xml | 2 +- 15 files changed, 287 insertions(+), 24 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 4be43fd66..b36d15c3a 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 02bc0c41a..18652be0f 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index ed18de391..bb3528f3d 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index d8d8b6645..bd7e97fc0 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index d0978413b..fad69414e 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index dbe40ec7c..642736ef0 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 22a44c1e0..fcf73c709 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 44ffb4cbf..ac09eb559 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 648072ded..e3a78510c 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 8995855a5..71567d14c 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 73f4cd631..cb606d7b3 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -1153,14 +1153,14 @@ class KaChangePackageComputer( val accessorType = provider?.returnTypeInferredFrom(delegateType) ?: delegateType val operators = buildList { if (provider != null) add(provider) - samePackageDelegateAccessor( + property.selectedDelegateAccessor( accessorType, "getValue", originalPackage, movedPaths, )?.let { symbol -> add(symbol) } if (property.isVar) { - samePackageDelegateAccessor( + property.selectedDelegateAccessor( accessorType, "setValue", originalPackage, @@ -1432,26 +1432,30 @@ class KaChangePackageComputer( }.getOrNull() /** - * Finds the delegate accessor a moved file still needs from the original package. + * Finds the delegate accessor the property currently selects. * - * A member accessor wins over every extension, so the same-package lookup only applies when the - * receiver type declares no member of that name. + * A member accessor wins over every extension and needs no import, so the lookup only applies + * when the receiver type declares no member of that name. Otherwise the property's own scope + * decides, exactly as the compiler resolves the implicit call: a wildcard-imported accessor is + * found as well as a same-package one. The original package stays a fallback for scopes that + * do not expose its declarations. * * @param accessorType type the accessor is selected on * @param operatorName `getValue` or `setValue` * @param originalPackage package the moved file declares before the change * @param movedPaths paths of the files whose package changes - * @return the remaining accessor extension, or `null` when none applies + * @return the selected accessor extension, or `null` when none applies */ context(_: KaSession) - private fun samePackageDelegateAccessor( + private fun KtProperty.selectedDelegateAccessor( accessorType: KaType, operatorName: String, originalPackage: FqName, movedPaths: Set, ): KaCallableSymbol? { if (accessorType.memberDelegateOperator(operatorName) != null) return null - return samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths) + return scopeDelegateOperator(accessorType, operatorName) + ?: samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths) } /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 6ad208452..83379d645 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -32,6 +32,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.symbols.symbol import org.jetbrains.kotlin.analysis.api.types.KaCapturedType import org.jetbrains.kotlin.analysis.api.types.KaClassType import org.jetbrains.kotlin.analysis.api.types.KaDefinitelyNotNullType @@ -43,6 +44,7 @@ import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtBinaryExpression +import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtUnaryExpression /** @@ -55,7 +57,10 @@ import org.jetbrains.kotlin.psi.KtUnaryExpression * be used directly. Unifying the declared receiver with the actual one yields the mapping. * * A callable without a receiver keeps its declared return type; unifyable structures also fall - * back to it, because guessing an instantiation would select the wrong operator. + * back to it, because guessing an instantiation would select the wrong operator. A member + * provider has no extension receiver, so its type arguments come from the instantiated scope of + * the receiver type: `Delegate.provideDelegate` selected on `Delegate` returns + * `Provided`. * * @param receiverType actual type the callable is invoked on * @return the return type with the inferred type arguments substituted @@ -63,7 +68,8 @@ import org.jetbrains.kotlin.psi.KtUnaryExpression @OptIn(KaExperimentalApi::class) context(_: KaSession) internal fun KaCallableSymbol.returnTypeInferredFrom(receiverType: KaType): KaType { - val declaredReceiverType = receiverParameter?.returnType ?: return returnType + val declaredReceiverType = receiverParameter?.returnType + ?: return memberReturnTypeInferredFrom(receiverType) return runCatching { val mappings = linkedMapOf() if (!collectTypeMappings(declaredReceiverType, receiverType, mappings)) return@runCatching returnType @@ -72,6 +78,35 @@ internal fun KaCallableSymbol.returnTypeInferredFrom(receiverType: KaType): KaTy }.getOrDefault(returnType) } +/** + * Returns this member's return type with the receiver class's arguments substituted. + * + * A member symbol spells its class type parameters (`Provided`), while the receiver type is + * the instantiated class (`Delegate`). Mapping the class declaration's parameters to the + * receiver's arguments gives the instantiation the accessors are selected on. A receiver that is + * not the declaring class (an inherited member) keeps the declared return type. + * + * @param receiverType instantiated type the member is invoked on + * @return the return type with the class's type arguments substituted + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.memberReturnTypeInferredFrom(receiverType: KaType): KaType = runCatching { + val classType = receiverType as? KaClassType ?: return@runCatching returnType + val classParameters = (classType.expandedSymbol?.psi as? KtClassOrObject)?.typeParameters + ?: return@runCatching returnType + val typeParameters = classParameters.mapNotNull { parameter -> + runCatching { parameter.symbol }.getOrNull() + } + val arguments = classType.typeArguments + if (typeParameters.size != arguments.size) return@runCatching returnType + val mappings = typeParameters.mapIndexedNotNull { index, parameter -> + arguments[index].type?.let { argument -> parameter to argument } + }.toMap() + if (mappings.isEmpty()) return@runCatching returnType + createSubstitutor(mappings).substitute(returnType) +}.getOrDefault(returnType) + /** * Collects the type-parameter instantiations that make [actual] match [declared]. * @@ -209,7 +244,8 @@ private fun acceptsArgument(parameterType: KaType, argument: KaType): Boolean = * Every candidate is visible through the same package scopes, so overload resolution ranks them * by receiver specificity: `SubBox.set` wins over `Box.set` for a `SubBox` receiver. Candidates * that cannot take [arguments] are dropped first, which separates overloads declared on the same - * receiver type. + * receiver type; among those, the more specific parameter tuple wins, because `Int` fits a + * `Number` parameter as well. * * @param candidates extensions visible under the same simple name * @param receiverType type of the value the operator is invoked on @@ -228,10 +264,68 @@ internal fun selectExtension( (arguments == null || candidate.acceptsIndexedSetterArguments(arguments)) } return applicable.firstOrNull { candidate -> - applicable.none { other -> other !== candidate && other.receiverIsMoreSpecificThan(candidate) } + applicable.none { other -> other !== candidate && other.isMoreSpecificThan(candidate) } } ?: applicable.firstOrNull() } +/** + * Tells whether this callable's overload is preferred over [other] by the compiler. + * + * A more specific receiver wins first. When both are declared on the same receiver type, the + * preference falls back to the value parameters: `Box.set(Int, Int)` is more specific than + * `Box.set(Int, Number)` for a write whose value is an `Int`. + * + * @param other competing callable + * @return `true` when this callable is a strictly better overload + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.isMoreSpecificThan(other: KaCallableSymbol): Boolean = + receiverIsMoreSpecificThan(other) || + (hasSameReceiverAs(other) && parametersAreMoreSpecificThan(other)) + +/** + * Tells whether both callables declare the same receiver type. + * + * @param other competing callable + * @return `true` when the declared receivers are equivalent + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.hasSameReceiverAs(other: KaCallableSymbol): Boolean = runCatching { + val receiver = receiverParameter?.returnType ?: return@runCatching false + val otherReceiver = other.receiverParameter?.returnType ?: return@runCatching false + receiver.semanticallyEquals(otherReceiver) +}.getOrDefault(false) + +/** + * Tells whether this callable's value parameters are strictly more specific than [other]'s. + * + * Every parameter must accept the other's type, and at least one must be a proper subtype: + * `(Int, Int)` is more specific than `(Int, Number)`. A type parameter or a differing arity + * leaves the comparison undecided, because the instantiation is what would decide it. + * + * @param other competing callable + * @return `true` when this callable takes a strictly narrower parameter tuple + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.parametersAreMoreSpecificThan(other: KaCallableSymbol): Boolean = runCatching { + val mine = (this as? KaFunctionSymbol)?.valueParameters ?: return@runCatching false + val theirs = (other as? KaFunctionSymbol)?.valueParameters ?: return@runCatching false + if (mine.size != theirs.size || mine.isEmpty()) return@runCatching false + var strict = false + val fits = mine.indices.all { index -> + val mineType = mine[index].returnType + val theirType = theirs[index].returnType + if (mineType is KaTypeParameterType || theirType is KaTypeParameterType) return@all false + if (!mineType.isSubtypeOf(theirType)) return@all false + if (!theirType.isSubtypeOf(mineType)) strict = true + true + } + fits && strict +}.getOrDefault(false) + /** * Tells whether this callable's receiver is a proper subtype of [other]'s. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 6612a2adb..4a7b13d1b 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 10ab25faa..0ae5a7957 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5214,6 +5214,171 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies setter overloads declared on the same receiver are ranked by parameter specificity. + * + * `Box.set(Int, Number)` stays while the more specific `Box.set(Int, Int)` moves. Argument + * applicability alone cannot tell them apart for `box[0]++`, because `Int` fits both, so the + * enumeration order would decide; the compiler selects the `Int` overload, and only importing + * it keeps the write bound to the same declaration. + */ + fun testApply_realSession_importsSetterWithMoreSpecificParameters() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-specific-parameters") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box {\n operator fun get(i: Int): Int = 0\n}\n\n" + + "operator fun Box.set(i: Int, v: Number) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(i: Int, v: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n box[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-specific-parameters-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the setter taking Int: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a wildcard-bound delegate accessor is preserved against the destination package. + * + * The moved file imports `lib.*`, so `val value by 1` binds `lib.getValue`. Joining `dest`, + * which declares its own `Number.getValue`, makes that same-package accessor outrank the + * wildcard; an explicit `lib.getValue` import is needed to keep the property's meaning. + */ + fun testApply_realSession_preservesWildcardBoundDelegateAccessorAgainstDestinationDeclaration() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-delegate-shadow") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("dest")) + Files.createDirectories(temporaryRoot.resolve("source")) + val sourcePath = temporaryRoot.resolve("source/Usage.kt") + Files.writeString( + temporaryRoot.resolve("lib/Lib.kt"), + "package lib\n\noperator fun Int.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + temporaryRoot.resolve("dest/Dest.kt"), + "package dest\n\noperator fun Number.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + Files.writeString( + sourcePath, + "package source\n\nimport lib.*\n\nval value by 1\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-delegate-shadow-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must keep binding lib.getValue explicitly: $text", + text.contains("import lib.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a member provider's class type arguments reach the accessor selection. + * + * `Delegate.provideDelegate` returns `Provided`, so `Delegate(7)` instantiates the + * accessor selection on `Provided`. A member has no extension receiver, so the declared + * `Provided` has to be unified with the dispatch receiver's `Delegate` instead. + */ + fun testApply_realSession_infersAccessorFromMemberProviderTypeArguments() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-member-provider") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Delegate.kt"), + "package old\n\nclass Delegate(val value: T) {\n" + + " operator fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n" + + "}\n\nclass Provided\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\nval value by Delegate(7)\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-member-provider-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the accessor selected on Provided: $text", + text.contains("import old.getValue"), + ) + } 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")) diff --git a/pom.xml b/pom.xml index 7032c7ed6..abe18bd10 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.53-SNAPSHOT + 0.12.54-SNAPSHOT pom Netbeans Plugin Kotlin From 875e99a6eb11e4882eb9f5833e73afbcb53b2556 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 09:37:57 +0300 Subject: [PATCH 45/55] Fixed aliased rename conflicts, operator bindings and delegate thisRef filtering - Rename preflight now treats references spelled with an alias of the destination name (`import p.After as make`) as references the renamed constructor can rebind. - Package change resolves operation references (`+`) to their callable name, so a wildcard-imported operator shadowed by a destination declaration keeps an explicit import. - Delegate accessor and provider overloads are filtered by the actual `thisRef` argument (null for top-level properties, enclosing instance or extension receiver otherwise), in both package change and import-binding checks. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 52 ++++++-- .../refactoring/KaOperatorApplicability.kt | 40 ++++++ .../refactoring/KotlinImportBindingCheck.kt | 54 +++++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 114 ++++++++++++++++++ .../KotlinRenameClassifierConflictTest.kt | 37 ++++++ pom.xml | 2 +- 17 files changed, 288 insertions(+), 33 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index b36d15c3a..54fbe3d37 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 18652be0f..06d3a5ef1 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index bb3528f3d..0cf5cb45b 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index bd7e97fc0..9fbc81abc 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index fad69414e..b709b07e3 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 642736ef0..4cda88dce 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index fcf73c709..66e557bd7 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index ac09eb559..601e12017 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index e3a78510c..4d3157ff5 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 71567d14c..9cf6a3460 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index cb606d7b3..e8f5651b0 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -62,6 +62,7 @@ import org.jetbrains.kotlin.psi.KtForExpression import org.jetbrains.kotlin.psi.KtImportDirective import org.jetbrains.kotlin.psi.KtNamedDeclaration import org.jetbrains.kotlin.psi.KtNamedFunction +import org.jetbrains.kotlin.psi.KtOperationReferenceExpression import org.jetbrains.kotlin.psi.KtParameter import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtSimpleNameExpression @@ -556,7 +557,8 @@ class KaChangePackageComputer( if (declaration in moved) operators += declaration } fun selectedOperator(type: KaType, name: String): KaCallableSymbol? = - type.memberDelegateOperator(name) ?: scopeDelegateOperator(type, name) + type.memberDelegateOperator(name) + ?: scopeDelegateOperator(type, name, delegationArgumentTypes(name)) val provider = selectedOperator(delegateType, "provideDelegate") record(provider) // The provider's return type is declared in terms of its own type parameters; only the @@ -1139,12 +1141,17 @@ class KaChangePackageComputer( // survives the move unchanged and is found through the property's // scope, not through the old package. val provider = delegateType.memberDelegateOperator("provideDelegate") - ?: property.scopeDelegateOperator(delegateType, "provideDelegate") + ?: property.scopeDelegateOperator( + delegateType, + "provideDelegate", + property.delegationArgumentTypes("provideDelegate"), + ) ?: samePackageDelegateOperator( delegateType, "provideDelegate", originalPackage, movedPaths = null, + arguments = property.delegationArgumentTypes("provideDelegate"), ) // `provideDelegate` runs before the accessors and its return type is the // type the accessors are selected on, so it replaces the expression @@ -1326,7 +1333,9 @@ class KaChangePackageComputer( if (expression.qualifiedReceiver() != null && (symbol as? KaCallableSymbol)?.isExtension != true) { return null } - val name = expression.getReferencedName() + // An operation reference spells the operator token (`+`, `in`) rather than the function it + // selects, so only the resolved callable's name can be compared with the destination names. + val name = expression.boundDeclarationName(symbol) if (name !in destinationNames) return null // A type alias is spelled by its own name; a declaration of another package that only // expands the alias cannot be imported under the spelled name. @@ -1335,6 +1344,23 @@ class KaChangePackageComputer( return shadowedDependency(symbol, destination, destinationNames, importedFqNames, movedPaths) } + /** + * Returns the simple name under which this reference binds [symbol]. + * + * A regular reference spells the declaration's name. An operation reference (`+`, `==`, `in`) + * spells only the operator token, so the resolved callable's name is the declaration name the + * reference actually selects. + * + * @param symbol declaration the reference resolves to + * @return name used for destination-shadowing comparisons + */ + private fun KtSimpleNameExpression.boundDeclarationName(symbol: KaSymbol): String = + if (this is KtOperationReferenceExpression) { + (symbol as? KaNamedSymbol)?.name?.asString() ?: getReferencedName() + } else { + getReferencedName() + } + /** * Returns the import that keeps an implicitly selected declaration bound after the move. * @@ -1454,8 +1480,9 @@ class KaChangePackageComputer( movedPaths: Set, ): KaCallableSymbol? { if (accessorType.memberDelegateOperator(operatorName) != null) return null - return scopeDelegateOperator(accessorType, operatorName) - ?: samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths) + val arguments = delegationArgumentTypes(operatorName) + return scopeDelegateOperator(accessorType, operatorName, arguments) + ?: samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths, arguments) } /** @@ -1468,11 +1495,16 @@ class KaChangePackageComputer( * * @param type type the operator is selected on * @param operatorName `provideDelegate`, `getValue`, or `setValue` + * @param arguments argument types of the implicit call, or `null` when they cannot be resolved * @return the selected extension, or `null` when none is in scope */ @OptIn(KaExperimentalApi::class) context(_: KaSession) - private fun KtElement.scopeDelegateOperator(type: KaType, operatorName: String): KaCallableSymbol? = runCatching { + private fun KtElement.scopeDelegateOperator( + type: KaType, + operatorName: String, + arguments: List? = null, + ): KaCallableSymbol? = runCatching { val identifier = Name.identifier(operatorName) // Scopes follow import priority, so the first one that provides an applicable operator // decides; inside it, the most specific receiver wins (`Int.getValue` over @@ -1481,7 +1513,7 @@ class KaChangePackageComputer( val candidates = scopeWithKind.scope.callables(identifier) .filter { symbol -> symbol.isExtension && symbol.isDelegateOperatorShape(operatorName) } .toList() - selectExtension(candidates, type)?.let { selected -> return@runCatching selected } + selectExtension(candidates, type, arguments)?.let { selected -> return@runCatching selected } } null }.getOrNull() @@ -1500,6 +1532,7 @@ class KaChangePackageComputer( * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @param originalPackage package the moved file declares before the change * @param movedPaths paths of the files whose package changes, or `null` to accept moving ones + * @param arguments argument types of the implicit call, or `null` when they cannot be resolved * @return the selected operator, or `null` when none applies */ @OptIn(KaExperimentalApi::class) @@ -1509,6 +1542,7 @@ class KaChangePackageComputer( operatorName: String, originalPackage: FqName, movedPaths: Set?, + arguments: List? = null, ): KaCallableSymbol? { val candidates = findPackage(originalPackage)?.packageScope?.declarations ?.filterIsInstance() @@ -1521,8 +1555,8 @@ class KaChangePackageComputer( .orEmpty() .toList() // A package can declare several overloads of the operator; only the most specific - // applicable one is selected for this receiver. - return selectExtension(candidates, type) + // applicable one is selected for this receiver and these arguments. + return selectExtension(candidates, type, arguments) } /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 83379d645..a8b3de9d2 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -19,8 +19,10 @@ package io.github.nbplugins.kotlin.refactoring import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.components.allSupertypes +import org.jetbrains.kotlin.analysis.api.components.builtinTypes import org.jetbrains.kotlin.analysis.api.components.canBeCalledAsExtensionOn import org.jetbrains.kotlin.analysis.api.components.createSubstitutor +import org.jetbrains.kotlin.analysis.api.components.defaultType import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.expressionType import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf @@ -28,7 +30,9 @@ import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.components.resolveToSymbols import org.jetbrains.kotlin.analysis.api.components.scopeContext import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals +import org.jetbrains.kotlin.analysis.api.components.type import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol @@ -45,7 +49,9 @@ import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtBinaryExpression import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtUnaryExpression +import org.jetbrains.kotlin.psi.psiUtil.containingClassOrObject /** * Returns this callable's return type with its type arguments inferred from [receiverType]. @@ -201,6 +207,40 @@ internal fun KtArrayAccessExpression.setterArgumentTypes(): List? { return indices + runCatching { getter.returnType }.getOrNull() } +/** + * Returns the type of the implicit `thisRef` argument a delegated property passes. + * + * A member property passes its enclosing instance, an extension property passes its extension + * receiver, and a top-level or local property passes `null`. The compiler ignores accessor and + * provider overloads whose first parameter cannot take that argument, so selecting an operator on + * the receiver type alone can pick an inapplicable overload. + * + * @return static type of the implicit `thisRef` argument + */ +context(_: KaSession) +internal fun KtProperty.delegationThisRefType(): KaType = + receiverTypeReference?.type + ?: containingClassOrObject?.let { enclosing -> + runCatching { (enclosing.symbol as? KaClassSymbol)?.defaultType }.getOrNull() + } + ?: builtinTypes.nullableNothing + +/** + * Returns the argument types a delegated property passes to one of its operators. + * + * `provideDelegate` and `getValue` receive `thisRef` and the `KProperty` instance; `setValue` also + * receives the assigned value. The value's type is unknown here, so it is left unchecked, while + * the `thisRef` type decides which overloads the compiler can select at all. + * + * @param operatorName `provideDelegate`, `getValue`, or `setValue` + * @return argument types in call order; `null` entries are unresolved expressions + */ +context(_: KaSession) +internal fun KtProperty.delegationArgumentTypes(operatorName: String): List { + val thisRef = delegationThisRefType() + return if (operatorName == "setValue") listOf(thisRef, null, null) else listOf(thisRef, null) +} + /** * Tells whether this callable accepts the arguments of an indexed write. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index b02a7494e..159c04f23 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -482,7 +482,14 @@ object KotlinImportBindingCheck { override fun visitSimpleNameExpression(expression: KtSimpleNameExpression) { super.visitSimpleNameExpression(expression) if (conflicting) return - if (expression.getReferencedName() != name) return + val referencedName = expression.getReferencedName() + // An aliased import of the destination name binds the whole overload set under + // its alias, so a reference spelling only the alias is affected as well. + if (referencedName != name && + !file.bindsAliasOf(packageName.child(Name.identifier(name)), referencedName) + ) { + return + } if (PsiTreeUtil.getParentOfType(expression, KtImportDirective::class.java) != null) return val symbol = expression.references.filterIsInstance().firstOrNull() ?.resolveToSymbol() ?: return @@ -494,7 +501,7 @@ object KotlinImportBindingCheck { if (expression.isPackageQualifiedCall(packageName)) conflicting = true return } - if (!file.canSeeDeclarationOf(packageName, name)) return + if (!file.canSeeDeclarationOf(packageName, name, referencedName)) return // A reference that already binds a declaration of the package changes overload // resolution once the new declaration joins the package. A reference bound // through a star or default import is outranked by the package in the same @@ -512,21 +519,39 @@ object KotlinImportBindingCheck { }.getOrDefault(false) /** - * Tells whether this file can bind [name] to a declaration of [packageName]. + * Tells whether this file binds [name] to a declaration of [packageName]. * * The added declaration is visible through the file's own package, an explicit import of that * declaration, or a wildcard import of its package. Any other import binds another declaration * and keeps outranking the package. * + * An aliased import of the declaration binds its overload set under the alias, so a reference + * spelling the alias sees the declaration just as one spelling its own name does. + * * @param packageName package the declaration is added to * @param name simple name of the declaration + * @param referencedName name the reference actually spells * @return `true` when the file can see declarations of that package under that name */ - private fun KtFile.canSeeDeclarationOf(packageName: FqName, name: String): Boolean = - packageFqName == packageName || importDirectives.any { directive -> - if (directive.isAllUnder) return@any directive.importedFqName == packageName + private fun KtFile.canSeeDeclarationOf(packageName: FqName, name: String, referencedName: String): Boolean = + (packageFqName == packageName && referencedName == name) || importDirectives.any { directive -> + if (directive.isAllUnder) { + return@any directive.importedFqName == packageName && referencedName == name + } directive.importedFqName == packageName.child(Name.identifier(name)) && - (directive.aliasName == null || directive.aliasName == name) + (directive.aliasName ?: name) == referencedName + } + + /** + * Tells whether this file imports [fqName] under [alias]. + * + * @param fqName fully qualified name the directive imports + * @param alias name the directive binds the declaration to + * @return `true` when an aliased import binds that name + */ + private fun KtFile.bindsAliasOf(fqName: FqName, alias: String): Boolean = + importDirectives.any { directive -> + !directive.isAllUnder && directive.importedFqName == fqName && directive.aliasName == alias } /** @@ -1139,7 +1164,7 @@ object KotlinImportBindingCheck { return if (delegateType.memberDelegateOperator(name) != null) { emptyList() } else { - listOfNotNull(selectedOperator(delegateType, name)) + listOfNotNull(selectedOperator(delegateType, name, delegationArgumentTypes(name))) } } val accessorNames = if (isVar) listOf("getValue", "setValue") else listOf("getValue") @@ -1153,7 +1178,7 @@ object KotlinImportBindingCheck { if (accessorType.memberDelegateOperator(name) != null) { null } else { - selectedOperator(accessorType, name) + selectedOperator(accessorType, name, delegationArgumentTypes(name)) } } } @@ -1173,7 +1198,7 @@ object KotlinImportBindingCheck { */ context(_: KaSession) private fun KtProperty.delegateOperator(type: KaType, name: String): KaCallableSymbol? = - type.memberDelegateOperator(name) ?: selectedOperator(type, name) + type.memberDelegateOperator(name) ?: selectedOperator(type, name, delegationArgumentTypes(name)) /** * Returns the member delegate operator of [name] on this type, or `null` when it has none. @@ -1204,17 +1229,22 @@ object KotlinImportBindingCheck { * * @param type receiver type the operator is selected on * @param name operator name + * @param arguments argument types of the implicit call, or `null` when they cannot be resolved * @return the selected extension, or `null` when none is visible at this element */ @OptIn(KaExperimentalApi::class) context(_: KaSession) - private fun KtElement.selectedOperator(type: KaType, name: String): KaCallableSymbol? = runCatching { + private fun KtElement.selectedOperator( + type: KaType, + name: String, + arguments: List? = null, + ): KaCallableSymbol? = runCatching { val identifier = Name.identifier(name) containingKtFile.scopeContext(this).scopes.forEach { scopeWithKind -> val candidates = scopeWithKind.scope.callables(identifier) .filter { symbol -> symbol.isExtension } .toList() - selectExtension(candidates, type)?.let { selected -> return@runCatching selected } + selectExtension(candidates, type, arguments)?.let { selected -> return@runCatching selected } } null }.getOrNull() diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 4a7b13d1b..85a347aed 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 0ae5a7957..c6332769b 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -4997,6 +4997,61 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a wildcard-bound binary operator keeps its binding after the move. + * + * The moved file computes `Box() + Box()` through `import lib.*`. Operator references spell + * `+` rather than `plus`, so only the resolved callable's name reveals that the destination's + * `Any.plus(Box)` would outrank the wildcard import and silently take over the expression. The + * reference needs an explicit `lib.plus` import. + */ + fun testApply_realSession_preservesWildcardBoundOperatorAgainstDestinationDeclaration() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-operator-shadow") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("dest")) + Files.createDirectories(temporaryRoot.resolve("source")) + val sourcePath = temporaryRoot.resolve("source/Usage.kt") + Files.writeString( + temporaryRoot.resolve("lib/Lib.kt"), + "package lib\n\nclass Box\n\noperator fun Box.plus(other: Box): String = \"lib\"\n", + ) + Files.writeString( + temporaryRoot.resolve("dest/Dest.kt"), + "package dest\n\nimport lib.Box\n\noperator fun Any.plus(other: Box): String = \"dest\"\n", + ) + Files.writeString( + sourcePath, + "package source\n\nimport lib.*\n\nfun read() = Box() + Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-operator-shadow-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must keep binding lib.plus explicitly: $text", + text.contains("import lib.plus"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies the moved usage discovers the most specific setter overload. * @@ -5214,6 +5269,65 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies delegate accessor overloads are filtered by the actual `thisRef` argument. + * + * A top-level `val value by Box()` passes `null` as `thisRef`, so the staying + * `Box.getValue(Host, KProperty<*>)` overload is inapplicable while the moving + * `Box.getValue(Any?, KProperty<*>)` is the one the compiler selects. Ranking by receiver and + * parameter types alone picks the inapplicable `Host` overload, records no moved accessor, and + * lets the property silently fall back to `Any.getValue` after the move. + */ + fun testApply_realSession_filtersDelegateAccessorByThisRefArgument() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-delegate-thisref") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box\n\nclass Host\n\n" + + "operator fun Box.getValue(thisRef: Host, property: kotlin.reflect.KProperty<*>): Int = 1\n\n" + + "operator fun Any.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 3\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-delegate-thisref-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved accessor applicable to a null thisRef: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies setter overloads declared on the same receiver are ranked by parameter specificity. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt index 94d9a6e1f..eb7508d2c 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -365,6 +365,43 @@ class KotlinRenameClassifierConflictTest : KotlinTestCase("KotlinRenameClassifie } } + /** + * Verifies a caller that imports the new name under an alias blocks the rename. + * + * `import p.After as make` binds the whole overload set of `p.After` under `make`, so the + * caller's untouched `make(1)` selects the newly named `After(Int)` constructor instead of the + * `fun After(n: Number)` it currently calls. A reference spelling only the alias must therefore + * count as a reference to the destination name. + */ + fun testFindSamePackageOverloadConflict_reportsAliasedImportedNewName() { + withSession( + name = "external-aliased-import", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.createDirectories(root.resolve("usage")) + Files.writeString(root.resolve("source/Foo.kt"), "package p\n\nclass Foo(val n: Int)\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After(n: Number) = \"other\"\n") + Files.writeString( + root.resolve("usage/Usage.kt"), + "package q\n\nimport p.After as make\n\nfun use() = make(1).toString()\n", + ) + }, + ) { session, root -> + val source = session.ktFile(root.resolve("source/Foo.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + val conflict = findSamePackageOverloadConflict( + source, + primaryClass(source), + "After", + listOf(source, functions, usage), + ) + + assertEquals("Usage.kt", conflict?.virtualFile?.name) + } + } + /** * Verifies a package-qualified call of another package blocks the rename. * diff --git a/pom.xml b/pom.xml index abe18bd10..cd28c35b8 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.54-SNAPSHOT + 0.12.55-SNAPSHOT pom Netbeans Plugin Kotlin From eadb29e6daa8d3c7e8ff31800631af4eb11b36d6 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 10:09:18 +0300 Subject: [PATCH 46/55] Fixed receiver classification and delegate operator applicability The import-safety check classified a qualified receiver by its leftmost name, so `Holder.box.choose()` looked like a type qualifier and a conflicting import was missed. The whole receiver expression is now classified: only a bare class or package name in qualifier position is immune to an import. Member delegate operators were accepted by shape alone. A member now has to accept the implicit call's arguments, so a `getValue(Host, KProperty<*>)` member no longer hides the extension accessor a null-`thisRef` property selects. `provideDelegate` receives the dispatch receiver only, never an extension receiver, so the provider's `thisRef` is computed separately from the accessors'. Kotlin also allows `vararg` in any parameter position, which the indexed-setter applicability check now handles. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 36 +++- .../refactoring/KaOperatorApplicability.kt | 84 +++++++-- .../refactoring/KotlinImportBindingCheck.kt | 69 +++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 176 ++++++++++++++++++ .../KotlinImportBindingCheckTest.kt | 41 ++++ pom.xml | 2 +- 17 files changed, 370 insertions(+), 60 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 54fbe3d37..b2ed6d9e1 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 06d3a5ef1..dafa5e068 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 0cf5cb45b..873b36403 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 9fbc81abc..c69d29419 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index b709b07e3..9f1ca5886 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 4cda88dce..24fca8287 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 66e557bd7..39ef14500 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 601e12017..d3f9814ae 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 4d3157ff5..05786b603 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 9cf6a3460..d5ee76870 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index e8f5651b0..466e65947 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -556,9 +556,11 @@ class KaChangePackageComputer( val declaration = symbol?.psi as? KtNamedDeclaration ?: return if (declaration in moved) operators += declaration } - fun selectedOperator(type: KaType, name: String): KaCallableSymbol? = - type.memberDelegateOperator(name) - ?: scopeDelegateOperator(type, name, delegationArgumentTypes(name)) + fun selectedOperator(type: KaType, name: String): KaCallableSymbol? { + val arguments = delegationArgumentTypes(name) + return type.memberDelegateOperator(name, arguments) + ?: scopeDelegateOperator(type, name, arguments) + } val provider = selectedOperator(delegateType, "provideDelegate") record(provider) // The provider's return type is declared in terms of its own type parameters; only the @@ -1140,18 +1142,23 @@ class KaChangePackageComputer( // import is skipped below. A provider imported from another package // survives the move unchanged and is found through the property's // scope, not through the old package. - val provider = delegateType.memberDelegateOperator("provideDelegate") + val providerArguments = + property.delegationArgumentTypes("provideDelegate") + val provider = delegateType.memberDelegateOperator( + "provideDelegate", + providerArguments, + ) ?: property.scopeDelegateOperator( delegateType, "provideDelegate", - property.delegationArgumentTypes("provideDelegate"), + providerArguments, ) ?: samePackageDelegateOperator( delegateType, "provideDelegate", originalPackage, movedPaths = null, - arguments = property.delegationArgumentTypes("provideDelegate"), + arguments = providerArguments, ) // `provideDelegate` runs before the accessors and its return type is the // type the accessors are selected on, so it replaces the expression @@ -1443,17 +1450,26 @@ class KaChangePackageComputer( * * A member always wins over an extension, so the compiler never falls back to a same-package * operator when the type declares a fitting one. Matching by name alone would treat an - * ordinary member whose signature does not fit the delegation protocol as an accessor. + * ordinary member whose signature does not fit the delegation protocol as an accessor, and a + * member that matches the shape but not the actual arguments does not win either: the + * compiler skips a `getValue(Host, KProperty<*>)` member for a property passing a null + * `thisRef` and uses the extension accessor instead. * * @param name operator name + * @param arguments argument types of the implicit call * @return the applicable member operator, or `null` when the type declares none */ context(_: KaSession) - private fun KaType.memberDelegateOperator(name: String): KaCallableSymbol? = runCatching { + private fun KaType.memberDelegateOperator( + name: String, + arguments: List, + ): KaCallableSymbol? = runCatching { expandedSymbol?.memberScope?.declarations ?.filterIsInstance() ?.firstOrNull { symbol -> - (symbol as? KaNamedSymbol)?.name?.asString() == name && symbol.isDelegateOperatorShape(name) + (symbol as? KaNamedSymbol)?.name?.asString() == name && + symbol.isDelegateOperatorShape(name) && + symbol.acceptsIndexedSetterArguments(arguments) } }.getOrNull() @@ -1479,8 +1495,8 @@ class KaChangePackageComputer( originalPackage: FqName, movedPaths: Set, ): KaCallableSymbol? { - if (accessorType.memberDelegateOperator(operatorName) != null) return null val arguments = delegationArgumentTypes(operatorName) + if (accessorType.memberDelegateOperator(operatorName, arguments) != null) return null return scopeDelegateOperator(accessorType, operatorName, arguments) ?: samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths, arguments) } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index a8b3de9d2..001214a31 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -208,45 +208,79 @@ internal fun KtArrayAccessExpression.setterArgumentTypes(): List? { } /** - * Returns the type of the implicit `thisRef` argument a delegated property passes. + * Returns the type of the implicit `thisRef` argument a delegated property's accessors receive. * - * A member property passes its enclosing instance, an extension property passes its extension - * receiver, and a top-level or local property passes `null`. The compiler ignores accessor and - * provider overloads whose first parameter cannot take that argument, so selecting an operator on - * the receiver type alone can pick an inapplicable overload. + * An extension property passes its extension receiver, a member property passes its enclosing + * instance, and a top-level or local property passes `null`. The compiler ignores accessor + * overloads whose first parameter cannot take that argument, so selecting an operator on the + * receiver type alone can pick an inapplicable overload. * * @return static type of the implicit `thisRef` argument */ context(_: KaSession) internal fun KtProperty.delegationThisRefType(): KaType = - receiverTypeReference?.type - ?: containingClassOrObject?.let { enclosing -> + receiverTypeReference?.type ?: enclosingInstanceType() ?: builtinTypes.nullableNothing + +/** + * Returns the type of the implicit `thisRef` argument `provideDelegate` receives. + * + * The provider runs while the delegate is created, before any extension receiver exists: the + * compiler passes the dispatch receiver only, so a top-level or extension property passes `null` + * where its accessors would pass the extension receiver. Member properties still pass their + * enclosing instance. + * + * @return static type of the implicit provider `thisRef` argument + */ +context(_: KaSession) +internal fun KtProperty.providerThisRefType(): KaType = + enclosingInstanceType() ?: builtinTypes.nullableNothing + +/** + * Returns the type of the instance a member property passes as `thisRef`. + * + * A local property is not a member of the class that lexically contains it: the compiler passes + * `null` for it even though [containingClassOrObject] finds that class. + * + * @return type of the enclosing instance, or `null` when the property passes none + */ +context(_: KaSession) +private fun KtProperty.enclosingInstanceType(): KaType? = + if (isLocal) { + null + } else { + containingClassOrObject?.let { enclosing -> runCatching { (enclosing.symbol as? KaClassSymbol)?.defaultType }.getOrNull() } - ?: builtinTypes.nullableNothing + } /** * Returns the argument types a delegated property passes to one of its operators. * * `provideDelegate` and `getValue` receive `thisRef` and the `KProperty` instance; `setValue` also * receives the assigned value. The value's type is unknown here, so it is left unchecked, while - * the `thisRef` type decides which overloads the compiler can select at all. + * the `thisRef` type decides which overloads the compiler can select at all. The provider is the + * one operator that never receives an extension receiver. * * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @return argument types in call order; `null` entries are unresolved expressions */ context(_: KaSession) internal fun KtProperty.delegationArgumentTypes(operatorName: String): List { - val thisRef = delegationThisRefType() + val thisRef = + if (operatorName == "provideDelegate") providerThisRefType() else delegationThisRefType() return if (operatorName == "setValue") listOf(thisRef, null, null) else listOf(thisRef, null) } /** - * Tells whether this callable accepts the arguments of an indexed write. + * Tells whether this callable accepts the arguments of an indexed write or a delegate call. * - * An indexed write passes the indices and then the assigned value. A parameter whose type is a - * type parameter or cannot be compared is accepted, because the alternative — treating it as - * inapplicable — would import an extension the member already shadows. + * An indexed write passes the indices and then the assigned value. Kotlin allows `vararg` in any + * parameter position (`fun set(vararg indices: Int, value: Int)`), so every argument takes the + * type of the parameter it lands on: arguments before the vararg match their own parameter, + * arguments after it match the trailing fixed parameters, and the rest match the vararg element + * type. A parameter whose type is a type parameter or cannot be compared is accepted, because the + * alternative — treating it as inapplicable — would import an extension the member already + * shadows. * * @param arguments argument types in call order; `null` entries are unresolved expressions * @return `true` when the callable can be selected for those arguments @@ -256,12 +290,24 @@ internal fun KaCallableSymbol.acceptsIndexedSetterArguments(arguments: List + argument == null || acceptsArgument(parameters[index].returnType, argument) + } + } + // The vararg may take any number of arguments, including none, so only the fixed parameters + // constrain the argument count. + if (arguments.size < parameters.size - 1) return@runCatching false + val trailingCount = parameters.size - varargIndex - 1 + val varargEnd = arguments.size - trailingCount arguments.withIndex().all { (index, argument) -> - val parameter = parameters.getOrNull(index) ?: lastParameter + val parameter = when { + index < varargIndex -> parameters[index] + index >= varargEnd -> parameters[index - varargEnd + varargIndex + 1] + else -> parameters[varargIndex] + } argument == null || acceptsArgument(parameter.returnType, argument) } }.getOrDefault(false) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 159c04f23..635e51c6b 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -1161,10 +1161,11 @@ object KotlinImportBindingCheck { val delegateType = runCatching { delegateExpression?.expressionType }.getOrNull() ?: return emptyList() if (name == "provideDelegate") { // The provider is selected on the delegate expression's own type, before any accessor. - return if (delegateType.memberDelegateOperator(name) != null) { + val arguments = delegationArgumentTypes(name) + return if (delegateType.memberDelegateOperator(name, arguments) != null) { emptyList() } else { - listOfNotNull(selectedOperator(delegateType, name, delegationArgumentTypes(name))) + listOfNotNull(selectedOperator(delegateType, name, arguments)) } } val accessorNames = if (isVar) listOf("getValue", "setValue") else listOf("getValue") @@ -1174,11 +1175,12 @@ object KotlinImportBindingCheck { ?.let { symbol -> runCatching { symbol.returnTypeInferredFrom(delegateType) }.getOrNull() } ?.let { providedType -> listOf(providedType) } ?: listOf(delegateType) + val arguments = delegationArgumentTypes(name) return accessorTypes.mapNotNull { accessorType -> - if (accessorType.memberDelegateOperator(name) != null) { + if (accessorType.memberDelegateOperator(name, arguments) != null) { null } else { - selectedOperator(accessorType, name, delegationArgumentTypes(name)) + selectedOperator(accessorType, name, arguments) } } } @@ -1197,24 +1199,34 @@ object KotlinImportBindingCheck { * @return the selected operator, or `null` when none applies */ context(_: KaSession) - private fun KtProperty.delegateOperator(type: KaType, name: String): KaCallableSymbol? = - type.memberDelegateOperator(name) ?: selectedOperator(type, name, delegationArgumentTypes(name)) + private fun KtProperty.delegateOperator(type: KaType, name: String): KaCallableSymbol? { + val arguments = delegationArgumentTypes(name) + return type.memberDelegateOperator(name, arguments) ?: selectedOperator(type, name, arguments) + } /** * Returns the member delegate operator of [name] on this type, or `null` when it has none. * * Matching by name alone would treat an ordinary member whose signature does not fit the - * delegation protocol as an accessor and hide the extension the property actually uses. + * delegation protocol as an accessor and hide the extension the property actually uses. A + * member that fits the shape but not the actual arguments does not win either, so the + * applicability of the implicit call is checked as well. * * @param name operator name + * @param arguments argument types of the implicit call * @return the applicable member operator, or `null` when the type declares none */ context(_: KaSession) - private fun KaType.memberDelegateOperator(name: String): KaCallableSymbol? = runCatching { + private fun KaType.memberDelegateOperator( + name: String, + arguments: List, + ): KaCallableSymbol? = runCatching { expandedSymbol?.memberScope?.declarations ?.filterIsInstance() ?.firstOrNull { symbol -> - (symbol as? KaNamedSymbol)?.name?.asString() == name && symbol.isDelegateOperatorShape(name) + (symbol as? KaNamedSymbol)?.name?.asString() == name && + symbol.isDelegateOperatorShape(name) && + symbol.acceptsIndexedSetterArguments(arguments) } }.getOrNull() @@ -1323,16 +1335,35 @@ object KotlinImportBindingCheck { ) { return true } - val receiverRoot = qualified.receiverExpression.leadingReference() ?: return true - val receiverSymbol = runCatching { - receiverRoot.references.filterIsInstance().firstOrNull()?.resolveToSymbol() - }.getOrNull() - // A named object is a value even though K2 reports it as a class-like symbol: extensions - // declared on the object's supertypes are selected by overload resolution for - // `singleton.choose()`, so an import can still rebind the call. A class or package name in - // the same position is a qualifier, which an import cannot affect. - if (receiverSymbol is KaClassSymbol) return receiverSymbol.classKind == KaClassKind.OBJECT - return receiverSymbol !is KaPackageSymbol + return qualified.receiverExpression.bindsToValue() + } + + /** + * Tells whether this expression denotes a value rather than a type or package qualifier. + * + * `Holder.box.choose()` passes the value of the `box` property, even though its leftmost name + * is a class, so the whole receiver chain has to be classified rather than its leading name. + * Only a bare class or package name used as a qualifier (`Int.choose()`) cannot be affected + * by an import. A named object is a value even though K2 reports it as a class-like symbol: + * extensions declared on the object's supertypes are selected by overload resolution for + * `singleton.choose()`, so an import can still rebind the call. + * + * @return `true` when the expression evaluates to a value + */ + context(_: KaSession) + private fun KtExpression.bindsToValue(): Boolean = when (this) { + is KtDotQualifiedExpression, is KtCallExpression -> true + is KtSimpleNameExpression -> { + val symbol = runCatching { + references.filterIsInstance().firstOrNull()?.resolveToSymbol() + }.getOrNull() + when (symbol) { + is KaClassSymbol -> symbol.classKind == KaClassKind.OBJECT + is KaPackageSymbol -> false + else -> true + } + } + else -> true } /** diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 85a347aed..b89c05d7b 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index c6332769b..056ee5485 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5328,6 +5328,182 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies member delegate operators are filtered by the actual arguments before suppressing + * extension lookup. + * + * `Box` declares a member `getValue(Host, KProperty<*>)`, but a top-level `val value by Box()` + * passes `null` as `thisRef`, so the member is inapplicable and the moving extension + * `Box.getValue(Any?, KProperty<*>)` is selected instead. Treating the member as a match by + * shape alone records no moved accessor, and the property silently falls back to + * `Any.getValue` after the move. + */ + fun testApply_realSession_filtersMemberDelegateOperatorByArguments() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-member-delegate-args") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Host\n\nclass Box {\n operator fun getValue(" + + "thisRef: Host, property: kotlin.reflect.KProperty<*>): Int = 1\n}\n\n" + + "operator fun Any.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 3\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-member-delegate-args-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the extension the inapplicable member leaves in charge: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies `provideDelegate` is filtered by a null `thisRef` for extension properties. + * + * For a top-level `val String.value by Delegate()`, Kotlin passes `null` to `provideDelegate` + * and the extension receiver only to the accessor. Supplying the receiver type for every + * operator selects the inapplicable staying `Delegate.provideDelegate(String, ...)` and plans + * no import for the actually selected `Delegate.provideDelegate(Any?, ...)`. + */ + fun testApply_realSession_filtersProviderByNullThisRef() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-provider-thisref") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Stays.kt"), + "package old\n\nclass Delegate\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: String, property: kotlin.reflect.KProperty<*>): Delegate = this\n\n" + + "operator fun Delegate.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 7\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Delegate.provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Delegate = this\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval String.value: Int by Delegate()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-provider-thisref-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved provider applicable to a null thisRef: $text", + text.contains("import dest.provideDelegate"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a setter with a non-final vararg parameter stays applicable to an indexed write. + * + * `operator fun Box.set(vararg indices: Int, value: Int)` declares two parameters but accepts + * any number of indices, so `box[0, 1]++` passes three arguments. Treating only a final vararg + * as variable-arity rejects the setter the compiler selects, and the moved declaration is not + * imported; a staying `Any.set` fallback then silently takes over the write. + */ + fun testApply_realSession_importsSetterWithNonFinalVararg() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-non-final-vararg") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box {\n operator fun get(vararg indices: Int): Int = 0\n}\n\n" + + "operator fun Any.set(vararg indices: Int, value: Int) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(vararg indices: Int, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n box[0, 1]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-non-final-vararg-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved vararg setter: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies setter overloads declared on the same receiver are ranked by parameter specificity. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 84e805ce7..186eb6729 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -448,6 +448,47 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies an extension called on a property-valued receiver is treated as a value receiver. + * + * `Holder.box` is a property access whose type is `Derived`, so `Holder.box.choose()` selects + * `Derived.choose` by extension overload resolution even though the chain starts with the + * class-like `Holder`. Classifying the receiver by its leftmost name treats the call as a + * type-qualified access and approves an import of `Base.choose` that would reselect it. + */ + fun testHasConflictingReference_reportsPropertyChainValueReceiver() { + withSession( + name = "property-chain-receiver", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\n" + + "open class Base\n\n" + + "open class Derived : Base()\n\n" + + "class Holder {\n companion object {\n val box: Derived = Derived()\n }\n}\n\n" + + "fun Derived.choose() = 2\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\nfun Base.choose() = 1\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nfun use() = Holder.box.choose()\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + assertTrue( + "an import of the moved overload would reselect it for Holder.box.choose()", + KotlinImportBindingCheck.hasConflictingReference(usage, "choose", emptySet()), + ) + } + } + /** * Verifies an extension called on a named object is treated as a value receiver. * diff --git a/pom.xml b/pom.xml index cd28c35b8..3433a4df0 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.55-SNAPSHOT + 0.12.56-SNAPSHOT pom Netbeans Plugin Kotlin From 686eccdd23e526db85cda570d27049df5e193cd9 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 10:51:49 +0300 Subject: [PATCH 47/55] Fixed compound assignment, delegated setter and companion receiver resolution `setterArgumentTypes` passed the getter's type as the value of a compound assignment, but Kotlin passes the compound operator's result. The value type now comes from the resolved implicit `set` call for binary assignments and from the instantiated `inc`/`dec` return type for increments, so a setter overload declared for the updated type is no longer rejected. Delegated setters were filtered with the value type left unknown. `setValue` receives the property's own type, so it is now supplied: an inapplicable member `setValue(..., Int)` no longer suppresses the extension the property selects. The import-safety check classified a class name used with the companion shorthand as a type qualifier. A companion object is an implicit value, so `Holder.choose()` now goes through receiver classification like `singleton.choose()`. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaOperatorApplicability.kt | 71 ++++++++-- .../refactoring/KotlinImportBindingCheck.kt | 17 ++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 124 ++++++++++++++++++ .../KotlinImportBindingCheckTest.kt | 42 ++++++ pom.xml | 2 +- 16 files changed, 252 insertions(+), 26 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index b2ed6d9e1..4a4fef469 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index dafa5e068..d7be2e3a9 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 873b36403..561895ff4 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index c69d29419..11868ef60 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 9f1ca5886..14e09ca95 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 24fca8287..b7a98cc94 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 39ef14500..61143f038 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index d3f9814ae..bbf35458b 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 05786b603..155eaa873 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index d5ee76870..cd39b6f4d 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 001214a31..21a5b208f 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -35,6 +35,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol import org.jetbrains.kotlin.analysis.api.symbols.symbol import org.jetbrains.kotlin.analysis.api.types.KaCapturedType @@ -187,11 +188,13 @@ private fun unwrapType(type: KaType): KaType = when (type) { * Returns the argument types a compound indexed write passes to `set`. * * The write passes its indices and then the value produced by the getter and the compound - * operation. The getter's return type stands in for that value: for the integer and boxed types - * the operators produce the same type, and the member-setter applicability check only needs - * enough precision to reject a signature whose index or value type cannot fit. + * operation. That value is the compound operator's result, not the getter's result: for a member + * `get(Int): Initial` and an extension `Initial.plus(Int): Updated`, `box[0] += 1` passes + * `Updated`, which selects a different setter overload than `Initial` would. When the operator + * cannot be resolved, the getter's type stands in for it, which still distinguishes index and + * genuinely unrelated value types. * - * @return index types followed by the getter's return type, or `null` when unresolved + * @return index types followed by the type of the assigned value, or `null` when unresolved */ @OptIn(KaExperimentalApi::class) context(_: KaSession) @@ -204,7 +207,44 @@ internal fun KtArrayAccessExpression.setterArgumentTypes(): List? { .filterIsInstance() .firstOrNull { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "get" } ?: return null - return indices + runCatching { getter.returnType }.getOrNull() + val receiverType = runCatching { arrayExpression?.expressionType }.getOrNull() + val getterType = receiverType + ?.let { type -> runCatching { getter.returnTypeInferredFrom(type) }.getOrNull() } + ?: runCatching { getter.returnType }.getOrNull() + return indices + assignedValueType(getterType) +} + +/** + * Returns the type of the value a compound indexed write assigns through `set`. + * + * A binary compound assignment is resolved as the implicit `set` call, so the resolved operation is + * the selected setter and its value parameter type is the one the argument was accepted by. A + * prefix or postfix increment resolves to `inc`/`dec` instead, whose instantiated return type is + * the assigned value. The getter's type stands in only when the operation cannot be resolved. + * + * @param getterType return type of the getter the write reads + * @return type of the assigned value, or `null` when it cannot be resolved + */ +context(_: KaSession) +private fun KtArrayAccessExpression.assignedValueType(getterType: KaType?): KaType? { + val operationReference = when (val parent = parent) { + is KtBinaryExpression -> parent.operationReference.takeIf { parent.left == this } + is KtUnaryExpression -> parent.operationReference.takeIf { parent.baseExpression == this } + else -> null + } ?: return getterType + val operation = runCatching { + operationReference.references.filterIsInstance() + .flatMap { reference -> reference.resolveToSymbols().orEmpty() } + .filterIsInstance() + .firstOrNull() + }.getOrNull() ?: return getterType + if ((operation as? KaNamedSymbol)?.name?.asString() == "set") { + return (operation as? KaFunctionSymbol)?.valueParameters?.lastOrNull()?.returnType + ?: getterType + } + return getterType + ?.let { type -> runCatching { operation.returnTypeInferredFrom(type) }.getOrNull() } + ?: getterType } /** @@ -257,9 +297,9 @@ private fun KtProperty.enclosingInstanceType(): KaType? = * Returns the argument types a delegated property passes to one of its operators. * * `provideDelegate` and `getValue` receive `thisRef` and the `KProperty` instance; `setValue` also - * receives the assigned value. The value's type is unknown here, so it is left unchecked, while - * the `thisRef` type decides which overloads the compiler can select at all. The provider is the - * one operator that never receives an extension receiver. + * receives the assigned value, whose type is the property's own type. The `thisRef` and the value + * decide which overloads the compiler can select at all. The provider is the one operator that + * never receives an extension receiver. * * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @return argument types in call order; `null` entries are unresolved expressions @@ -268,9 +308,22 @@ context(_: KaSession) internal fun KtProperty.delegationArgumentTypes(operatorName: String): List { val thisRef = if (operatorName == "provideDelegate") providerThisRefType() else delegationThisRefType() - return if (operatorName == "setValue") listOf(thisRef, null, null) else listOf(thisRef, null) + return if (operatorName == "setValue") listOf(thisRef, null, propertyValueType()) else listOf(thisRef, null) } +/** + * Returns the type of the value assigned through this property. + * + * A delegated setter receives exactly the property type, so an accessor declared with an + * unrelated value type (`setValue(..., Int)` for a `String` property) is inapplicable and must not + * hide the applicable extension. + * + * @return declared or inferred property type, or `null` when it cannot be resolved + */ +context(_: KaSession) +private fun KtProperty.propertyValueType(): KaType? = + runCatching { (symbol as? KaVariableSymbol)?.returnType }.getOrNull() + /** * Tells whether this callable accepts the arguments of an indexed write or a delegate call. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 635e51c6b..3d5ccd0f4 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -35,6 +35,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaClassKind import org.jetbrains.kotlin.analysis.api.symbols.KaClassLikeSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaConstructorSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaNamedClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaPackageSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol @@ -1343,10 +1344,11 @@ object KotlinImportBindingCheck { * * `Holder.box.choose()` passes the value of the `box` property, even though its leftmost name * is a class, so the whole receiver chain has to be classified rather than its leading name. - * Only a bare class or package name used as a qualifier (`Int.choose()`) cannot be affected - * by an import. A named object is a value even though K2 reports it as a class-like symbol: - * extensions declared on the object's supertypes are selected by overload resolution for - * `singleton.choose()`, so an import can still rebind the call. + * Only a bare class or package name without an implicit value (`Int.choose()`) is a qualifier + * an import cannot affect. A named object is a value even though K2 reports it as a class-like + * symbol, and so is a class name whose companion object supplies the receiver for + * `Holder.choose()`: extensions declared on the object's or companion's supertypes are + * selected by overload resolution, so an import can still rebind the call. * * @return `true` when the expression evaluates to a value */ @@ -1358,7 +1360,12 @@ object KotlinImportBindingCheck { references.filterIsInstance().firstOrNull()?.resolveToSymbol() }.getOrNull() when (symbol) { - is KaClassSymbol -> symbol.classKind == KaClassKind.OBJECT + is KaClassSymbol -> { + val companion = (symbol as? KaNamedClassSymbol)?.companionObject + symbol.classKind == KaClassKind.OBJECT || + symbol.classKind == KaClassKind.COMPANION_OBJECT || + companion != null + } is KaPackageSymbol -> false else -> true } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index b89c05d7b..2ef21c187 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 056ee5485..33b44fad2 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5504,6 +5504,130 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies setter filtering uses the compound operation's result, not the getter's result. + * + * `box[0] += 1` passes the result of `plus` to `set`. A member `get(Int): Initial` and an + * extension `Initial.plus(Int): Updated` make the assigned value `Updated`, so the moving + * `Box.set(Int, Updated)` is what Kotlin selects while `Box.set(Int, Any?)` stays. Supplying + * the getter's `Initial` instead rejects the moving overload and plans no import, and the + * write silently falls back to the staying `Any?` one after the change. + */ + fun testApply_realSession_usesCompoundOperationResultForSetterArguments() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-compound-result") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\n" + + "open class Initial\n\n" + + "class Updated\n\n" + + "open class Box {\n operator fun get(index: Int): Initial = Initial()\n}\n\n" + + "operator fun Initial.plus(other: Int): Updated = Updated()\n\n" + + "operator fun Box.set(index: Int, value: Any?) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(index: Int, value: Updated) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n box[0] += 1\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-compound-result-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the setter selected for the compound result: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies delegated setter filtering uses the property's value type. + * + * For `var value: String by Box()`, Kotlin passes a `String` to `setValue`, so the staying + * member `Box.setValue(..., Int)` is inapplicable and the moving extension + * `Box.setValue(..., Any?)` is selected. Leaving the value type unknown keeps the `Int` member + * applicable, records no moved setter, and lets a staying `Any.setValue` fallback take over. + */ + fun testApply_realSession_filtersDelegateSetterByPropertyType() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-delegate-setter-type") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box {\n" + + " operator fun getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): String = \"\"\n\n" + + " operator fun setValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>, value: Int) {}\n" + + "}\n\n" + + "operator fun Any.setValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>, value: Any?) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.setValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>, value: Any?) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nvar value: String by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-delegate-setter-type-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the setter applicable to the property type: $text", + text.contains("import dest.setValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies setter overloads declared on the same receiver are ranked by parameter specificity. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 186eb6729..6f354a03a 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -489,6 +489,48 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies the companion shorthand of a class is treated as a value receiver. + * + * `Holder.choose()` passes the companion instance, which may extend the extension's declared + * receiver (`companion object : Derived()`), even though the spelled name is the class itself. + * K2 reports `Holder` as a class-like symbol, so the class-qualifier shortcut skips it, and an + * import of a `Base.choose` overload would be approved even though the explicit import outranks + * the same-package `Derived.choose` and reselects it. + */ + fun testHasConflictingReference_reportsCompanionShorthandValueReceiver() { + withSession( + name = "companion-shorthand-receiver", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.createDirectories(root.resolve("usage")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\n" + + "open class Base\n\n" + + "open class Derived : Base()\n\n" + + "class Holder {\n companion object : Derived()\n}\n\n" + + "fun Derived.choose() = 2\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\nfun Base.choose() = 1\n", + ) + Files.writeString( + root.resolve("usage/Usage.kt"), + "package usage\n\nimport old.*\n\nfun use() = Holder.choose()\n", + ) + }, + ) { session, root -> + val usage = session.ktFile(root.resolve("usage/Usage.kt")) + + assertTrue( + "an import of the moved overload would reselect it for Holder.choose()", + KotlinImportBindingCheck.hasConflictingReference(usage, "choose", emptySet()), + ) + } + } + /** * Verifies an extension called on a named object is treated as a value receiver. * diff --git a/pom.xml b/pom.xml index 3433a4df0..9eee6c627 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.56-SNAPSHOT + 0.12.57-SNAPSHOT pom Netbeans Plugin Kotlin From 3333f9ad5e0e35a091b12fb0ffff7ea1d0a7356a Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Wed, 4 Nov 2026 11:18:27 +0300 Subject: [PATCH 48/55] Fixed delegate operator bounds, member provider selection and interface constructors - Enforced the upper bounds of a type-parameter value parameter when filtering delegate and indexed operator arguments, so a generic accessor bounded by a supertype no longer accepts a null thisRef. - Selected the applicable member overload by parameter specificity before using its return type, in both the change-package computer and the import-binding helper. - Excluded interfaces from implicit constructor discovery so renaming an interface to a same-package function's name is not reported as a constructor collision. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 14 +- .../refactoring/KaOperatorApplicability.kt | 65 +++++++++- .../KotlinConstructorSignatureCheck.kt | 11 +- .../refactoring/KotlinImportBindingCheck.kt | 14 +- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 120 ++++++++++++++++++ .../KotlinConstructorSignatureCheckTest.kt | 29 +++++ .../KotlinImportBindingCheckTest.kt | 45 +++++++ pom.xml | 2 +- 19 files changed, 295 insertions(+), 27 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 4a4fef469..043e61e2d 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index d7be2e3a9..333425717 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 561895ff4..66f5a0821 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 11868ef60..53f0e5203 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 14e09ca95..2de5b6057 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index b7a98cc94..d07172015 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 61143f038..7333828ce 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index bbf35458b..27e0da0a9 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 155eaa873..865ae6300 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index cd39b6f4d..a099acd87 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 466e65947..149c17ebd 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -1453,7 +1453,9 @@ class KaChangePackageComputer( * ordinary member whose signature does not fit the delegation protocol as an accessor, and a * member that matches the shape but not the actual arguments does not win either: the * compiler skips a `getValue(Host, KProperty<*>)` member for a property passing a null - * `thisRef` and uses the extension accessor instead. + * `thisRef` and uses the extension accessor instead. When several members fit, overload + * resolution prefers the more specific parameter tuple, and that overload's result type is + * what the accessor lookup must use. * * @param name operator name * @param arguments argument types of the implicit call @@ -1464,13 +1466,15 @@ class KaChangePackageComputer( name: String, arguments: List, ): KaCallableSymbol? = runCatching { - expandedSymbol?.memberScope?.declarations + val candidates = expandedSymbol?.memberScope?.declarations ?.filterIsInstance() - ?.firstOrNull { symbol -> + ?.filter { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name && - symbol.isDelegateOperatorShape(name) && - symbol.acceptsIndexedSetterArguments(arguments) + symbol.isDelegateOperatorShape(name) } + ?.toList() + .orEmpty() + selectMember(candidates, arguments) }.getOrNull() /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 21a5b208f..a790654a9 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -42,6 +42,8 @@ import org.jetbrains.kotlin.analysis.api.types.KaCapturedType import org.jetbrains.kotlin.analysis.api.types.KaClassType import org.jetbrains.kotlin.analysis.api.types.KaDefinitelyNotNullType import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType +import org.jetbrains.kotlin.analysis.api.types.KaFunctionType +import org.jetbrains.kotlin.analysis.api.types.KaIntersectionType import org.jetbrains.kotlin.analysis.api.types.KaType import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType import org.jetbrains.kotlin.idea.references.KtReference @@ -368,15 +370,76 @@ internal fun KaCallableSymbol.acceptsIndexedSetterArguments(arguments: List`) cannot be compared without instantiating it, so + * that bound stays inconclusive instead of rejecting a possibly valid argument. + * * @param parameterType declared parameter type * @param argument actual argument type * @return `true` when assignability holds or cannot be decided */ context(_: KaSession) private fun acceptsArgument(parameterType: KaType, argument: KaType): Boolean = runCatching { - parameterType is KaTypeParameterType || argument.isSubtypeOf(parameterType) + if (parameterType is KaTypeParameterType) { + parameterType.symbol.upperBounds.all { bound -> + bound.mentionsTypeParameter() || acceptsArgument(bound, argument) + } + } else { + argument.isSubtypeOf(parameterType) + } }.getOrDefault(true) +/** + * Tells whether a type parameter appears anywhere in this type's structure. + * + * A bound such as `Comparable` cannot be checked against a concrete argument without + * instantiating `T`; a raw subtype test would reject a valid argument, so such a bound is treated + * as inconclusive. + * + * @return `true` when this type mentions a type parameter + */ +private fun KaType.mentionsTypeParameter(): Boolean = when (this) { + is KaTypeParameterType -> true + is KaClassType -> typeArguments.any { projection -> projection.type?.mentionsTypeParameter() == true } + is KaFunctionType -> receiverType?.mentionsTypeParameter() == true || + parameterTypes.any { it.mentionsTypeParameter() } || + returnType.mentionsTypeParameter() + is KaCapturedType -> projection.type?.mentionsTypeParameter() == true + is KaDefinitelyNotNullType -> original.mentionsTypeParameter() + is KaFlexibleType -> lowerBound.mentionsTypeParameter() || upperBound.mentionsTypeParameter() + is KaIntersectionType -> conjuncts.any { it.mentionsTypeParameter() } + else -> false +} + +/** + * Selects the member overload the compiler binds for a convention call. + * + * Members have no extension receiver, so overload resolution compares their value parameter + * tuples: `provideDelegate(Host, KProperty<*>)` is preferred over + * `provideDelegate(Any?, KProperty<*>)` for a `Host` thisRef. The selected overload decides the + * provider's result type and therefore the accessor lookup, so returning the first declared match + * would make the accessor selection use an unrelated type. + * + * @param candidates member callables visible under the same simple name + * @param arguments argument types of the implicit call + * @return the selected member, or `null` when none applies + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun selectMember( + candidates: List, + arguments: List, +): KaCallableSymbol? { + val applicable = candidates.filter { candidate -> + candidate.acceptsIndexedSetterArguments(arguments) + } + return applicable.firstOrNull { candidate -> + applicable.none { other -> other !== candidate && other.parametersAreMoreSpecificThan(candidate) } + } ?: applicable.firstOrNull() +} + /** * Selects the extension the compiler binds for a convention call. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt index 5ee3d34c8..e7e46d68a 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -56,7 +56,8 @@ object KotlinConstructorSignatureCheck { * * Every constructor counts, not only the primary one: a class that declares only secondary * constructors has no primary constructor at all, while a class that declares none gets an - * implicit no-argument primary constructor. An extension function has an implicit receiver and + * implicit no-argument primary constructor. An interface declares no constructors, so no + * function can collide with it. An extension function has an implicit receiver and * therefore cannot conflict with a constructor. A parameter whose type cannot be resolved makes * the comparison inconclusive, which is reported as a conflict so the rename is rejected * instead of producing uncompilable code. The vararg status of each parameter is part of the @@ -258,8 +259,9 @@ object KotlinConstructorSignatureCheck { * Returns the parameter list of every constructor the renamed class would expose. * * A class with a primary constructor uses its parameters; one that declares only secondary - * constructors has no primary constructor, and one that declares none gets the implicit - * no-argument constructor. + * constructors has no primary constructor, and a class that declares none gets the implicit + * no-argument constructor. An interface declares no constructors at all, so it exposes no + * signature that a same-name function could collide with. * * @param classOrObject class whose constructors are being compared * @return one parameter list per constructor @@ -269,7 +271,8 @@ object KotlinConstructorSignatureCheck { val primaryConstructor = classOrObject.primaryConstructor when { primaryConstructor != null -> signatures += primaryConstructor.valueParameters - classOrObject.secondaryConstructors.isEmpty() -> signatures.add(emptyList()) + classOrObject.secondaryConstructors.isEmpty() && + !classOrObject.isInterface() -> signatures.add(emptyList()) } classOrObject.secondaryConstructors.forEach { constructor -> signatures.add(constructor.valueParameters) } return signatures diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 3d5ccd0f4..ff417ed6c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -1211,7 +1211,9 @@ object KotlinImportBindingCheck { * Matching by name alone would treat an ordinary member whose signature does not fit the * delegation protocol as an accessor and hide the extension the property actually uses. A * member that fits the shape but not the actual arguments does not win either, so the - * applicability of the implicit call is checked as well. + * applicability of the implicit call is checked as well. When several members fit, overload + * resolution prefers the more specific parameter tuple, and that overload's result type is + * what the accessor lookup must use. * * @param name operator name * @param arguments argument types of the implicit call @@ -1222,13 +1224,15 @@ object KotlinImportBindingCheck { name: String, arguments: List, ): KaCallableSymbol? = runCatching { - expandedSymbol?.memberScope?.declarations + val candidates = expandedSymbol?.memberScope?.declarations ?.filterIsInstance() - ?.firstOrNull { symbol -> + ?.filter { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == name && - symbol.isDelegateOperatorShape(name) && - symbol.acceptsIndexedSetterArguments(arguments) + symbol.isDelegateOperatorShape(name) } + ?.toList() + .orEmpty() + selectMember(candidates, arguments) }.getOrNull() /** diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 2ef21c187..97603e3a6 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 33b44fad2..773060f08 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5388,6 +5388,126 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a delegate accessor argument must satisfy the parameter's type-parameter bounds. + * + * A top-level property passes `null` (the `Nothing?` type) as `thisRef`, so + * `operator fun Box.getValue(thisRef: T, ...)` cannot take part in the call. + * Treating every type-parameter parameter as applicable selects this staying overload through + * its more specific `Box` receiver instead of the moving `Base.getValue(Any?, ...)`, and the + * destination import the compiler needs is never planned. + */ + fun testApply_realSession_filtersDelegateAccessorByTypeParameterBounds() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-type-parameter-bound") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Stays.kt"), + "package old\n\nclass Host\n\nopen class Base\n\nclass Box : Base()\n\n" + + "operator fun Box.getValue(" + + "thisRef: T, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Base.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-type-parameter-bound-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved accessor whose bounds fit a null thisRef: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies the member provider overload is selected by its parameters, not by declaration order. + * + * `Box` declares `provideDelegate(Any?, ...)` returning the staying `General` and + * `provideDelegate(Host, ...)` returning the moving `Specific`. For a property inside `Host` + * the compiler prefers the more specific overload, so the moving `Specific.getValue` is the + * accessor the caller has to import. Using the first matching member instead selects `General` + * and plans no import, leaving the property uncompilable after the change. + */ + fun testApply_realSession_selectsMemberProviderOverloadByArguments() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-member-provider-overload") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val staysPath = temporaryRoot.resolve("old/Stays.kt") + Files.writeString( + staysPath, + "package old\n\nclass General\n\n" + + "operator fun General.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n\n" + + "class Host {\n val value: Int by Box()\n}\n", + ) + Files.writeString( + sourcePath, + "package old\n\nclass Specific\n\nclass Box {\n" + + " operator fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): General = General()\n" + + " operator fun provideDelegate(" + + "thisRef: Host, property: kotlin.reflect.KProperty<*>): Specific = Specific()\n" + + "}\n\n" + + "operator fun Specific.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-member-provider-overload-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[staysPath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the accessor of the selected provider overload: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies `provideDelegate` is filtered by a null `thisRef` for extension properties. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt index 5713a7914..82afe30e4 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -361,6 +361,35 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig } } + /** + * Verifies renaming an interface to a function's name does not report a constructor collision. + * + * An interface declares no constructors, so `interface After` and the same-package + * `fun After()` coexist. Inventing an implicit no-argument constructor for the interface + * would reject a valid rename. + */ + fun testConflictsWithRenamedConstructor_ignoresInterfaceWithoutConstructors() { + withSession( + name = "interface-without-constructors", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Types.kt"), "package p\n\ninterface Before\n") + Files.writeString(root.resolve("source/Functions.kt"), "package p\n\nfun After() = 1\n") + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertFalse( + "an interface has no constructor to collide with", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + /** @return the single top-level class of [file]. */ private fun primaryClass(file: KtFile): KtClass = file.declarations.filterIsInstance().single() diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 6f354a03a..7da3da175 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -797,6 +797,51 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies the binding helper follows the member provider overload resolution selects. + * + * `Box` declares `provideDelegate(Any?, ...)` returning the staying `General` and + * `provideDelegate(Host, ...)` returning the moving `Specific`. The property inside `Host` + * binds `Specific.getValue`, so importing that moved accessor captures nothing and must not be + * reported. Matching the first declared member would bind `General.getValue` and report a + * false conflict. + */ + fun testHasConflictingReference_usesSelectedMemberProviderOverload() { + withSession( + name = "member-provider-overload-selection", + prepare = { root -> + Files.createDirectories(root.resolve("old")) + Files.writeString( + root.resolve("old/Staying.kt"), + "package old\n\nclass General\n\n" + + "operator fun General.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n\n" + + "class Host {\n val value: Int by Box()\n}\n", + ) + Files.writeString( + root.resolve("old/Moving.kt"), + "package old\n\nclass Specific\n\nclass Box {\n" + + " operator fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): General = General()\n" + + " operator fun provideDelegate(" + + "thisRef: Host, property: kotlin.reflect.KProperty<*>): Specific = Specific()\n" + + "}\n\n" + + "operator fun Specific.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 2\n", + ) + }, + ) { session, root -> + val staying = session.ktFile(root.resolve("old/Staying.kt")) + val moving = session.ktFile(root.resolve("old/Moving.kt")) + val accessor = moving.declarations.filterIsInstance().single { it.name == "getValue" } + + assertFalse( + "the property already binds the moved accessor, so importing it captures nothing", + KotlinImportBindingCheck.hasConflictingReference(staying, "getValue", setOf(accessor)), + ) + } + } + /** * Verifies a generic retained accessor blocks rewriting a direct import. * diff --git a/pom.xml b/pom.xml index 9eee6c627..29301b0fb 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.57-SNAPSHOT + 0.12.58-SNAPSHOT pom Netbeans Plugin Kotlin From ba316cc48fddb310b2cbec0ad1adb93e67f54cca Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 11:41:16 +0300 Subject: [PATCH 49/55] Fixed nullable type-parameter bounds, instantiated member scopes and bound order - Preserved the nullability of a nullable type-parameter occurrence when checking operator arguments, so ` getValue(thisRef: T?, ...)` still accepts a null thisRef. - Substituted the receiver's type arguments into member operator parameter types before the applicability check, so a `Grid` member setter is no longer treated as fitting a `Grid` write it cannot serve. - Compared constructor type-parameter bounds as an unordered set, so the same intersection written in a different order is reported as a conflicting overload. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 2 +- .../refactoring/KaOperatorApplicability.kt | 73 +++++++++-- .../KotlinConstructorSignatureCheck.kt | 23 +++- .../refactoring/KotlinImportBindingCheck.kt | 2 +- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 118 ++++++++++++++++++ .../KotlinConstructorSignatureCheckTest.kt | 37 ++++++ pom.xml | 2 +- 18 files changed, 247 insertions(+), 32 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 043e61e2d..a02fc8cff 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 333425717..9df6940c0 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 66f5a0821..b23052c3e 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 53f0e5203..71cd19e04 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 2de5b6057..78fbe7ea4 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index d07172015..0050de915 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 7333828ce..3e038637e 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 27e0da0a9..dc496b1d3 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 865ae6300..7f02c2369 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index a099acd87..9f1351eb2 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 149c17ebd..aec6cf81b 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -1474,7 +1474,7 @@ class KaChangePackageComputer( } ?.toList() .orEmpty() - selectMember(candidates, arguments) + selectMember(candidates, arguments, classTypeSubstitutor()) }.getOrNull() /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index a790654a9..5afbbb5a6 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -38,13 +38,16 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol import org.jetbrains.kotlin.analysis.api.symbols.symbol +import org.jetbrains.kotlin.analysis.api.symbols.typeParameters import org.jetbrains.kotlin.analysis.api.types.KaCapturedType import org.jetbrains.kotlin.analysis.api.types.KaClassType import org.jetbrains.kotlin.analysis.api.types.KaDefinitelyNotNullType import org.jetbrains.kotlin.analysis.api.types.KaFlexibleType import org.jetbrains.kotlin.analysis.api.types.KaFunctionType import org.jetbrains.kotlin.analysis.api.types.KaIntersectionType +import org.jetbrains.kotlin.analysis.api.types.KaSubstitutor import org.jetbrains.kotlin.analysis.api.types.KaType +import org.jetbrains.kotlin.analysis.api.types.KaTypeNullability import org.jetbrains.kotlin.analysis.api.types.KaTypeParameterType import org.jetbrains.kotlin.idea.references.KtReference import org.jetbrains.kotlin.lexer.KtTokens @@ -335,21 +338,32 @@ private fun KtProperty.propertyValueType(): KaType? = * arguments after it match the trailing fixed parameters, and the rest match the vararg element * type. A parameter whose type is a type parameter or cannot be compared is accepted, because the * alternative — treating it as inapplicable — would import an extension the member already - * shadows. + * shadows. A member scope is declared, not instantiated, so a [substitutor] replacing the class's + * type parameters with the receiver's type arguments is applied to every parameter type first. * * @param arguments argument types in call order; `null` entries are unresolved expressions + * @param substitutor maps the declaring class's type parameters to the receiver's arguments * @return `true` when the callable can be selected for those arguments */ context(_: KaSession) -internal fun KaCallableSymbol.acceptsIndexedSetterArguments(arguments: List): Boolean = runCatching { +internal fun KaCallableSymbol.acceptsIndexedSetterArguments( + arguments: List, + substitutor: KaSubstitutor? = null, +): Boolean = runCatching { val function = this as? KaFunctionSymbol ?: return@runCatching false val parameters = function.valueParameters if (parameters.isEmpty()) return@runCatching false + val parameterTypes = parameters.map { parameter -> + val declaredType = parameter.returnType + substitutor?.let { typeSubstitutor -> + runCatching { typeSubstitutor.substitute(declaredType) }.getOrDefault(declaredType) + } ?: declaredType + } val varargIndex = parameters.indexOfFirst { it.isVararg } if (varargIndex < 0) { if (arguments.size != parameters.size) return@runCatching false return@runCatching arguments.withIndex().all { (index, argument) -> - argument == null || acceptsArgument(parameters[index].returnType, argument) + argument == null || acceptsArgument(parameterTypes[index], argument) } } // The vararg may take any number of arguments, including none, so only the fixed parameters @@ -358,15 +372,43 @@ internal fun KaCallableSymbol.acceptsIndexedSetterArguments(arguments: List - val parameter = when { - index < varargIndex -> parameters[index] - index >= varargEnd -> parameters[index - varargEnd + varargIndex + 1] - else -> parameters[varargIndex] + val parameterIndex = when { + index < varargIndex -> index + index >= varargEnd -> index - varargEnd + varargIndex + 1 + else -> varargIndex } - argument == null || acceptsArgument(parameter.returnType, argument) + argument == null || acceptsArgument(parameterTypes[parameterIndex], argument) } }.getOrDefault(false) +/** + * Returns the substitutor mapping this type's class parameters to its actual type arguments. + * + * The member scope of a class symbol is declared, not instantiated: the setter of `Grid` is + * reported with the parameter type `T`, whose bound `Any?` would accept every argument. Only + * substituting `T` with the receiver's `String` tells that `set(String, Int)` cannot take + * `grid[0]++`. Types that are not a class type keep their declared view. + * + * @return the substitutor, or `null` when the type arguments cannot be read + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun KaType.classTypeSubstitutor(): KaSubstitutor? = runCatching { + val classSymbol = expandedSymbol as? KaClassSymbol ?: return@runCatching null + val typeArguments = (this as? KaClassType) + ?.takeIf { classType -> classType.classId == classSymbol.classId } + ?.typeArguments + ?: return@runCatching null + val parameters = classSymbol.typeParameters + if (parameters.size != typeArguments.size) return@runCatching null + val mapping = parameters.mapIndexedNotNull { index, parameter -> + val argument = typeArguments[index].type ?: return@mapIndexedNotNull null + parameter to argument + } + if (mapping.size != parameters.size) return@runCatching null + createSubstitutor(mapping.toMap()) +}.getOrNull() + /** * Tells whether a value parameter of [parameterType] takes [argument]. * @@ -374,7 +416,8 @@ internal fun KaCallableSymbol.acceptsIndexedSetterArguments(arguments: List`) cannot be compared without instantiating it, so - * that bound stays inconclusive instead of rejecting a possibly valid argument. + * that bound stays inconclusive instead of rejecting a possibly valid argument. The nullability of + * the occurrence matters as well: `T?` accepts null even when the bound of `T` is non-null. * * @param parameterType declared parameter type * @param argument actual argument type @@ -383,7 +426,10 @@ internal fun KaCallableSymbol.acceptsIndexedSetterArguments(arguments: List + val nullableOccurrenceTakesNull = + parameterType.nullability == KaTypeNullability.NULLABLE && + argument.semanticallyEquals(builtinTypes.nullableNothing) + nullableOccurrenceTakesNull || parameterType.symbol.upperBounds.all { bound -> bound.mentionsTypeParameter() || acceptsArgument(bound, argument) } } else { @@ -424,6 +470,7 @@ private fun KaType.mentionsTypeParameter(): Boolean = when (this) { * * @param candidates member callables visible under the same simple name * @param arguments argument types of the implicit call + * @param substitutor maps the declaring class's type parameters to the receiver's arguments * @return the selected member, or `null` when none applies */ @OptIn(KaExperimentalApi::class) @@ -431,9 +478,10 @@ context(_: KaSession) internal fun selectMember( candidates: List, arguments: List, + substitutor: KaSubstitutor? = null, ): KaCallableSymbol? { val applicable = candidates.filter { candidate -> - candidate.acceptsIndexedSetterArguments(arguments) + candidate.acceptsIndexedSetterArguments(arguments, substitutor) } return applicable.firstOrNull { candidate -> applicable.none { other -> other !== candidate && other.parametersAreMoreSpecificThan(candidate) } @@ -607,11 +655,12 @@ internal fun KtArrayAccessExpression.selectedIndexedSetter(): KaCallableSymbol? context(_: KaSession) internal fun KaType.hasApplicableMemberIndexedSetter(arguments: List?): Boolean = runCatching { if (arguments == null) return@runCatching true + val substitutor = classTypeSubstitutor() expandedSymbol?.memberScope?.declarations .orEmpty() .filterIsInstance() .any { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "set" && - symbol.acceptsIndexedSetterArguments(arguments) + symbol.acceptsIndexedSetterArguments(arguments, substitutor) } }.getOrDefault(true) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt index e7e46d68a..36ee4d221 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -230,6 +230,12 @@ object KotlinConstructorSignatureCheck { /** * Tells whether a class type parameter and its function counterpart allow the same arguments. * + * The bound lists are compared as an unordered set: `where T : Left, T : Right` and + * `where T : Right, T : Left` impose the same intersection, but declaration order differs and a + * positional zip would compare `Right` with `Left`. Each substituted function bound therefore + * consumes a matching class bound, which also keeps duplicated bounds from hiding a real + * difference. + * * A missing function counterpart or unreadable bounds leave the comparison inconclusive, which * keeps the conflict decision conservative instead of hiding a real collision. * @@ -247,12 +253,17 @@ object KotlinConstructorSignatureCheck { functionParameter ?: return true val functionBounds = runCatching { functionParameter.symbol.upperBounds }.getOrNull() ?: return true val classBounds = runCatching { classParameter.symbol.upperBounds }.getOrNull() ?: return true - return functionBounds.size == classBounds.size && - functionBounds.zip(classBounds).all { (functionBound, classBound) -> - runCatching { - substitutor.substitute(functionBound).semanticallyEquals(classBound) - }.getOrDefault(false) - } + if (functionBounds.size != classBounds.size) return false + val remaining = classBounds.toMutableList() + return functionBounds.all { functionBound -> + val substituted = runCatching { substitutor.substitute(functionBound) }.getOrNull() + ?: return@all false + val match = remaining.firstOrNull { classBound -> + runCatching { substituted.semanticallyEquals(classBound) }.getOrDefault(false) + } ?: return@all false + remaining.remove(match) + true + } } /** diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index ff417ed6c..f259dc309 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -1232,7 +1232,7 @@ object KotlinImportBindingCheck { } ?.toList() .orEmpty() - selectMember(candidates, arguments) + selectMember(candidates, arguments, classTypeSubstitutor()) }.getOrNull() /** diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 97603e3a6..1dda1c5cf 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 773060f08..40fcd4462 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5508,6 +5508,124 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a nullable type-parameter occurrence accepts a null argument. + * + * For `val value by Box()` the top-level property passes a null `thisRef`, which + * `operator fun Box.getValue(thisRef: T?, ...)` accepts because `T?` is + * nullable. Checking null against the non-null `Host` bound of the symbol instead rejects the + * moving accessor, selects the staying `Any.getValue(Any?, ...)` fallback and plans no import. + */ + fun testApply_realSession_acceptsNullThisRefForNullableTypeParameter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-nullable-type-parameter") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Stays.kt"), + "package old\n\nclass Host\n\n" + + "operator fun Any.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 3\n", + ) + Files.writeString( + sourcePath, + "package old\n\nclass Box\n\n" + + "operator fun Box.getValue(" + + "thisRef: T?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-nullable-type-parameter-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moved accessor accepting a null thisRef through T?: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies member setters are checked on the receiver's instantiated type. + * + * `Grid` declares `set(value: T, index: Int)`, so the member takes `String` for a + * `Grid` receiver and cannot serve `grid[0]++`, which passes two `Int`s. Reading the + * member scope from the class symbol leaves `T` with its `Any?` bound, treats the member as + * applicable and skips the moving `Grid.set(Int, Int)` extension, so the write falls + * back to the staying `Any.set(Int, Int)` after the change. + */ + fun testApply_realSession_substitutesReceiverArgumentsForMemberSetters() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-member-setter-substitution") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Stays.kt"), + "package old\n\nclass Grid {\n" + + " operator fun get(index: Int): Int = 0\n" + + " operator fun set(value: T, index: Int) {}\n}\n\n" + + "operator fun Any.set(index: Int, value: Int) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Grid.set(index: Int, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(grid: Grid) {\n grid[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-member-setter-substitution-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the instantiated extension the member cannot serve: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies `provideDelegate` is filtered by a null `thisRef` for extension properties. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt index 82afe30e4..2105c0e18 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -390,6 +390,43 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig } } + /** + * Verifies bounds are compared as an unordered set, not by declaration order. + * + * `class Before where T : Left, T : Right` and + * `fun After(x: T) where T : Right, T : Left` impose the same intersection of bounds, so + * Kotlin reports conflicting overloads and the rename must be rejected. Zipping the bound + * lists positionally compares `Right` with `Left` and lets the invalid rename through. + */ + fun testConflictsWithRenamedConstructor_matchesBoundsRegardlessOfOrder() { + withSession( + name = "bound-order", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString(root.resolve("source/Interfaces.kt"), "package p\n\ninterface Left\n\ninterface Right\n") + Files.writeString( + root.resolve("source/Types.kt"), + "package p\n\nclass Before(val x: T) where T : Left, T : Right\n", + ) + Files.writeString( + root.resolve("source/Functions.kt"), + "package p\n\nfun After(x: T) where T : Right, T : Left = x\n", + ) + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertTrue( + "the same intersection of bounds collides regardless of its declaration order", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + /** @return the single top-level class of [file]. */ private fun primaryClass(file: KtFile): KtClass = file.declarations.filterIsInstance().single() diff --git a/pom.xml b/pom.xml index 29301b0fb..f6f38840d 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.58-SNAPSHOT + 0.12.59-SNAPSHOT pom Netbeans Plugin Kotlin From c004d82af5283884993e48703fe3b86baf323c94 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 12:24:04 +0300 Subject: [PATCH 50/55] Fixed extension setter substitution, provider argument inference and rewritten-import captures Reported by Codex review 5871698109: - selectExtension compared extension arguments with the unsubstituted type parameters of the declared receiver, so `operator fun Grid.set(index: T, value: Int)` looked applicable for a `Grid` receiver and shadowed the moving `Base.set`. - returnTypeInferredFrom only unified the declared receiver, so a provider whose type parameter appears only in `thisRef` (`operator fun Delegate.provideDelegate(thisRef: T, ...)`) kept returning `Provided` and the moving accessor was never imported. - isClassifierBoundInScope consulted default imports and the caller's package for callers whose explicit import the rename rewrites, rejecting a safe rename to a name such as `String`. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 15 +- .../refactoring/KaOperatorApplicability.kt | 169 +++++++++++++----- .../kotlin/refactoring/KaRenameComputer.kt | 46 ++++- .../refactoring/KotlinImportBindingCheck.kt | 35 +++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 120 +++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 88 +++++++++ .../KotlinImportBindingCheckTest.kt | 94 ++++++++++ pom.xml | 2 +- 19 files changed, 524 insertions(+), 67 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index a02fc8cff..96a45cb2e 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 9df6940c0..bae6f39e3 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index b23052c3e..028ea9bfa 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 71cd19e04..ea26bd0ab 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 78fbe7ea4..1f8c66743 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 0050de915..87b5c2d2a 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 3e038637e..911e28b86 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index dc496b1d3..b1f89f198 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 7f02c2369..49c25917a 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 9f1351eb2..ae945f47c 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index aec6cf81b..e8f21bec8 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -564,8 +564,12 @@ class KaChangePackageComputer( val provider = selectedOperator(delegateType, "provideDelegate") record(provider) // The provider's return type is declared in terms of its own type parameters; only the - // instantiation inferred from the delegate receiver selects the real accessor. - val accessorType = provider?.returnTypeInferredFrom(delegateType) ?: delegateType + // instantiation inferred from the delegate receiver and the implicit call's arguments + // selects the real accessor. + val accessorType = provider?.returnTypeInferredFrom( + delegateType, + delegationArgumentTypes("provideDelegate"), + ) ?: delegateType listOf("getValue", "setValue").forEach { name -> if (name == "setValue" && !isVar) return@forEach record(selectedOperator(accessorType, name)) @@ -1163,8 +1167,11 @@ class KaChangePackageComputer( // `provideDelegate` runs before the accessors and its return type is the // type the accessors are selected on, so it replaces the expression // type. A generic provider returns `Provided`, so the type has to - // be instantiated from the delegate receiver first. - val accessorType = provider?.returnTypeInferredFrom(delegateType) ?: delegateType + // be instantiated from the delegate receiver and the implicit call's + // arguments (`thisRef`) first. + val accessorType = + provider?.returnTypeInferredFrom(delegateType, providerArguments) + ?: delegateType val operators = buildList { if (provider != null) add(provider) property.selectedDelegateAccessor( diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 5afbbb5a6..bb36dd254 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -60,64 +60,120 @@ import org.jetbrains.kotlin.psi.KtUnaryExpression import org.jetbrains.kotlin.psi.psiUtil.containingClassOrObject /** - * Returns this callable's return type with its type arguments inferred from [receiverType]. + * Returns this callable's return type with its type arguments inferred from its call. * * A generic extension declares its return type in terms of its own type parameters * (`operator fun Delegate.provideDelegate(...): Provided`), while the compiler - * instantiates them from the receiver. The delegated property's accessors are selected on - * `Provided` for a `Delegate` delegate expression, so the declared `Provided` cannot - * be used directly. Unifying the declared receiver with the actual one yields the mapping. - * - * A callable without a receiver keeps its declared return type; unifyable structures also fall - * back to it, because guessing an instantiation would select the wrong operator. A member - * provider has no extension receiver, so its type arguments come from the instantiated scope of - * the receiver type: `Delegate.provideDelegate` selected on `Delegate` returns - * `Provided`. + * instantiates them from the receiver and from the implicit call's arguments. The delegated + * property's accessors are selected on `Provided` for a `Delegate` delegate expression, + * and on `Provided` for a provider declared as + * `operator fun Delegate.provideDelegate(thisRef: T, ...)`, whose `T` only the `thisRef` + * argument determines. Unifying the declared receiver and parameters with the actual ones yields + * the mapping. + * + * A callable whose structures cannot be unified keeps its declared return type, because guessing + * an instantiation would select the wrong operator. A member provider has no extension receiver, + * so its class type arguments come from the instantiated scope of the receiver type: + * `Delegate.provideDelegate` selected on `Delegate` returns `Provided`. * * @param receiverType actual type the callable is invoked on + * @param arguments argument types of the implicit call, or `null` when they cannot be resolved * @return the return type with the inferred type arguments substituted */ @OptIn(KaExperimentalApi::class) context(_: KaSession) -internal fun KaCallableSymbol.returnTypeInferredFrom(receiverType: KaType): KaType { +internal fun KaCallableSymbol.returnTypeInferredFrom( + receiverType: KaType, + arguments: List? = null, +): KaType = runCatching { + val declaredReturnType = returnType + val mappings = linkedMapOf() val declaredReceiverType = receiverParameter?.returnType - ?: return memberReturnTypeInferredFrom(receiverType) - return runCatching { - val mappings = linkedMapOf() - if (!collectTypeMappings(declaredReceiverType, receiverType, mappings)) return@runCatching returnType - if (mappings.isEmpty()) return@runCatching returnType - createSubstitutor(mappings).substitute(returnType) - }.getOrDefault(returnType) + val classSubstitutor = if (declaredReceiverType != null) { + if (!collectTypeMappings(declaredReceiverType, receiverType, mappings)) return@runCatching declaredReturnType + null + } else { + // A member spells its class type parameters (`Provided`) while the receiver is the + // instantiated class (`Delegate`), so the class's parameters provide the mapping and + // its instantiation is what the value parameters are spelled with. + collectClassTypeMappings(receiverType, mappings) + receiverType.classTypeSubstitutor() + } + if (arguments != null && !collectArgumentTypeMappings(this, arguments, classSubstitutor, mappings)) { + return@runCatching declaredReturnType + } + if (mappings.isEmpty()) return@runCatching declaredReturnType + createSubstitutor(mappings).substitute(declaredReturnType) +}.getOrDefault(returnType) + +/** + * Collects the type-parameter instantiations that the receiver class's arguments supply. + * + * A receiver that is not a class type, or whose arity does not match the declaring class's type + * parameters, contributes nothing and leaves the other sources of inference untouched. + * + * @param receiverType instantiated type the callable is invoked on + * @param mappings accumulator of type-parameter instantiations + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun collectClassTypeMappings( + receiverType: KaType, + mappings: MutableMap, +) { + runCatching { + val classType = receiverType as? KaClassType ?: return@runCatching + val classParameters = (classType.expandedSymbol?.psi as? KtClassOrObject)?.typeParameters + ?: return@runCatching + val typeParameters = classParameters.mapNotNull { parameter -> + runCatching { parameter.symbol }.getOrNull() + } + val arguments = classType.typeArguments + if (typeParameters.size != arguments.size) return@runCatching + typeParameters.forEachIndexed { index, parameter -> + arguments[index].type?.let { argument -> mappings[parameter] = argument } + } + } } /** - * Returns this member's return type with the receiver class's arguments substituted. + * Collects the type-parameter instantiations the implicit call's arguments imply. * - * A member symbol spells its class type parameters (`Provided`), while the receiver type is - * the instantiated class (`Delegate`). Mapping the class declaration's parameters to the - * receiver's arguments gives the instantiation the accessors are selected on. A receiver that is - * not the declaring class (an inherited member) keeps the declared return type. + * The provider `operator fun Delegate.provideDelegate(thisRef: T, ...)` declares `T` only in a + * value parameter, so the delegate type instantiates nothing and only the property's `thisRef` + * tells that the provider returns `Provided`. A null literal constrains no type parameter — + * a top-level property passes `null` as `thisRef` — so it contributes no mapping. A vararg call + * passes a different number of arguments than the declaration has parameters, which positional + * unification cannot express, so such a call leaves the inference untouched. * - * @param receiverType instantiated type the member is invoked on - * @return the return type with the class's type arguments substituted + * @param callable callable whose value parameters are unified + * @param arguments argument types of the implicit call + * @param classSubstitutor instantiation of the declaring class, or `null` for an extension + * @param mappings accumulator of type-parameter instantiations + * @return `false` when the same type parameter would need two different instantiations */ @OptIn(KaExperimentalApi::class) context(_: KaSession) -private fun KaCallableSymbol.memberReturnTypeInferredFrom(receiverType: KaType): KaType = runCatching { - val classType = receiverType as? KaClassType ?: return@runCatching returnType - val classParameters = (classType.expandedSymbol?.psi as? KtClassOrObject)?.typeParameters - ?: return@runCatching returnType - val typeParameters = classParameters.mapNotNull { parameter -> - runCatching { parameter.symbol }.getOrNull() +private fun collectArgumentTypeMappings( + callable: KaCallableSymbol, + arguments: List, + classSubstitutor: KaSubstitutor?, + mappings: MutableMap, +): Boolean { + val parameters = (callable as? KaFunctionSymbol)?.valueParameters ?: return true + if (parameters.size != arguments.size) return true + val parameterTypes = parameters.map { parameter -> + val declaredType = parameter.returnType + classSubstitutor?.let { substitutor -> + runCatching { substitutor.substitute(declaredType) }.getOrDefault(declaredType) + } ?: declaredType } - val arguments = classType.typeArguments - if (typeParameters.size != arguments.size) return@runCatching returnType - val mappings = typeParameters.mapIndexedNotNull { index, parameter -> - arguments[index].type?.let { argument -> parameter to argument } - }.toMap() - if (mappings.isEmpty()) return@runCatching returnType - createSubstitutor(mappings).substitute(returnType) -}.getOrDefault(returnType) + return arguments.withIndex().all { (index, argument) -> + argument == null || + argument.semanticallyEquals(builtinTypes.nullableNothing) || + collectTypeMappings(parameterTypes[index], argument, mappings) + } +} /** * Collects the type-parameter instantiations that make [actual] match [declared]. @@ -338,11 +394,12 @@ private fun KtProperty.propertyValueType(): KaType? = * arguments after it match the trailing fixed parameters, and the rest match the vararg element * type. A parameter whose type is a type parameter or cannot be compared is accepted, because the * alternative — treating it as inapplicable — would import an extension the member already - * shadows. A member scope is declared, not instantiated, so a [substitutor] replacing the class's - * type parameters with the receiver's type arguments is applied to every parameter type first. + * shadows. A declared scope is not instantiated, so a [substitutor] replacing the declared type + * parameters — the declaring class's for a member, the receiver's for an extension — with the + * actual type arguments is applied to every parameter type first. * * @param arguments argument types in call order; `null` entries are unresolved expressions - * @param substitutor maps the declaring class's type parameters to the receiver's arguments + * @param substitutor instantiates the type parameters the parameter types are spelled with * @return `true` when the callable can be selected for those arguments */ context(_: KaSession) @@ -511,13 +568,39 @@ internal fun selectExtension( ): KaCallableSymbol? { val applicable = candidates.filter { candidate -> runCatching { candidate.canBeCalledAsExtensionOn(receiverType) }.getOrDefault(false) && - (arguments == null || candidate.acceptsIndexedSetterArguments(arguments)) + (arguments == null || + candidate.acceptsIndexedSetterArguments( + arguments, + candidate.receiverTypeSubstitutor(receiverType), + )) } return applicable.firstOrNull { candidate -> applicable.none { other -> other !== candidate && other.isMoreSpecificThan(candidate) } } ?: applicable.firstOrNull() } +/** + * Returns the substitutor instantiating this extension's type parameters from [receiverType]. + * + * An extension spells its value parameters with the type parameters of its declaring receiver + * (`operator fun Grid.set(index: T, value: Int)`), so `T : Any?` accepts every argument + * until the receiver type supplies the instantiation. For a `Grid` receiver the setter + * takes a `String` index and cannot serve `grid[0]++`, whose index is an `Int`. A declared + * receiver that is not a class type supplies no instantiation. + * + * @param receiverType actual type the extension is invoked on + * @return the substitutor, or `null` when the receiver instantiates no type parameter + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun KaCallableSymbol.receiverTypeSubstitutor(receiverType: KaType): KaSubstitutor? = runCatching { + val declaredReceiverType = receiverParameter?.returnType ?: return@runCatching null + val mappings = linkedMapOf() + if (!collectTypeMappings(declaredReceiverType, receiverType, mappings)) return@runCatching null + if (mappings.isEmpty()) return@runCatching null + createSubstitutor(mappings) +}.getOrNull() + /** * Tells whether this callable's overload is preferred over [other] by the compiler. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index cd7bf7701..af33a65cb 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -35,7 +35,9 @@ import org.jetbrains.kotlin.idea.references.mainReference import org.jetbrains.kotlin.kdoc.lexer.KDocTokens import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtCallExpression +import org.jetbrains.kotlin.psi.KtClassBody import org.jetbrains.kotlin.psi.KtClassOrObject import org.jetbrains.kotlin.psi.KtConstructorDelegationReferenceExpression import org.jetbrains.kotlin.psi.KtDotQualifiedExpression @@ -387,6 +389,13 @@ class KaRenameComputer( val targetPackage = (targetPsi as? KtNamedDeclaration)?.containingKtFile?.packageFqName for (kaFile in projectKtFiles) { for (target in targets) { + // The rename rewrites a non-aliased import of the target verbatim, and an explicit + // import outranks the caller's declarations, its package, wildcard imports, and + // Kotlin's default imports. Such a caller therefore cannot capture the new name + // through any of those scopes. + val rewrittenImport = kaFile.bindsExplicitImportOf( + (target as? KtNamedDeclaration)?.importableFqName(), + ) for (element in collectReferenceElements(kaFile, target)) { // A receiver label such as `this@After` names a labeled scope, not a // declaration, so a local or member of that name cannot capture it. @@ -403,7 +412,7 @@ class KaRenameComputer( // overload resolution and can replace the renamed declaration. KotlinImportBindingCheck.isDeclaredInQualifiedPackage(element, newName, typePosition) } else if (typePosition) { - KotlinImportBindingCheck.isClassifierBoundInScope(element, newName) + KotlinImportBindingCheck.isClassifierBoundInScope(element, newName, rewrittenImport) } else { KotlinImportBindingCheck.isNameBoundInScope(element, newName) || isDeclaredInPackageScope(kaFile, targetPackage) @@ -415,6 +424,41 @@ class KaRenameComputer( return null } + /** + * Tells whether this file imports the declaration of [fqName] by its own name. + * + * Only such a directive is rewritten verbatim by a rename. A wildcard import keeps its + * spelled path, and an aliased directive binds a name that does not change, so neither + * introduces the new simple name into the file. + * + * @param fqName importable name of the renamed declaration, or `null` when it has none + * @return `true` when the file's explicit import binds the renamed simple name + */ + private fun KtFile.bindsExplicitImportOf(fqName: FqName?): Boolean = + fqName != null && importDirectives.any { directive -> + !directive.isAllUnder && directive.aliasName == null && directive.importedFqName == fqName + } + + /** + * Returns the name this declaration is imported by, or `null` when it cannot be imported. + * + * A top-level declaration is named by its package and its own name, while a nested classifier + * adds its containers: `import p.Outer.Inner` binds `Inner`, so renaming it rewrites that + * directive too. A declaration that cannot be imported at all — a local, a member, or a + * top-level declaration without a name — reports `null`. + * + * @return fully qualified name the import directives spell, or `null` + */ + private fun KtNamedDeclaration.importableFqName(): FqName? { + val declarationName = name ?: return null + return when (val container = parent) { + is KtFile -> container.packageFqName.child(Name.identifier(declarationName)) + is KtClassBody -> (container.parent as? KtNamedDeclaration)?.importableFqName() + ?.child(Name.identifier(declarationName)) + else -> null + } + } + /** * Tells whether a declaration of the caller's own package binds the new name. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index f259dc309..4e30d8192 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -586,18 +586,35 @@ object KotlinImportBindingCheck { * name — not by a function or property. Imports are treated conservatively because telling a * classifier import from a callable import needs resolution. * + * A rewritten explicit import outranks every file-level scope: the file's own declarations, + * its package's declarations, wildcard imports, and Kotlin's default imports all lose to it, + * so renaming a class to `String` in a file that imports it explicitly is safe even though + * `kotlin.String` is imported by default. Lexical bindings are not affected — a type parameter + * or a local class resolves before any import. + * * @param element position whose rewritten text denotes a type * @param name new simple name the position is about to spell + * @param rewrittenImport `true` when the refactoring rewrites an explicit import to bind + * [name], which makes the file-level scopes irrelevant * @return `true` when another classifier would win over the intended target */ - fun isClassifierBoundInScope(element: KtElement, name: String): Boolean { + fun isClassifierBoundInScope( + element: KtElement, + name: String, + rewrittenImport: Boolean = false, + ): Boolean { val file = element.containingKtFile + // Another directive binding the same name cannot be outranked by the rewritten import: + // two explicit imports of one name are a conflicting import. if (file.importDirectives.any { directive -> directive.bindsName(name, emptySet()) }) return true - if (file.declarations.any { declaration -> declaration.declaresClassifierName(name) }) return true - // A sibling file of the caller's package, a wildcard import, and Kotlin's default imports - // bind a classifier without any directive of this file, so the composite scope has to be - // consulted as well: the rewritten type reference would resolve to that classifier. - if (isClassifierBoundByScope(element, name)) return true + if (!rewrittenImport) { + if (file.declarations.any { declaration -> declaration.declaresClassifierName(name) }) return true + // A sibling file of the caller's package, a wildcard import, and Kotlin's default + // imports bind a classifier without any directive of this file, so the composite scope + // has to be consulted as well: the rewritten type reference would resolve to that + // classifier. + if (isClassifierBoundByScope(element, name)) return true + } var child: PsiElement = element var current: PsiElement? = element.parent while (current != null) { @@ -1173,7 +1190,11 @@ object KotlinImportBindingCheck { if (name !in accessorNames) return emptyList() val provider = delegateOperator(delegateType, "provideDelegate") val accessorTypes = provider - ?.let { symbol -> runCatching { symbol.returnTypeInferredFrom(delegateType) }.getOrNull() } + ?.let { symbol -> + runCatching { + symbol.returnTypeInferredFrom(delegateType, delegationArgumentTypes("provideDelegate")) + }.getOrNull() + } ?.let { providedType -> listOf(providedType) } ?: listOf(delegateType) val arguments = delegationArgumentTypes(name) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 1dda1c5cf..d4818b3ed 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 40fcd4462..caba81f4a 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5626,6 +5626,126 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies extension setters are checked on the receiver's instantiated type. + * + * `operator fun Grid.set(index: T, value: Int)` cannot serve `grid[0]++` for a + * `Grid` receiver, which passes two `Int`s, so overload resolution selects the more + * specific moving `Base.set(Int, Int)` over the staying `Any.set(Int, Int)`. Comparing the + * index against the unsubstituted `T : Any?` keeps the generic extension applicable and + * prefers its narrower receiver, so the caller never imports the setter it actually calls. + */ + fun testApply_realSession_substitutesReceiverArgumentsForExtensions() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-extension-setter-substitution") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Grid.kt"), + "package old\n\nopen class Grid : Base() {\n" + + " operator fun get(index: Int): Int = 0\n}\n\n" + + "operator fun Grid.set(index: T, value: Int) {}\n\n" + + "operator fun Any.set(index: Int, value: Int) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\nopen class Base\n\noperator fun Base.set(index: Int, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(grid: Grid) {\n grid[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-extension-setter-substitution-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the extension overload resolution selects: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a generic provider's result is instantiated from its call arguments. + * + * `operator fun Delegate.provideDelegate(thisRef: T, ...): Provided` declares no type + * argument on the receiver, so the delegate type alone instantiates nothing and the accessor + * lookup runs on the declared `Provided`. The compiler infers `T = Host` from the member + * property's `thisRef` and selects the moving `Provided.getValue`, which the staying file + * then has to import; otherwise the write silently falls back to the staying `Any.getValue`. + */ + fun testApply_realSession_infersProviderResultFromCallArguments() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-provider-argument-inference") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val stayingPath = temporaryRoot.resolve("old/Stays.kt") + Files.writeString( + stayingPath, + "package old\n\nclass Delegate\n\nclass Host {\n val value by Delegate()\n}\n\n" + + "operator fun Any.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\nclass Provided\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: T, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Host, property: kotlin.reflect.KProperty<*>): Int = 0\n", + ) + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.Host\n\nfun use(host: Host) = host.value\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-provider-argument-inference-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[stayingPath.toString()] ?: error("The staying file must change") + assertTrue( + "The staying file must import the accessor the provider's instantiated result selects: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies `provideDelegate` is filtered by a null `thisRef` for extension properties. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index aa56c34d6..71a9eca2d 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -599,6 +599,94 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a default-import classifier does not capture a reference the rewrite imports. + * + * Renaming `p.Before` to `String` rewrites the caller's `import p.Before` to `import p.String`, + * and an explicit import outranks the default `kotlin.String`. The pre-rename composite scope + * still offers `kotlin.String`, so consulting it would reject a rename that resolves fine. + */ + fun testFindCaptureConflicts_ignoresClassifierOutrankedByRewrittenImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-rewritten-import") + try { + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("q/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package q\n\nimport p.Before\n\nfun consume(x: Before) = x.toString()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-rewritten-import-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "String", + ).findCaptureConflicts() + + assertNull( + "The rewritten explicit import outranks the default kotlin.String", + conflict, + ) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a default-import classifier captures a reference without a rewritten import. + * + * The caller reaches `p.Before` through a wildcard import, so the rename adds no explicit + * directive and the rewritten `String` keeps resolving to the default `kotlin.String`. + */ + fun testFindCaptureConflicts_reportsDefaultClassifierWithoutRewrittenImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-default-import") + try { + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("q/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before\n") + Files.writeString( + callerPath, + "package q\n\nimport p.*\n\nfun consume(x: Before) = x.toString()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-default-import-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "String", + ).findCaptureConflicts() + + assertEquals( + "The rewritten type would bind the default kotlin.String", + caller, + conflict, + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a type-position reference is not treated as a capture. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 7da3da175..404e9ba64 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -976,6 +976,100 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies the file-level scopes are skipped when a rewritten explicit import binds the name. + * + * The caller writes `import p.Before`, which the rename rewrites to `import p.String`, and an + * explicit import outranks the default `kotlin.String` as well as wildcard and same-package + * declarations. Consulting those scopes would report a capture the rewritten import prevents. + */ + fun testIsClassifierBoundInScope_ignoresScopesOutrankedByRewrittenImport() { + withSession( + name = "classifier-rewritten-import-outranks", + prepare = { root -> + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport p.Before\n\nfun consume(x: Before) = x.toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val typeElement = caller.declarations.filterIsInstance().single() + .valueParameters.single().typeReference?.typeElement ?: error("No type element") + + assertFalse( + "the rewritten explicit import outranks the default kotlin.String", + KotlinImportBindingCheck.isClassifierBoundInScope(typeElement, "String", rewrittenImport = true), + ) + } + } + + /** + * Verifies a lexical classifier still captures a reference bound by a rewritten import. + * + * A type parameter is resolved before any import, so `fun consume(x: Before)` keeps + * binding the rewritten `String` type reference to its own type parameter. + */ + fun testIsClassifierBoundInScope_reportsTypeParameterDespiteRewrittenImport() { + withSession( + name = "classifier-rewritten-import-type-parameter", + prepare = { root -> + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport p.Before\n\nfun consume(x: Before) = x.toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val typeElement = caller.declarations.filterIsInstance().single() + .valueParameters.single().typeReference?.typeElement ?: error("No type element") + + assertTrue( + "a type parameter outranks the import, so the rewritten type would bind it", + KotlinImportBindingCheck.isClassifierBoundInScope(typeElement, "String", rewrittenImport = true), + ) + } + } + + /** + * Verifies another explicit import of the same name stays a conflict. + * + * Two explicit directives binding one name cannot both win, so a rewritten import cannot make + * the rewrite safe while the file also imports a different `String`. + */ + fun testIsClassifierBoundInScope_reportsOtherExplicitImportDespiteRewrittenImport() { + withSession( + name = "classifier-rewritten-import-other-directive", + prepare = { root -> + for (directory in listOf("p", "q", "lib")) { + Files.createDirectories(root.resolve(directory)) + } + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before\n") + Files.writeString(root.resolve("lib/String.kt"), "package lib\n\nclass String\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport lib.String\nimport p.Before\n\n" + + "fun consume(x: Before) = x.toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val typeElement = caller.declarations.filterIsInstance().single() + .valueParameters.single().typeReference?.typeElement ?: error("No type element") + + assertTrue( + "another explicit directive already binds the name", + KotlinImportBindingCheck.isClassifierBoundInScope(typeElement, "String", rewrittenImport = true), + ) + } + } + /** @return the session source for [path], failing the test when it is absent. */ private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") diff --git a/pom.xml b/pom.xml index f6f38840d..1581a8798 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.59-SNAPSHOT + 0.12.60-SNAPSHOT pom Netbeans Plugin Kotlin From abd8f990110d21654c1e90104896f7dc844e8f14 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 12:58:51 +0300 Subject: [PATCH 51/55] Fixed receiver inference over subtypes, nullable setter arguments and rewritten value imports - KaOperatorApplicability: substituted the receiver-derived instantiations into the provider's parameter types, so an argument only has to be assignable to the instantiated parameter instead of contradicting the receiver (`Host : Base` with `Delegate` still infers `Provided`). - KaOperatorApplicability: a nullable type-parameter occurrence (`T?`) now checks the argument's non-nullable part against the bounds, so `var value: Host?` fits `operator fun Box.setValue(..., value: T?)`. - KotlinImportBindingCheck/KaRenameComputer: value positions (constructor calls) honour the rewritten explicit import priority, which outranks the file's declarations, wildcard imports and Kotlin's default imports. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaOperatorApplicability.kt | 76 +++++++++-- .../kotlin/refactoring/KaRenameComputer.kt | 9 +- .../refactoring/KotlinImportBindingCheck.kt | 26 ++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 122 ++++++++++++++++++ .../kotlin/nbm/refactoring/KaRenameTest.kt | 89 +++++++++++++ .../KotlinImportBindingCheckTest.kt | 105 +++++++++++++++ pom.xml | 2 +- 18 files changed, 417 insertions(+), 34 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 96a45cb2e..632b82fa2 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index bae6f39e3..c430c4f83 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 028ea9bfa..db58a0ef7 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index ea26bd0ab..f6b86fc32 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 1f8c66743..33c6d46d6 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 87b5c2d2a..bb5b717f5 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 911e28b86..33da97432 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index b1f89f198..8576d7a73 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 49c25917a..0a24e27e5 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index ae945f47c..27432a55c 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index bb36dd254..1619eb816 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -19,6 +19,8 @@ package io.github.nbplugins.kotlin.refactoring import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.components.allSupertypes +import org.jetbrains.kotlin.analysis.api.components.buildClassType +import org.jetbrains.kotlin.analysis.api.components.buildTypeParameterType import org.jetbrains.kotlin.analysis.api.components.builtinTypes import org.jetbrains.kotlin.analysis.api.components.canBeCalledAsExtensionOn import org.jetbrains.kotlin.analysis.api.components.createSubstitutor @@ -146,6 +148,12 @@ private fun collectClassTypeMappings( * passes a different number of arguments than the declaration has parameters, which positional * unification cannot express, so such a call leaves the inference untouched. * + * The receiver may already have instantiated some type parameters (`Delegate` fixes + * `T = Base`). Those instantiations are substituted into the parameter types first, so an argument + * only has to be assignable to the parameter the receiver instantiated instead of contradicting + * the receiver: `Host : Base` still passes `T = Base` to + * `provideDelegate(thisRef: T)`. + * * @param callable callable whose value parameters are unified * @param arguments argument types of the implicit call * @param classSubstitutor instantiation of the declaring class, or `null` for an extension @@ -162,16 +170,27 @@ private fun collectArgumentTypeMappings( ): Boolean { val parameters = (callable as? KaFunctionSymbol)?.valueParameters ?: return true if (parameters.size != arguments.size) return true + val knownSubstitutor = if (mappings.isEmpty()) { + null + } else { + runCatching { createSubstitutor(mappings.toMap()) }.getOrNull() + } val parameterTypes = parameters.map { parameter -> val declaredType = parameter.returnType - classSubstitutor?.let { substitutor -> - runCatching { substitutor.substitute(declaredType) }.getOrDefault(declaredType) - } ?: declaredType + listOfNotNull(classSubstitutor, knownSubstitutor).fold(declaredType) { type, substitutor -> + runCatching { substitutor.substitute(type) }.getOrDefault(type) + } } return arguments.withIndex().all { (index, argument) -> - argument == null || - argument.semanticallyEquals(builtinTypes.nullableNothing) || - collectTypeMappings(parameterTypes[index], argument, mappings) + if (argument == null || argument.semanticallyEquals(builtinTypes.nullableNothing)) return@all true + val parameterType = parameterTypes[index] + // A parameter type whose type parameters are all instantiated is concrete: the argument + // only has to be assignable, and requiring an exact match would reject a subtype. + if (parameterType.mentionsTypeParameter()) { + collectTypeMappings(parameterType, argument, mappings) + } else { + acceptsArgument(parameterType, argument) + } } } @@ -474,7 +493,9 @@ internal fun KaType.classTypeSubstitutor(): KaSubstitutor? = runCatching { * overload the compiler skips and hides the one that is actually bound. A bound that itself * mentions a type parameter (`T : Comparable`) cannot be compared without instantiating it, so * that bound stays inconclusive instead of rejecting a possibly valid argument. The nullability of - * the occurrence matters as well: `T?` accepts null even when the bound of `T` is non-null. + * the occurrence matters as well: `T?` accepts null even when the bound of `T` is non-null, and it + * accepts a nullable argument (`T?` with `T : Host` takes `Host?`), so only the argument's + * non-nullable part faces the bounds. * * @param parameterType declared parameter type * @param argument actual argument type @@ -483,17 +504,48 @@ internal fun KaType.classTypeSubstitutor(): KaSubstitutor? = runCatching { context(_: KaSession) private fun acceptsArgument(parameterType: KaType, argument: KaType): Boolean = runCatching { if (parameterType is KaTypeParameterType) { - val nullableOccurrenceTakesNull = - parameterType.nullability == KaTypeNullability.NULLABLE && - argument.semanticallyEquals(builtinTypes.nullableNothing) - nullableOccurrenceTakesNull || parameterType.symbol.upperBounds.all { bound -> - bound.mentionsTypeParameter() || acceptsArgument(bound, argument) + val nullableOccurrence = parameterType.nullability == KaTypeNullability.NULLABLE + if (nullableOccurrence && argument.semanticallyEquals(builtinTypes.nullableNothing)) { + return@runCatching true + } + val checkedArgument = + if (nullableOccurrence && argument.nullability == KaTypeNullability.NULLABLE) { + argument.asNonNullable() ?: argument + } else { + argument + } + parameterType.symbol.upperBounds.all { bound -> + bound.mentionsTypeParameter() || acceptsArgument(bound, checkedArgument) } } else { argument.isSubtypeOf(parameterType) } }.getOrDefault(true) +/** + * Rebuilds this type without its nullability marker. + * + * A nullable occurrence `T?` accepts a nullable argument as long as the argument's non-nullable + * part satisfies the bounds of `T`. The analysis API exposes no nullability setter on an existing + * type, so the type is rebuilt from its class or type-parameter symbol. + * + * @return the non-nullable counterpart of this type, or `null` when it cannot be rebuilt + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaType.asNonNullable(): KaType? = runCatching { + when (this) { + is KaClassType -> buildClassType(classId) { + typeArguments.forEach { projection -> argument(projection) } + nullability = KaTypeNullability.NON_NULLABLE + } + is KaTypeParameterType -> buildTypeParameterType(symbol) { + nullability = KaTypeNullability.NON_NULLABLE + } + else -> null + } +}.getOrNull() + /** * Tells whether a type parameter appears anywhere in this type's structure. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index af33a65cb..535d39b58 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt @@ -414,8 +414,13 @@ class KaRenameComputer( } else if (typePosition) { KotlinImportBindingCheck.isClassifierBoundInScope(element, newName, rewrittenImport) } else { - KotlinImportBindingCheck.isNameBoundInScope(element, newName) || - isDeclaredInPackageScope(kaFile, targetPackage) + // A constructor call is a value reference, but the rewritten import + // outranks the file-level scopes for it exactly as for a type reference. + KotlinImportBindingCheck.isNameBoundInScope( + element, + newName, + rewrittenImport = rewrittenImport, + ) || isDeclaredInPackageScope(kaFile, targetPackage) } if (captured) return kaFile } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt index 4e30d8192..04c50a07f 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -671,6 +671,9 @@ object KotlinImportBindingCheck { * re-spelling [name] through them is not a capture * @param ignoredDeclarations declarations that are the intended target themselves; a package * change leaves their names in place, so they must not be reported as captures + * @param rewrittenImport `true` when the refactoring rewrites an explicit import to bind + * [name], which outranks the file's own declarations, wildcard imports, and Kotlin's + * default imports * @return `true` when another declaration would win over the intended target */ fun isNameBoundInScope( @@ -678,17 +681,24 @@ object KotlinImportBindingCheck { name: String, ignoredImports: Set = emptySet(), ignoredDeclarations: Set = emptySet(), + rewrittenImport: Boolean = false, ): Boolean { val file = element.containingKtFile if (file.importDirectives.any { directive -> directive.bindsName(name, ignoredImports) }) return true - if (file.declarations.any { declaration -> - declaration !in ignoredDeclarations && declaration.declaresName(name) - }) return true - // A wildcard or default import binds names without spelling any directive of this file, so - // a classifier or callable it provides hides a same-named package segment or declaration - // exactly like an explicit import does. The composite scope also contains the explicit - // import this check deliberately ignores, so the ignore sets have to follow along. - if (isNameBoundByWildcardImport(element, name, ignoredImports, ignoredDeclarations)) return true + if (!rewrittenImport) { + // The rewritten directive outranks the file-level scopes, so neither a declaration of + // the file nor a wildcard or default import can capture the rewritten name. Those + // bindings are not free either: the rewrite replaces them with the target's import. + if (file.declarations.any { declaration -> + declaration !in ignoredDeclarations && declaration.declaresName(name) + }) return true + // A wildcard or default import binds names without spelling any directive of this file, + // so a classifier or callable it provides hides a same-named package segment or + // declaration exactly like an explicit import does. The composite scope also contains + // the explicit import this check deliberately ignores, so the ignore sets have to + // follow along. + if (isNameBoundByWildcardImport(element, name, ignoredImports, ignoredDeclarations)) return true + } var child: PsiElement = element var current: PsiElement? = element.parent while (current != null) { diff --git a/Nbm/pom.xml b/Nbm/pom.xml index d4818b3ed..bd977d2d2 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index caba81f4a..fdb879a63 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -5746,6 +5746,128 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a receiver-derived instantiation survives a subtype call argument. + * + * `operator fun Delegate.provideDelegate(thisRef: T, ...): Provided` instantiates + * `T = Base` from the receiver `Delegate`, while the enclosing `Host : Base` passes a + * subtype as `thisRef`. Requiring the argument to equal the inferred instantiation contradicts + * the receiver and falls back to the declared `Provided`, which selects no accessor at all; + * the argument only has to be assignable to the instantiated parameter. + */ + fun testApply_realSession_keepsReceiverInferenceForSubtypeArguments() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-provider-subtype-argument") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val stayingPath = temporaryRoot.resolve("old/Stays.kt") + Files.writeString( + stayingPath, + "package old\n\nopen class Base\n\nclass Delegate\n\n" + + "class Host : Base() {\n val value by Delegate()\n}\n\n" + + "operator fun Any.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\nclass Provided\n\n" + + "operator fun Delegate.provideDelegate(" + + "thisRef: T, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Host, property: kotlin.reflect.KProperty<*>): Int = 0\n", + ) + Files.writeString( + temporaryRoot.resolve("usage/Usage.kt"), + "package usage\n\nimport old.Host\n\nfun use(host: Host) = host.value\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-provider-subtype-argument-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[stayingPath.toString()] ?: error("The staying file must change") + assertTrue( + "The staying file must import the accessor of the receiver-instantiated provider result: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a nullable value argument fits a nullable type-parameter setter. + * + * `operator fun Box.setValue(..., value: T?)` takes `Host?` for `T = Host`, but the + * nullable occurrence only accepts `null` explicitly: the bound of `T` is non-null, so the + * `Host?` value fails a raw subtype test and the moving setter is dropped in favour of the + * staying `Any.setValue`. The occurrence's nullability is what the argument has to satisfy, so + * only the argument's non-nullable part faces the bounds. + */ + fun testApply_realSession_keepsNullableValueForNullableSetter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-nullable-setter") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box\n\nopen class Host\n\n" + + "operator fun Any.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Any? = null\n\n" + + "operator fun Any.setValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>, " + + "value: Any?) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.setValue(thisRef: Any?, " + + "property: kotlin.reflect.KProperty<*>, value: T?) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nvar value: Host? by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-nullable-setter-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the setter the nullable property value fits: $text", + text.contains("import dest.setValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies `provideDelegate` is filtered by a null `thisRef` for extension properties. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 71a9eca2d..20c7a8527 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt @@ -643,6 +643,95 @@ class KaRenameTest : KotlinTestCase("KaRenameTest", "rename") { } } + /** + * Verifies a default-import name does not capture a constructor call the rewrite imports. + * + * Renaming `p.Before` to `String` rewrites the caller's `import p.Before` to `import p.String`, + * which outranks the default `kotlin.String` in a value position exactly as it does in a type + * position. The pre-rename composite scope still offers `kotlin.String`, so consulting it + * would reject the constructor call `Before(1)` that resolves fine after the rewrite. + */ + fun testFindCaptureConflicts_ignoresValueOutrankedByRewrittenImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-rewritten-import-value") + try { + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("q/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before(val value: Int = 0)\n") + Files.writeString( + callerPath, + "package q\n\nimport p.Before\n\nfun make() = Before(1).toString()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-rewritten-import-value-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "String", + ).findCaptureConflicts() + + assertNull( + "The rewritten explicit import outranks the default kotlin.String value binding", + conflict, + ) + } finally { + root.toFile().deleteRecursively() + } + } + + /** + * Verifies a default-import name captures a constructor call without a rewritten import. + * + * The caller reaches `p.Before` through a wildcard import, so the rename adds no explicit + * directive and the rewritten `String(1)` keeps resolving through the default import. + */ + fun testFindCaptureConflicts_reportsValueWithoutRewrittenImport() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping capture-conflict test") + return + } + val root = Files.createTempDirectory("nbkotlin-rename-capture-default-import-value") + try { + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + val sourcePath = root.resolve("p/Before.kt") + val callerPath = root.resolve("q/Use.kt") + Files.writeString(sourcePath, "package p\n\nclass Before(val value: Int = 0)\n") + Files.writeString( + callerPath, + "package q\n\nimport p.*\n\nfun make() = Before(1).toString()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "rename-capture-default-import-value-integration", + binaryJars = listOf(stdlib), + sourceRoots = listOf(root), + ) + val source = session.getKtFileForPath(sourcePath.toString()) ?: error("Could not obtain source PSI") + val caller = session.getKtFileForPath(callerPath.toString()) ?: error("Could not obtain caller PSI") + + val conflict = KaRenameComputer( + source, source.text.indexOf("Before"), setOf(source, caller), "String", + ).findCaptureConflicts() + + assertEquals( + "The default import keeps binding the rewritten constructor call", + caller, + conflict, + ) + } finally { + root.toFile().deleteRecursively() + } + } + /** * Verifies a default-import classifier captures a reference without a rewritten import. * diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt index 404e9ba64..8bb6c8939 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -22,10 +22,12 @@ import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.name.FqName import org.jetbrains.kotlin.psi.KtCallExpression import org.jetbrains.kotlin.psi.KtClass +import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtFile import org.jetbrains.kotlin.psi.KtNameReferenceExpression import org.jetbrains.kotlin.psi.KtNamedFunction import org.jetbrains.kotlin.psi.KtProperty +import org.jetbrains.kotlin.psi.KtReturnExpression import utils.KotlinTestCase import java.nio.file.Files import java.nio.file.Path @@ -1070,6 +1072,109 @@ class KotlinImportBindingCheckTest : KotlinTestCase("KotlinImportBindingCheckTes } } + /** + * Verifies a value position outranked by a rewritten import is not reported. + * + * The caller spells `import p.Before`, which the rename rewrites to `import p.String`; that + * explicit directive outranks the default `kotlin.String`, so the constructor call it binds is + * safe to rename even though the pre-rename scope offers a `String` of its own. + */ + fun testIsNameBoundInScope_ignoresScopesOutrankedByRewrittenImport() { + withSession( + name = "value-rewritten-import-outranks", + prepare = { root -> + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before(val value: Int = 0)\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport p.Before\n\nfun make() = Before(1).toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val callee = caller.constructorCallee("Before") + + assertFalse( + "the rewritten explicit import outranks the default kotlin.String value binding", + KotlinImportBindingCheck.isNameBoundInScope(callee, "String", rewrittenImport = true), + ) + } + } + + /** + * Verifies a lexical value binding still captures a reference bound by a rewritten import. + * + * A local declaration is resolved before any import, so the rewritten `String` keeps binding + * the local instead of the import the rename introduces. + */ + fun testIsNameBoundInScope_reportsLocalDespiteRewrittenImport() { + withSession( + name = "value-rewritten-import-local", + prepare = { root -> + Files.createDirectories(root.resolve("p")) + Files.createDirectories(root.resolve("q")) + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before(val value: Int = 0)\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport p.Before\n\n" + + "fun make(): Any {\n val String = 1\n return Before(String)\n}\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val callee = caller.declarations.filterIsInstance().single() + .bodyBlockExpression?.statements?.lastOrNull()?.let { statement -> + (((statement as? KtReturnExpression)?.returnedExpression) as? KtCallExpression) + ?.calleeExpression + } + ?: error("No constructor call") + + assertTrue( + "a local declaration outranks the import, so the rewritten call would bind it", + KotlinImportBindingCheck.isNameBoundInScope(callee, "String", rewrittenImport = true), + ) + } + } + + /** + * Verifies another explicit import of the same name stays a conflict in a value position. + * + * Two explicit directives binding one name cannot both win, so a rewritten import cannot make + * the constructor call safe while the file also imports a different `String`. + */ + fun testIsNameBoundInScope_reportsOtherExplicitImportDespiteRewrittenImport() { + withSession( + name = "value-rewritten-import-other-directive", + prepare = { root -> + for (directory in listOf("p", "q", "lib")) { + Files.createDirectories(root.resolve(directory)) + } + Files.writeString(root.resolve("p/Before.kt"), "package p\n\nclass Before(val value: Int = 0)\n") + Files.writeString(root.resolve("lib/String.kt"), "package lib\n\nclass String(val value: Int = 0)\n") + Files.writeString( + root.resolve("q/Caller.kt"), + "package q\n\nimport lib.String\nimport p.Before\n\nfun make() = Before(1).toString()\n", + ) + }, + ) { session, root -> + val caller = session.ktFile(root.resolve("q/Caller.kt")) + val callee = caller.constructorCallee("Before") + + assertTrue( + "another explicit directive already binds the name", + KotlinImportBindingCheck.isNameBoundInScope(callee, "String", rewrittenImport = true), + ) + } + } + + /** @return the callee of the constructor call spelling [name], failing when it is absent. */ + private fun KtFile.constructorCallee(name: String): KtExpression = + PsiTreeUtil.findChildrenOfType(this, KtCallExpression::class.java) + .firstOrNull { call -> call.calleeExpression?.text == name } + ?.calleeExpression + ?: error("No constructor call of $name") + /** @return the session source for [path], failing the test when it is absent. */ private fun KotlinAnalysisAPISession.ktFile(path: Path): KtFile = getKtFileForPath(path.toString()) ?: error("No Kotlin source PSI for $path") diff --git a/pom.xml b/pom.xml index 1581a8798..1af97206a 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.60-SNAPSHOT + 0.12.61-SNAPSHOT pom Netbeans Plugin Kotlin From cdb0b7c58d5c489de3da62d82c58f2f403799aa7 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 13:34:53 +0300 Subject: [PATCH 52/55] Fixed delegate operator modifiers, defaulted index setters, concrete receiver ranking and reordered type parameters - Rejected non-operator provideDelegate methods so only operators decide the delegated accessor. - Bound the assigned value of an indexed write to the setter's final parameter and allowed omitted defaulted index parameters in setter applicability. - Compared extension receivers as instantiated for the actual receiver and ranked concrete receivers ahead of generic ones with fewer type parameters. - Derived the constructor/function type-parameter correspondence from the compared signature instead of the declaration positions. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 4 +- .../refactoring/KaDelegateOperatorShape.kt | 19 +- .../refactoring/KaOperatorApplicability.kt | 133 +++++++++---- .../KotlinConstructorSignatureCheck.kt | 174 ++++++++++++++--- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 177 ++++++++++++++++++ .../KotlinConstructorSignatureCheckTest.kt | 37 ++++ pom.xml | 2 +- 18 files changed, 489 insertions(+), 79 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 632b82fa2..84201a562 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index c430c4f83..4cbc0d9c9 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index db58a0ef7..d01b499cb 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index f6b86fc32..a38e5f1b6 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 33c6d46d6..52cd58df1 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index bb5b717f5..e83db6909 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 33da97432..4e2624445 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 8576d7a73..2bfea6c35 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 0a24e27e5..5e8559637 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 27432a55c..bf6f85de0 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index e8f21bec8..3cb54322a 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -485,7 +485,7 @@ class KaChangePackageComputer( // Both `Box.set` and the more specific `SubBox.set` can be visible at once; only the // one overload resolution selects may be recorded, or the caller would import a // setter it does not call. - return selectExtension(candidates, type, arguments) + return selectExtension(candidates, type, arguments, indexedWrite = true) } accept(object : KtTreeVisitorVoid() { override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { @@ -1447,7 +1447,7 @@ class KaChangePackageComputer( .toList() // The receiver alone cannot tell `Box.set` from the more specific `SubBox.set`; only the // overload resolution result may be imported. - val selected = selectExtension(candidates, receiverType, expression.setterArgumentTypes()) + val selected = selectExtension(candidates, receiverType, expression.setterArgumentTypes(), indexedWrite = true) ?: return null return samePackageTopLevelName(selected, originalPackage, movedPaths) } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt index 7cd09b212..df6446373 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt @@ -23,21 +23,26 @@ import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.findClass +import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName +import org.jetbrains.kotlin.psi.KtModifierListOwner /** * Tells whether this callable has the call shape Kotlin requires of a delegate operator. * - * Delegated properties select their operators by name, but a same-named member is only an - * accessor when its signature fits the delegation protocol: `provideDelegate` and `getValue` take - * the delegating receiver and the property, while `setValue` adds the assigned value. A - * parameterless `fun getValue()` is therefore an ordinary member and must not hide the extension - * the property actually uses. + * Delegated properties select their operators by name, but a same-named method takes part only + * when it is declared `operator` *and* its signature fits the delegation protocol: + * `provideDelegate` and `getValue` take the delegating receiver and the property, while `setValue` + * adds the assigned value. An ordinary `fun getValue()` is therefore ignored, and so is a + * non-operator `fun provideDelegate(...)` that only happens to match the parameter shape — Kotlin + * never calls such a method, so treating it as a provider would switch the accessor lookup to its + * result type. * * Kotlin passes a `KProperty` instance to the property parameter, which may be declared with any * supertype of it (`Any?`, for example), so the parameter is matched by assignability rather than - * by its classifier. + * by its classifier. Library declarations expose no PSI, so only a declaration that visibly lacks + * the modifier is rejected. * * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @return `true` when this callable can be selected as that delegate operator @@ -46,6 +51,8 @@ import org.jetbrains.kotlin.name.FqName context(_: KaSession) internal fun KaCallableSymbol.isDelegateOperatorShape(operatorName: String): Boolean { val function = this as? KaFunctionSymbol ?: return false + val modifierList = function.psi as? KtModifierListOwner + if (modifierList != null && !modifierList.hasModifier(KtTokens.OPERATOR_KEYWORD)) return false val expectedParameters = if (operatorName == "setValue") 3 else 2 if (function.valueParameters.size != expectedParameters) return false return runCatching { diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 1619eb816..f0ffb40d4 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -37,6 +37,7 @@ import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol import org.jetbrains.kotlin.analysis.api.symbols.symbol @@ -417,14 +418,25 @@ private fun KtProperty.propertyValueType(): KaType? = * parameters — the declaring class's for a member, the receiver's for an extension — with the * actual type arguments is applied to every parameter type first. * + * An indexed write may also omit index parameters: + * `operator fun set(i: Int, j: Int = 0, value: Int)` serves `box[0] = 1` by binding the index to + * `i`, the assigned value to the last parameter and leaving the defaulted `j` out. Such a call + * passes fewer arguments than the declaration has parameters, so [indexedWrite] binds the assigned + * value to the final parameter and requires every parameter between the supplied indices and it to + * have a default. A delegate call always passes one argument per parameter and keeps the positional + * rule. + * * @param arguments argument types in call order; `null` entries are unresolved expressions * @param substitutor instantiates the type parameters the parameter types are spelled with + * @param indexedWrite `true` for the arguments of an indexed write, which always bind the assigned + * value to the final parameter and may omit defaulted index parameters * @return `true` when the callable can be selected for those arguments */ context(_: KaSession) internal fun KaCallableSymbol.acceptsIndexedSetterArguments( arguments: List, substitutor: KaSubstitutor? = null, + indexedWrite: Boolean = false, ): Boolean = runCatching { val function = this as? KaFunctionSymbol ?: return@runCatching false val parameters = function.valueParameters @@ -436,6 +448,9 @@ internal fun KaCallableSymbol.acceptsIndexedSetterArguments( } ?: declaredType } val varargIndex = parameters.indexOfFirst { it.isVararg } + if (indexedWrite && varargIndex < 0) { + return@runCatching acceptsIndexedWrite(arguments, parameters, parameterTypes) + } if (varargIndex < 0) { if (arguments.size != parameters.size) return@runCatching false return@runCatching arguments.withIndex().all { (index, argument) -> @@ -457,6 +472,41 @@ internal fun KaCallableSymbol.acceptsIndexedSetterArguments( } }.getOrDefault(false) +/** + * Tells whether these parameters accept the indices and the assigned value of an indexed write. + * + * The assigned value always takes the final parameter and the indices fill the leading parameters + * in order, so the parameters in between are the ones the call omits and each of them must have a + * default. `set(i: Int, j: Int, value: Int)` therefore rejects `box[0] = 1`, while + * `set(i: Int, j: Int = 0, value: Int)` accepts it; a setter without any index parameter rejects + * every write. + * + * @param arguments index types followed by the assigned value's type; `null` entries are unresolved + * @param parameters declared value parameters of the setter + * @param parameterTypes instantiated types of [parameters] + * @return `true` when the write can be selected + */ +context(_: KaSession) +private fun acceptsIndexedWrite( + arguments: List, + parameters: List, + parameterTypes: List, +): Boolean { + if (arguments.isEmpty()) return false + val indexCount = arguments.size - 1 + if (indexCount > parameters.size - 1) return false + if (parameters.subList(indexCount, parameters.size - 1).any { parameter -> !parameter.hasDefaultValue }) { + return false + } + val indicesMatch = (0 until indexCount).all { index -> + val argument = arguments[index] ?: return@all true + acceptsArgument(parameterTypes[index], argument) + } + if (!indicesMatch) return false + val value = arguments.last() ?: return true + return acceptsArgument(parameterTypes.last(), value) +} + /** * Returns the substitutor mapping this type's class parameters to its actual type arguments. * @@ -580,6 +630,8 @@ private fun KaType.mentionsTypeParameter(): Boolean = when (this) { * @param candidates member callables visible under the same simple name * @param arguments argument types of the implicit call * @param substitutor maps the declaring class's type parameters to the receiver's arguments + * @param indexedWrite `true` when [arguments] belong to an indexed write, whose assigned value + * binds to the final parameter and whose defaulted index parameters may be omitted * @return the selected member, or `null` when none applies */ @OptIn(KaExperimentalApi::class) @@ -588,9 +640,10 @@ internal fun selectMember( candidates: List, arguments: List, substitutor: KaSubstitutor? = null, + indexedWrite: Boolean = false, ): KaCallableSymbol? { val applicable = candidates.filter { candidate -> - candidate.acceptsIndexedSetterArguments(arguments, substitutor) + candidate.acceptsIndexedSetterArguments(arguments, substitutor, indexedWrite) } return applicable.firstOrNull { candidate -> applicable.none { other -> other !== candidate && other.parametersAreMoreSpecificThan(candidate) } @@ -609,6 +662,8 @@ internal fun selectMember( * @param candidates extensions visible under the same simple name * @param receiverType type of the value the operator is invoked on * @param arguments argument types of the call, or `null` when they cannot be resolved + * @param indexedWrite `true` when [arguments] belong to an indexed write, whose assigned value + * binds to the final parameter and whose defaulted index parameters may be omitted * @return the selected extension, or `null` when none applies */ @OptIn(KaExperimentalApi::class) @@ -617,6 +672,7 @@ internal fun selectExtension( candidates: List, receiverType: KaType, arguments: List? = null, + indexedWrite: Boolean = false, ): KaCallableSymbol? { val applicable = candidates.filter { candidate -> runCatching { candidate.canBeCalledAsExtensionOn(receiverType) }.getOrDefault(false) && @@ -624,10 +680,11 @@ internal fun selectExtension( candidate.acceptsIndexedSetterArguments( arguments, candidate.receiverTypeSubstitutor(receiverType), + indexedWrite, )) } return applicable.firstOrNull { candidate -> - applicable.none { other -> other !== candidate && other.isMoreSpecificThan(candidate) } + applicable.none { other -> other !== candidate && other.isMoreSpecificThan(candidate, receiverType) } } ?: applicable.firstOrNull() } @@ -654,34 +711,55 @@ internal fun KaCallableSymbol.receiverTypeSubstitutor(receiverType: KaType): KaS }.getOrNull() /** - * Tells whether this callable's overload is preferred over [other] by the compiler. + * Tells whether this callable's overload is preferred over [other] for [receiverType]. * - * A more specific receiver wins first. When both are declared on the same receiver type, the - * preference falls back to the value parameters: `Box.set(Int, Int)` is more specific than - * `Box.set(Int, Number)` for a write whose value is an `Int`. + * The receivers are compared as they are instantiated for the actual receiver, because a generic + * declared receiver (`Box`) has no subtype relation with a concrete one (`Box`) until + * the receiver supplies `T`. Two overloads whose instantiated receivers agree are ranked by their + * type parameters: the concrete receiver needs fewer of them, and Kotlin prefers it over the + * generic one. When both need as many, the preference falls back to the value parameters: + * `Box.set(Int, Int)` is more specific than `Box.set(Int, Number)` for a write whose value is an + * `Int`. * * @param other competing callable + * @param receiverType type of the value the operator is invoked on * @return `true` when this callable is a strictly better overload */ @OptIn(KaExperimentalApi::class) context(_: KaSession) -private fun KaCallableSymbol.isMoreSpecificThan(other: KaCallableSymbol): Boolean = - receiverIsMoreSpecificThan(other) || - (hasSameReceiverAs(other) && parametersAreMoreSpecificThan(other)) +private fun KaCallableSymbol.isMoreSpecificThan( + other: KaCallableSymbol, + receiverType: KaType, +): Boolean = runCatching { + val receiver = instantiatedReceiverType(receiverType) ?: return@runCatching false + val otherReceiver = other.instantiatedReceiverType(receiverType) ?: return@runCatching false + if (!receiver.semanticallyEquals(otherReceiver)) { + return@runCatching receiver.isSubtypeOf(otherReceiver) && !otherReceiver.isSubtypeOf(receiver) + } + val ownParameters = (this as? KaFunctionSymbol)?.typeParameters?.size ?: 0 + val otherParameters = (other as? KaFunctionSymbol)?.typeParameters?.size ?: 0 + if (ownParameters != otherParameters) return@runCatching ownParameters < otherParameters + parametersAreMoreSpecificThan(other) +}.getOrDefault(false) /** - * Tells whether both callables declare the same receiver type. + * Returns this extension's declared receiver as instantiated for [receiverType]. * - * @param other competing callable - * @return `true` when the declared receivers are equivalent + * `operator fun Box.getValue(...)` declares the receiver `Box`, and only the actual + * `Box` receiver turns it into the `Box` the compiler compares. A declaration that + * instantiates no type parameter keeps its declared receiver. + * + * @param receiverType type of the value the extension is invoked on + * @return the instantiated receiver, or `null` when there is none */ @OptIn(KaExperimentalApi::class) context(_: KaSession) -private fun KaCallableSymbol.hasSameReceiverAs(other: KaCallableSymbol): Boolean = runCatching { - val receiver = receiverParameter?.returnType ?: return@runCatching false - val otherReceiver = other.receiverParameter?.returnType ?: return@runCatching false - receiver.semanticallyEquals(otherReceiver) -}.getOrDefault(false) +private fun KaCallableSymbol.instantiatedReceiverType(receiverType: KaType): KaType? = runCatching { + val declaredReceiver = receiverParameter?.returnType ?: return@runCatching null + receiverTypeSubstitutor(receiverType)?.let { substitutor -> + runCatching { substitutor.substitute(declaredReceiver) }.getOrDefault(declaredReceiver) + } ?: declaredReceiver +}.getOrNull() /** * Tells whether this callable's value parameters are strictly more specific than [other]'s. @@ -711,23 +789,6 @@ private fun KaCallableSymbol.parametersAreMoreSpecificThan(other: KaCallableSymb fits && strict }.getOrDefault(false) -/** - * Tells whether this callable's receiver is a proper subtype of [other]'s. - * - * The declared receiver types decide which extension overload wins when both are visible through - * the same scope. Equal types are not "more specific", so a tie keeps the declaration order. - * - * @param other competing callable - * @return `true` when this receiver fits [other]'s receiver but not the other way round - */ -@OptIn(KaExperimentalApi::class) -context(_: KaSession) -private fun KaCallableSymbol.receiverIsMoreSpecificThan(other: KaCallableSymbol): Boolean = runCatching { - val receiver = receiverParameter?.returnType ?: return@runCatching false - val otherReceiver = other.receiverParameter?.returnType ?: return@runCatching false - receiver.isSubtypeOf(otherReceiver) && !otherReceiver.isSubtypeOf(receiver) -}.getOrDefault(false) - /** * Tells whether this indexed access is updated implicitly, not merely assigned or read. * @@ -772,7 +833,7 @@ internal fun KtArrayAccessExpression.selectedIndexedSetter(): KaCallableSymbol? val candidates = scopeWithKind.scope.callables(identifier) .filter { symbol -> symbol.isExtension } .toList() - selectExtension(candidates, receiverType, arguments)?.let { return it } + selectExtension(candidates, receiverType, arguments, indexedWrite = true)?.let { return it } } return null } @@ -796,6 +857,6 @@ internal fun KaType.hasApplicableMemberIndexedSetter(arguments: List?): .filterIsInstance() .any { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "set" && - symbol.acceptsIndexedSetterArguments(arguments, substitutor) + symbol.acceptsIndexedSetterArguments(arguments, substitutor, indexedWrite = true) } }.getOrDefault(true) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt index 36ee4d221..12e695733 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -45,9 +45,9 @@ import org.jetbrains.kotlin.psi.KtTypeParameter * Renaming a class writes its new name verbatim, so a same-package function whose call signature * matches a constructor becomes a conflicting overload that Kotlin rejects. Parameter types are * compared as resolved types, because `Int` and a fully qualified `kotlin.Int` are the same Kotlin - * type even though their source spellings differ. Type parameters are compared by position after - * substituting the function's parameters with the class's, and the bounds of those the value - * parameters actually use are compared as well. + * type even though their source spellings differ. Type parameters are matched by the role they play + * in the compared signature and then substituted by their counterparts, and the bounds of those the + * value parameters actually use are compared as well. */ object KotlinConstructorSignatureCheck { @@ -73,7 +73,6 @@ object KotlinConstructorSignatureCheck { .filter { parameters -> parameters.size == function.valueParameters.size } if (matching.isEmpty()) return false return analyze(function) { - val substitutor = typeParameterSubstitutor(primaryClass, function) matching.any { parameters -> // A vararg and a fixed parameter of the same type are distinct overloads in // Kotlin, so a constructor cannot collide with a function whose vararg status @@ -83,6 +82,9 @@ object KotlinConstructorSignatureCheck { function.valueParameters[index].hasModifier(KtTokens.VARARG_KEYWORD) } if (!varargsMatch) return@any false + val correspondence = typeParameterCorrespondence(primaryClass, function, parameters) + ?: return@any false + val substitutor = typeParameterSubstitutor(correspondence) val typesMatch = parameters.indices.all { index -> val constructorType = runCatching { parameters[index].typeReference?.type }.getOrNull() val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() @@ -93,35 +95,148 @@ object KotlinConstructorSignatureCheck { // Bounds are part of the signature only for the type parameters the matched value // parameters actually use; an unused parameter cannot tell two calls apart even // when its bounds differ, so it must not hide a real conflict. - typesMatch && typeParametersMatch(primaryClass, function, substitutor, parameters) + typesMatch && + typeParametersMatch(primaryClass, function, substitutor, parameters, correspondence) } } } /** - * Maps the function's type parameters to the class's parameters at the same position. + * Builds the substitutor replacing the function's type parameters with their class counterparts. * - * After the rename the function spells the class's parameters, so a parameter type using the - * function's first type parameter corresponds to one using the class's first type parameter. - * Their resolved type symbols differ, which would make semantically equivalent signatures - * compare as different. Function parameters without a counterpart stay unmapped and therefore - * keep a parameter type that uses them different. - * - * @param classOrObject class whose type parameters the function would spell - * @param function function whose type parameters are substituted + * @param correspondence function type parameters mapped to the class parameters they stand for * @return substitutor replacing the function's type parameters with the class's */ context(_: KaSession) private fun typeParameterSubstitutor( + correspondence: Map, + ): KaSubstitutor = createSubstitutor( + correspondence.mapValues { (_, classSymbol) -> buildTypeParameterType(classSymbol) }, + ) + + /** + * Derives which function type parameter stands for which class parameter. + * + * Type parameters are identified by their declaration, but the function being compared may + * declare them in another order: `class Before(val x: B, val y: A)` exposes the + * constructor `After(B, A)` after the rename, and `fun After(x: A, y: B)` accepts + * exactly the same calls once its own parameters are read as `B, A`. The correspondence is + * therefore read off the compared value parameter types instead of their positions: each pair + * is unified structurally, recording which function parameter stands for which class + * parameter. The relation must stay a bijection, so a pair that would map two function + * parameters to one class parameter — or the other way round — means the signatures differ. + * + * A type parameter no value parameter spells keeps its positional counterpart, because an + * unused parameter cannot distinguish calls while a bound that mentions another parameter still + * takes part in the comparison, and position is the only relation left for it. A value + * parameter whose type cannot be resolved supplies no correspondence and keeps that positional + * pairing as well. + * + * @param classOrObject class whose type parameters the function would spell + * @param function function whose type parameters are mapped + * @param parameters value parameters of the compared constructor + * @return function parameter symbol to class parameter symbol, or `null` when a resolved + * parameter pair cannot be unified + */ + context(_: KaSession) + private fun typeParameterCorrespondence( classOrObject: KtClass, function: KtNamedFunction, - ): KaSubstitutor { - val classParameters = classOrObject.typeParameters - val mapping = function.typeParameters.mapIndexedNotNull { index, parameter -> - val classParameter = classParameters.getOrNull(index) ?: return@mapIndexedNotNull null - parameter.symbol to buildTypeParameterType(classParameter.symbol) - }.toMap() - return createSubstitutor(mapping) + parameters: List, + ): Map? = runCatching { + val classSymbols = classOrObject.typeParameters.mapNotNull { parameter -> + runCatching { parameter.symbol }.getOrNull() + }.toSet() + val functionSymbols = function.typeParameters.mapNotNull { parameter -> + runCatching { parameter.symbol }.getOrNull() + }.toSet() + if (classSymbols.isEmpty() && functionSymbols.isEmpty()) return@runCatching emptyMap() + val mappings = linkedMapOf() + parameters.indices.forEach { index -> + val constructorType = runCatching { parameters[index].typeReference?.type }.getOrNull() + ?: return@forEach + val functionType = runCatching { function.valueParameters[index].typeReference?.type }.getOrNull() + ?: return@forEach + val unified = unifyTypeParameters( + constructorType, + functionType, + classSymbols, + functionSymbols, + mappings, + ) + if (!unified) return@runCatching null + } + classOrObject.typeParameters.forEachIndexed { index, classParameter -> + val classSymbol = runCatching { classParameter.symbol }.getOrNull() ?: return@forEachIndexed + if (classSymbol in mappings.values) return@forEachIndexed + val functionSymbol = runCatching { function.typeParameters.getOrNull(index)?.symbol }.getOrNull() + ?: return@forEachIndexed + if (functionSymbol !in functionSymbols || functionSymbol in mappings) return@forEachIndexed + mappings[functionSymbol] = classSymbol + } + mappings.toMap() + }.getOrNull() + + /** + * Unifies a constructor parameter type with the function's, recording the type parameters. + * + * Two type parameters correspond to each other and are recorded; two class types correspond + * when they denote the same class and their type arguments unify pairwise; anything else + * corresponds only when the types are semantically equal. A class type parameter facing a + * concrete type, or a correspondence that would have to be many-to-one, means the signatures + * differ. + * + * @param classType parameter type of the constructor being renamed + * @param functionType parameter type of the function + * @param classSymbols type parameters of the class + * @param functionSymbols type parameters of the function + * @param mappings correspondence recorded so far, extended in place + * @return `true` when the types can be the same after renaming the type parameters + */ + context(_: KaSession) + private fun unifyTypeParameters( + classType: KaType, + functionType: KaType, + classSymbols: Set, + functionSymbols: Set, + mappings: MutableMap, + ): Boolean { + val functionParameter = (functionType as? KaTypeParameterType) + ?.takeIf { type -> type.symbol in functionSymbols } + if (functionParameter != null) { + val classParameter = (classType as? KaTypeParameterType) + ?.takeIf { type -> type.symbol in classSymbols } + ?: return false + if (classParameter.nullability != functionParameter.nullability) return false + val recorded = mappings[functionParameter.symbol] + if (recorded != null) return recorded == classParameter.symbol + if (classParameter.symbol in mappings.values) return false + mappings[functionParameter.symbol] = classParameter.symbol + return true + } + val classParameterSymbol = (classType as? KaTypeParameterType)?.symbol + if (classParameterSymbol != null && classParameterSymbol in classSymbols) return false + if (classType is KaClassType && functionType is KaClassType) { + if (classType.classId != functionType.classId) return false + if (classType.nullability != functionType.nullability) return false + if (classType.typeArguments.size != functionType.typeArguments.size) return false + return classType.typeArguments.indices.all { index -> + val classArgument = classType.typeArguments[index].type + val functionArgument = functionType.typeArguments[index].type + when { + classArgument == null || functionArgument == null -> + classArgument == null && functionArgument == null + else -> unifyTypeParameters( + classArgument, + functionArgument, + classSymbols, + functionSymbols, + mappings, + ) + } + } + } + return runCatching { classType.semanticallyEquals(functionType) }.getOrDefault(false) } /** @@ -139,6 +254,7 @@ object KotlinConstructorSignatureCheck { * @param function function whose type parameter bounds are compared * @param substitutor maps the function's type parameters to the class's * @param parameters matched value parameters of the compared signature + * @param correspondence function type parameters mapped to the class parameters they stand for * @return `true` when every relevant corresponding bound is equivalent */ context(_: KaSession) @@ -147,14 +263,26 @@ object KotlinConstructorSignatureCheck { function: KtNamedFunction, substitutor: KaSubstitutor, parameters: List, + correspondence: Map, ): Boolean { val classParameters = classOrObject.typeParameters val indexBySymbol = classParameters.mapIndexedNotNull { index, parameter -> runCatching { parameter.symbol }.getOrNull()?.let { symbol -> symbol to index } }.toMap() + val functionParameterBySymbol = function.typeParameters.mapNotNull { parameter -> + runCatching { parameter.symbol }.getOrNull()?.let { symbol -> symbol to parameter } + }.toMap() + val functionSymbolByClassSymbol = correspondence.entries.associate { (functionSymbol, classSymbol) -> + classSymbol to functionSymbol + } + fun functionCounterpart(classParameter: KtTypeParameter): KtTypeParameter? { + val classSymbol = runCatching { classParameter.symbol }.getOrNull() ?: return null + val functionSymbol = functionSymbolByClassSymbol[classSymbol] ?: return null + return functionParameterBySymbol[functionSymbol] + } val usedIndices = usedTypeParameterIndices(parameters, indexBySymbol) ?: return classParameters.indices.all { index -> - boundsMatch(classParameters[index], function.typeParameters.getOrNull(index), substitutor) + boundsMatch(classParameters[index], functionCounterpart(classParameters[index]), substitutor) } val pending = ArrayDeque(usedIndices) while (pending.isNotEmpty()) { @@ -165,7 +293,7 @@ object KotlinConstructorSignatureCheck { .forEach(pending::addLast) } return usedIndices.all { index -> - boundsMatch(classParameters[index], function.typeParameters.getOrNull(index), substitutor) + boundsMatch(classParameters[index], functionCounterpart(classParameters[index]), substitutor) } } diff --git a/Nbm/pom.xml b/Nbm/pom.xml index bd977d2d2..cf8dc7c6c 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index fdb879a63..e6f89e1f0 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -6273,6 +6273,183 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies an ordinary method named `provideDelegate` does not decide the accessor. + * + * Kotlin selects a provider only when it is an operator: the non-operator + * `Box.provideDelegate(...): Decoy` declared by the moving file is ignored, so + * `val value: Int by Box()` keeps using the staying `Box.getValue`. Recognizing the method by + * its signature shape alone redirects the accessor lookup to `Decoy.getValue`, records that + * moving accessor and imports it even though the property never calls it. + */ + fun testApply_realSession_ignoresNonOperatorProvider() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-non-operator-provider") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box\n\n" + + "operator fun Box.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\nclass Decoy\n\n" + + "fun Box.provideDelegate(thisRef: Any?, property: kotlin.reflect.KProperty<*>): Decoy = Decoy()\n\n" + + "operator fun Decoy.getValue(thisRef: Any?, property: kotlin.reflect.KProperty<*>): String = \"decoy\"\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value: Int by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-non-operator-provider-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()].orEmpty() + assertFalse( + "The property keeps the staying accessor, so the caller must gain no operator import: $text", + text.contains("import dest."), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a setter with a defaulted index parameter stays applicable to an indexed write. + * + * `operator fun Box.set(i: Int, j: Int = 0, value: Int)` accepts `box[0]++`: the index fills + * `i`, the assigned value fills the final parameter and the defaulted `j` is omitted. The + * moving setter therefore shadows the staying `Any.set(i, value)`. Comparing the argument + * count with the declared parameter count rejects it and plans no import, so the write + * silently falls back to the staying setter after the change. + */ + fun testApply_realSession_importsSetterWithDefaultedIndexParameter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-defaulted-index") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\n" + + "open class Box {\n operator fun get(index: Int): Int = 0\n}\n\n" + + "operator fun Any.set(i: Int, value: Int) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(i: Int, j: Int = 0, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n box[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-defaulted-index-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the moving setter with a defaulted index parameter: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a concrete receiver overload outranks a generic one. + * + * For an invariant `Box`, both ` Box.getValue(...)` and `Box.getValue(...)` + * accept `val value by Box()`, and Kotlin selects the concrete overload. Comparing raw + * declared receivers finds neither more specific, so the generic accessor that stays behind is + * selected and no `dest.getValue` import is planned, silently changing the property's type + * after the move. + */ + fun testApply_realSession_prefersConcreteReceiverAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-concrete-receiver") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\nclass Box\n\n" + + "operator fun Box.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\n" + + "operator fun Box.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): String = \"moved\"\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value by Box()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-concrete-receiver-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the accessor with the concrete receiver: $text", + text.contains("import dest.getValue"), + ) + } 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")) diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt index 2105c0e18..3dcbb06fe 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -427,6 +427,43 @@ class KotlinConstructorSignatureCheckTest : KotlinTestCase("KotlinConstructorSig } } + /** + * Verifies the type-parameter correspondence is read from the signatures, not their positions. + * + * `class Before(val x: B, val y: A)` exposes the constructor `After(B, A)` once renamed, + * while `fun After(x: A, y: B)` accepts the same calls as soon as its own parameters are + * read as `B, A`. Both declarations are therefore the same signature and Kotlin reports + * conflicting overloads. Pairing type parameters by declaration index compares the + * constructor's `B` against the function's `A` and lets the invalid rename through. + */ + fun testConflictsWithRenamedConstructor_matchesReorderedTypeParameters() { + withSession( + name = "reordered-type-parameters", + prepare = { root -> + Files.createDirectories(root.resolve("source")) + Files.writeString( + root.resolve("source/Types.kt"), + "package p\n\nclass Before(val x: B, val y: A)\n", + ) + Files.writeString( + root.resolve("source/Functions.kt"), + "package p\n\nfun After(x: A, y: B) = x\n", + ) + }, + ) { session, root -> + val types = session.ktFile(root.resolve("source/Types.kt")) + val functions = session.ktFile(root.resolve("source/Functions.kt")) + + assertTrue( + "renaming the function's type parameters makes both signatures identical", + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + /** @return the single top-level class of [file]. */ private fun primaryClass(file: KtFile): KtClass = file.declarations.filterIsInstance().single() diff --git a/pom.xml b/pom.xml index 1af97206a..44a21bbd3 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.61-SNAPSHOT + 0.12.62-SNAPSHOT pom Netbeans Plugin Kotlin From 4f655e97e2cef58563eab5f34d272e1ee5d29899 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 14:03:34 +0300 Subject: [PATCH 53/55] Fixed inherited operator eligibility and defaulted setter ranking Inherited an operator modifier through overrides: a delegate provider override without its own modifier still decides the accessor, while an override of an ordinary method stays ineligible. Non-operator members no longer hide operator extensions of indexed writes, and overload ranking prefers the setter that omits no default parameters. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaDelegateOperatorShape.kt | 79 +++++++- .../refactoring/KaOperatorApplicability.kt | 47 ++++- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 186 ++++++++++++++++++ pom.xml | 2 +- 15 files changed, 314 insertions(+), 22 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index 84201a562..ae57187d6 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 4cbc0d9c9..3a8110baa 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index d01b499cb..7d38f44d9 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index a38e5f1b6..c76dde005 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 52cd58df1..2189965ba 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index e83db6909..6fc9979eb 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 4e2624445..c8894d4a3 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 2bfea6c35..b1ee33734 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 5e8559637..0d60346d5 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index bf6f85de0..41f68dc6e 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt index df6446373..ab495852c 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt @@ -18,11 +18,16 @@ package io.github.nbplugins.kotlin.refactoring import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession +import org.jetbrains.kotlin.analysis.api.components.allSupertypes +import org.jetbrains.kotlin.analysis.api.components.defaultType import org.jetbrains.kotlin.analysis.api.components.defaultTypeWithStarProjections +import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf +import org.jetbrains.kotlin.analysis.api.components.memberScope import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.findClass +import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName @@ -32,7 +37,7 @@ import org.jetbrains.kotlin.psi.KtModifierListOwner * Tells whether this callable has the call shape Kotlin requires of a delegate operator. * * Delegated properties select their operators by name, but a same-named method takes part only - * when it is declared `operator` *and* its signature fits the delegation protocol: + * when it is an [operator][isOperatorEligible] *and* its signature fits the delegation protocol: * `provideDelegate` and `getValue` take the delegating receiver and the property, while `setValue` * adds the assigned value. An ordinary `fun getValue()` is therefore ignored, and so is a * non-operator `fun provideDelegate(...)` that only happens to match the parameter shape — Kotlin @@ -41,8 +46,7 @@ import org.jetbrains.kotlin.psi.KtModifierListOwner * * Kotlin passes a `KProperty` instance to the property parameter, which may be declared with any * supertype of it (`Any?`, for example), so the parameter is matched by assignability rather than - * by its classifier. Library declarations expose no PSI, so only a declaration that visibly lacks - * the modifier is rejected. + * by its classifier. * * @param operatorName `provideDelegate`, `getValue`, or `setValue` * @return `true` when this callable can be selected as that delegate operator @@ -51,8 +55,7 @@ import org.jetbrains.kotlin.psi.KtModifierListOwner context(_: KaSession) internal fun KaCallableSymbol.isDelegateOperatorShape(operatorName: String): Boolean { val function = this as? KaFunctionSymbol ?: return false - val modifierList = function.psi as? KtModifierListOwner - if (modifierList != null && !modifierList.hasModifier(KtTokens.OPERATOR_KEYWORD)) return false + if (!function.isOperatorEligible()) return false val expectedParameters = if (operatorName == "setValue") 3 else 2 if (function.valueParameters.size != expectedParameters) return false return runCatching { @@ -61,5 +64,71 @@ internal fun KaCallableSymbol.isDelegateOperatorShape(operatorName: String): Boo }.getOrDefault(false) } +/** + * Tells whether Kotlin may select this callable for a convention call. + * + * The `operator` modifier is inherited through overrides: `class Delegate : Base() { override fun + * provideDelegate(...) }` stays the provider of the `operator` base even though the declaration + * itself does not repeat the keyword, while an override of an ordinary method is not eligible and + * the compiler reports `OPERATOR_MODIFIER_REQUIRED` for such a call. The modifier is therefore + * checked on the declaration first and, for an override, on the declarations it overrides. A + * library declaration exposes no PSI, so one whose modifier cannot be inspected is accepted, and + * a callable whose declaring class cannot be resolved keeps the same conservative answer. + * + * @return `true` when the compiler can use this callable as an operator + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +internal fun KaCallableSymbol.isOperatorEligible(): Boolean { + val function = this as? KaFunctionSymbol ?: return true + val modifierList = function.psi as? KtModifierListOwner ?: return true + if (modifierList.hasModifier(KtTokens.OPERATOR_KEYWORD)) return true + if (!modifierList.hasModifier(KtTokens.OVERRIDE_KEYWORD)) return false + return runCatching { function.overridesOperator() }.getOrDefault(true) +} + +/** + * Tells whether this override overrides a declaration marked `operator`. + * + * A class reports only its own declaration under a member name — the inherited one is hidden by + * the override — so the operator modifier of the base declaration has to be looked up in the + * supertypes. All of them are searched, which covers a modifier inherited transitively: an override + * of an override that also omits the keyword still finds the original operator declaration. The + * override cannot change the parameter count, so a same-named declaration with the same number of + * value parameters is the one it overrides. + * + * @return `true` when a supertype declares this callable's signature as an operator + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaFunctionSymbol.overridesOperator(): Boolean { + val classId = callableId?.classId ?: return true + val classSymbol = findClass(classId) ?: return true + val name = (this as? KaNamedSymbol)?.name ?: return true + val parameterCount = valueParameters.size + return classSymbol.defaultType.allSupertypes.any { supertype -> + supertype.expandedSymbol?.memberScope?.callables(name) + ?.filterIsInstance() + ?.any { candidate -> + candidate.valueParameters.size == parameterCount && candidate.declaresOperatorModifier() + } == true + } +} + +/** + * Tells whether this declaration itself carries the `operator` modifier. + * + * A declaration without PSI cannot be inspected and is treated as an operator, matching the + * conservative answer [isOperatorEligible] gives to declarations the plugin cannot see. + * + * @return `true` when the modifier is present or cannot be inspected + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaFunctionSymbol.declaresOperatorModifier(): Boolean { + val modifierList = psi as? KtModifierListOwner ?: return true + return modifierList.hasModifier(KtTokens.OPERATOR_KEYWORD) +} + /** Class id of `kotlin.reflect.KProperty`, the property parameter every accessor declares. */ private val K_PROPERTY_CLASS_ID = ClassId.topLevel(FqName("kotlin.reflect.KProperty")) diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index f0ffb40d4..2a6bc093f 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -684,7 +684,9 @@ internal fun selectExtension( )) } return applicable.firstOrNull { candidate -> - applicable.none { other -> other !== candidate && other.isMoreSpecificThan(candidate, receiverType) } + applicable.none { other -> + other !== candidate && other.isMoreSpecificThan(candidate, receiverType, arguments, indexedWrite) + } } ?: applicable.firstOrNull() } @@ -717,12 +719,17 @@ internal fun KaCallableSymbol.receiverTypeSubstitutor(receiverType: KaType): KaS * declared receiver (`Box`) has no subtype relation with a concrete one (`Box`) until * the receiver supplies `T`. Two overloads whose instantiated receivers agree are ranked by their * type parameters: the concrete receiver needs fewer of them, and Kotlin prefers it over the - * generic one. When both need as many, the preference falls back to the value parameters: - * `Box.set(Int, Int)` is more specific than `Box.set(Int, Number)` for a write whose value is an - * `Int`. + * generic one. When both need as many, a callable that leaves out fewer default arguments wins: + * for `box[0] = 1` both `Box.set(i, j = 0, value)` and `Box.set(i, value)` apply, and the compiler + * binds the one without a default. The preference falls back to the value parameters only when + * neither of those separates the candidates: `Box.set(Int, Int)` is more specific than + * `Box.set(Int, Number)` for a write whose value is an `Int`. * * @param other competing callable * @param receiverType type of the value the operator is invoked on + * @param arguments argument types of the call, or `null` when they cannot be resolved + * @param indexedWrite `true` when [arguments] belong to an indexed write, whose defaulted index + * parameters may be omitted * @return `true` when this callable is a strictly better overload */ @OptIn(KaExperimentalApi::class) @@ -730,6 +737,8 @@ context(_: KaSession) private fun KaCallableSymbol.isMoreSpecificThan( other: KaCallableSymbol, receiverType: KaType, + arguments: List?, + indexedWrite: Boolean, ): Boolean = runCatching { val receiver = instantiatedReceiverType(receiverType) ?: return@runCatching false val otherReceiver = other.instantiatedReceiverType(receiverType) ?: return@runCatching false @@ -739,9 +748,34 @@ private fun KaCallableSymbol.isMoreSpecificThan( val ownParameters = (this as? KaFunctionSymbol)?.typeParameters?.size ?: 0 val otherParameters = (other as? KaFunctionSymbol)?.typeParameters?.size ?: 0 if (ownParameters != otherParameters) return@runCatching ownParameters < otherParameters + val ownOmittedDefaults = omittedDefaultCount(arguments, indexedWrite) + val otherOmittedDefaults = other.omittedDefaultCount(arguments, indexedWrite) + if (ownOmittedDefaults != otherOmittedDefaults) return@runCatching ownOmittedDefaults < otherOmittedDefaults parametersAreMoreSpecificThan(other) }.getOrDefault(false) +/** + * Counts the defaulted parameters this callable's declaration leaves out for [arguments]. + * + * An indexed write binds its indices to the leading parameters and the assigned value to the final + * one, so every parameter in between is omitted and must have a default. Kotlin prefers the + * applicable callable that omits fewer of them: `set(i, value)` beats `set(i, j = 0, value)` for + * `box[0] = 1`. A delegate call always passes one argument per parameter and therefore omits none, + * which leaves every candidate with the same count and the comparison undecided. + * + * @param arguments argument types of the call, or `null` when they cannot be resolved + * @param indexedWrite `true` when [arguments] belong to an indexed write + * @return the number of omitted defaulted parameters, or `0` when the call omits none + */ +context(_: KaSession) +private fun KaCallableSymbol.omittedDefaultCount(arguments: List?, indexedWrite: Boolean): Int { + if (!indexedWrite || arguments.isNullOrEmpty()) return 0 + val parameters = (this as? KaFunctionSymbol)?.valueParameters ?: return 0 + val indexCount = arguments.size - 1 + if (indexCount > parameters.size - 1) return 0 + return parameters.subList(indexCount, parameters.size - 1).count { parameter -> parameter.hasDefaultValue } +} + /** * Returns this extension's declared receiver as instantiated for [receiverType]. * @@ -841,7 +875,9 @@ internal fun KtArrayAccessExpression.selectedIndexedSetter(): KaCallableSymbol? /** * Tells whether this type declares a member `set` that accepts the indexed-write arguments. * - * A member setter always wins over an extension, but only when its signature actually fits: a + * A member setter always wins over an extension, but only when Kotlin can select it: it must be + * [operator-eligible][isOperatorEligible] — a plain `fun set(i: Int, value: Int)` never serves + * `box[0] = 1`, so it cannot hide an extension — and its signature must actually fit, because a * `set(String, Int)` member does not shadow `box[0]++`, which needs `set(Int, Int)`. An unknown * argument list keeps the member as applicable, preserving the previous conservative behavior. * @@ -857,6 +893,7 @@ internal fun KaType.hasApplicableMemberIndexedSetter(arguments: List?): .filterIsInstance() .any { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "set" && + symbol.isOperatorEligible() && symbol.acceptsIndexedSetterArguments(arguments, substitutor, indexedWrite = true) } }.getOrDefault(true) diff --git a/Nbm/pom.xml b/Nbm/pom.xml index cf8dc7c6c..a5e30682f 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index e6f89e1f0..525f8ba1c 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -6450,6 +6450,192 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a provider override inherits the `operator` modifier from its base declaration. + * + * `Delegate` overrides `Base.provideDelegate` without repeating `operator`, which Kotlin + * allows: the override is still the delegate provider and its result type decides the accessor. + * The override is the only member the delegate type reports, so requiring the modifier in its + * own PSI finds no provider, falls back to the staying `Any.getValue` and plans no import even + * though the property calls the moving `Provided.getValue`. + */ + fun testApply_realSession_importsInheritedOperatorAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-inherited-operator") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Base.kt"), + "package old\n\n" + + "open class Base {\n" + + " open operator fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n" + + "}\n\n" + + "class Provided\n\n" + + "operator fun Any.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): String = \"fallback\"\n", + ) + Files.writeString( + sourcePath, + "package old\n\n" + + "class Delegate : Base() {\n" + + " override fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Provided = Provided()\n" + + "}\n\n" + + "operator fun Provided.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value: Int by Delegate()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-inherited-operator-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the accessor selected through the overriding provider: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies an ordinary member named `set` does not hide an operator extension. + * + * `Box.set(i, value)` is not declared `operator`, so Kotlin never uses it for `box[0]++` and + * binds the extension setter instead. Treating the member as applicable suppresses the + * extension lookup, plans no import and silently leaves the write calling the staying + * `Any.set` after the move. + */ + fun testApply_realSession_ignoresNonOperatorMemberSetter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-non-operator-member") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\n" + + "open class Box {\n" + + " operator fun get(i: Int): Int = 0\n" + + " fun set(i: Int, value: Int) {}\n" + + "}\n\n" + + "operator fun Any.set(i: Int, value: Int) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(i: Int, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n box[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-non-operator-member-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the extension setter the non-operator member cannot hide: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies overload resolution prefers the setter that omits no default parameters. + * + * For `box[0] = 1` both `Box.set(i: Int, j: Int = 0, value: Int)` and `Box.set(i, value)` + * accept the write, and Kotlin binds the one without a default argument. Ranking only by the + * receiver and value parameters finds neither more specific, so the first enumerated setter — + * here the staying one — wins unaided and no import of the moving overload is planned. + */ + fun testApply_realSession_prefersSetterWithoutOmittedDefaults() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-omitted-defaults") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\n" + + "open class Box {\n operator fun get(i: Int): Int = 0\n}\n\n" + + "operator fun Box.set(i: Int, j: Int = 0, value: Int) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(i: Int, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n box[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-omitted-defaults-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the setter that omits no default parameters: $text", + text.contains("import dest.set"), + ) + } 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")) diff --git a/pom.xml b/pom.xml index 44a21bbd3..83d31496f 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.62-SNAPSHOT + 0.12.63-SNAPSHOT pom Netbeans Plugin Kotlin From 9805f6b9548360c9769c10cdb4308d03528c2dca Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 14:58:32 +0300 Subject: [PATCH 54/55] Fixed operator overload ranking for bounded parameters and inherited overrides - Overload selection now compares parameter specificity before the type-parameter count and the omitted-default count, and compares it symmetrically: Box.getValue(T, ...) beats Box.getValue(Any?, ...), and set(i: Int, j: Int = 0, value) beats set(i: Number, j: Number) for `box[0]++`. - A generic parameter is compared by its upper bound, which is exactly what it accepts, and only the parameters the call binds take part. - Operator eligibility inherited through an override now requires the overridden declaration to have the same (instantiated) signature, so overriding an ordinary same-arity overload next to an operator one is no longer treated as an operator. - Added regression tests pinning the alias case: K2 reports a type alias's own classifier, so a wildcard-imported alias stays imported when its target package gains a same-named declaration. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaDelegateOperatorShape.kt | 47 +++- .../refactoring/KaOperatorApplicability.kt | 252 +++++++++++++---- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 254 ++++++++++++++++++ pom.xml | 2 +- 15 files changed, 508 insertions(+), 69 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index ae57187d6..b759b6271 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 3a8110baa..f1b004dda 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 7d38f44d9..329b8372f 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index c76dde005..1586f315a 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 2189965ba..feb1eebf6 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 6fc9979eb..3d0b0a054 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index c8894d4a3..7fddbcd0e 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index b1ee33734..166ac8d37 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 0d60346d5..88e5c0594 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 41f68dc6e..68421a7ad 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt index ab495852c..265f011e9 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt @@ -24,10 +24,12 @@ import org.jetbrains.kotlin.analysis.api.components.defaultTypeWithStarProjectio import org.jetbrains.kotlin.analysis.api.components.expandedSymbol import org.jetbrains.kotlin.analysis.api.components.isSubtypeOf import org.jetbrains.kotlin.analysis.api.components.memberScope +import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol import org.jetbrains.kotlin.analysis.api.symbols.findClass import org.jetbrains.kotlin.analysis.api.symbols.markers.KaNamedSymbol +import org.jetbrains.kotlin.analysis.api.types.KaSubstitutor import org.jetbrains.kotlin.lexer.KtTokens import org.jetbrains.kotlin.name.ClassId import org.jetbrains.kotlin.name.FqName @@ -93,9 +95,14 @@ internal fun KaCallableSymbol.isOperatorEligible(): Boolean { * A class reports only its own declaration under a member name — the inherited one is hidden by * the override — so the operator modifier of the base declaration has to be looked up in the * supertypes. All of them are searched, which covers a modifier inherited transitively: an override - * of an override that also omits the keyword still finds the original operator declaration. The - * override cannot change the parameter count, so a same-named declaration with the same number of - * value parameters is the one it overrides. + * of an override that also omits the keyword still finds the original operator declaration. + * + * The overridden declaration is the one whose signature matches, not merely any same-named and + * same-arity one: a base class may declare both an `operator provideDelegate(Host, KProperty<*>)` + * and an ordinary `provideDelegate(Any?, KProperty<*>)`, and an override of the ordinary one is + * not an operator even though the operator overload exists in the same superclass. The base + * declaration is compared as instantiated through the supertype entry, so `Base` becomes the + * argument the class actually passes. * * @return `true` when a supertype declares this callable's signature as an operator */ @@ -105,16 +112,46 @@ private fun KaFunctionSymbol.overridesOperator(): Boolean { val classId = callableId?.classId ?: return true val classSymbol = findClass(classId) ?: return true val name = (this as? KaNamedSymbol)?.name ?: return true - val parameterCount = valueParameters.size return classSymbol.defaultType.allSupertypes.any { supertype -> + val substitutor = supertype.classTypeSubstitutor() supertype.expandedSymbol?.memberScope?.callables(name) ?.filterIsInstance() ?.any { candidate -> - candidate.valueParameters.size == parameterCount && candidate.declaresOperatorModifier() + candidate.declaresOperatorModifier() && overridesSignatureOf(candidate, substitutor) } == true } } +/** + * Tells whether this callable's value parameter list matches [candidate]'s. + * + * The types are compared as the supertype entry instantiates them, because that is the signature + * the override is checked against. A spelling that still mentions a type parameter cannot be + * compared without inferring it, so such a pair is treated as matching — the alternative would + * reject overrides of library generic operators the plugin cannot inspect further. + * + * @param candidate declaration found in a supertype + * @param substitutor instantiation of [candidate]'s declaring class through the supertype entry + * @return `true` when the signatures match or cannot be distinguished + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaFunctionSymbol.overridesSignatureOf( + candidate: KaFunctionSymbol, + substitutor: KaSubstitutor?, +): Boolean { + if (candidate.valueParameters.size != valueParameters.size) return false + return valueParameters.indices.all { index -> + val declaredType = candidate.valueParameters[index].returnType + val theirType = substitutor?.let { current -> + runCatching { current.substitute(declaredType) }.getOrDefault(declaredType) + } ?: declaredType + val myType = valueParameters[index].returnType + if (myType.mentionsTypeParameter() || theirType.mentionsTypeParameter()) return@all true + runCatching { myType.semanticallyEquals(theirType) }.getOrDefault(true) + } +} + /** * Tells whether this declaration itself carries the `operator` modifier. * diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 2a6bc093f..987d3265d 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -601,11 +601,12 @@ private fun KaType.asNonNullable(): KaType? = runCatching { * * A bound such as `Comparable` cannot be checked against a concrete argument without * instantiating `T`; a raw subtype test would reject a valid argument, so such a bound is treated - * as inconclusive. + * as inconclusive. Override matching uses the same rule: a signature spelled with a type parameter + * only fits after instantiation. * * @return `true` when this type mentions a type parameter */ -private fun KaType.mentionsTypeParameter(): Boolean = when (this) { +internal fun KaType.mentionsTypeParameter(): Boolean = when (this) { is KaTypeParameterType -> true is KaClassType -> typeArguments.any { projection -> projection.type?.mentionsTypeParameter() == true } is KaFunctionType -> receiverType?.mentionsTypeParameter() == true || @@ -646,7 +647,9 @@ internal fun selectMember( candidate.acceptsIndexedSetterArguments(arguments, substitutor, indexedWrite) } return applicable.firstOrNull { candidate -> - applicable.none { other -> other !== candidate && other.parametersAreMoreSpecificThan(candidate) } + applicable.none { other -> + other !== candidate && other.compareParameterPreference(candidate, arguments, indexedWrite) < 0 + } } ?: applicable.firstOrNull() } @@ -715,44 +718,217 @@ internal fun KaCallableSymbol.receiverTypeSubstitutor(receiverType: KaType): KaS /** * Tells whether this callable's overload is preferred over [other] for [receiverType]. * + * @param other competing callable + * @param receiverType type of the value the operator is invoked on + * @param arguments argument types of the call, or `null` when they cannot be resolved + * @param indexedWrite `true` when [arguments] belong to an indexed write, whose defaulted index + * parameters may be omitted + * @return `true` when this callable is a strictly better overload + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.isMoreSpecificThan( + other: KaCallableSymbol, + receiverType: KaType, + arguments: List?, + indexedWrite: Boolean, +): Boolean = compareOverloadPreference(other, receiverType, arguments, indexedWrite) < 0 + +/** + * Orders this callable against [other] the way Kotlin ranks applicable overloads. + * + * The criteria follow the compiler's order: the more specific receiver first, then the more + * specific parameter tuple, then the callable that needs fewer type parameters, and finally the one + * that omits fewer default arguments. A criterion that prefers neither side — equal or incomparable + * types — leaves the decision to the next one, and every criterion is symmetric, so the comparison + * of `a` with `b` negates the comparison of `b` with `a`. + * + * Parameter specificity comes before the tie-breakers because a narrower parameter type is what + * the compiler compares first: ` Box.getValue(T, ...)` beats + * `Box.getValue(Any?, ...)` even though it is generic, and `Box.set(i: Int, j: Int = 0, value)` + * beats `Box.set(i: Number, j: Number)` even though it omits a default argument. + * * The receivers are compared as they are instantiated for the actual receiver, because a generic * declared receiver (`Box`) has no subtype relation with a concrete one (`Box`) until - * the receiver supplies `T`. Two overloads whose instantiated receivers agree are ranked by their - * type parameters: the concrete receiver needs fewer of them, and Kotlin prefers it over the - * generic one. When both need as many, a callable that leaves out fewer default arguments wins: - * for `box[0] = 1` both `Box.set(i, j = 0, value)` and `Box.set(i, value)` apply, and the compiler - * binds the one without a default. The preference falls back to the value parameters only when - * neither of those separates the candidates: `Box.set(Int, Int)` is more specific than - * `Box.set(Int, Number)` for a write whose value is an `Int`. + * the receiver supplies `T`. Parameters are compared the same way: only the ones the call binds + * take part, and a type parameter is replaced by its upper bound, which is exactly what it accepts. * * @param other competing callable * @param receiverType type of the value the operator is invoked on * @param arguments argument types of the call, or `null` when they cannot be resolved * @param indexedWrite `true` when [arguments] belong to an indexed write, whose defaulted index * parameters may be omitted - * @return `true` when this callable is a strictly better overload + * @return a negative value when this callable is preferred, a positive one when [other] is, and + * `0` when neither is strictly preferred */ @OptIn(KaExperimentalApi::class) context(_: KaSession) -private fun KaCallableSymbol.isMoreSpecificThan( +private fun KaCallableSymbol.compareOverloadPreference( other: KaCallableSymbol, receiverType: KaType, arguments: List?, indexedWrite: Boolean, -): Boolean = runCatching { - val receiver = instantiatedReceiverType(receiverType) ?: return@runCatching false - val otherReceiver = other.instantiatedReceiverType(receiverType) ?: return@runCatching false - if (!receiver.semanticallyEquals(otherReceiver)) { - return@runCatching receiver.isSubtypeOf(otherReceiver) && !otherReceiver.isSubtypeOf(receiver) - } - val ownParameters = (this as? KaFunctionSymbol)?.typeParameters?.size ?: 0 - val otherParameters = (other as? KaFunctionSymbol)?.typeParameters?.size ?: 0 - if (ownParameters != otherParameters) return@runCatching ownParameters < otherParameters +): Int = runCatching { + val receiver = instantiatedReceiverType(receiverType) + val otherReceiver = other.instantiatedReceiverType(receiverType) + if (receiver != null && otherReceiver != null) { + compareTypeSpecificity(receiver, otherReceiver).let { preference -> + if (preference != 0) return@runCatching preference + } + } + compareParameterPreference(other, arguments, indexedWrite) +}.getOrDefault(0) + +/** + * Orders this callable against [other] by the parameters [arguments] bind to. + * + * Used on its own for member overloads, which all have the same receiver and are separated by + * their value parameters alone. + * + * @param other competing callable + * @param arguments argument types of the call, or `null` when they cannot be resolved + * @param indexedWrite `true` when [arguments] belong to an indexed write, whose defaulted index + * parameters may be omitted + * @return a negative value when this callable is preferred, a positive one when [other] is, and + * `0` when neither is strictly preferred + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.compareParameterPreference( + other: KaCallableSymbol, + arguments: List?, + indexedWrite: Boolean, +): Int = runCatching { + val ownBoundTypes = boundParameterTypes(arguments, indexedWrite) + val otherBoundTypes = other.boundParameterTypes(arguments, indexedWrite) + if (ownBoundTypes != null && otherBoundTypes != null) { + compareParameterTuples(ownBoundTypes, otherBoundTypes).let { preference -> + if (preference != 0) return@runCatching preference + } + } + val ownTypeParameters = (this as? KaFunctionSymbol)?.typeParameters?.size ?: 0 + val otherTypeParameters = (other as? KaFunctionSymbol)?.typeParameters?.size ?: 0 + if (ownTypeParameters != otherTypeParameters) { + return@runCatching ownTypeParameters.compareTo(otherTypeParameters) + } val ownOmittedDefaults = omittedDefaultCount(arguments, indexedWrite) val otherOmittedDefaults = other.omittedDefaultCount(arguments, indexedWrite) - if (ownOmittedDefaults != otherOmittedDefaults) return@runCatching ownOmittedDefaults < otherOmittedDefaults - parametersAreMoreSpecificThan(other) -}.getOrDefault(false) + if (ownOmittedDefaults != otherOmittedDefaults) { + return@runCatching ownOmittedDefaults.compareTo(otherOmittedDefaults) + } + // Without resolved arguments nothing binds, so the declared tuples are all that remains. + if (ownBoundTypes == null || otherBoundTypes == null) { + compareParameterTuples(declaredParameterTypes(), other.declaredParameterTypes()) + } else { + 0 + } +}.getOrDefault(0) + +/** + * Compares two types as overload-resolution candidates. + * + * @param mine type of the preferred candidate + * @param theirs type of the competing candidate + * @return a negative value when [mine] is strictly more specific, a positive one when [theirs] is, + * and `0` when they are equal or incomparable + */ +context(_: KaSession) +private fun compareTypeSpecificity(mine: KaType, theirs: KaType): Int { + if (runCatching { mine.semanticallyEquals(theirs) }.getOrDefault(false)) return 0 + val mineFits = runCatching { mine.isSubtypeOf(theirs) }.getOrDefault(false) + val theirsFits = runCatching { theirs.isSubtypeOf(mine) }.getOrDefault(false) + return when { + mineFits == theirsFits -> 0 + mineFits -> -1 + else -> 1 + } +} + +/** + * Compares two bound parameter tuples as overload-resolution candidates. + * + * A tuple is more specific only when every one of its types is a subtype of the competing tuple's + * type; one incomparable or equal pair leaves the comparison undecided. Type parameters are + * compared by their upper bound, so `` prefers the same parameter types as `Host`. + * + * @param mine parameter types the arguments bind to in the preferred candidate + * @param theirs parameter types the same arguments bind to in the competing candidate + * @return a negative value when [mine] is strictly more specific, a positive one when [theirs] is, + * and `0` when neither is + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun compareParameterTuples(mine: List, theirs: List): Int { + if (mine.size != theirs.size || mine.isEmpty()) return 0 + val mineTypes = mine.map { type -> type.effectiveParameterType() } + val theirTypes = theirs.map { type -> type.effectiveParameterType() } + val mineFits = mineTypes.indices.all { index -> + runCatching { mineTypes[index].isSubtypeOf(theirTypes[index]) }.getOrDefault(false) + } + val theirsFits = mineTypes.indices.all { index -> + runCatching { theirTypes[index].isSubtypeOf(mineTypes[index]) }.getOrDefault(false) + } + return when { + mineFits == theirsFits -> 0 + mineFits -> -1 + else -> 1 + } +} + +/** + * Returns the type this parameter contributes to the specificity comparison. + * + * A parameter typed with a type parameter accepts exactly what the parameter's upper bound + * accepts — `T : Host` takes a `Host` — so the bound is what the compiler compares with the other + * overload's concrete type. A type parameter bounded only by `Any?` compares equal to `Any?`, + * which leaves the choice between it and a non-generic overload to the type-parameter tie-breaker. + * + * @return the upper bound of a type-parameter type, or the type itself + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaType.effectiveParameterType(): KaType = + if (this is KaTypeParameterType) symbol.upperBounds.firstOrNull() ?: this else this + +/** + * Returns this callable's value parameter types as declared. + * + * @return the declared tuple, or an empty list for a callable without value parameters + */ +context(_: KaSession) +private fun KaCallableSymbol.declaredParameterTypes(): List = + (this as? KaFunctionSymbol)?.valueParameters?.map { parameter -> parameter.returnType }.orEmpty() + +/** + * Returns this callable's value parameter types as the arguments of a call bind them. + * + * A regular call passes one argument per parameter, so every parameter takes part. An indexed write + * binds its indices to the leading parameters, the assigned value to the final one and leaves the + * defaulted parameters in between unsupplied, so only the bound ones can be compared — the omitted + * ones are subject of the default-argument tie-breaker instead. A `vararg` declaration maps its + * arguments to parameters by count only, which this comparison cannot reproduce. + * + * @param arguments argument types of the call, or `null` when they cannot be resolved + * @param indexedWrite `true` when [arguments] belong to an indexed write + * @return the parameter types the arguments bind to, or `null` when they cannot be mapped + */ +context(_: KaSession) +private fun KaCallableSymbol.boundParameterTypes( + arguments: List?, + indexedWrite: Boolean, +): List? { + if (arguments == null) return null + val parameters = (this as? KaFunctionSymbol)?.valueParameters ?: return null + if (parameters.any { parameter -> parameter.isVararg }) return null + if (!indexedWrite) { + if (parameters.size != arguments.size) return null + return parameters.map { parameter -> parameter.returnType } + } + val indexCount = arguments.size - 1 + if (indexCount < 0 || indexCount > parameters.size - 1) return null + return parameters.take(indexCount).map { parameter -> parameter.returnType } + + parameters.last().returnType +} /** * Counts the defaulted parameters this callable's declaration leaves out for [arguments]. @@ -795,34 +971,6 @@ private fun KaCallableSymbol.instantiatedReceiverType(receiverType: KaType): KaT } ?: declaredReceiver }.getOrNull() -/** - * Tells whether this callable's value parameters are strictly more specific than [other]'s. - * - * Every parameter must accept the other's type, and at least one must be a proper subtype: - * `(Int, Int)` is more specific than `(Int, Number)`. A type parameter or a differing arity - * leaves the comparison undecided, because the instantiation is what would decide it. - * - * @param other competing callable - * @return `true` when this callable takes a strictly narrower parameter tuple - */ -@OptIn(KaExperimentalApi::class) -context(_: KaSession) -private fun KaCallableSymbol.parametersAreMoreSpecificThan(other: KaCallableSymbol): Boolean = runCatching { - val mine = (this as? KaFunctionSymbol)?.valueParameters ?: return@runCatching false - val theirs = (other as? KaFunctionSymbol)?.valueParameters ?: return@runCatching false - if (mine.size != theirs.size || mine.isEmpty()) return@runCatching false - var strict = false - val fits = mine.indices.all { index -> - val mineType = mine[index].returnType - val theirType = theirs[index].returnType - if (mineType is KaTypeParameterType || theirType is KaTypeParameterType) return@all false - if (!mineType.isSubtypeOf(theirType)) return@all false - if (!theirType.isSubtypeOf(mineType)) strict = true - true - } - fits && strict -}.getOrDefault(false) - /** * Tells whether this indexed access is updated implicitly, not merely assigned or read. * diff --git a/Nbm/pom.xml b/Nbm/pom.xml index a5e30682f..24aceae51 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index 525f8ba1c..f4bb2da2a 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -6636,6 +6636,260 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a bounded generic accessor outranks an `Any?` one. + * + * For a member property of `Local : Host()`, both `Box.getValue(Any?, KProperty<*>)` and + * ` Box.getValue(T, KProperty<*>)` accept the call, and Kotlin selects the bounded + * generic overload because `Host` is more specific than `Any?`. Comparing the type-parameter + * count first prefers the non-generic overload, which stays behind, so the caller silently + * switches getters after the move and no `dest.getValue` import is planned. + */ + fun testApply_realSession_prefersBoundedGenericAccessor() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-bounded-generic") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\n" + + "open class Host\n\n" + + "class Box\n\n" + + "operator fun Box.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + sourcePath, + "package old\n\n" + + "operator fun Box.getValue(" + + "thisRef: T, property: kotlin.reflect.KProperty<*>): String = \"moved\"\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\n" + + "class Local : Host() {\n val value: String by Box()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-bounded-generic-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the bounded accessor Kotlin selects: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a more specific parameter tuple outranks the omitted-default preference. + * + * For `box[0]++` both `Box.set(i: Number, j: Number)` and + * `Box.set(i: Int, j: Int = 0, value: Int)` accept the write, and Kotlin selects the latter: + * its bound parameters are `Int`, which is more specific than `Number`. Comparing the omitted + * defaults first prefers the `Number` setter, which stays behind, so no `dest.set` import is + * planned and the write silently falls back to it after the move. + */ + fun testApply_realSession_prefersParameterSpecificSetterOverDefaults() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-specific-over-defaults") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Box.kt"), + "package old\n\n" + + "open class Box {\n operator fun get(i: Int): Int = 0\n}\n\n" + + "operator fun Box.set(i: Number, j: Number) {}\n", + ) + Files.writeString( + sourcePath, + "package old\n\noperator fun Box.set(i: Int, j: Int = 0, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nfun use(box: Box) {\n box[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-specific-over-defaults-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the setter with the more specific bound parameters: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies overriding an ordinary `provideDelegate` does not inherit operator eligibility. + * + * `Base` declares both an operator `provideDelegate(Host, KProperty<*>)` and an ordinary open + * `provideDelegate(Any?, KProperty<*>)`; `Delegate` overrides only the ordinary one, so Kotlin + * keeps using `Delegate` itself as the delegate. Matching the supertype declaration by name and + * arity finds the unrelated operator overload, invents a provider returning `Decoy` and searches + * accessors there, so the moving `Delegate.getValue` is never imported. + */ + fun testApply_realSession_ignoresOverrideOfOrdinaryProvider() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-override-ordinary-provider") + try { + Files.createDirectories(temporaryRoot.resolve("old")) + Files.createDirectories(temporaryRoot.resolve("usage")) + val sourcePath = temporaryRoot.resolve("old/Moved.kt") + val usagePath = temporaryRoot.resolve("usage/Usage.kt") + Files.writeString( + temporaryRoot.resolve("old/Base.kt"), + "package old\n\n" + + "open class Host\n\n" + + "class Decoy\n\n" + + "open class Base {\n" + + " open operator fun provideDelegate(" + + "thisRef: Host, property: kotlin.reflect.KProperty<*>): Decoy = Decoy()\n" + + " open fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Decoy = Decoy()\n" + + "}\n\n" + + "operator fun Any.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): String = \"fallback\"\n", + ) + Files.writeString( + sourcePath, + "package old\n\n" + + "class Delegate : Base() {\n" + + " override fun provideDelegate(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Decoy = Decoy()\n" + + "}\n\n" + + "operator fun Delegate.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): Int = 1\n", + ) + Files.writeString( + usagePath, + "package usage\n\nimport old.*\n\nval value: Int by Delegate()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-override-ordinary-provider-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the accessor of the delegate itself: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies a wildcard-imported type alias survives a move into a shadowing package. + * + * `fun make() = Alias()` in `old` resolves through `import lib.*` to `lib.Alias`, which expands + * to `other.Impl`. Moving the file into `dest`, which declares its own `Alias`, makes the + * same-package declaration outrank the star import, so the star import has to be spelled out. + * K2 reports the alias's own classifier (`lib.Alias`, with the `KtTypeAlias` as its PSI) rather + * than the expanded target, which is what lets the refactoring keep the spelled name and add + * the explicit import instead of silently retargeting the call to `dest.Alias`. + */ + fun testApply_realSession_importsShadowedWildcardTypeAlias() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-shadowed-alias") + try { + for (directory in listOf("lib", "other", "dest", "old")) { + Files.createDirectories(temporaryRoot.resolve(directory)) + } + val sourcePath = temporaryRoot.resolve("old/Usage.kt") + Files.writeString( + temporaryRoot.resolve("other/Impl.kt"), + "package other\n\nclass Impl\n", + ) + Files.writeString( + temporaryRoot.resolve("lib/Aliases.kt"), + "package lib\n\ntypealias Alias = other.Impl\n", + ) + Files.writeString( + temporaryRoot.resolve("dest/Aliases.kt"), + "package dest\n\nclass Alias\n", + ) + Files.writeString( + sourcePath, + "package old\n\nimport lib.*\n\nfun make() = Alias()\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-shadowed-alias-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[sourcePath.toString()] ?: error("The moved file must change") + assertTrue( + "The moved file must import the type alias it still spells: $text", + text.contains("import lib.Alias"), + ) + } 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")) diff --git a/pom.xml b/pom.xml index 83d31496f..b3caf7f3e 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.63-SNAPSHOT + 0.12.64-SNAPSHOT pom Netbeans Plugin Kotlin From 2635da265ff83f63ea01a43d5b0b13cd52c6e681 Mon Sep 17 00:00:00 2001 From: Oleg Samarin Date: Thu, 12 Nov 2026 15:30:12 +0300 Subject: [PATCH 55/55] Fixed private member setters hiding extensions and uninstantiated provider ranking - A `private` member `set` is no longer counted as hiding an extension at a caller outside its declaring class, so a compound indexed write that binds an extension keeps its import when that extension moves. - Member overload ranking now instantiates the parameter types with the declaring class's type arguments, so `Delegate` selects `provideDelegate(T, ...)` over `provideDelegate(Host, ...)` and the accessor of the selected provider is the one recorded. - Added regression tests for both shapes. Co-Authored-By: Claude Code --- CoreImpl/pom.xml | 2 +- KotlinCodeInsightImpl/pom.xml | 2 +- KotlinCompilerCliBase/pom.xml | 2 +- KotlinFixesImpl/pom.xml | 2 +- KotlinFormatter/pom.xml | 2 +- KotlinHighlighting/pom.xml | 2 +- KotlinIcons/pom.xml | 2 +- KotlinIntentionUtils/pom.xml | 2 +- KotlinJ2K/pom.xml | 2 +- KotlinRefactoring/pom.xml | 2 +- .../refactoring/KaChangePackageComputer.kt | 2 +- .../refactoring/KaOperatorApplicability.kt | 91 +++++++++++-- Nbm/pom.xml | 2 +- .../nbm/refactoring/KaChangePackageTest.kt | 126 ++++++++++++++++++ pom.xml | 2 +- 15 files changed, 217 insertions(+), 26 deletions(-) diff --git a/CoreImpl/pom.xml b/CoreImpl/pom.xml index b759b6271..4545e1857 100644 --- a/CoreImpl/pom.xml +++ b/CoreImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index f1b004dda..70f2f2ae2 100644 --- a/KotlinCodeInsightImpl/pom.xml +++ b/KotlinCodeInsightImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 329b8372f..07bd34a96 100644 --- a/KotlinCompilerCliBase/pom.xml +++ b/KotlinCompilerCliBase/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 1586f315a..28f61e04c 100644 --- a/KotlinFixesImpl/pom.xml +++ b/KotlinFixesImpl/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index feb1eebf6..0efdc67ff 100644 --- a/KotlinFormatter/pom.xml +++ b/KotlinFormatter/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 3d0b0a054..8b26ba3a0 100644 --- a/KotlinHighlighting/pom.xml +++ b/KotlinHighlighting/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 7fddbcd0e..afa19ea76 100644 --- a/KotlinIcons/pom.xml +++ b/KotlinIcons/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 166ac8d37..bb497dabc 100644 --- a/KotlinIntentionUtils/pom.xml +++ b/KotlinIntentionUtils/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index 88e5c0594..a5844a087 100644 --- a/KotlinJ2K/pom.xml +++ b/KotlinJ2K/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 68421a7ad..80bfb2c6e 100644 --- a/KotlinRefactoring/pom.xml +++ b/KotlinRefactoring/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-refactoring 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 index 3cb54322a..507b1388d 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaChangePackageComputer.kt @@ -497,7 +497,7 @@ class KaChangePackageComputer( // misfitting overload (`set(String, Int)` for `box[0]++`) leaves the write to // the applicable extension, which the caller then has to import. val arguments = expression.setterArgumentTypes() - if (!receiverType.hasApplicableMemberIndexedSetter(arguments)) { + if (!receiverType.hasApplicableMemberIndexedSetter(arguments, expression)) { record(movedOperator(receiverType, "set", arguments)) } } diff --git a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt index 987d3265d..bcaa604f7 100644 --- a/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -16,6 +16,7 @@ *******************************************************************************/ package io.github.nbplugins.kotlin.refactoring +import com.intellij.psi.util.PsiTreeUtil import org.jetbrains.kotlin.analysis.api.KaExperimentalApi import org.jetbrains.kotlin.analysis.api.KaSession import org.jetbrains.kotlin.analysis.api.components.allSupertypes @@ -35,7 +36,9 @@ import org.jetbrains.kotlin.analysis.api.components.semanticallyEquals import org.jetbrains.kotlin.analysis.api.components.type import org.jetbrains.kotlin.analysis.api.symbols.KaCallableSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaClassSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaDeclarationSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaFunctionSymbol +import org.jetbrains.kotlin.analysis.api.symbols.KaSymbolVisibility import org.jetbrains.kotlin.analysis.api.symbols.KaTypeParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaValueParameterSymbol import org.jetbrains.kotlin.analysis.api.symbols.KaVariableSymbol @@ -58,6 +61,8 @@ import org.jetbrains.kotlin.name.Name import org.jetbrains.kotlin.psi.KtArrayAccessExpression import org.jetbrains.kotlin.psi.KtBinaryExpression import org.jetbrains.kotlin.psi.KtClassOrObject +import org.jetbrains.kotlin.psi.KtDeclaration +import org.jetbrains.kotlin.psi.KtExpression import org.jetbrains.kotlin.psi.KtProperty import org.jetbrains.kotlin.psi.KtUnaryExpression import org.jetbrains.kotlin.psi.psiUtil.containingClassOrObject @@ -648,7 +653,7 @@ internal fun selectMember( } return applicable.firstOrNull { candidate -> applicable.none { other -> - other !== candidate && other.compareParameterPreference(candidate, arguments, indexedWrite) < 0 + other !== candidate && other.compareParameterPreference(candidate, arguments, indexedWrite, substitutor) < 0 } } ?: applicable.firstOrNull() } @@ -776,19 +781,29 @@ private fun KaCallableSymbol.compareOverloadPreference( if (preference != 0) return@runCatching preference } } - compareParameterPreference(other, arguments, indexedWrite) + compareParameterPreference( + other, + arguments, + indexedWrite, + receiverTypeSubstitutor(receiverType), + other.receiverTypeSubstitutor(receiverType), + ) }.getOrDefault(0) /** * Orders this callable against [other] by the parameters [arguments] bind to. * * Used on its own for member overloads, which all have the same receiver and are separated by - * their value parameters alone. + * their value parameters alone. The parameter types are compared as [substitutor] instantiates + * them, because that is what the compiler sees: for `Delegate`, the `provideDelegate(T, …)` + * of `Delegate` takes a `Local`, not an unconstrained type parameter. * * @param other competing callable * @param arguments argument types of the call, or `null` when they cannot be resolved * @param indexedWrite `true` when [arguments] belong to an indexed write, whose defaulted index * parameters may be omitted + * @param substitutor instantiates this callable's declaring class, or `null` when it is not generic + * @param otherSubstitutor the same for [other] * @return a negative value when this callable is preferred, a positive one when [other] is, and * `0` when neither is strictly preferred */ @@ -798,9 +813,11 @@ private fun KaCallableSymbol.compareParameterPreference( other: KaCallableSymbol, arguments: List?, indexedWrite: Boolean, + substitutor: KaSubstitutor? = null, + otherSubstitutor: KaSubstitutor? = substitutor, ): Int = runCatching { - val ownBoundTypes = boundParameterTypes(arguments, indexedWrite) - val otherBoundTypes = other.boundParameterTypes(arguments, indexedWrite) + val ownBoundTypes = boundParameterTypes(arguments, indexedWrite)?.substituted(substitutor) + val otherBoundTypes = other.boundParameterTypes(arguments, indexedWrite)?.substituted(otherSubstitutor) if (ownBoundTypes != null && otherBoundTypes != null) { compareParameterTuples(ownBoundTypes, otherBoundTypes).let { preference -> if (preference != 0) return@runCatching preference @@ -818,12 +835,27 @@ private fun KaCallableSymbol.compareParameterPreference( } // Without resolved arguments nothing binds, so the declared tuples are all that remains. if (ownBoundTypes == null || otherBoundTypes == null) { - compareParameterTuples(declaredParameterTypes(), other.declaredParameterTypes()) + compareParameterTuples( + declaredParameterTypes().substituted(substitutor), + other.declaredParameterTypes().substituted(otherSubstitutor), + ) } else { 0 } }.getOrDefault(0) +/** + * Instantiates these types with [substitutor], keeping each one unchanged when it cannot be. + * + * @param substitutor class or receiver instantiation, or `null` when there is none + * @return the instantiated types + */ +context(_: KaSession) +private fun List.substituted(substitutor: KaSubstitutor?): List { + if (substitutor == null) return this + return map { type -> runCatching { substitutor.substitute(type) }.getOrDefault(type) } +} + /** * Compares two types as overload-resolution candidates. * @@ -1009,7 +1041,7 @@ internal fun KtArrayAccessExpression.selectedIndexedSetter(): KaCallableSymbol? val arguments = setterArgumentTypes() // A member setter wins only when its signature accepts the actual indices and value; a // misfitting overload leaves the write to an applicable extension. - if (receiverType.hasApplicableMemberIndexedSetter(arguments)) return null + if (receiverType.hasApplicableMemberIndexedSetter(arguments, this)) return null val identifier = Name.identifier("set") containingKtFile.scopeContext(this).scopes.forEach { scopeWithKind -> val candidates = scopeWithKind.scope.callables(identifier) @@ -1021,19 +1053,27 @@ internal fun KtArrayAccessExpression.selectedIndexedSetter(): KaCallableSymbol? } /** - * Tells whether this type declares a member `set` that accepts the indexed-write arguments. + * Tells whether this type declares a member `set` the use site can select for indexed writes. * * A member setter always wins over an extension, but only when Kotlin can select it: it must be * [operator-eligible][isOperatorEligible] — a plain `fun set(i: Int, value: Int)` never serves - * `box[0] = 1`, so it cannot hide an extension — and its signature must actually fit, because a - * `set(String, Int)` member does not shadow `box[0]++`, which needs `set(Int, Int)`. An unknown - * argument list keeps the member as applicable, preserving the previous conservative behavior. + * `box[0] = 1`, so it cannot hide an extension — its signature must actually fit, because a + * `set(String, Int)` member does not shadow `box[0]++`, which needs `set(Int, Int)`, and it must be + * visible at the use site. A `private operator fun set` is a candidate only inside its own class; + * a caller elsewhere binds the same write to an extension, which therefore still has to be looked + * up at that caller. An unknown argument list keeps the member as applicable, preserving the + * previous conservative behavior. * * @param arguments argument types of the write, or `null` when they cannot be resolved - * @return `true` when a member setter of this type can be selected + * @param useSite expression performing the write, or `null` when the caller is unknown and every + * member has to be counted + * @return `true` when a member setter of this type can be selected at [useSite] */ context(_: KaSession) -internal fun KaType.hasApplicableMemberIndexedSetter(arguments: List?): Boolean = runCatching { +internal fun KaType.hasApplicableMemberIndexedSetter( + arguments: List?, + useSite: KtExpression? = null, +): Boolean = runCatching { if (arguments == null) return@runCatching true val substitutor = classTypeSubstitutor() expandedSymbol?.memberScope?.declarations @@ -1042,6 +1082,31 @@ internal fun KaType.hasApplicableMemberIndexedSetter(arguments: List?): .any { symbol -> (symbol as? KaNamedSymbol)?.name?.asString() == "set" && symbol.isOperatorEligible() && + symbol.isVisibleAt(useSite) && symbol.acceptsIndexedSetterArguments(arguments, substitutor, indexedWrite = true) } }.getOrDefault(true) + +/** + * Tells whether this member can be called from [useSite]. + * + * Only `private` is filtered. A `private` member is selectable solely inside its declaring class + * and the classes nested in it, so a caller elsewhere binds an indexed write to an extension even + * though the class's member scope still lists the member. Every other visibility admits at least + * the callers this operation considers, and a `null` use site — a caller the operation cannot see — + * keeps the member, matching the conservative answer given when the arguments are unknown. + * + * @param useSite expression performing the access, or `null` when the caller is unknown + * @return `true` when this member may serve the access + */ +private fun KaDeclarationSymbol.isVisibleAt(useSite: KtExpression?): Boolean { + if (visibility != KaSymbolVisibility.PRIVATE) return true + if (useSite == null) return true + val declaringClass = (psi as? KtDeclaration)?.containingClassOrObject ?: return false + var current = PsiTreeUtil.getParentOfType(useSite, KtClassOrObject::class.java) + while (current != null) { + if (current == declaringClass) return true + current = PsiTreeUtil.getParentOfType(current, KtClassOrObject::class.java) + } + return false +} diff --git a/Nbm/pom.xml b/Nbm/pom.xml index 24aceae51..ab0b46d19 100644 --- a/Nbm/pom.xml +++ b/Nbm/pom.xml @@ -5,7 +5,7 @@ io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-nbm diff --git a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt index f4bb2da2a..ff9b0c269 100644 --- a/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaChangePackageTest.kt @@ -6829,6 +6829,132 @@ class KaChangePackageTest : KotlinTestCase("KaChangePackageTest", "moveFile") { } } + /** + * Verifies a private member setter does not hide a public extension at the caller. + * + * `Box` declares its operator `set(Int, Int)` as `private`, so only `Box` itself can select + * it and a caller in `old` binds `box[0]++` to the public `Box.set` extension. Counting the + * class's own members without their visibility suppresses the extension lookup, so moving the + * extension records no import and the write would silently fall back to `Any.set`. + */ + fun testApply_realSession_ignoresInvisibleMemberSetter() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-private-setter") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("lib/Set.kt") + val usagePath = temporaryRoot.resolve("old/Usage.kt") + Files.writeString( + temporaryRoot.resolve("lib/Box.kt"), + "package lib\n\n" + + "class Box {\n" + + " operator fun get(i: Int): Int = 0\n" + + " private operator fun set(i: Int, value: Int) {}\n" + + "}\n", + ) + Files.writeString( + sourcePath, + "package lib\n\noperator fun Box.set(i: Int, value: Int) {}\n", + ) + Files.writeString( + usagePath, + "package old\n\nimport lib.*\n\nfun use(box: Box) {\n box[0]++\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-private-setter-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the extension the private member does not hide: $text", + text.contains("import dest.set"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * Verifies member provider overloads are ranked with the class's type arguments. + * + * `Delegate` declares `provideDelegate(T, KProperty<*>): Picked` and + * `provideDelegate(Host, KProperty<*>): Decoy`. For `Delegate` Kotlin instantiates the + * first parameter as `Local`, which beats `Host`, so `Picked` is the provider. Ranking the raw + * declarations compares `T`'s upper bound `Any?` with `Host`, selects `Decoy`, and the moving + * `Picked.getValue` is never imported. + */ + fun testApply_realSession_ranksProviderOverloadsWithClassArguments() { + val stdlib = findStdlibJar() ?: run { + println("kotlin-stdlib not on test classpath — skipping Change Package integration test") + return + } + val temporaryRoot = Files.createTempDirectory("nbkotlin-change-package-provider-substitution") + try { + Files.createDirectories(temporaryRoot.resolve("lib")) + Files.createDirectories(temporaryRoot.resolve("old")) + val sourcePath = temporaryRoot.resolve("old/Accessor.kt") + val usagePath = temporaryRoot.resolve("old/Usage.kt") + Files.writeString( + temporaryRoot.resolve("lib/Delegate.kt"), + "package lib\n\n" + + "open class Host\n\n" + + "class Picked\n\n" + + "class Decoy\n\n" + + "class Delegate {\n" + + " operator fun provideDelegate(" + + "thisRef: Host, property: kotlin.reflect.KProperty<*>): Decoy = Decoy()\n" + + " operator fun provideDelegate(" + + "thisRef: T, property: kotlin.reflect.KProperty<*>): Picked = Picked()\n" + + "}\n", + ) + Files.writeString( + sourcePath, + "package old\n\nimport lib.Picked\n\noperator fun Picked.getValue(" + + "thisRef: Any?, property: kotlin.reflect.KProperty<*>): String = \"\"\n", + ) + Files.writeString( + usagePath, + "package old\n\nimport lib.*\n\nclass Local : Host() {\n" + + " val value: String by Delegate()\n}\n", + ) + val session = KotlinAnalysisAPISession.createWithJars( + moduleName = "change-package-provider-substitution-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("dest")), + updateReferences = true, + ) + + assertTrue("Expected Change Package success, got $outcome", outcome is KaChangePackageComputer.ApplyOutcome.Success) + val success = outcome as KaChangePackageComputer.ApplyOutcome.Success + val text = success.changedFiles[usagePath.toString()] ?: error("The caller must change") + assertTrue( + "The caller must import the accessor of the instantiated provider: $text", + text.contains("import dest.getValue"), + ) + } finally { + temporaryRoot.toFile().deleteRecursively() + } + } + /** * Verifies a wildcard-imported type alias survives a move into a shadowing package. * diff --git a/pom.xml b/pom.xml index b3caf7f3e..0c6bc86df 100644 --- a/pom.xml +++ b/pom.xml @@ -4,7 +4,7 @@ 4.0.0 io.github.nbplugins netbeans-plugin-kotlin - 0.12.64-SNAPSHOT + 0.12.65-SNAPSHOT pom Netbeans Plugin Kotlin