diff --git a/CHANGELOG.md b/CHANGELOG.md index c06474f6..a90074e2 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, 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 32c2fb11..4545e185 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-core-impl diff --git a/KotlinCodeInsightImpl/pom.xml b/KotlinCodeInsightImpl/pom.xml index 588d93e6..70f2f2ae 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-code-insight-impl diff --git a/KotlinCompilerCliBase/pom.xml b/KotlinCompilerCliBase/pom.xml index 7dbc1684..07bd34a9 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-compiler-cli-base diff --git a/KotlinFixesImpl/pom.xml b/KotlinFixesImpl/pom.xml index 0e8cf7ac..28f61e04 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-fixes-impl diff --git a/KotlinFormatter/pom.xml b/KotlinFormatter/pom.xml index 90f9d87d..0efdc67f 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-formatter diff --git a/KotlinHighlighting/pom.xml b/KotlinHighlighting/pom.xml index 2940d821..8b26ba3a 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-highlighting diff --git a/KotlinIcons/pom.xml b/KotlinIcons/pom.xml index 74365e0a..afa19ea7 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-icons diff --git a/KotlinIntentionUtils/pom.xml b/KotlinIntentionUtils/pom.xml index 21367336..bb497dab 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-intention-utils diff --git a/KotlinJ2K/pom.xml b/KotlinJ2K/pom.xml index fd0210b7..a5844a08 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.65-SNAPSHOT ../pom.xml netbeans-plugin-kotlin-j2k diff --git a/KotlinRefactoring/pom.xml b/KotlinRefactoring/pom.xml index 181f0539..80bfb2c6 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.65-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 337ad4db..507b1388 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,32 +16,78 @@ *******************************************************************************/ 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 +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.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 +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.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 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.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 +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.KtPsiFactory -import org.jetbrains.kotlin.resolve.ImportPath +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 +import org.jetbrains.kotlin.psi.KtTreeVisitorVoid +import org.jetbrains.kotlin.psi.KtTypeAlias +import org.jetbrains.kotlin.psi.KtUnaryExpression +import org.jetbrains.kotlin.psi.KtUserType /** * 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, ) { - /** 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. */ @@ -81,48 +127,149 @@ 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. + // 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 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() - val samePackageImportUpdates = if (updateReferences) { - findSamePackageImportUpdates(files, targets) + // 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 { - emptyMap() + 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 + 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. + 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() + } + 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() } 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 + 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 importEdits = captureImportEdits( + participants, + movedDeclarations, + movedClassFqNames, + movedPaths, + ) + (importEdits.keys + incoming.edits.keys).associateWith { file -> + importEdits[file].orEmpty() + incoming.edits[file].orEmpty() + } + } else { + emptyMap() + } + // 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 + 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 packageDirectiveRanges = participants.mapNotNull { file -> + val path = file.virtualFile?.path ?: return@mapNotNull null + 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 -> 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()) - file.virtualFile?.path?.let { changedFiles[it] = changedText } + participants.forEach { file -> + val path = file.virtualFile?.path ?: return@forEach + val originalText = originalTexts[path] ?: return@forEach + 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) directiveRange?.second else null + val retargeted = applyTextEdits(text, textEdits[file].orEmpty(), shift, shiftAfter) + changedFiles[path] = addSamePackageImports( + retargeted.text, + samePackageImportUpdates[file].orEmpty(), + importInsertions[path], + retargeted.edits, + shift, + ) } ApplyOutcome.Success(changedFiles, originalTexts) } catch (error: Throwable) { @@ -130,80 +277,1906 @@ class KaChangePackageComputer( } } + /** Caller-side text changes captured before the package directives mutate. */ + private data class IncomingRetargeting( + val imports: Map> = emptyMap(), + val edits: Map> = emptyMap(), + val conflicts: List = emptyList(), + ) + /** - * Finds old-package simple references that will lose their implicit package visibility. + * Captures how callers must change so references to moved declarations keep resolving. * - * 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. + * 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. 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 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 imports that callers still need and reference replacements captured from PSI */ - private fun findSamePackageImportUpdates( + 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(org.jetbrains.kotlin.name.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) -> + if (movedDeclarations.isEmpty()) return IncomingRetargeting() + 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 + val originalPackage = oldFqName.parent() session.findUsages(declaration).forEach { reference -> - val file = reference.element?.containingFile as? KtFile ?: return@forEach - if (file in movedFiles || file.packageFqName != declaration.containingKtFile.packageFqName) return@forEach - updates.getOrPut(file, ::linkedSetOf) += targetFqName + val element = reference.element as? KtElement ?: return@forEach + val file = element.containingFile as? KtFile ?: 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, 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 + 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.aliasName == null && + directive.importedFqName == oldFqName + } + if (importsDirectly) return@forEach + // 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 + } + 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 + 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." + } + } + } + // 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. 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() + } + session.findImportingFiles(ktFile.project, movedImports).forEach { file -> + file.importDirectives.forEach directives@{ directive -> + val importedFqName = directive.importedFqName ?: return@directives + if (directive.isAllUnder) return@directives + if (importedFqName !in movedOldFqNames) return@directives + // 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, aliased = directive.aliasName != null, + ) + ) { + conflicts += "Cannot update ${file.name}: import ${importedFqName.asString()} " + + "also binds declarations that stay behind." + } + } + } + return IncomingRetargeting( + imports, + edits.mapValues { (_, fileEdits) -> fileEdits.toList() }, + conflicts, + ) + } + + /** + * 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, 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, indexedWrite = true) + } + 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 + // 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. + val arguments = expression.setterArgumentTypes() + if (!receiverType.hasApplicableMemberIndexedSetter(arguments, expression)) { + record(movedOperator(receiverType, "set", arguments)) + } + } + + // 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? { + 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 + // 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)) + } + 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. + * + * 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. + * + * 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, + } + + /** + * 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. 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. + * + * 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 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 + * @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, + movedDeclaration: KtNamedDeclaration, + oldFqName: FqName, + newFqName: FqName, + edits: MutableMap>, + 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 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 + if (root != null) { + val rootSymbol = runCatching { + analyze(file) { root.mainReference?.resolveToSymbol() } + }.getOrNull() + 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) { + 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, + KotlinSourceNames.renderFqName(newFqName), + ) + return QualifiedRewrite.NEEDS_IMPORT + } + val packageHead = newFqName.parent().pathSegments().first().asString() + // 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 + // the package, so the chain is shortened and the declaration imported instead. + val shortName = newFqName.shortName().asString() + 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 + } + 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 [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, 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, imports, 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) { + // 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) + } + }) + return found + } + + /** + * 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, + 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. + * + * 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 (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 + .filterNot { directive -> directive.isAllUnder || directive.aliasName != null } + .mapNotNull { it.importedFqName } + .toSet() + val starPackages = file.importDirectives + .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) { + 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 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, + // 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 + 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) + ?: return + } + 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`, 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 != null && symbol !is KaPackageSymbol + } == true + if (!receiverIsValue && expression.isPackageQualified(originalPackage)) return + missingImports += importFqName + } + + // A call on a value spells no name for the convention it invokes: + // `(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) + val symbols = expression.references.filterIsInstance() + .flatMap { reference -> + runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() + } + // 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 + } + .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 + // 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 + 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 + if (importFqName in importedFqNames || originalPackage in starPackages) { + return@forEach + } + missingImports += importFqName + } + } + + // 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) + val symbols = expression.references + .filterIsInstance() + .flatMap { reference -> + runCatching { reference.resolveToSymbols() }.getOrNull().orEmpty() + } + 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 + } + .forEach { importFqName -> 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. 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) + val resolvedOperators = expression.references + .filterIsInstance() + .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) + } + retainedOperators + .filter { importFqName -> + importFqName !in importedFqNames && originalPackage !in starPackages + } + .forEach { importFqName -> missingImports += importFqName } + // 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()) { + 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 + } + ?.let { importFqName -> missingImports += importFqName } + } + } + + // A delegated property spells neither accessor: `val value by Delegate()` + // 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 + // 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. A provider imported from another package + // survives the move unchanged and is found through the property's + // scope, not through the old package. + val providerArguments = + property.delegationArgumentTypes("provideDelegate") + val provider = delegateType.memberDelegateOperator( + "provideDelegate", + providerArguments, + ) + ?: property.scopeDelegateOperator( + delegateType, + "provideDelegate", + providerArguments, + ) + ?: samePackageDelegateOperator( + delegateType, + "provideDelegate", + originalPackage, + movedPaths = null, + arguments = providerArguments, + ) + // `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 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( + accessorType, + "getValue", + originalPackage, + movedPaths, + )?.let { symbol -> add(symbol) } + if (property.isVar) { + property.selectedDelegateAccessor( + accessorType, + "setValue", + originalPackage, + movedPaths, + )?.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) + } + .filter { importFqName -> + importFqName !in importedFqNames && originalPackage !in starPackages + } + .forEach { importFqName -> missingImports += importFqName } + } + }) + } + } + if (missingImports.isNotEmpty()) updates[file] = missingImports } return updates } /** - * Adds imports for former same-package declarations that no longer resolve implicitly. + * 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. 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 + * @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 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 + is KaClassLikeSymbol -> symbol.classId + else -> null + } + if (classId != null) { + if (classId.isLocal || classId.isNestedClass) 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. + if (symbol is KaCallableSymbol) { + val callableId = symbol.callableId ?: return null + if (callableId.className != null) return null + return callableId.asSingleFqName() + } + if (psi !is KtNamedDeclaration) return null + if (psi.parent !is KtFile) return null + val name = psi.name ?: return null + 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 + // 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 + } + // 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. + val fqName = topLevelDeclarationFqName(symbol) ?: return null + if (fqName.shortName().asString() != name) return null + 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. + * + * 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. + 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 + } + + /** + * Finds the `set` extension a compound indexed write needs from the original package. * - * 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. + * 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. * - * @return final source text with a canonical package/import boundary + * @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 */ - 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()}" + @OptIn(KaExperimentalApi::class) + context(_: KaSession) + private fun samePackageIndexedSetter( + expression: KtArrayAccessExpression, + originalPackage: FqName, + movedPaths: Set, + ): FqName? { + val receiverType = expression.arrayExpression?.expressionType ?: return null + val candidates = findPackage(originalPackage)?.packageScope?.declarations + ?.filterIsInstance() + ?.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(), indexedWrite = true) + ?: return null + return samePackageTopLevelName(selected, originalPackage, movedPaths) + } + + /** + * 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 a fitting one. Matching by name alone would treat an + * 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. 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 + * @return the applicable member operator, or `null` when the type declares none + */ + context(_: KaSession) + private fun KaType.memberDelegateOperator( + name: String, + arguments: List, + ): KaCallableSymbol? = runCatching { + val candidates = expandedSymbol?.memberScope?.declarations + ?.filterIsInstance() + ?.filter { symbol -> + (symbol as? KaNamedSymbol)?.name?.asString() == name && + symbol.isDelegateOperatorShape(name) + } + ?.toList() + .orEmpty() + selectMember(candidates, arguments, classTypeSubstitutor()) + }.getOrNull() + + /** + * Finds the delegate accessor the property currently selects. + * + * 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 selected accessor extension, or `null` when none applies + */ + context(_: KaSession) + private fun KtProperty.selectedDelegateAccessor( + accessorType: KaType, + operatorName: String, + originalPackage: FqName, + movedPaths: Set, + ): KaCallableSymbol? { + val arguments = delegationArgumentTypes(operatorName) + if (accessorType.memberDelegateOperator(operatorName, arguments) != null) return null + return scopeDelegateOperator(accessorType, operatorName, arguments) + ?: samePackageDelegateOperator(accessorType, operatorName, originalPackage, movedPaths, arguments) + } + + /** + * Finds the delegate operator of [name] the file's scope selects on [type]. + * + * 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 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, + 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 + // `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, arguments)?.let { selected -> return@runCatching selected } } - 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) + null + }.getOrNull() + + /** + * 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, 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, 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) + context(_: KaSession) + private fun samePackageDelegateOperator( + type: KaType, + operatorName: String, + originalPackage: FqName, + movedPaths: Set?, + arguments: List? = null, + ): KaCallableSymbol? { + val candidates = findPackage(originalPackage)?.packageScope?.declarations + ?.filterIsInstance() + ?.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 and these arguments. + return selectExtension(candidates, type, arguments) + } + + /** + * 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() + // 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 -> + !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]. + * + * `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 + } + } + + /** + * 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.qualifiedReceiver(): KtElement? { + val selector = (parent as? KtCallExpression)?.takeIf { it.calleeExpression == this } ?: this + return when (val qualified = selector.parent as? KtDotQualifiedExpression) { + null -> { + val userType = parent as? KtUserType + if (userType == null || userType.referenceExpression != this) return null + userType.qualifier ?: return null + } + else -> { + 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.") + } + + /** + * 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 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. + * + * @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, startOffset: Int): String { + if (targetPackage.isRoot) return text + val directive = "package ${targetPackage.asString()}" + 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. */ + 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. + * 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 + */ + 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 || directive.aliasName != null } + .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", "") + } + // 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") + } + + /** + * Adds imports that no longer arrive implicitly from the package or a wildcard import. + * + * 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 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, + insertion: ImportInsertion?, + appliedEdits: List, + shift: Int, + ): String { + if (imports.isEmpty() || insertion == null) return usageText + val missingImports = imports.filterNot { imported -> imported in insertion.existingFqNames } + .map { imported -> "import ${KotlinSourceNames.renderFqName(imported)}" } + 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") + 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. */ 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) + + /** + * 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. A wildcard import + * follows only when its prefix itself is a moved class; a package wildcard keeps binding + * 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 + .sortedByDescending { (oldFqName, _) -> oldFqName.pathSegments().size } + val edits = linkedMapOf>() + participants.forEach { file -> + file.importDirectives.forEach { directive -> + 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 + 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) + 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()) { + "" + } else { + suffixSegments.joinToString(separator = ".", prefix = ".") { segment -> + KotlinSourceNames.renderIdentifier(segment.asString()) + } + } + 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, 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 + * @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.hasDeclarationsInPackage(ktFile.project, imported, movedPaths)) return null + // 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 && + (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 + } + /** - * Replaces direct imports after K2 code retargeting. + * Returns the deepest moved class whose FQN is [imported] or a prefix of it. * - * 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. + * 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 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 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. + * + * @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. + * + * 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. + * + * 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) + } + return KotlinSourceNames.renderFqName(newFqName) + suffixText + } + return null + } + + /** + * 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 name of the qualified chain ending at a reference. + * + * [identifiers] ignores source spelling (backticks, comments), and [rootReference] is the + * 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: KtSimpleNameExpression?, + ) + + /** + * Tells whether this chain's PSI identifiers spell [fqName]. + * + * 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 identifiers denote exactly that FQN + */ + 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 identifiers `old`, `pkg`, `moved` while dropping any + * 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? { + var current: KtElement = this + var start = textRange.startOffset + val end = textRange.endOffset + var rootReference: KtSimpleNameExpression? = null + while (true) { + val parent = current.parent + when { + parent is KtDotQualifiedExpression && parent.selectorExpression == current -> { + 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() + } + current = parent + } + else -> return QualifiedChain(start, end, current.identifiersWithin(start, end), rootReference) + } + } + } + + /** + * 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. + * + * 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(): KtSimpleNameExpression? = when (this) { + is KtDotQualifiedExpression -> receiverExpression.leadingReference() + is KtUserType -> qualifier?.leadingReference() ?: referenceExpression + is KtSimpleNameExpression -> this + else -> null + } + + /** 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. + * + * 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 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, + shiftAfter: Int?, + ): AppliedEdits { + if (edits.isEmpty()) return AppliedEdits(text, emptyList()) + val shifted = edits.asSequence() + .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 }) + .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 AppliedEdits(result.toString(), accepted) } } 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 00000000..265f011e --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaDelegateOperatorShape.kt @@ -0,0 +1,171 @@ +/******************************************************************************* + * 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.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.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 +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 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 + * 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. + * + * @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 + if (!function.isOperatorEligible()) return false + val expectedParameters = if (operatorName == "setValue") 3 else 2 + if (function.valueParameters.size != expectedParameters) return false + return runCatching { + val propertyType = findClass(K_PROPERTY_CLASS_ID)?.defaultTypeWithStarProjections ?: return@runCatching false + propertyType.isSubtypeOf(function.valueParameters[1].returnType) + }.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 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 + */ +@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 + return classSymbol.defaultType.allSupertypes.any { supertype -> + val substitutor = supertype.classTypeSubstitutor() + supertype.expandedSymbol?.memberScope?.callables(name) + ?.filterIsInstance() + ?.any { candidate -> + 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. + * + * 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 new file mode 100644 index 00000000..bcaa604f --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaOperatorApplicability.kt @@ -0,0 +1,1112 @@ +/******************************************************************************* + * 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.util.PsiTreeUtil +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 +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 +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.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 +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 +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 + +/** + * 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 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, + arguments: List? = null, +): KaType = runCatching { + val declaredReturnType = returnType + val mappings = linkedMapOf() + val declaredReceiverType = receiverParameter?.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 } + } + } +} + +/** + * Collects the type-parameter instantiations the implicit call's arguments imply. + * + * 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. + * + * 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 + * @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 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 knownSubstitutor = if (mappings.isEmpty()) { + null + } else { + runCatching { createSubstitutor(mappings.toMap()) }.getOrNull() + } + val parameterTypes = parameters.map { parameter -> + val declaredType = parameter.returnType + listOfNotNull(classSubstitutor, knownSubstitutor).fold(declaredType) { type, substitutor -> + runCatching { substitutor.substitute(type) }.getOrDefault(type) + } + } + return arguments.withIndex().all { (index, argument) -> + 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) + } + } +} + +/** + * 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 + */ +@OptIn(KaExperimentalApi::class) +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 + 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 = matchingType.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. 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 type of the assigned value, 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 + 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 +} + +/** + * Returns the type of the implicit `thisRef` argument a delegated property's accessors receive. + * + * 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 ?: 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() + } + } + +/** + * 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, 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 + */ +context(_: KaSession) +internal fun KtProperty.delegationArgumentTypes(operatorName: String): List { + val thisRef = + if (operatorName == "provideDelegate") providerThisRefType() else delegationThisRefType() + 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. + * + * 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. 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. + * + * 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 + 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 (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) -> + argument == null || acceptsArgument(parameterTypes[index], 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 parameterIndex = when { + index < varargIndex -> index + index >= varargEnd -> index - varargEnd + varargIndex + 1 + else -> varargIndex + } + argument == null || acceptsArgument(parameterTypes[parameterIndex], argument) + } +}.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. + * + * 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]. + * + * A type parameter is not a wildcard: `T : Host` accepts only arguments that satisfy `Host`, so + * its bounds decide applicability. Treating every type parameter as applicable selects an + * 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, 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 + * @return `true` when assignability holds or cannot be decided + */ +context(_: KaSession) +private fun acceptsArgument(parameterType: KaType, argument: KaType): Boolean = runCatching { + if (parameterType is KaTypeParameterType) { + 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. + * + * 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. 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 + */ +internal 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 + * @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) +context(_: KaSession) +internal fun selectMember( + candidates: List, + arguments: List, + substitutor: KaSubstitutor? = null, + indexedWrite: Boolean = false, +): KaCallableSymbol? { + val applicable = candidates.filter { candidate -> + candidate.acceptsIndexedSetterArguments(arguments, substitutor, indexedWrite) + } + return applicable.firstOrNull { candidate -> + applicable.none { other -> + other !== candidate && other.compareParameterPreference(candidate, arguments, indexedWrite, substitutor) < 0 + } + } ?: applicable.firstOrNull() +} + +/** + * 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; 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 + * @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) +context(_: KaSession) +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) && + (arguments == null || + candidate.acceptsIndexedSetterArguments( + arguments, + candidate.receiverTypeSubstitutor(receiverType), + indexedWrite, + )) + } + return applicable.firstOrNull { candidate -> + applicable.none { other -> + other !== candidate && other.isMoreSpecificThan(candidate, receiverType, arguments, indexedWrite) + } + } ?: 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] 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`. 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 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.compareOverloadPreference( + other: KaCallableSymbol, + receiverType: KaType, + arguments: List?, + indexedWrite: Boolean, +): 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, + 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. 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 + */ +@OptIn(KaExperimentalApi::class) +context(_: KaSession) +private fun KaCallableSymbol.compareParameterPreference( + other: KaCallableSymbol, + arguments: List?, + indexedWrite: Boolean, + substitutor: KaSubstitutor? = null, + otherSubstitutor: KaSubstitutor? = substitutor, +): Int = runCatching { + 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 + } + } + 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.compareTo(otherOmittedDefaults) + } + // Without resolved arguments nothing binds, so the declared tuples are all that remains. + if (ownBoundTypes == null || otherBoundTypes == null) { + 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. + * + * @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]. + * + * 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]. + * + * `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.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 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, this)) 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, indexedWrite = true)?.let { return it } + } + return null +} + +/** + * 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 — 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 + * @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?, + useSite: KtExpression? = null, +): 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.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/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KaRenameComputer.kt index 000a0c4b..535d39b5 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,17 +23,34 @@ 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.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 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 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 /** @@ -187,6 +204,49 @@ 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 + } + + /** + * 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]. * @@ -303,25 +363,214 @@ class KaRenameComputer( return result } - /** Returns all reference expression ranges in [kaFile] that resolve to [targetPsi]. */ - private fun searchFileForUsages(kaFile: KtFile, targetPsi: PsiElement): List> { - val ranges = mutableListOf>() + /** + * 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 checked against classifiers only, because Kotlin resolves type names in a + * 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 + */ + fun findCaptureConflicts(): KtFile? { + if (newName.isBlank()) return null + 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) { + // 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. + if (element is KtLabelReferenceExpression) continue + if (PsiTreeUtil.getParentOfType(element, KtImportDirective::class.java) != null) 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 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, rewrittenImport) + } else { + // 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 + } + } + } + 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. + * + * 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. + * + * 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]. + * + * 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> = + 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 + // 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) + // `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 + // 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 val sym = runCatching { expression.mainReference?.resolveToSymbol() }.getOrNull() ?: return - if (sym.psi == targetPsi) { - val tr = expression.textRange - ranges.add(tr.startOffset to tr.endOffset) + val constructsTarget = targetClassId != null && + (sym as? KaConstructorSymbol)?.containingClassId == targetClassId + 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) } }) } }.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/KotlinConstructorSignatureCheck.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt new file mode 100644 index 00000000..12e69573 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinConstructorSignatureCheck.kt @@ -0,0 +1,419 @@ +/******************************************************************************* + * 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.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.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.lexer.KtTokens +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. + * + * 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 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 { + + /** + * Tells whether [function] has the same call signature as a constructor of [primaryClass]. + * + * 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 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 + * 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 + * @return `true` when a call could not tell a constructor and the function apart + */ + fun conflictsWithRenamedConstructor(primaryClass: KtClass, function: KtNamedFunction): Boolean { + if (function.receiverTypeReference != null) return false + val matching = constructorSignatures(primaryClass) + .filter { parameters -> parameters.size == function.valueParameters.size } + if (matching.isEmpty()) return false + return analyze(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 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() + 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, correspondence) + } + } + } + + /** + * Builds the substitutor replacing the function's type parameters with their class counterparts. + * + * @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, + 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) + } + + /** + * Tells whether corresponding type parameters of a matched signature have equivalent bounds. + * + * 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 + * @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) + private fun typeParametersMatch( + classOrObject: KtClass, + 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], functionCounterpart(classParameters[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], functionCounterpart(classParameters[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. + * + * 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. + * + * @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 + 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 + } + } + + /** + * 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 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 + */ + private fun constructorSignatures(classOrObject: KtClass): List> { + val signatures = mutableListOf>() + val primaryConstructor = classOrObject.primaryConstructor + when { + primaryConstructor != null -> signatures += primaryConstructor.valueParameters + 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 new file mode 100644 index 00000000..04c50a07 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinImportBindingCheck.kt @@ -0,0 +1,1433 @@ +/******************************************************************************* + * 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.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.expandedSymbol +import org.jetbrains.kotlin.analysis.api.components.expressionType +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 +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 +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.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 +import org.jetbrains.kotlin.psi.KtClass +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.KtExpression +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 +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.KtTypeParameterListOwner +import org.jetbrains.kotlin.psi.KtUnaryExpression +import org.jetbrains.kotlin.psi.KtUserType +import org.jetbrains.kotlin.psi.KtWhenExpression + +/** + * 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 + // 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) + if (conflicting) 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. + if (expression.isQualifiedSelector() && !expression.isValueReceiverExtension()) return + 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 + } + + // 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 + // reference, so they go through the same check. + override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { + super.visitArrayAccessExpression(expression) + if (!conflicting) { + expression.capturesTopLevelName( + name, + { 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") { + val setter = runCatching { expression.selectedIndexedSetter() }.getOrNull() + if (setter != null && + !setter.belongsTo(excludedDeclarations) && + setter.isTopLevelBinding() + ) { + conflicting = true + } + } + } + + override fun visitDestructuringDeclarationEntry(entry: KtDestructuringDeclarationEntry) { + super.visitDestructuringDeclarationEntry(entry) + if (!conflicting) { + entry.capturesTopLevelName( + name, + { symbol -> !symbol.belongsTo(excludedDeclarations) }, + ) { conflicting = true } + } + } + + override fun visitForExpression(expression: KtForExpression) { + super.visitForExpression(expression) + if (!conflicting) { + expression.capturesTopLevelName( + name, + { symbol -> !symbol.belongsTo(excludedDeclarations) }, + ) { 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 + } + }.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 + * @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( + file: KtFile, + imported: FqName, + movingPaths: Set, + boundName: String = imported.shortName().asString(), + aliased: Boolean = false, + ): Boolean = runCatching { + 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) + if (conflicting) return + 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 + // 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 != boundName && symbol.declaredName() != importedName) 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 + } + + // 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. + override fun visitArrayAccessExpression(expression: KtArrayAccessExpression) { + super.visitArrayAccessExpression(expression) + if (!conflicting && !aliased) { + expression.capturesTopLevelName( + 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") { + val setter = runCatching { expression.selectedIndexedSetter() }.getOrNull() + if (setter != null && + setter.isTopLevelBinding() && + setter.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 } + } + } + + // 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 + } + }.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 + 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) + if (conflicting) return + 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 + 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) { + super.visitArrayAccessExpression(expression) + if (!conflicting) { + expression.capturesTopLevelName( + 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") { + val setter = runCatching { expression.selectedIndexedSetter() }.getOrNull() + if (setter != null && + setter.isTopLevelBinding() && + setter.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 } + } + } + + // 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 + } + }.getOrDefault(false) + + /** + * 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 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 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 + 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 + if (symbol.belongsTo(excludedDeclarations)) return + if (!symbol.isTopLevelBinding()) return + 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, 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 + // 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 + } + }.getOrDefault(false) + + /** + * 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, 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 ?: 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 + } + + /** + * 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]. + * + * 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. + * + * 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, + 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 (!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) { + 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) + } + + /** + * 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]. + * + * 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, 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 + * @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 + * @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( + element: KtElement, + 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 (!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) { + 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) } + // 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) } + // 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 + is KtCatchClause -> current.catchParameter?.bindsName(name) == true + is KtWhenExpression -> current.subjectVariable?.name == name + else -> false + } + if (bound) return true + child = current + current = current.parent + } + 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 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]. + * + * `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. + * + * 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, + 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 { 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]. + * + * 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. 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 or implicit receiver 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 + } + // 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) + + /** + * 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 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 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. + * + * @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 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. + * + * @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. + * + * 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 + } + + /** + * 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. [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 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, + isCapture: (KaSymbol) -> Boolean, + onCapture: () -> Unit, + ) { + forEachConventionSymbol { symbol -> + 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() + } + + /** + * 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() + } + + /** + * 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 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, 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 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. + val arguments = delegationArgumentTypes(name) + return if (delegateType.memberDelegateOperator(name, arguments) != null) { + emptyList() + } else { + listOfNotNull(selectedOperator(delegateType, name, arguments)) + } + } + val accessorNames = if (isVar) listOf("getValue", "setValue") else listOf("getValue") + if (name !in accessorNames) return emptyList() + val provider = delegateOperator(delegateType, "provideDelegate") + val accessorTypes = provider + ?.let { symbol -> + runCatching { + symbol.returnTypeInferredFrom(delegateType, delegationArgumentTypes("provideDelegate")) + }.getOrNull() + } + ?.let { providedType -> listOf(providedType) } + ?: listOf(delegateType) + val arguments = delegationArgumentTypes(name) + return accessorTypes.mapNotNull { accessorType -> + if (accessorType.memberDelegateOperator(name, arguments) != null) { + null + } else { + selectedOperator(accessorType, name, arguments) + } + } + } + + /** + * Finds the operator of [name] the compiler would select on [type]. + * + * 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 + * @return the selected operator, or `null` when none applies + */ + context(_: KaSession) + 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. 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. 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 + * @return the applicable member operator, or `null` when the type declares none + */ + context(_: KaSession) + private fun KaType.memberDelegateOperator( + name: String, + arguments: List, + ): KaCallableSymbol? = runCatching { + val candidates = expandedSymbol?.memberScope?.declarations + ?.filterIsInstance() + ?.filter { symbol -> + (symbol as? KaNamedSymbol)?.name?.asString() == name && + symbol.isDelegateOperatorShape(name) + } + ?.toList() + .orEmpty() + selectMember(candidates, arguments, classTypeSubstitutor()) + }.getOrNull() + + /** + * 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. 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 + * @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, + 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, arguments)?.let { selected -> return@runCatching selected } + } + null + }.getOrNull() + + /** + * 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 [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. + * + * 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 + } + + /** + * 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 — 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 + */ + 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 + // `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 + } + 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 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 + */ + 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 -> { + val companion = (symbol as? KaNamedClassSymbol)?.companionObject + symbol.classKind == KaClassKind.OBJECT || + symbol.classKind == KaClassKind.COMPANION_OBJECT || + companion != null + } + is KaPackageSymbol -> false + else -> true + } + } + else -> true + } + + /** + * 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/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt new file mode 100644 index 00000000..9c9f2630 --- /dev/null +++ b/KotlinRefactoring/src/main/kotlin/io/github/nbplugins/kotlin/refactoring/KotlinSourceNames.kt @@ -0,0 +1,73 @@ +/******************************************************************************* + * 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. + * + * 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 + */ + fun isPlainIdentifier(name: String): Boolean = + name.isNotEmpty() && + (name.first().isLetter() || name.first() == '_') && + name.all { symbol -> symbol.isLetterOrDigit() || symbol == '_' } && + name.any { symbol -> 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/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 c8ad5ac8..59cf4027 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. * @@ -48,6 +60,55 @@ 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() + + /** + * 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]. + * + * 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 a source file or a dependency declaration still belongs to the package + */ + fun hasDeclarationsInPackage( + 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 3ba44a06..ab0b46d1 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.65-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 4f1efc2d..cd8b6e51 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 e611d9d8..fb94416d 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 07fd861e..37f59b46 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 c2fb5ea5..2ee99cc2 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 d0c4a5f7..f69043c5 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/KotlinMoveUsageSearchServiceImpl.kt b/Nbm/src/main/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinMoveUsageSearchServiceImpl.kt index e73f2cf4..c3c2d9cf 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,17 @@ 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.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 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 +54,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 +75,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 } } @@ -83,15 +94,31 @@ class KotlinMoveUsageSearchServiceImpl : KotlinMoveUsageSearchService { } /** - * Finds Kotlin files that directly import a moved top-level declaration. + * 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. * * 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. 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, @@ -103,8 +130,8 @@ 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 -> - MoveImport(imported.parent(), imported.shortName().asString()) in movedImports + !directive.isAllUnder && directive.importedFqName?.let { imported -> + importsMovedDeclaration(imported, movedImports) } == true } } @@ -116,4 +143,132 @@ 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. + * + * 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. 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 + * @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?.let { imported -> + classFqNames.any { movedClass -> movedClass.isPrefixOf(imported) } + } == true + } + } + } + } + + /** + * 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]. + * + * 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 a source file or a dependency declaration still belongs to the package + */ + override fun hasDeclarationsInPackage( + project: com.intellij.openapi.project.Project, + packageFqName: FqName, + excludedPaths: Set, + ): Boolean { + val session = KotlinAnalysisAPISession.forProject(project) ?: return true + 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) + } + + /** + * 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/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 00000000..7eb8b1a8 --- /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 fdee9c15..c066b9f4 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 fb3b9323..12135ba6 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 922378b4..95ea3e93 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 6022e8d0..4338be7b 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,26 @@ 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.KotlinConstructorSignatureCheck +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 import org.netbeans.modules.csl.api.OffsetRange import org.netbeans.modules.refactoring.api.Problem import org.netbeans.modules.refactoring.spi.ProgressProviderAdapter @@ -27,11 +44,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. @@ -46,7 +67,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() @@ -58,10 +81,82 @@ 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 = session.fileMap.values.mapNotNull { lightFile -> session.getKtFileForPath(lightFile.path) } + 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) + ?: findSamePackageOverloadConflict(source, primaryClass, refactoring.newName, files) + ?: 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.", + ) + } } /** @@ -93,8 +188,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 +202,58 @@ 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 + // 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 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 +266,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 +286,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 +327,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 +339,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 +362,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 +406,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 +517,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.") @@ -263,6 +543,159 @@ 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 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 has that name in the same package would be redeclared. A + * 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 + * @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 findRenameConflict( + 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.name == newName } + .map { declaration -> declaration as KtNamedDeclaration } + .filter { declaration -> !declaration.isSameDeclaration(primaryClass) } + .firstOrNull { declaration -> + when (declaration) { + is KtClassOrObject, is KtTypeAlias -> true + is KtNamedFunction -> + KotlinConstructorSignatureCheck.conflictsWithRenamedConstructor(primaryClass, declaration) + else -> false + } + } +} + +/** + * 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. 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 + * @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 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 + } && + (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 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. 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 + * @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 -> + KotlinImportBindingCheck.hasSamePackageReferenceToOtherDeclaration( + file, + newName, + packageName, + setOf(primaryClass), + ) + } +} + +/** 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 1ee7fd44..ff9b0c26 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") { @@ -251,9 +255,6808 @@ 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() + } + } + + /** + * 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() + } + } + + /** + * 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(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", + 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 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(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( + 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 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(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", + ) + 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. + * + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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 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 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 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 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 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. + * + * `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 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 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. + * + * 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. + * + * `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 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 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. + * + * `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. + * + * 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 a caller's alias-bound usage needs no incoming unaliased import. + * + * 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_skipsIncomingImportForAliasBoundUsage() { + 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() = 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 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 { + temporaryRoot.toFile().deleteRecursively() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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 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 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. + * + * `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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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 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. + * + * `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() + } + } + + /** + * 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() + } + } + + /** + * 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 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 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 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 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 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. + * + * 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 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. + * + * `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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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 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. + * + * `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")) .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/KaRenameTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KaRenameTest.kt index 63bca149..20c7a852 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,1111 @@ 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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() + } + } + + /** + * 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 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. + * + * 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 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 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. + * + * 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. + * + * `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() + } + } + + /** + * 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() + } + } + + /** + * 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 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 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 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. + * + * `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 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. + * + * 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. + * + * 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")) + .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/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 00000000..3dcbb06f --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinConstructorSignatureCheckTest.kt @@ -0,0 +1,507 @@ +/******************************************************************************* + * 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 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 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. + * + * `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.conflictsWithRenamedConstructor( + 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.conflictsWithRenamedConstructor( + 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.conflictsWithRenamedConstructor( + primaryClass(types), + functions.declarations.filterIsInstance().single(), + ), + ) + } + } + + /** + * 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(), + ), + ) + } + } + + /** + * 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(), + ), + ) + } + } + + /** + * 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(), + ), + ) + } + } + + /** + * 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(), + ), + ) + } + } + + /** + * 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() + + /** @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/KotlinFilesystemRenameUITest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinFilesystemRenameUITest.kt new file mode 100644 index 00000000..ac94326d --- /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/KotlinImportBindingCheckTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt new file mode 100644 index 00000000..8bb6c893 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinImportBindingCheckTest.kt @@ -0,0 +1,1215 @@ +/******************************************************************************* + * 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 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 + +/** 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()), + ) + } + } + + /** + * 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 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 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 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 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 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. + * + * `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( + 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 { type -> type.name == "Caller" } + .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"), + ) + } + } + + /** + * 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"), + ) + } + } + + /** + * 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"), + ) + } + } + + /** + * 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 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. + * + * `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()), + ), + ) + } + } + + /** + * 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"), + ) + } + } + + /** + * 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), + ) + } + } + + /** + * 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") + + /** + * 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/KotlinPackageRenameMappingTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinPackageRenameMappingTest.kt new file mode 100644 index 00000000..db16e08d --- /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 b89c6872..91ff85d6 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 e0e60a21..79fe0971 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/KotlinRenameClassifierConflictTest.kt b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt new file mode 100644 index 00000000..eb7508d2 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinRenameClassifierConflictTest.kt @@ -0,0 +1,577 @@ +/******************************************************************************* + * 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 = findRenameConflict(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 = findRenameConflict(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 = findRenameConflict(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 = 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) + } + } + + /** + * 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) + } + } + + /** + * 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) + } + } + + /** + * 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 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. + * + * 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. + * + * `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() + + /** @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 bf0e9b15..fb5c1e2d 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,38 @@ 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 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") @@ -42,6 +71,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 +119,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/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 00000000..aaeef9f7 --- /dev/null +++ b/Nbm/src/test/kotlin/io/github/nbplugins/kotlin/nbm/refactoring/KotlinSourceNamesTest.kt @@ -0,0 +1,71 @@ +/******************************************************************************* + * 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("")) + assertFalse("all-underscore names are reserved", KotlinSourceNames.isPlainIdentifier("_")) + assertFalse("all-underscore names are reserved", 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")) + assertEquals("`_`", KotlinSourceNames.renderIdentifier("_")) + } + + /** 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/docs/development-plan.md b/docs/development-plan.md index 802d7593..541911ac 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 b27ea037..75a581f1 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 8438a8cb..0c6bc86d 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.65-SNAPSHOT pom Netbeans Plugin Kotlin