diff --git a/java/idea-ui/BUILD.bazel b/java/idea-ui/BUILD.bazel index 0467906557d4..0d30847ad78d 100644 --- a/java/idea-ui/BUILD.bazel +++ b/java/idea-ui/BUILD.bazel @@ -52,6 +52,7 @@ jvm_library( "@lib//:maven-resolver-provider", "//java/java-syntax:syntax", "@lib//:slf4j-api", + "@lib//:asm", ], runtime_deps = [":ui_resources"] ) diff --git a/java/idea-ui/intellij.java.ui.iml b/java/idea-ui/intellij.java.ui.iml index fcf57a3179b6..bc0f509c8e9c 100644 --- a/java/idea-ui/intellij.java.ui.iml +++ b/java/idea-ui/intellij.java.ui.iml @@ -48,5 +48,6 @@ + \ No newline at end of file diff --git a/java/idea-ui/resources/messages/JavaUiBundle.properties b/java/idea-ui/resources/messages/JavaUiBundle.properties index f1642113b251..9617b8ec0121 100644 --- a/java/idea-ui/resources/messages/JavaUiBundle.properties +++ b/java/idea-ui/resources/messages/JavaUiBundle.properties @@ -598,6 +598,7 @@ progress.title.extract.module.analyzing.dependencies=Analyzing dependencies of ' button.text.extract.module=Extract checkbox.move.classes.to.separate.source.root=Move classes to a separate source root: dialog.title.specify.path.to.new.source.root=Specify Path to New Source Root +dialog.comment.compile.modules=Project will be compiled to find references. dialog.message.failed.to.extract.module=Failed to extract a module: {0} select=Select intellij.idea.module.file.iml=Intellij IDEA module file (*.iml) diff --git a/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFileProcessor.kt b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFileProcessor.kt new file mode 100644 index 000000000000..991b1372ffd0 --- /dev/null +++ b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFileProcessor.kt @@ -0,0 +1,34 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.extractModule + +import com.intellij.java.analysis.bytecode.JvmBytecodeAnalysis +import com.intellij.java.analysis.bytecode.JvmBytecodeReferenceProcessor +import com.intellij.java.analysis.bytecode.JvmClassBytecodeDeclaration +import java.nio.file.Path + +internal class ExtractModuleFileProcessor { + private val mutableReferencedClasses: MutableSet = HashSet() + private val mutableGatheredClassLinks: MutableMap> = HashMap() + + private val referenceProcessor = object : JvmBytecodeReferenceProcessor { + override fun processClassReference(targetClass: JvmClassBytecodeDeclaration, sourceClass: JvmClassBytecodeDeclaration) { + val targetClassName = targetClass.topLevelSourceClassName + if (targetClassName.startsWith("[L")) return // ignore array classes + val sourceClassName = sourceClass.topLevelSourceClassName + if (sourceClassName != targetClassName) { + mutableReferencedClasses.add(targetClassName) + mutableGatheredClassLinks.computeIfAbsent(sourceClassName) { HashSet() }.add(targetClassName) + } + } + } + + val classFileAnalyzer = JvmBytecodeAnalysis.getInstance().createReferenceAnalyzer(referenceProcessor) + + val referencedClasses: Set + get() = mutableReferencedClasses + + val gatheredClassLinks: Map> + get() = mutableGatheredClassLinks + + fun processFile(path: Path) = classFileAnalyzer.processFile(path) +} \ No newline at end of file diff --git a/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageAction.kt b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageAction.kt index 1e34d8f601a6..343e00baeea0 100644 --- a/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageAction.kt +++ b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageAction.kt @@ -1,37 +1,14 @@ // Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.ide.extractModule -import com.intellij.CommonBundle -import com.intellij.analysis.AnalysisScope -import com.intellij.ide.JavaUiBundle -import com.intellij.ide.SaveAndSyncHandler import com.intellij.openapi.actionSystem.ActionUpdateThread import com.intellij.openapi.actionSystem.AnAction import com.intellij.openapi.actionSystem.AnActionEvent import com.intellij.openapi.actionSystem.CommonDataKeys -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runReadAction -import com.intellij.openapi.application.runWriteAction -import com.intellij.openapi.diagnostic.ControlFlowException -import com.intellij.openapi.diagnostic.logger -import com.intellij.openapi.module.Module -import com.intellij.openapi.module.ModuleManager -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.Task -import com.intellij.openapi.roots.* -import com.intellij.openapi.roots.libraries.Library -import com.intellij.openapi.ui.Messages -import com.intellij.openapi.util.NlsSafe -import com.intellij.openapi.vfs.VfsUtil -import com.intellij.packageDependencies.ForwardDependenciesBuilder -import com.intellij.psi.JavaDirectoryService -import com.intellij.psi.PsiDirectory +import com.intellij.openapi.components.service +import com.intellij.openapi.roots.ModuleRootManager +import com.intellij.openapi.roots.ProjectFileIndex import com.intellij.psi.PsiManager -import com.intellij.refactoring.move.moveClassesOrPackages.MoveClassesOrPackagesUtil -import com.intellij.workspaceModel.ide.legacyBridge.impl.java.JAVA_MODULE_ENTITY_TYPE_ID_NAME -import org.jetbrains.annotations.TestOnly -import org.jetbrains.concurrency.AsyncPromise -import org.jetbrains.concurrency.Promise import java.nio.file.Path class ExtractModuleFromPackageAction : AnAction() { @@ -46,10 +23,11 @@ class ExtractModuleFromPackageAction : AnAction() { Path.of(parentContentRoot.path, directory.name, "src").toString()) if (!dialog.showAndGet()) return - analyzeDependenciesAndCreateModule(directory, module, dialog.moduleName, dialog.targetSourceRootPath) + project.service() + .analyzeDependenciesAndCreateModuleInBackground(directory, module, dialog.moduleName, dialog.targetSourceRootPath) } - override fun getActionUpdateThread() = ActionUpdateThread.BGT + override fun getActionUpdateThread(): ActionUpdateThread = ActionUpdateThread.BGT override fun update(e: AnActionEvent) { val project = e.project @@ -57,113 +35,4 @@ class ExtractModuleFromPackageAction : AnAction() { e.presentation.isEnabledAndVisible = project != null && file != null && file.isDirectory && ProjectFileIndex.getInstance(project).isInSourceContent(file) } - - companion object { - private val LOG = logger() - - private fun analyzeDependenciesAndCreateModule(directory: PsiDirectory, - module: Module, - moduleName: @NlsSafe String, - targetSourceRootPath: String?): Promise { - val promise = AsyncPromise() - val dependenciesBuilder = ForwardDependenciesBuilder(module.project, AnalysisScope(directory)) - object : Task.Backgroundable(module.project, - JavaUiBundle.message("progress.title.extract.module.analyzing.dependencies", directory.name)) { - override fun run(indicator: ProgressIndicator) { - indicator.isIndeterminate = false - dependenciesBuilder.analyze() - val usedModules = LinkedHashSet() - val usedLibraries = LinkedHashSet() - runReadAction { - val fileIndex = ProjectFileIndex.getInstance(module.project) - dependenciesBuilder.directDependencies.values.asSequence().flatten().forEach { file -> - val virtualFile = file.virtualFile ?: return@forEach - val depModule = fileIndex.getModuleForFile(virtualFile) - if (depModule != null) { - usedModules.add(depModule) - return@forEach - } - val library = fileIndex.getOrderEntriesForFile(virtualFile).asSequence() - .filterIsInstance() - .filter { !it.isModuleLevel } - .mapNotNull { it.library } - .firstOrNull() - if (library != null) { - usedLibraries.add(library) - } - } - } - ApplicationManager.getApplication().invokeLater { - try { - runWriteAction { - extractModule(directory, module, moduleName, usedModules, usedLibraries, targetSourceRootPath) - } - ModuleDependenciesCleaner(module, usedModules).startInBackground(promise) - } - catch (e: Throwable) { - if (e !is ControlFlowException) { - LOG.info(e) - Messages.showErrorDialog(project, JavaUiBundle.message("dialog.message.failed.to.extract.module", e), CommonBundle.getErrorTitle()) - } - promise.setError(e) - } - } - } - }.queue() - return promise - } - - private fun extractModule(directory: PsiDirectory, module: Module, moduleName: @NlsSafe String, - usedModules: Set, usedLibraries: Set, targetSourceRootPath: String?) { - val packagePrefix = JavaDirectoryService.getInstance().getPackage(directory)?.qualifiedName ?: "" - val targetSourceRoot = targetSourceRootPath?.let { VfsUtil.createDirectories(it) } - val (contentRoot, imlFileDirectory) = if (targetSourceRoot != null) { - val parent = targetSourceRoot.parent - if (parent in ModuleRootManager.getInstance(module).contentRoots) targetSourceRoot to module.moduleNioFile.parent - else parent to parent.toNioPath() - } - else { - directory.virtualFile to module.moduleNioFile.parent - } - - val newModule = ModuleManager.getInstance(module.project).newModule(imlFileDirectory.resolve("$moduleName.iml"), - JAVA_MODULE_ENTITY_TYPE_ID_NAME) - - ModuleRootModificationUtil.updateModel(newModule) { model -> - if (ModuleRootManager.getInstance(module).isSdkInherited) { - model.inheritSdk() - } - else { - model.sdk = ModuleRootManager.getInstance(module).sdk - } - val contentEntry = model.addContentEntry(contentRoot) - if (targetSourceRoot != null) { - contentEntry.addSourceFolder(targetSourceRoot, false) - } - else { - contentEntry.addSourceFolder(directory.virtualFile, false, packagePrefix) - } - val moduleDependencies = JavaProjectDependenciesAnalyzer.removeDuplicatingDependencies(usedModules) - moduleDependencies.forEach { model.addModuleOrderEntry(it) } - val exportedLibraries = HashSet() - for (moduleDependency in moduleDependencies) { - ModuleRootManager.getInstance(moduleDependency).orderEntries().exportedOnly().recursively().forEachLibrary { - exportedLibraries.add(it) - } - } - (usedLibraries - exportedLibraries).forEach { model.addLibraryEntry(it) } - } - if (targetSourceRoot != null) { - val targetDirectory = VfsUtil.createDirectoryIfMissing(targetSourceRoot, packagePrefix.replace('.', '/')) - MoveClassesOrPackagesUtil.moveDirectoryRecursively(directory, PsiManager.getInstance(module.project).findDirectory(targetDirectory.parent)) - } - SaveAndSyncHandler.getInstance().scheduleProjectSave(module.project) - } - - @TestOnly - fun extractModuleFromDirectory(directory: PsiDirectory, module: Module, moduleName: @NlsSafe String, targetSourceRoot: String?): Promise { - return analyzeDependenciesAndCreateModule(directory, module, moduleName, targetSourceRoot) - } - } } - diff --git a/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageDialog.kt b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageDialog.kt index 488878a6b8f1..cca591498743 100644 --- a/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageDialog.kt +++ b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleFromPackageDialog.kt @@ -61,6 +61,9 @@ class ExtractModuleFromPackageDialog(private val project: Project, moduleName: S .component } } + row { + comment(JavaUiBundle.message("dialog.comment.compile.modules")) + } } nameField.select(nameField.text.lastIndexOf('.') + 1, nameField.text.length) return panel diff --git a/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleService.kt b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleService.kt new file mode 100644 index 000000000000..c672726e7c67 --- /dev/null +++ b/java/idea-ui/src/com/intellij/ide/extractModule/ExtractModuleService.kt @@ -0,0 +1,248 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package com.intellij.ide.extractModule + +import com.intellij.CommonBundle +import com.intellij.ide.JavaUiBundle +import com.intellij.ide.SaveAndSyncHandler +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.writeAction +import com.intellij.openapi.compiler.CompilerManager +import com.intellij.openapi.components.Service +import com.intellij.openapi.diagnostic.ControlFlowException +import com.intellij.openapi.diagnostic.logger +import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager +import com.intellij.openapi.project.Project +import com.intellij.openapi.roots.* +import com.intellij.openapi.roots.libraries.Library +import com.intellij.openapi.ui.Messages +import com.intellij.openapi.util.NlsSafe +import com.intellij.openapi.vfs.VfsUtil +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.psi.JavaDirectoryService +import com.intellij.psi.JavaPsiFacade +import com.intellij.psi.PsiDirectory +import com.intellij.psi.PsiManager +import com.intellij.refactoring.move.moveClassesOrPackages.MoveClassesOrPackagesUtil +import com.intellij.util.concurrency.annotations.RequiresEdt +import com.intellij.util.concurrency.annotations.RequiresReadLock +import com.intellij.util.concurrency.annotations.RequiresWriteLock +import com.intellij.util.graph.GraphAlgorithms +import com.intellij.workspaceModel.ide.legacyBridge.impl.java.JAVA_MODULE_ENTITY_TYPE_ID_NAME +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import org.jetbrains.annotations.TestOnly +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi +import kotlin.io.path.extension +import kotlin.io.path.walk + +private val LOG = logger() + +internal class DependentModule( + val module: Module, + val stillDependsOnOldModule: Boolean, +) + +internal suspend fun compilerOutputPath(module: Module): Path? = readAction { + CompilerModuleExtension.getInstance(module)?.compilerOutputPath?.toNioPath() +} + +internal suspend fun Path.forEachClassfile(action: suspend (Path) -> Unit) { + walk().filter { it.extension == "class" }.forEach { path -> + action(path) + } +} + +@Service(Service.Level.PROJECT) +class ExtractModuleService( + private val project: Project, + private val coroutineScope: CoroutineScope, +) { + @RequiresEdt + fun analyzeDependenciesAndCreateModuleInBackground( + directory: PsiDirectory, + module: Module, + moduleName: @NlsSafe String, + targetSourceRootPath: String?, + ) { + CompilerManager.getInstance(project).make { aborted, errors, _, _ -> + if (aborted || errors > 0) { + return@make + } + coroutineScope.launch { + analyzeDependenciesAndCreateModule(directory, module, moduleName, targetSourceRootPath) + } + } + } + + @OptIn(ExperimentalPathApi::class) + private suspend fun analyzeDependenciesAndCreateModule( + directory: PsiDirectory, + module: Module, + moduleName: @NlsSafe String, + targetSourceRootPath: String?, + ) { + withBackgroundProgress(project, JavaUiBundle.message("progress.title.extract.module.analyzing.dependencies", directory.name)) { + val usedModules = LinkedHashSet() + val usedLibraries = LinkedHashSet() + val compilerOutputPath = compilerOutputPath(module) ?: return@withBackgroundProgress + + val packageName = readAction { + JavaDirectoryService.getInstance().getPackage(directory)?.qualifiedName + } ?: return@withBackgroundProgress + val compiledPackagePath = packageName.replace('.', '/').let { compilerOutputPath.resolve(it) } + + val fileProcessor = ExtractModuleFileProcessor() + compiledPackagePath.forEachClassfile { path -> + fileProcessor.processFile(path) + } + + readAction { + val fileIndex = ProjectFileIndex.getInstance(module.project) + fileProcessor.referencedClasses.forEach { className -> + val file = JavaPsiFacade.getInstance(module.project).findClass(className, module.getModuleWithDependenciesAndLibrariesScope( + false))?.containingFile + val virtualFile = file?.virtualFile ?: return@forEach + val depModule = fileIndex.getModuleForFile(virtualFile) + if (depModule != null) { + usedModules.add(depModule) + return@forEach + } + val library = fileIndex.getOrderEntriesForFile(virtualFile).asSequence() + .filterIsInstance().filter { !it.isModuleLevel }.mapNotNull { it.library }.firstOrNull() + if (library != null) { + usedLibraries.add(library) + } + } + } + + try { + val allDependentModules = readAction { + collectDependentModules(module) + } + val packageClasses = fileProcessor.gatheredClassLinks.keys.filterTo(HashSet()) { it.startsWith("$packageName.") } + val moduleClasses = ExtractModuleFileProcessor().let { fileProcessor -> + compilerOutputPath.forEachClassfile { path -> + fileProcessor.processFile(path) + } + fileProcessor.gatheredClassLinks.keys.filterTo(HashSet()) { it !in packageClasses } + } + + val packageDependentModules = filterDependentModules(allDependentModules, packageClasses, moduleClasses) + writeAction { + extractModule(directory, module, moduleName, usedModules, usedLibraries, targetSourceRootPath, packageDependentModules) + } + ModuleDependenciesCleaner(module, usedModules, compilerOutputPath, compiledPackagePath).startInBackground() + } + catch (e: Throwable) { + if (e !is ControlFlowException) { + LOG.info(e) + Messages.showErrorDialog(project, JavaUiBundle.message("dialog.message.failed.to.extract.module", e), + CommonBundle.getErrorTitle()) + } + } + + } + } + + @OptIn(ExperimentalPathApi::class) + private suspend fun filterDependentModules(dependentModules: Set, packageClasses: Set, moduleClasses: HashSet) = + dependentModules.mapNotNull { + val compilerOutputPath = compilerOutputPath(it) ?: return@mapNotNull null + val fileProcessor = ExtractModuleFileProcessor() + + compilerOutputPath.forEachClassfile { path -> + withContext(Dispatchers.IO) { + fileProcessor.processFile(path) + } + } + val packageDependency = fileProcessor.referencedClasses.any { it in packageClasses } + if (!packageDependency) return@mapNotNull null + val stillDependsOnModule = fileProcessor.referencedClasses.any { it in moduleClasses } + DependentModule(it, stillDependsOnModule) + } + + @RequiresReadLock + private fun collectDependentModules(module: Module): Set { + val moduleGraph = ModuleManager.getInstance(project).moduleGraph() + val dependentModules = LinkedHashSet() + GraphAlgorithms.getInstance().collectOutsRecursively(moduleGraph, module, dependentModules) + return dependentModules + } + + @RequiresWriteLock + private fun extractModule( + directory: PsiDirectory, + module: Module, + moduleName: @NlsSafe String, + usedModules: Set, + usedLibraries: Set, + targetSourceRootPath: String?, + packageDependentModules: List, + ) { + val packagePrefix = JavaDirectoryService.getInstance().getPackage(directory)?.qualifiedName ?: "" + val targetSourceRoot = targetSourceRootPath?.let { VfsUtil.createDirectories(it) } + val (contentRoot, imlFileDirectory) = if (targetSourceRoot != null) { + val parent = targetSourceRoot.parent + if (parent in ModuleRootManager.getInstance(module).contentRoots) targetSourceRoot to module.moduleNioFile.parent + else parent to parent.toNioPath() + } + else { + directory.virtualFile to module.moduleNioFile.parent + } + + val newModule = ModuleManager.getInstance(module.project).newModule(imlFileDirectory.resolve("$moduleName.iml"), + JAVA_MODULE_ENTITY_TYPE_ID_NAME) + + ModuleRootModificationUtil.updateModel(newModule) { model -> + if (ModuleRootManager.getInstance(module).isSdkInherited) { + model.inheritSdk() + } + else { + model.sdk = ModuleRootManager.getInstance(module).sdk + } + val contentEntry = model.addContentEntry(contentRoot) + if (targetSourceRoot != null) { + contentEntry.addSourceFolder(targetSourceRoot, false) + } + else { + contentEntry.addSourceFolder(directory.virtualFile, false, packagePrefix) + } + val moduleDependencies = JavaProjectDependenciesAnalyzer.removeDuplicatingDependencies(usedModules) + moduleDependencies.forEach { model.addModuleOrderEntry(it) } + val exportedLibraries = HashSet() + for (moduleDependency in moduleDependencies) { + ModuleRootManager.getInstance(moduleDependency).orderEntries().exportedOnly().recursively().forEachLibrary { + exportedLibraries.add(it) + } + } + (usedLibraries - exportedLibraries).forEach { model.addLibraryEntry(it) } + } + + packageDependentModules.forEach { dependentModule -> + ModuleRootModificationUtil.updateModel(dependentModule.module) { model -> + model.addModuleOrderEntry(newModule) + if (!dependentModule.stillDependsOnOldModule) { + model.findModuleOrderEntry(module)?.let { orderEntry -> + model.removeOrderEntry(orderEntry) + } ?: LOG.error("Could not find module order entry for $module in ${dependentModule.module}") + } + } + } + + if (targetSourceRoot != null) { + val targetDirectory = VfsUtil.createDirectoryIfMissing(targetSourceRoot, packagePrefix.replace('.', '/')) + MoveClassesOrPackagesUtil.moveDirectoryRecursively(directory, + PsiManager.getInstance(module.project).findDirectory(targetDirectory.parent)) + } + SaveAndSyncHandler.getInstance().scheduleProjectSave(module.project) + } + + @TestOnly + suspend fun extractModuleFromDirectory(directory: PsiDirectory, module: Module, moduleName: @NlsSafe String, targetSourceRoot: String?) { + analyzeDependenciesAndCreateModule(directory, module, moduleName, targetSourceRoot) + } +} diff --git a/java/idea-ui/src/com/intellij/ide/extractModule/ModuleDependenciesCleaner.kt b/java/idea-ui/src/com/intellij/ide/extractModule/ModuleDependenciesCleaner.kt index 01fb4f8b6968..16309d150d5a 100644 --- a/java/idea-ui/src/com/intellij/ide/extractModule/ModuleDependenciesCleaner.kt +++ b/java/idea-ui/src/com/intellij/ide/extractModule/ModuleDependenciesCleaner.kt @@ -1,4 +1,4 @@ -// Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.ide.extractModule import com.intellij.analysis.AnalysisScope @@ -7,73 +7,116 @@ import com.intellij.notification.Notification import com.intellij.notification.NotificationAction import com.intellij.notification.NotificationType import com.intellij.openapi.actionSystem.AnActionEvent -import com.intellij.openapi.application.ApplicationManager -import com.intellij.openapi.application.runReadAction -import com.intellij.openapi.application.runWriteAction +import com.intellij.openapi.application.readAction +import com.intellij.openapi.application.writeAction import com.intellij.openapi.module.Module import com.intellij.openapi.module.ModuleManager -import com.intellij.openapi.progress.ProgressIndicator -import com.intellij.openapi.progress.Task import com.intellij.openapi.roots.* import com.intellij.openapi.roots.impl.OrderEntryUtil +import com.intellij.openapi.roots.libraries.Library import com.intellij.packageDependencies.DependenciesToolWindow import com.intellij.packageDependencies.ForwardDependenciesBuilder import com.intellij.packageDependencies.ui.DependenciesPanel -import com.intellij.psi.PsiFile +import com.intellij.platform.ide.progress.withBackgroundProgress +import com.intellij.psi.JavaPsiFacade import com.intellij.ui.content.ContentFactory -import org.jetbrains.concurrency.AsyncPromise +import com.intellij.util.concurrency.annotations.RequiresReadLock +import java.nio.file.Path +import kotlin.io.path.ExperimentalPathApi /** * Finds and removes dependencies which aren't used in the code. */ -class ModuleDependenciesCleaner(private val module: Module, dependenciesToCheck: Collection) { +class ModuleDependenciesCleaner( + private val module: Module, + dependenciesToCheck: Collection, + private val compilerOutputPath: Path, + private val compiledPackagePath: Path, +) { private val dependenciesToCheck = dependenciesToCheck.toSet() private val project = module.project - fun startInBackground(promise: AsyncPromise) { - val builder = ForwardDependenciesBuilder(project, AnalysisScope(module)) - object : Task.Backgroundable(project, JavaUiBundle.message("progress.title.searching.for.redundant.dependencies", module.name)) { - override fun run(indicator: ProgressIndicator) { - indicator.isIndeterminate = false - builder.analyze() - runReadAction { processRedundantDependencies(builder.directDependencies, promise) } + @OptIn(ExperimentalPathApi::class) + suspend fun startInBackground() { + val fileProcessor = ExtractModuleFileProcessor() + compilerOutputPath.forEachClassfile { path -> + if (!path.startsWith(compiledPackagePath)) { + fileProcessor.processFile(path) } - }.queue() + } + + val usedModules: MutableSet = HashSet() + val usedLibraries: MutableSet = HashSet() + + readAction { + val fileIndex = ProjectFileIndex.getInstance(module.project) + fileProcessor.referencedClasses + .asSequence() + .mapNotNull { className -> + findFile(className)?.virtualFile + } + .forEach { virtualFile -> + val dependencyModule = fileIndex.getModuleForFile(virtualFile) + if (dependencyModule != null) { + usedModules.add(dependencyModule) + } + else { + fileIndex.getOrderEntriesForFile(virtualFile).asSequence() + .filterIsInstance() + .filter { !it.isModuleLevel } + .mapNotNull { it.library } + .firstOrNull() + ?.let { usedLibraries.add(it) } + } + } + } + + + val builder = ForwardDependenciesBuilder(project, AnalysisScope(module)) + val dependenciesToRemove = + withBackgroundProgress(project, JavaUiBundle.message("progress.title.searching.for.redundant.dependencies", module.name)) { + builder.analyze() + readAction { processRedundantDependencies(usedModules, fileProcessor.gatheredClassLinks) } + } ?: return + + writeAction { + removeDependencies(dependenciesToRemove, usedModules) + } } - private fun processRedundantDependencies(directDependencies: Map>, promise: AsyncPromise) { - val fileIndex = ProjectFileIndex.getInstance(module.project) - val usedModules = directDependencies.values.asSequence().flatten().mapNotNullTo(HashSet()) { - it.virtualFile?.let { file -> fileIndex.getModuleForFile(file) } - } + @RequiresReadLock + private fun processRedundantDependencies(usedModules: Set, classLinks: Map>): Set? { val dependenciesToRemove = dependenciesToCheck - usedModules if (dependenciesToRemove.isEmpty()) { + val fileIndex = ProjectFileIndex.getInstance(module.project) val builder = ForwardDependenciesBuilder(project, AnalysisScope(module)) - for ((file, dependencies) in directDependencies) { - if (!file.isValid) continue - val relevantDependencies = dependencies.filterTo(LinkedHashSet()) { psiFile -> - val virtualFile = psiFile.virtualFile - virtualFile != null && fileIndex.getModuleForFile(virtualFile) in dependenciesToCheck + + for ((className, dependenciesNames) in classLinks) { + val file = findFile(className) + if (file == null || !file.isValid) continue + val relevantDependencies = dependenciesNames.mapNotNullTo(LinkedHashSet()) { + findFile(it)?.takeIf { psiFile -> + val virtualFile = psiFile.virtualFile + virtualFile != null && fileIndex.getModuleForFile(virtualFile) in dependenciesToCheck + } } + if (relevantDependencies.isNotEmpty()) { builder.directDependencies[file] = relevantDependencies builder.dependencies[file] = relevantDependencies } } showNothingToCleanNotification(builder) - promise.setResult(Unit) - return + return null } - ApplicationManager.getApplication().invokeLater { - runWriteAction { - removeDependencies(dependenciesToRemove, usedModules) - promise.setResult(Unit) - } - } + return dependenciesToRemove } - private fun removeDependencies(dependenciesToRemove: Set, usedModules: HashSet) { + private fun findFile(className: String) = JavaPsiFacade.getInstance(module.project).findClass(className, module.getModuleWithDependenciesAndLibrariesScope( + false))?.containingFile + + private fun removeDependencies(dependenciesToRemove: Set, usedModules: Set) { if (module.isDisposed || dependenciesToRemove.any { it.isDisposed }) return val model = ModuleRootManager.getInstance(module).modifiableModel @@ -110,15 +153,21 @@ class ModuleDependenciesCleaner(private val module: Module, dependenciesToCheck: val transitiveDependenciesMessage = if (transitiveDependenciesToAdd.isNotEmpty()) JavaUiBundle.message("notification.content.transitive.dependencies.were.added", transitiveDependenciesToAdd.first().name, transitiveDependenciesToAdd.size - 1) else "" - val notification = Notification("Dependencies", JavaUiBundle.message("notification.title.dependencies.were.cleaned.up", module.name), - JavaUiBundle.message("notification.content.unused.dependencies.were.removed", dependenciesToRemove.first().name, dependenciesToRemove.size - 1, transitiveDependenciesMessage), - NotificationType.INFORMATION) + val notification = Notification( + "Dependencies", + JavaUiBundle.message("notification.title.dependencies.were.cleaned.up", module.name), + JavaUiBundle.message("notification.content.unused.dependencies.were.removed", dependenciesToRemove.first().name, dependenciesToRemove.size - 1, transitiveDependenciesMessage), + NotificationType.INFORMATION + ) notification.notify(project) } private fun showNothingToCleanNotification(builder: ForwardDependenciesBuilder) { - val notification = Notification("Dependencies", - JavaUiBundle.message("notification.content.none.module.dependencies.can.be.safely.removed", module.name), NotificationType.INFORMATION) + val notification = Notification( + "Dependencies", + JavaUiBundle.message("notification.content.none.module.dependencies.can.be.safely.removed", module.name), + NotificationType.INFORMATION + ) notification.addAction(ShowDependenciesAction(module, builder)) notification.notify(project) } @@ -135,4 +184,4 @@ class ModuleDependenciesCleaner(private val module: Module, dependenciesToCheck: DependenciesToolWindow.getInstance(project).addContent(content) } } -} \ No newline at end of file +} diff --git a/java/idea-ui/testSrc/com/intellij/ide/projectView/actions/ExtractModuleFromPackageActionTest.kt b/java/idea-ui/testSrc/com/intellij/ide/projectView/actions/ExtractModuleFromPackageActionTest.kt index ae75bf0315e7..c7ac364e3acb 100644 --- a/java/idea-ui/testSrc/com/intellij/ide/projectView/actions/ExtractModuleFromPackageActionTest.kt +++ b/java/idea-ui/testSrc/com/intellij/ide/projectView/actions/ExtractModuleFromPackageActionTest.kt @@ -1,9 +1,12 @@ // Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package com.intellij.ide.projectView.actions -import com.intellij.ide.extractModule.ExtractModuleFromPackageAction +import com.intellij.ide.extractModule.ExtractModuleService import com.intellij.openapi.application.runReadAction +import com.intellij.openapi.compiler.CompilerMessageCategory +import com.intellij.openapi.components.service import com.intellij.openapi.module.Module +import com.intellij.openapi.module.ModuleManager import com.intellij.openapi.roots.DependencyScope import com.intellij.openapi.roots.ModuleRootManager import com.intellij.openapi.roots.ModuleRootModificationUtil @@ -11,15 +14,18 @@ import com.intellij.openapi.vfs.LocalFileSystem import com.intellij.psi.PsiDirectory import com.intellij.psi.PsiManager import com.intellij.testFramework.ApplicationRule +import com.intellij.testFramework.CompilerTester +import com.intellij.testFramework.DisposableRule import com.intellij.testFramework.VfsTestUtil import com.intellij.testFramework.rules.ProjectModelRule +import kotlinx.coroutines.runBlocking import org.assertj.core.api.Assertions.assertThat +import org.assertj.core.api.SoftAssertions import org.jetbrains.jps.model.java.JavaSourceRootType import org.junit.ClassRule import org.junit.Rule import org.junit.Test import java.nio.file.Path -import java.util.concurrent.TimeUnit import kotlin.io.path.invariantSeparatorsPathString class ExtractModuleFromPackageActionTest { @@ -33,6 +39,10 @@ class ExtractModuleFromPackageActionTest { @JvmField val projectModel = ProjectModelRule() + @Rule + @JvmField + val disposableRule = DisposableRule() + @Test fun `extract module in place`() { val (main, directory) = prepareProject() @@ -78,10 +88,44 @@ class ExtractModuleFromPackageActionTest { assertThat(mainRoots.dependencies).containsExactly(dep2, exported) } + @Test + fun `add dependencies from others modules after extracting`() { + val (main, directory) = prepareProject() + val otherModuleWithoutReference = projectModel.createModule("otherWithoutReference") + + val otherModuleWithReference = projectModel.createModule("otherWithReference") + projectModel.addSourceRoot(otherModuleWithReference, "src", JavaSourceRootType.SOURCE).let { + VfsTestUtil.createFile(it, "other/Other.java", "package other;\npublic class Other { main.MyClass myClass; xxx.Main main; }") + } + + val otherModuleWithReferenceOnExtractedOnly = projectModel.createModule("otherWithReferenceOnExtracedOnly") + projectModel.addSourceRoot(otherModuleWithReferenceOnExtractedOnly, "src", JavaSourceRootType.SOURCE).let { + VfsTestUtil.createFile(it, "other2/Other2.java", "package other2;\npublic class Other2 { xxx.Main other2; }") + } + + ModuleRootModificationUtil.addDependency(otherModuleWithoutReference, main) + ModuleRootModificationUtil.addDependency(otherModuleWithReference, main) + ModuleRootModificationUtil.addDependency(otherModuleWithReferenceOnExtractedOnly, main) + + extractModule(directory, main, null) + val extracted = projectModel.moduleManager.findModuleByName("main.xxx")!! + + with(SoftAssertions()) { + assertThat(ModuleRootManager.getInstance(otherModuleWithReference).dependencies).containsExactly(main, extracted) + assertThat(ModuleRootManager.getInstance(otherModuleWithoutReference).dependencies).containsExactly(main) + assertThat(ModuleRootManager.getInstance(otherModuleWithReferenceOnExtractedOnly).dependencies).containsExactly(extracted) + assertAll() + } + } + private fun extractModule(directory: PsiDirectory, main: Module, targetSourceRoot: String?) { - val promise = ExtractModuleFromPackageAction.extractModuleFromDirectory(directory, main, "main.xxx", - targetSourceRoot) - promise.blockingGet(10, TimeUnit.SECONDS) + val compilerTester = CompilerTester(projectModel.project, ModuleManager.getInstance(projectModel.project).modules.toList(), disposableRule.disposable) + val messages = compilerTester.rebuild() + assertThat(messages.filter { it.category == CompilerMessageCategory.ERROR }).isEmpty() + runBlocking { + projectModel.project.service().extractModuleFromDirectory(directory, main, "main.xxx", + targetSourceRoot) + } } private fun prepareProject(addDirectUsageOfExportedModule: Boolean = false): Pair { @@ -96,8 +140,8 @@ class ExtractModuleFromPackageActionTest { val dep1Src = projectModel.addSourceRoot(dep1, "src", JavaSourceRootType.SOURCE) val dep2Src = projectModel.addSourceRoot(dep2, "src", JavaSourceRootType.SOURCE) val exportedSrc = projectModel.addSourceRoot(exported, "src", JavaSourceRootType.SOURCE) - val mainClass = VfsTestUtil.createFile(mainSrc, "xxx/Main.java", "package xxx;\nclass Main extends dep1.Dep1 { exported.Util u; }") - VfsTestUtil.createFile(mainSrc, "main/MyClass.java", "package main;\nclass MyClass {}") + val mainClass = VfsTestUtil.createFile(mainSrc, "xxx/Main.java", "package xxx;\npublic class Main extends dep1.Dep1 { exported.Util u; }") + VfsTestUtil.createFile(mainSrc, "main/MyClass.java", "package main;\npublic class MyClass { dep2.Dep2 d; }") if (addDirectUsageOfExportedModule) { VfsTestUtil.createFile(mainSrc, "main/ExportedUsage.java", "package main;\nclass ExportedUsage { exported.Util u; }") }