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