From 45af6be7814b2dd3e11bc7fe1581a283d08f5a67 Mon Sep 17 00:00:00 2001 From: Ilya Korennoy Date: Mon, 8 Jun 2026 14:48:45 +0400 Subject: [PATCH] IJPL-246737 Use ModuleDependenciesGraph to compute module unloaded dependents GitOrigin-RevId: 512fbeaab7193361f48b1f136cab920b808e5c9c --- .../src/impl/WorkspaceModelInternal.kt | 3 +- .../ModuleDependenciesGraphServiceImplTest.kt | 159 +++++++++++++++++ .../ModuleDependenciesGraphService.kt | 17 +- .../resources/META-INF/WorkspaceModelImpl.xml | 3 + .../roots/impl/DirectoryIndexImpl.java | 17 +- .../ide/impl/WorkspaceModelImpl.kt | 2 +- .../ModuleDependenciesGraphServiceImpl.kt | 163 +++++++++++------- 7 files changed, 295 insertions(+), 69 deletions(-) diff --git a/platform/backend/workspace/src/impl/WorkspaceModelInternal.kt b/platform/backend/workspace/src/impl/WorkspaceModelInternal.kt index 9c06581aa6b3..257372008d55 100644 --- a/platform/backend/workspace/src/impl/WorkspaceModelInternal.kt +++ b/platform/backend/workspace/src/impl/WorkspaceModelInternal.kt @@ -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() } - diff --git a/platform/platform-tests/testSrc/com/intellij/workspaceModel/ide/legacyBridge/ModuleDependenciesGraphServiceImplTest.kt b/platform/platform-tests/testSrc/com/intellij/workspaceModel/ide/legacyBridge/ModuleDependenciesGraphServiceImplTest.kt index 05aaf4464492..813af28402ba 100644 --- a/platform/platform-tests/testSrc/com/intellij/workspaceModel/ide/legacyBridge/ModuleDependenciesGraphServiceImplTest.kt +++ b/platform/platform-tests/testSrc/com/intellij/workspaceModel/ide/legacyBridge/ModuleDependenciesGraphServiceImplTest.kt @@ -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") + } } diff --git a/platform/projectModel-api/src/com/intellij/projectModel/ModuleDependenciesGraphService.kt b/platform/projectModel-api/src/com/intellij/projectModel/ModuleDependenciesGraphService.kt index 3afaf2cac0f5..f53af2887a5e 100644 --- a/platform/projectModel-api/src/com/intellij/projectModel/ModuleDependenciesGraphService.kt +++ b/platform/projectModel-api/src/com/intellij/projectModel/ModuleDependenciesGraphService.kt @@ -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 + + /** + * Returns a collection of [LibraryOrSdkDependencyEdge] which contains a dependent on give [SymbolicEntityId] module and its order number. + */ fun getLibraryOrSdkDependants(libraryOrSdk: SymbolicEntityId<*>): Collection + + /** + * Returns unloaded [ModuleEntity]s that transitively depend on [module]. + */ + fun getModuleUnloadedDependents(module: ModuleEntity): Collection } \ No newline at end of file diff --git a/platform/projectModel-impl/resources/META-INF/WorkspaceModelImpl.xml b/platform/projectModel-impl/resources/META-INF/WorkspaceModelImpl.xml index bf8ca6e01e90..c69d7aa1fb6d 100644 --- a/platform/projectModel-impl/resources/META-INF/WorkspaceModelImpl.xml +++ b/platform/projectModel-impl/resources/META-INF/WorkspaceModelImpl.xml @@ -49,6 +49,9 @@ + + \ No newline at end of file diff --git a/platform/projectModel-impl/src/com/intellij/openapi/roots/impl/DirectoryIndexImpl.java b/platform/projectModel-impl/src/com/intellij/openapi/roots/impl/DirectoryIndexImpl.java index bcf46c5fa826..b2e9ba7ec6bf 100644 --- a/platform/projectModel-impl/src/com/intellij/openapi/roots/impl/DirectoryIndexImpl.java +++ b/platform/projectModel-impl/src/com/intellij/openapi/roots/impl/DirectoryIndexImpl.java @@ -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 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() { diff --git a/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/WorkspaceModelImpl.kt b/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/WorkspaceModelImpl.kt index 3fbffd4f48fd..4a5f4ab29b10 100644 --- a/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/WorkspaceModelImpl.kt +++ b/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/WorkspaceModelImpl.kt @@ -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. diff --git a/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/legacyBridge/module/ModuleDependenciesGraphServiceImpl.kt b/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/legacyBridge/module/ModuleDependenciesGraphServiceImpl.kt index bc453f82ce77..4e725de10eed 100644 --- a/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/legacyBridge/module/ModuleDependenciesGraphServiceImpl.kt +++ b/platform/projectModel-impl/src/com/intellij/workspaceModel/ide/impl/legacyBridge/module/ModuleDependenciesGraphServiceImpl.kt @@ -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> - private val libraryDependents: Map, List> + private class LoadedDependents( + val moduleDirectDependents: Map>, + val libraryDependents: Map, List>, + ) - init { - val dependentsMap = HashMap>() - val libraryDependentsMap = HashMap, MutableList>() + private class UnloadedDirectDependents( + val moduleDirectDependents: Map>, + ) - 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 { + return loaded.libraryDependents[libraryOrSdk] ?: emptyList() + } + + override fun getModuleDependants(module: ModuleEntity): Collection { + val result = HashSet() + val queue = ArrayDeque() + 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 { - return libraryDependents[libraryOrSdk] ?: emptyList() - } - - override fun getModuleDependants(module: ModuleEntity): Collection { - val result = HashSet() - val queue = ArrayDeque() - 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 { + val result = LinkedHashSet() + val visited = HashSet() + val queue = ArrayDeque() + + 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>() + val libraryDependentsMap = HashMap, MutableList>() + + 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>() + 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) } } }