diff --git a/plugins/gradle/plugin-resources/META-INF/plugin.xml b/plugins/gradle/plugin-resources/META-INF/plugin.xml index 5d4b1542e020e..35ba3fbd163b3 100644 --- a/plugins/gradle/plugin-resources/META-INF/plugin.xml +++ b/plugins/gradle/plugin-resources/META-INF/plugin.xml @@ -71,6 +71,7 @@ + diff --git a/plugins/gradle/src/org/jetbrains/plugins/gradle/service/syncAction/impl/extensions/GradleDependencySyncExtension.kt b/plugins/gradle/src/org/jetbrains/plugins/gradle/service/syncAction/impl/extensions/GradleDependencySyncExtension.kt new file mode 100644 index 0000000000000..8333374188358 --- /dev/null +++ b/plugins/gradle/src/org/jetbrains/plugins/gradle/service/syncAction/impl/extensions/GradleDependencySyncExtension.kt @@ -0,0 +1,58 @@ +// Copyright 2000-2025 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. +package org.jetbrains.plugins.gradle.service.syncAction.impl.extensions + +import com.intellij.openapi.externalSystem.util.Order +import com.intellij.platform.workspace.jps.entities.InheritedSdkDependency +import com.intellij.platform.workspace.jps.entities.LibraryDependency +import com.intellij.platform.workspace.jps.entities.ModuleEntity +import com.intellij.platform.workspace.jps.entities.SdkDependency +import com.intellij.platform.workspace.jps.entities.modifyModuleEntity +import com.intellij.platform.workspace.storage.MutableEntityStorage +import com.intellij.platform.workspace.storage.createEntityTreeCopy +import org.jetbrains.plugins.gradle.service.project.ProjectResolverContext +import org.jetbrains.plugins.gradle.service.syncAction.GradleSyncExtension +import org.jetbrains.plugins.gradle.service.syncAction.GradleSyncPhase + +/** + * Since dependencies are not expressed as entities, they are kept manually by this sync extension. + * + * By nature, this only preserves the already existing dependencies, and doesn't allow any cleanup of dependencies the entity source for + * each dependency is not known. Cleanup is expected to be done by data services instead. + */ +@Order(GradleDependencySyncExtension.ORDER) +class GradleDependencySyncExtension : GradleSyncExtension { + override fun updateProjectModel( + context: ProjectResolverContext, + syncStorage: MutableEntityStorage, + projectStorage: MutableEntityStorage, + phase: GradleSyncPhase, + ) { + syncStorage.entitiesToReplace(context, phase).forEach { moduleEntity -> + val existingDependencies = projectStorage.resolve(moduleEntity.symbolicId)?.dependencies ?: return@forEach + val syncDependencies = moduleEntity.dependencies.toHashSet() + val explicitSdk = moduleEntity.dependencies.find { it is SdkDependency } + + syncStorage.modifyModuleEntity(moduleEntity) { + // Clean up all existing SDKs and set the explicitly set SDK if known already in the sync storage, + // otherwise use the project's explicit SDK, and otherwise inherit. + dependencies.removeAll { it is InheritedSdkDependency || it is SdkDependency } + dependencies.add(explicitSdk ?: existingDependencies.find { it is SdkDependency } ?: InheritedSdkDependency) + val dependenciesToAdd = existingDependencies.filterNot { syncDependencies.contains(it) || it is SdkDependency || it is InheritedSdkDependency } + dependencies.addAll(dependenciesToAdd) + // Add the corresponding library entities from project storage to sync storage as well. + dependenciesToAdd + .filterIsInstance() + .filterNot { syncStorage.contains(it.library) } + .forEach { + val existingProjectEntity = projectStorage.resolve(it.library) + existingProjectEntity?.let { syncStorage.addEntity(it.createEntityTreeCopy())} + } + } + } + } + + companion object { + + const val ORDER: Int = GradleBaseSyncExtension.ORDER - 500 + } +} diff --git a/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/GradleDependenciesImportingTest.java b/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/GradleDependenciesImportingTest.java index 79a9abadc19dd..236fd90d9e9c8 100644 --- a/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/GradleDependenciesImportingTest.java +++ b/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/GradleDependenciesImportingTest.java @@ -21,6 +21,7 @@ import com.intellij.openapi.roots.libraries.LibraryTablesRegistrar; import com.intellij.openapi.util.Ref; import com.intellij.openapi.util.io.FileUtil; +import com.intellij.openapi.util.registry.Registry; import com.intellij.openapi.vfs.VirtualFile; import com.intellij.testFramework.RunAll; import com.intellij.util.ArrayUtil; @@ -29,6 +30,7 @@ import com.intellij.util.containers.ContainerUtil; import org.jetbrains.annotations.NonNls; import org.jetbrains.annotations.NotNull; +import org.jetbrains.jps.util.JpsPathUtil; import org.jetbrains.plugins.gradle.frameworkSupport.buildscript.GradleBuildScriptBuilderUtil; import org.jetbrains.plugins.gradle.service.resolve.VersionCatalogsLocator; import org.jetbrains.plugins.gradle.settings.GradleSystemSettings; @@ -483,6 +485,44 @@ public void testLocalFileDepsImportedAsModuleLibraries() throws Exception { } } + @Test + public void testLocalFileDepsImportedAsModuleLibraries_nonExistentPath() throws Exception { + Registry.get("gradle.phased.sync.bridge.disabled").setValue(true, getTestRootDisposable()); + + var jarPath = "deps/dep.jar"; + var expectedPath = JpsPathUtil.getLibraryRootUrl(getProjectPath(jarPath)); + + String config = createBuildScriptBuilder() + .allprojects(p -> { + p + .withJavaPlugin() + .addImplementationDependency(p.code("files('" + jarPath + "')")); + }) + .generate(); + + importProject(config); + + assertModules("project", "project.main", "project.test"); + + var moduleLibDeps = getModuleLibDeps("project.main", "Gradle: dep.jar"); + assertEquals("Should have a single module level dependency", 1, moduleLibDeps.size()); + + var libDep = moduleLibDeps.getFirst(); + assertTrue("Dependency must be module level: " + libDep.toString(), libDep.isModuleLevel()); + assertEquals("URLs must be in the correct format", expectedPath, libDep.getLibrary().getUrls(OrderRootType.CLASSES)[0]); + + // Try another import attempt and make sure it doesn't throw anything + importProject(); + assertModules("project", "project.main", "project.test"); + + moduleLibDeps = getModuleLibDeps("project.main", "Gradle: dep.jar"); + assertEquals("Should have a single module level dependency", 1, moduleLibDeps.size()); + + libDep = moduleLibDeps.getFirst(); + assertTrue("Dependency must be module level: " + libDep.toString(), libDep.isModuleLevel()); + assertEquals("URLs must be in the correct format", expectedPath, libDep.getLibrary().getUrls(OrderRootType.CLASSES)[0]); + } + @Test public void testProjectWithUnresolvedDependency() throws Exception { final VirtualFile depJar = createProjectJarSubFile("lib/dep/dep/1.0/dep-1.0.jar"); diff --git a/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/syncAction/GradlePhasedSyncTest.kt b/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/syncAction/GradlePhasedSyncTest.kt index 28fa3c633f402..07f1aefaf2c4e 100644 --- a/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/syncAction/GradlePhasedSyncTest.kt +++ b/plugins/gradle/testSources/org/jetbrains/plugins/gradle/importing/syncAction/GradlePhasedSyncTest.kt @@ -1,21 +1,44 @@ // Copyright 2000-2024 JetBrains s.r.o. and contributors. Use of this source code is governed by the Apache 2.0 license. package org.jetbrains.plugins.gradle.importing.syncAction +import com.google.common.collect.HashBasedTable import com.intellij.gradle.toolingExtension.modelAction.GradleModelFetchPhase +import com.intellij.openapi.application.WriteAction +import com.intellij.openapi.components.service +import com.intellij.openapi.externalSystem.model.DataNode +import com.intellij.openapi.externalSystem.model.ProjectKeys +import com.intellij.openapi.externalSystem.model.project.* +import com.intellij.openapi.externalSystem.util.ExternalSystemApiUtil import com.intellij.openapi.observable.operation.OperationExecutionStatus import com.intellij.openapi.progress.ProgressManager +import com.intellij.openapi.project.modules +import com.intellij.openapi.projectRoots.JavaSdk +import com.intellij.openapi.projectRoots.ProjectJdkTable import com.intellij.openapi.util.Disposer +import com.intellij.openapi.util.NlsSafe +import com.intellij.openapi.util.registry.Registry import com.intellij.openapi.util.use +import com.intellij.platform.backend.workspace.workspaceModel import com.intellij.platform.testFramework.assertion.WorkspaceAssertions +import com.intellij.platform.testFramework.assertion.collectionAssertion.CollectionAssertions import com.intellij.platform.testFramework.assertion.listenerAssertion.ListenerAssertion +import com.intellij.platform.workspace.jps.entities.* +import com.intellij.platform.workspace.storage.entities import com.intellij.platform.workspace.storage.toBuilder +import com.intellij.testFramework.registerServiceInstance import kotlinx.coroutines.delay +import org.gradle.tooling.model.idea.IdeaModule +import org.gradle.tooling.model.idea.IdeaProject import org.jetbrains.plugins.gradle.importing.TestModelProvider import org.jetbrains.plugins.gradle.importing.TestPhasedModel +import org.jetbrains.plugins.gradle.model.data.GradleSourceSetData +import org.jetbrains.plugins.gradle.service.project.AbstractProjectResolverExtension import org.jetbrains.plugins.gradle.service.project.DefaultProjectResolverContext +import org.jetbrains.plugins.gradle.service.project.GradleProjectResolverExtension import org.jetbrains.plugins.gradle.service.project.ProjectResolverContext import org.jetbrains.plugins.gradle.service.syncAction.GradleSyncPhase import org.jetbrains.plugins.gradle.service.syncAction.GradleSyncPhase.Dynamic.Companion.asSyncPhase +import org.jetbrains.plugins.gradle.util.GradleConstants import org.jetbrains.plugins.gradle.util.entity.GradleTestBridgeEntitySource import org.jetbrains.plugins.gradle.util.entity.GradleTestEntity import org.jetbrains.plugins.gradle.util.entity.GradleTestEntityId @@ -419,6 +442,129 @@ class GradlePhasedSyncTest : GradlePhasedSyncTestCase() { `test phased Gradle sync cancellation by indicator`(GradleSyncPhase.ADDITIONAL_MODEL_PHASE) } + @Test + fun `test dependencies from previous sync are kept`() { + Disposer.newDisposable().use { disposable -> + Registry.get("gradle.phased.sync.bridge.disabled").setValue(true, disposable) + initMultiModuleProject( + useBuildSrc = false, // buildSrc modules are triggering issue IDEA-383593, and are not essential to this test + ) + + importProject() + assertMultiModuleProjectStructure(useBuildSrc = false) + + val moduleNames = myProject.modules.map { it.name } + + val dependencyListByModuleNameAtEndOfFirstSync = myProject.workspaceModel.currentSnapshot.entities().associate { + it.name to it.dependencies + } + + val dependencyListByModuleNamePerPhase = HashBasedTable.create>() + Disposer.newDisposable().use { disposable -> + DEFAULT_SYNC_PHASES.forEach { phase -> + whenSyncPhaseCompleted(phase, disposable) { context -> + context.project.workspaceModel.currentSnapshot.entities().forEach { moduleEntity -> + dependencyListByModuleNamePerPhase.put(phase, moduleEntity.name, moduleEntity.dependencies.toList()) + } + } + } + + importProject() + assertMultiModuleProjectStructure(useBuildSrc = false) + } + + val expectedPhases = DEFAULT_SYNC_PHASES.filterNot { + setOf( // These phase are not executed in this test case + GradleSyncPhase.DECLARATIVE_PHASE, + GradleModelFetchPhase.PROJECT_LOADED_PHASE.asSyncPhase(), + GradleSyncPhase.DEPENDENCY_MODEL_PHASE + ).contains(it) + } + + CollectionAssertions.assertEqualsUnordered(expectedPhases, dependencyListByModuleNamePerPhase.rowKeySet()) { + """ + Expected phases: $expectedPhases + Got phases: ${dependencyListByModuleNamePerPhase.rowKeySet()} + """.trimIndent() + } + + assertDependencyListPerModulePerPhase( + moduleNames, + dependencyListByModuleNameAtEndOfFirstSync, + expectedPhases, + dependencyListByModuleNamePerPhase + ) + } + } + + @Test + fun `test dependencies from previous sync are kept - sync contributors can add dependencies and override the sdk explicitly`() { + Disposer.newDisposable().use { disposable -> + Registry.get("gradle.phased.sync.bridge.disabled").setValue(true, disposable) + initMultiModuleProject( + useBuildSrc = false, // buildSrc modules are triggering issue IDEA-383593, and are not essential to this test + ) + + val modulesToSetSdks = listOf( + "project.module", + "includedProject.module" + ) + + val modulesToAddLibraries = listOf( + "project.main", "project.test", + "project.module.main", "project.module.test", + "includedProject.main", "includedProject.test", + "includedProject.module.main", "includedProject.module.test" + ) + + val (libraryDependency, libraryData) = prepareFakeLibrary() + val (sdkDependency, sdkData) = prepareFakeSdk() + val dependencyToAddByModuleName = modulesToAddLibraries.associateWith { libraryDependency } + modulesToSetSdks.associateWith { sdkDependency } + setupTestDataService(libraryData, sdkData, dependencyToAddByModuleName) + addDependencySyncContributor(dependencyToAddByModuleName) + + importProject() + + assertMultiModuleProjectStructure(useBuildSrc = false) + + val dependencyListByModuleNameAtEndOfFirstSync = assertDependencyAddedForModules(dependencyToAddByModuleName) + val dependencyListByModuleNamePerPhase = HashBasedTable.create>() + + DEFAULT_SYNC_PHASES.forEach { phase -> + whenSyncPhaseCompleted(phase, testRootDisposable) { context -> + context.project.workspaceModel.currentSnapshot.entities().forEach { moduleEntity -> + dependencyListByModuleNamePerPhase.put(phase, moduleEntity.name, moduleEntity.dependencies.toList()) + } + } + } + + importProject() + assertMultiModuleProjectStructure(useBuildSrc = false) + + val expectedPhases = DEFAULT_SYNC_PHASES.filterNot { + setOf( + // These phase are not executed in this test case + GradleSyncPhase.DECLARATIVE_PHASE, + GradleModelFetchPhase.PROJECT_LOADED_PHASE.asSyncPhase(), + ).contains(it) + } + + CollectionAssertions.assertEqualsUnordered(expectedPhases, dependencyListByModuleNamePerPhase.rowKeySet()) { + """ + Expected phases: $expectedPhases + Got phases: ${dependencyListByModuleNamePerPhase.rowKeySet()} + """.trimIndent() + } + + assertDependencyListPerModulePerPhase( + dependencyToAddByModuleName.keys, + dependencyListByModuleNameAtEndOfFirstSync, + expectedPhases, + dependencyListByModuleNamePerPhase + ) + } + } + private fun `test phased Gradle sync cancellation by indicator`(cancellationPhase: GradleSyncPhase) { `test phased Gradle sync cancellation`(cancellationPhase) { resolverContext -> resolverContext as DefaultProjectResolverContext @@ -571,4 +717,161 @@ class GradlePhasedSyncTest : GradlePhasedSyncTestCase() { } } } + + private fun assertDependencyAddedForModules(dependencyToAddByModuleName: Map): Map<@NlsSafe String, List> { + val dependencyListByModuleName = myProject.workspaceModel.currentSnapshot + .entities() + .filter { it.name in dependencyToAddByModuleName.keys } + .associate { + val expected = dependencyToAddByModuleName[it.name]!! + assertTrue(""" + Expected to contain the added dependency for module ${it.name}: + $expected + but is: + ${it.dependencies} + """.trimIndent(), it.dependencies.contains(expected)) + it.name to it.dependencies + } + + CollectionAssertions.assertContainsUnordered( + dependencyToAddByModuleName.keys, + dependencyListByModuleName.keys + ) + return dependencyListByModuleName + } + + private fun assertDependencyListPerModulePerPhase( + moduleNames: Collection, + dependencyListByModuleNameAtEndOfFirstSync: Map<@NlsSafe String, List>, + expectedPhases: List, + dependencyListByModuleNamePerPhase: HashBasedTable>, + ) { + moduleNames.forEach { moduleName -> + val originalDependencies = dependencyListByModuleNameAtEndOfFirstSync[moduleName] + assertNotNull("Expected dependency list of $moduleName to be initialized!", originalDependencies) + expectedPhases.forEach { phase -> + val dependenciesAtEndOfPhase = dependencyListByModuleNamePerPhase.get(phase, moduleName) + assertNotNull("Expected to find dependencies of $moduleName at the end of phase $phase", dependenciesAtEndOfPhase) + CollectionAssertions.assertEqualsUnordered(originalDependencies, dependenciesAtEndOfPhase) { + "Dependency list doesn't match for module: $moduleName" + } + } + } + } + + + private fun addDependencySyncContributor( + dependencyToAddByModuleName: Map, + ) { + addSyncContributor(GradleSyncPhase.DEPENDENCY_MODEL_PHASE, testRootDisposable) { context, storage -> + val mutableStorage = storage.toBuilder() + mutableStorage.entities() + .forEach { entity -> + dependencyToAddByModuleName[entity.name]?.let { + mutableStorage.modifyModuleEntity(entity) { + this.dependencies = mutableListOf(it) + } + } + } + mutableStorage.toSnapshot() + } + } + + private fun setupTestDataService( + libraryData: LibraryData, + sdkData: ModuleSdkData, + dependencyToAddByModuleName: Map, + ) { + GradleProjectResolverExtension.EP_NAME.point.registerExtension(TestDependencyResolverExtension(), testRootDisposable) + val service = TestDependencyProjectResolverService(libraryData, sdkData, dependencyToAddByModuleName) + myProject.registerServiceInstance(TestDependencyProjectResolverService::class.java, service) + } + + private fun prepareFakeSdk(): Pair { + val type = JavaSdk.getInstance() + val sdkDependency = SdkDependency(SdkId("sdk-name", type.name)) + val jdk = ProjectJdkTable.getInstance().createSdk(sdkDependency.sdk.name, type) + WriteAction.runAndWait { + ProjectJdkTable.getInstance().addJdk(jdk, testRootDisposable) + } + val sdkData = ModuleSdkData(sdkDependency.sdk.name) + return Pair(sdkDependency, sdkData) + } + + private fun prepareFakeLibrary(): Pair { + val libraryName = "some-library" + val libraryDependency = LibraryDependency( + LibraryId("Gradle: $libraryName", LibraryTableId.ProjectLibraryTableId), + exported = false, + DependencyScope.COMPILE + ) + val libraryData = LibraryData(GradleConstants.SYSTEM_ID, libraryName) + return Pair(libraryDependency, libraryData) + } + + /** Need to use a project level service for any data use by the [TestDependencyResolverExtension] as the instance gets recreated. */ + class TestDependencyProjectResolverService( + // This extension only supports adding a singular instance of a library data and an Sdk data for simplicity + val libraryData: LibraryData, + val sdkData: ModuleSdkData, + // Whether to use sdk or library data is determined by the type of the ModuleDependencyItem + val dependencyToAddByModuleName: Map, + ): AbstractTestProjectResolverService() + + /** + * A test resolver extension to populate fake library and sdk dependencies for modules according + * to the data provided by [TestDependencyProjectResolverService] + */ + class TestDependencyResolverExtension: AbstractProjectResolverExtension() { + val service get() = resolverCtx.externalSystemTaskId.findProject()!!.service() + val libraryData: LibraryData get() = service.libraryData + val sdkData: ModuleSdkData get() = service.sdkData + val dependencyToAddByModuleName: Map get() = service.dependencyToAddByModuleName + + override fun populateProjectExtraModels( + gradleProject: IdeaProject, + ideProject: DataNode, + ) { + ideProject.createChild(ProjectKeys.LIBRARY, libraryData); + super.populateProjectExtraModels(gradleProject, ideProject) + } + + override fun populateModuleDependencies( + gradleModule: IdeaModule, + ideModule: DataNode, + ideProject: DataNode, + ) { + (ExternalSystemApiUtil.findAll(ideModule, GradleSourceSetData.KEY) + ideModule).forEach { + val dependencyToAdd = dependencyToAddByModuleName[it.data.internalName] ?: return@forEach + if (dependencyToAdd !is LibraryDependency) return@forEach + it.createChild( + ProjectKeys.LIBRARY_DEPENDENCY, + LibraryDependencyData( + it.data, + libraryData, + LibraryLevel.PROJECT + ) + ) + } + super.populateModuleDependencies(gradleModule, ideModule, ideProject) + } + + override fun populateModuleExtraModels( + gradleModule: IdeaModule, + ideModule: DataNode, + ) { + (ExternalSystemApiUtil.findAll(ideModule, GradleSourceSetData.KEY) + ideModule).forEach { + val dependencyToAdd = dependencyToAddByModuleName[it.data.internalName] ?: return@forEach + if (dependencyToAdd !is SdkDependency) return@forEach + checkNotNull(ExternalSystemApiUtil.find(it, ModuleSdkData.KEY)) { + "Expected to find existing SDK data node for ${it.data.internalName}" + }.clear(true) + it.createChild( + ModuleSdkData.KEY, + sdkData + ) + } + super.populateModuleExtraModels(gradleModule, ideModule) + } + } } \ No newline at end of file