IJPL-246737 Use ModuleDependenciesGraph to compute module unloaded dependents

GitOrigin-RevId: 512fbeaab7193361f48b1f136cab920b808e5c9c
This commit is contained in:
Ilya Korennoy
2026-06-10 10:59:45 +00:00
committed by intellij-monorepo-bot
parent e1526be15a
commit 45af6be781
7 changed files with 295 additions and 69 deletions
@@ -22,6 +22,8 @@ import org.jetbrains.annotations.NonNls
public interface WorkspaceModelInternal: WorkspaceModel {
public val entityStorage: VersionedEntityStorageImpl
public val unloadedEntitiesStorage: VersionedEntityStorageImpl
/**
* Returns a snapshot of the storage containing unloaded entities.
* Unloaded entities must be ignored by almost all code in the IDE, so this property isn't supposed for general use.
@@ -112,4 +114,3 @@ public interface WorkspaceModelInternal: WorkspaceModel {
@ApiStatus.Experimental
public suspend fun awaitSynchronizationWithJpsModel()
}
@@ -3,6 +3,9 @@ package com.intellij.workspaceModel.ide.legacyBridge
import com.intellij.openapi.roots.DependencyScope
import com.intellij.openapi.roots.ModuleRootModificationUtil
import com.intellij.platform.backend.workspace.WorkspaceModel
import com.intellij.platform.backend.workspace.impl.WorkspaceModelInternal
import com.intellij.platform.workspace.jps.entities.ModuleEntity
import com.intellij.projectModel.ModuleDependenciesGraphService
import com.intellij.testFramework.junit5.TestApplication
import com.intellij.testFramework.rules.ProjectModelExtension
@@ -297,4 +300,160 @@ class ModuleDependenciesGraphServiceImplTest {
.describedAs("getIn() should return direct dependents even when not exported")
.containsExactlyInAnyOrder("module-b", "module-c")
}
@Test
fun `no unloaded modules returns empty`() {
val main = projectModel.createModule("main")
val mainEntity = main.findModuleEntity()!!
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(mainEntity))
.describedAs("With no unloaded modules in the project, the result must be empty")
.isEmpty()
}
@Test
fun `unloaded module directly depending on loaded module`() {
// unloaded → main
val unloaded = projectModel.createModule("unloaded")
val main = projectModel.createModule("main")
ModuleRootModificationUtil.addDependency(unloaded, main)
projectModel.setUnloadedModules("unloaded")
val mainEntity = main.findModuleEntity()!!
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(mainEntity).map { it.name })
.describedAs("Direct unloaded dependent must be returned")
.containsExactlyInAnyOrder("unloaded")
}
@Test
fun `unloaded module reached via exported transitive edge`() {
// unloaded → main, main → common (exported)
val unloaded = projectModel.createModule("unloaded")
val main = projectModel.createModule("main")
val common = projectModel.createModule("common")
ModuleRootModificationUtil.addDependency(unloaded, main)
ModuleRootModificationUtil.addDependency(main, common, DependencyScope.COMPILE, true)
projectModel.setUnloadedModules("unloaded")
val commonEntity = common.findModuleEntity()!!
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(commonEntity).map { it.name })
.describedAs("Unloaded module must reach `common` via main's exported edge")
.containsExactlyInAnyOrder("unloaded")
}
@Test
fun `non-exported edge blocks unloaded propagation`() {
// unloaded → main, main → util (NOT exported)
val unloaded = projectModel.createModule("unloaded")
val main = projectModel.createModule("main")
val util = projectModel.createModule("util")
ModuleRootModificationUtil.addDependency(unloaded, main)
ModuleRootModificationUtil.addDependency(main, util, DependencyScope.COMPILE, false)
projectModel.setUnloadedModules("unloaded")
val utilEntity = util.findModuleEntity()!!
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(utilEntity))
.describedAs("Non-exported main → util edge must NOT propagate the unloaded dependent")
.isEmpty()
}
@Test
fun `multiple unloaded modules depending on same loaded module`() {
// unloadedA → main, unloadedB → main
val unloadedA = projectModel.createModule("unloaded-a")
val unloadedB = projectModel.createModule("unloaded-b")
val main = projectModel.createModule("main")
ModuleRootModificationUtil.addDependency(unloadedA, main)
ModuleRootModificationUtil.addDependency(unloadedB, main)
projectModel.setUnloadedModules("unloaded-a", "unloaded-b")
val mainEntity = main.findModuleEntity()!!
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(mainEntity).map { it.name })
.describedAs("All unloaded direct dependents must be returned")
.containsExactlyInAnyOrder("unloaded-a", "unloaded-b")
}
@Test
fun `reloading clears unloaded dependents`() {
// unloaded → main
val unloaded = projectModel.createModule("unloaded")
val main = projectModel.createModule("main")
ModuleRootModificationUtil.addDependency(unloaded, main)
projectModel.setUnloadedModules("unloaded")
val mainEntityAfterUnload = main.findModuleEntity()!!
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(mainEntityAfterUnload).map { it.name })
.containsExactlyInAnyOrder("unloaded")
projectModel.setUnloadedModules()
val mainEntityAfterReload = main.findModuleEntity()!!
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(mainEntityAfterReload))
.describedAs("After reload, no unloaded dependents should remain")
.isEmpty()
}
@Test
fun `unloaded argument returns empty`() {
// unloaded → main
val unloaded = projectModel.createModule("unloaded")
val main = projectModel.createModule("main")
ModuleRootModificationUtil.addDependency(unloaded, main)
projectModel.setUnloadedModules("unloaded")
val unloadedEntity = (WorkspaceModel.getInstance(projectModel.project) as WorkspaceModelInternal)
.currentSnapshotOfUnloadedEntities
.entities(ModuleEntity::class.java)
.first { it.name == "unloaded" }
assertThat(graph.getModuleDependenciesGraph().getModuleUnloadedDependents(unloadedEntity))
.describedAs("Passing an unloaded ModuleEntity is not supported")
.isEmpty()
}
@Test
fun `getModuleDependants is not polluted by unloaded modules`() {
// unloaded → main, sibling → main (loaded, exported)
val unloaded = projectModel.createModule("unloaded")
val main = projectModel.createModule("main")
val sibling = projectModel.createModule("sibling")
ModuleRootModificationUtil.addDependency(unloaded, main)
ModuleRootModificationUtil.addDependency(sibling, main, DependencyScope.COMPILE, true)
projectModel.setUnloadedModules("unloaded")
val mainEntity = main.findModuleEntity()!!
val depsGraph = graph.getModuleDependenciesGraph()
assertThat(depsGraph.getModuleDependants(mainEntity).map { it.name })
.describedAs("getModuleDependants must contain only loaded modules")
.containsExactlyInAnyOrder("sibling")
assertThat(depsGraph.getModuleUnloadedDependents(mainEntity).map { it.name })
.containsExactlyInAnyOrder("unloaded")
}
@Test
fun `cyclic exported loaded graph with unloaded entry does not loop`() {
// unloaded → a; a → b (exported); b → a (exported)
val unloaded = projectModel.createModule("unloaded")
val a = projectModel.createModule("module-a")
val b = projectModel.createModule("module-b")
ModuleRootModificationUtil.addDependency(unloaded, a)
ModuleRootModificationUtil.addDependency(a, b, DependencyScope.COMPILE, true)
ModuleRootModificationUtil.addDependency(b, a, DependencyScope.COMPILE, true)
projectModel.setUnloadedModules("unloaded")
val aEntity = a.findModuleEntity()!!
val bEntity = b.findModuleEntity()!!
val depsGraph = graph.getModuleDependenciesGraph()
assertThat(depsGraph.getModuleUnloadedDependents(aEntity).map { it.name })
.describedAs("Query from `a`: unloaded harvested at `a` (direct dependent)")
.containsExactlyInAnyOrder("unloaded")
assertThat(depsGraph.getModuleUnloadedDependents(bEntity).map { it.name })
.describedAs("Query from `b`: traverses b → a via exported edge")
.containsExactlyInAnyOrder("unloaded")
}
}
@@ -24,13 +24,20 @@ data class LibraryOrSdkDependencyEdge(
val orderNumber: Int,
)
/**
* [getModuleDependants] returns [ModuleEntity] which depend on the given module taking into account exported dependencies.
*
* [getLibraryOrSdkDependants] returns a collection of [LibraryOrSdkDependencyEdge] which contains a dependent on give [SymbolicEntityId] module and its order number.
*/
@ApiStatus.Internal
interface ModuleDependenciesGraph {
/**
* Returns [ModuleEntity]s which depend on the given module taking into account exported dependencies.
*/
fun getModuleDependants(module: ModuleEntity): Collection<ModuleEntity>
/**
* Returns a collection of [LibraryOrSdkDependencyEdge] which contains a dependent on give [SymbolicEntityId] module and its order number.
*/
fun getLibraryOrSdkDependants(libraryOrSdk: SymbolicEntityId<*>): Collection<LibraryOrSdkDependencyEdge>
/**
* Returns unloaded [ModuleEntity]s that transitively depend on [module].
*/
fun getModuleUnloadedDependents(module: ModuleEntity): Collection<ModuleEntity>
}
@@ -49,6 +49,9 @@
<registryKey key="ide.workspace.model.relative.paths.to.exclude.automatically"
defaultValue=".worktrees;.claude/worktrees" restartRequired="true"
description="Semicolon-separated list of relative-paths from project root to automatically exclude from indexing and searching."/>
<registryKey key="use.wsm.for.dependent.unloaded.modules" defaultValue="true" restartRequired="true"
description="Use workspace model based modules graph for calculating dependent unloaded modules"/>
<persistentFsConnectionListener implementation="com.intellij.workspaceModel.core.fileIndex.impl.WorkspaceFileIndexCleaner"/>
</extensions>
</idea-plugin>
@@ -8,6 +8,7 @@ import com.intellij.openapi.module.Module;
import com.intellij.openapi.progress.ProgressManager;
import com.intellij.openapi.project.Project;
import com.intellij.openapi.roots.OrderEntry;
import com.intellij.openapi.util.registry.Registry;
import com.intellij.openapi.vfs.VirtualFile;
import com.intellij.openapi.vfs.VirtualFileManager;
import com.intellij.openapi.vfs.newvfs.BulkFileListener;
@@ -15,12 +16,17 @@ import com.intellij.openapi.vfs.newvfs.events.VFileCreateEvent;
import com.intellij.openapi.vfs.newvfs.events.VFileEvent;
import com.intellij.openapi.vfs.newvfs.events.VFileMoveEvent;
import com.intellij.openapi.vfs.newvfs.events.VFilePropertyChangeEvent;
import com.intellij.platform.backend.workspace.WorkspaceModel;
import com.intellij.platform.workspace.jps.entities.ModuleEntity;
import com.intellij.projectModel.ModuleDependenciesGraphService;
import com.intellij.util.containers.ContainerUtil;
import com.intellij.util.Query;
import com.intellij.util.concurrency.ThreadingAssertions;
import com.intellij.workspaceModel.core.fileIndex.WorkspaceFileIndex;
import com.intellij.workspaceModel.core.fileIndex.WorkspaceFileSetWithCustomData;
import com.intellij.workspaceModel.core.fileIndex.impl.WorkspaceFileIndexEx;
import com.intellij.workspaceModel.core.fileIndex.impl.WorkspaceFileInternalInfo;
import com.intellij.workspaceModel.ide.legacyBridge.ModuleBridges;
import org.jetbrains.annotations.ApiStatus;
import org.jetbrains.annotations.NotNull;
@@ -131,7 +137,16 @@ public final class DirectoryIndexImpl extends DirectoryIndex implements Disposab
@Override
public @NotNull Set<String> getDependentUnloadedModules(@NotNull Module module) {
checkAvailability();
return getRootIndex().getDependentUnloadedModules(module);
if (Registry.is("use.wsm.for.dependent.unloaded.modules")) {
ModuleEntity moduleEntity = ModuleBridges.findModuleEntity(module, WorkspaceModel.getInstance(myProject).getCurrentSnapshot());
if (moduleEntity == null) return Collections.emptySet();
return ContainerUtil.map2Set(
ModuleDependenciesGraphService.getInstance(myProject).getModuleDependenciesGraph().getModuleUnloadedDependents(moduleEntity),
m -> m.getName()
);
} else {
return getRootIndex().getDependentUnloadedModules(module);
}
}
private void checkAvailability() {
@@ -90,7 +90,7 @@ open class WorkspaceModelImpl : WorkspaceModelInternal {
private val reactive = WmReactive(this)
final override val entityStorage: VersionedEntityStorageImpl
private val unloadedEntitiesStorage: VersionedEntityStorageImpl
final override val unloadedEntitiesStorage: VersionedEntityStorageImpl
private val lock = ThreadContextAwareReentrantLock()
/** replay = 1 is needed to send the very first state when the subscription fo the flow happens.
@@ -12,88 +12,129 @@ import com.intellij.platform.workspace.jps.entities.SdkDependency
import com.intellij.platform.workspace.storage.CachedValue
import com.intellij.platform.workspace.storage.EntityStorage
import com.intellij.platform.workspace.storage.SymbolicEntityId
import com.intellij.platform.workspace.storage.VersionedEntityStorage
import com.intellij.projectModel.LibraryOrSdkDependencyEdge
import com.intellij.projectModel.ModuleDependenciesGraph
import com.intellij.projectModel.ModuleDependenciesGraphService
internal class ModuleDependenciesGraphServiceImpl(project: Project): ModuleDependenciesGraphService {
private val entityStore: VersionedEntityStorage = (WorkspaceModel.getInstance(project) as WorkspaceModelInternal).entityStorage
fun exportedDependentsGraph(): ModuleDependenciesGraph = entityStore.cachedValue(dependentsGraph)
private val workspaceModel: WorkspaceModelInternal = WorkspaceModel.getInstance(project) as WorkspaceModelInternal
override fun getModuleDependenciesGraph(): ModuleDependenciesGraph {
return exportedDependentsGraph()
val loaded = workspaceModel.entityStorage.cachedValue(loadedDependentsGraph)
val unloaded = workspaceModel.unloadedEntitiesStorage.cachedValue(unloadedDirectDependentsGraph)
return CompositeModuleDependenciesGraph(loaded, unloaded)
}
private val dependentsGraph = CachedValue { storage ->
buildGraph(storage)
}
private val loadedDependentsGraph = CachedValue { storage -> buildLoadedDependents(storage) }
private val unloadedDirectDependentsGraph = CachedValue { storage -> buildUnloadedDirectDependents(storage) }
private data class ModuleDependencyEdge(
val dependent: ModuleEntity,
val exported: Boolean,
)
companion object {
fun buildGraph(storage: EntityStorage): ModuleDependenciesGraph {
return object : ModuleDependenciesGraph {
private val moduleDirectDependents: Map<ModuleId, List<ModuleDependencyEdge>>
private val libraryDependents: Map<SymbolicEntityId<*>, List<LibraryOrSdkDependencyEdge>>
private class LoadedDependents(
val moduleDirectDependents: Map<ModuleId, List<ModuleDependencyEdge>>,
val libraryDependents: Map<SymbolicEntityId<*>, List<LibraryOrSdkDependencyEdge>>,
)
init {
val dependentsMap = HashMap<ModuleId, MutableList<ModuleDependencyEdge>>()
val libraryDependentsMap = HashMap<SymbolicEntityId<*>, MutableList<LibraryOrSdkDependencyEdge>>()
private class UnloadedDirectDependents(
val moduleDirectDependents: Map<ModuleId, List<ModuleEntity>>,
)
for (module in storage.entities(ModuleEntity::class.java)) {
module.dependencies.forEachIndexed { index, dep ->
when (dep) {
is ModuleDependency -> {
dependentsMap
.computeIfAbsent(dep.module) { mutableListOf() }
.add(ModuleDependencyEdge(module, dep.exported))
}
is LibraryDependency -> {
libraryDependentsMap.computeIfAbsent(dep.library) { mutableListOf() }
.add(LibraryOrSdkDependencyEdge(module, index))
dep.library
}
is SdkDependency -> {
libraryDependentsMap.computeIfAbsent(dep.sdk) { mutableListOf() }
.add(LibraryOrSdkDependencyEdge(module, index))
}
else -> {}
}
}
private class CompositeModuleDependenciesGraph(
private val loaded: LoadedDependents,
private val unloaded: UnloadedDirectDependents,
) : ModuleDependenciesGraph {
override fun getLibraryOrSdkDependants(libraryOrSdk: SymbolicEntityId<*>): Collection<LibraryOrSdkDependencyEdge> {
return loaded.libraryDependents[libraryOrSdk] ?: emptyList()
}
override fun getModuleDependants(module: ModuleEntity): Collection<ModuleEntity> {
val result = HashSet<ModuleEntity>()
val queue = ArrayDeque<ModuleEntity>()
queue.add(module)
while (queue.isNotEmpty()) {
val current = queue.removeFirst()
val edges = loaded.moduleDirectDependents[current.symbolicId] ?: continue
for (edge in edges) {
if (result.add(edge.dependent) && edge.exported) {
queue.add(edge.dependent)
}
this.libraryDependents = libraryDependentsMap
this.moduleDirectDependents = dependentsMap
}
override fun getLibraryOrSdkDependants(libraryOrSdk: SymbolicEntityId<*>): Collection<LibraryOrSdkDependencyEdge> {
return libraryDependents[libraryOrSdk] ?: emptyList()
}
override fun getModuleDependants(module: ModuleEntity): Collection<ModuleEntity> {
val result = HashSet<ModuleEntity>()
val queue = ArrayDeque<ModuleEntity>()
queue.add(module)
while (queue.isNotEmpty()) {
val current = queue.removeFirst()
val edges = moduleDirectDependents[current.symbolicId] ?: emptyList()
for (edge in edges) {
if (result.add(edge.dependent) && edge.exported) {
queue.add(edge.dependent)
}
}
}
return result
}
}
return result
}
override fun getModuleUnloadedDependents(module: ModuleEntity): Collection<ModuleEntity> {
val result = LinkedHashSet<ModuleEntity>()
val visited = HashSet<ModuleEntity>()
val queue = ArrayDeque<ModuleEntity>()
queue.add(module)
visited.add(module)
while (queue.isNotEmpty()) {
val current = queue.removeFirst()
unloaded.moduleDirectDependents[current.symbolicId]?.let {
result.addAll(it)
}
val edges = loaded.moduleDirectDependents[current.symbolicId] ?: continue
for (edge in edges) {
if (edge.exported && visited.add(edge.dependent)) {
queue.add(edge.dependent)
}
}
}
return result
}
}
companion object {
private fun buildLoadedDependents(storage: EntityStorage): LoadedDependents {
val dependentsMap = HashMap<ModuleId, MutableList<ModuleDependencyEdge>>()
val libraryDependentsMap = HashMap<SymbolicEntityId<*>, MutableList<LibraryOrSdkDependencyEdge>>()
for (module in storage.entities(ModuleEntity::class.java)) {
module.dependencies.forEachIndexed { index, dep ->
when (dep) {
is ModuleDependency -> {
dependentsMap
.computeIfAbsent(dep.module) { mutableListOf() }
.add(ModuleDependencyEdge(module, dep.exported))
}
is LibraryDependency -> {
libraryDependentsMap.computeIfAbsent(dep.library) { mutableListOf() }
.add(LibraryOrSdkDependencyEdge(module, index))
}
is SdkDependency -> {
libraryDependentsMap.computeIfAbsent(dep.sdk) { mutableListOf() }
.add(LibraryOrSdkDependencyEdge(module, index))
}
else -> {}
}
}
}
return LoadedDependents(dependentsMap, libraryDependentsMap)
}
private fun buildUnloadedDirectDependents(storage: EntityStorage): UnloadedDirectDependents {
val moduleDirectDependents = HashMap<ModuleId, MutableList<ModuleEntity>>()
for (unloadedModule in storage.entities(ModuleEntity::class.java)) {
for (dep in unloadedModule.dependencies) {
if (dep is ModuleDependency) {
moduleDirectDependents
.computeIfAbsent(dep.module) { mutableListOf() }
.add(unloadedModule)
}
}
}
return UnloadedDirectDependents(moduleDirectDependents)
}
}
}