diff --git a/src/Workspaces/Core/Portable/Workspace/IWorkspaceConfigurationService.cs b/src/Workspaces/Core/Portable/Workspace/IWorkspaceConfigurationService.cs index 288fbddf9fc72..fd34eec795b20 100644 --- a/src/Workspaces/Core/Portable/Workspace/IWorkspaceConfigurationService.cs +++ b/src/Workspaces/Core/Portable/Workspace/IWorkspaceConfigurationService.cs @@ -38,7 +38,8 @@ internal readonly record struct WorkspaceConfigurationOptions( [property: DataMember(Order = 1)] bool EnableOpeningSourceGeneratedFiles = false, [property: DataMember(Order = 2)] bool DisableRecoverableText = false, [property: DataMember(Order = 3)] SourceGeneratorExecutionPreference SourceGeneratorExecution = SourceGeneratorExecutionPreference.Automatic, - [property: DataMember(Order = 4)] bool ValidateCompilationTrackerStates = + [property: DataMember(Order = 4)] bool OnlyUnifyDocumentsAcrossProjectFlavors = true, + [property: DataMember(Order = 5)] bool ValidateCompilationTrackerStates = #if DEBUG // We will default this on in DEBUG builds true #else @@ -60,5 +61,6 @@ public WorkspaceConfigurationOptions() public static readonly WorkspaceConfigurationOptions RemoteDefault = new( CacheStorage: StorageDatabase.None, EnableOpeningSourceGeneratedFiles: false, - DisableRecoverableText: false); + DisableRecoverableText: false, + OnlyUnifyDocumentsAcrossProjectFlavors: true); } diff --git a/src/Workspaces/Core/Portable/Workspace/Workspace.cs b/src/Workspaces/Core/Portable/Workspace/Workspace.cs index 1741c35d530ee..a5697aca6c0a8 100644 --- a/src/Workspaces/Core/Portable/Workspace/Workspace.cs +++ b/src/Workspaces/Core/Portable/Workspace/Workspace.cs @@ -285,8 +285,8 @@ static Solution UnifyLinkedDocumentContents(Solution oldSolution, Solution newSo var changes = newSolution.GetChanges(oldSolution); - using var _1 = PooledHashSet.GetInstance(out var changedDocumentIds); - using var _2 = ArrayBuilder.GetInstance(out var addedDocumentIds); + using var _1 = ArrayBuilder.GetInstance(out var addedDocumentIds); + using var _2 = PooledHashSet.GetInstance(out var changedDocumentIds); // For all added documents, see if they link to an existing document. If so, use that existing documents text/tree. foreach (var addedProject in changes.GetAddedProjects()) @@ -309,14 +309,161 @@ static Solution UnifyLinkedDocumentContents(Solution oldSolution, Solution newSo changedDocumentIds.AddRange(projectChanges.GetChangedDocuments()); } - newSolution = UpdateAddedDocumentToExistingContentsInSolution(newSolution, addedDocumentIds); + var configService = newSolution.Workspace.Services.GetRequiredService(); + return configService.Options.OnlyUnifyDocumentsAcrossProjectFlavors + ? UnifyLinkedDocumentContentsAcrossProjectFlavors(newSolution, addedDocumentIds, changedDocumentIds) + : UnifyLinkedDocumentContentsAcrossEntireSolution(newSolution, addedDocumentIds, changedDocumentIds); + } + } + + private static Solution UnifyLinkedDocumentContentsAcrossProjectFlavors(Solution newSolution, ArrayBuilder addedDocumentIds, PooledHashSet changedDocumentIds) + { + // Mapping from a project to all its sibling flavored projects. For example, for a project "Workspaces + // (netstandard2.0)", this would be "Workspaces (net7.0)", "Workspaces (net8.0)", etc. + using var _3 = PooledDictionary>.GetInstance(out var projectToSiblingFlavors); + + newSolution = UpdateAddedDocumentToExistingContentsInSolution(newSolution, addedDocumentIds, projectToSiblingFlavors); + + // now, for any changed document, ensure we go and make all links to it have the same text/tree. + newSolution = UpdateExistingDocumentsToChangedDocumentContents(newSolution, changedDocumentIds, projectToSiblingFlavors); + + // Free the ArrayBuilders in projectToSiblingFlavors. The dictionary itself will be automatically freed at the end of this scope. + projectToSiblingFlavors.FreeValues(); + + return newSolution; + + static bool TryGetSiblingFlavoredProjects( + Solution solution, + ProjectId projectId, + Dictionary> projectIdToSiblingFlavors, + [NotNullWhen(true)] out ArrayBuilder? siblingFlavors) + { + siblingFlavors = null; - // now, for any changed document, ensure we go and make all links to it have the same text/tree. - newSolution = UpdateExistingDocumentsToChangedDocumentContents(newSolution, changedDocumentIds); + var projectState = solution.SolutionState.GetRequiredProjectState(projectId); + + // If this project doesn't have any flavors itself, then there's no sibling flavors of it to return. + if (projectState.NameAndFlavor.flavor is null) + return false; + + if (!projectIdToSiblingFlavors.TryGetValue(projectId, out siblingFlavors)) + { + siblingFlavors = ArrayBuilder.GetInstance(); + projectIdToSiblingFlavors.Add(projectId, siblingFlavors); + + foreach (var (siblingProjectId, siblingProject) in solution.SolutionState.ProjectStates) + { + if (projectId == siblingProjectId) + continue; + + if (siblingProject.NameAndFlavor.name == projectState.NameAndFlavor.name && + siblingProject.NameAndFlavor.flavor != null) + { + siblingFlavors.Add(siblingProject); + } + } + } - return newSolution; + return true; } + static Solution UpdateAddedDocumentToExistingContentsInSolution( + Solution solution, + ArrayBuilder addedDocumentIds, + Dictionary> projectIdToSiblingFlavors) + { + using var _ = ArrayBuilder<(DocumentId, DocumentState)>.GetInstance(out var relatedDocumentIdsAndStates); + + foreach (var group in addedDocumentIds.GroupBy(static d => d.ProjectId)) + { + var projectId = group.Key; + + if (!TryGetSiblingFlavoredProjects(solution, projectId, projectIdToSiblingFlavors, out var siblingFlavors)) + continue; + + foreach (var addedDocumentId in group) + { + var addedDocument = solution.SolutionState.GetRequiredDocumentState(addedDocumentId); + if (addedDocument.FilePath is null) + continue; + + foreach (var siblingProject in siblingFlavors) + { + // Should only be searching different projects from the original project. + Contract.ThrowIfTrue(siblingProject.Id == projectId); + + var relatedDocumentId = siblingProject.GetFirstDocumentIdWithFilePath(addedDocument.FilePath); + if (relatedDocumentId is null) + continue; + + var relatedDocument = solution.GetRequiredDocument(relatedDocumentId); + relatedDocumentIdsAndStates.Add((addedDocumentId, relatedDocument.DocumentState)); + break; + } + } + } + + if (relatedDocumentIdsAndStates.IsEmpty) + return solution; + + return solution.WithDocumentContentsFrom(relatedDocumentIdsAndStates.ToImmutableAndClear(), forceEvenIfTreesWouldDiffer: false); + } + + static Solution UpdateExistingDocumentsToChangedDocumentContents( + Solution solution, + HashSet changedDocumentIds, + Dictionary> projectIdToSiblingFlavors) + { + // Changing a document in a linked-doc-chain will end up producing N changed documents. We only want to + // process that chain once. + using var _ = PooledDictionary.GetInstance(out var relatedDocumentIdsAndStates); + + foreach (var group in changedDocumentIds.GroupBy(static d => d.ProjectId)) + { + var projectId = group.Key; + + if (!TryGetSiblingFlavoredProjects(solution, projectId, projectIdToSiblingFlavors, out var siblingFlavors)) + continue; + + foreach (var changedDocumentId in group) + { + var changedDocument = solution.SolutionState.GetRequiredDocumentState(changedDocumentId); + if (changedDocument.FilePath is null) + continue; + + foreach (var siblingProject in siblingFlavors) + { + // Should only be searching different projects from the original project. + Contract.ThrowIfTrue(siblingProject.Id == projectId); + + var relatedDocumentId = siblingProject.GetFirstDocumentIdWithFilePath(changedDocument.FilePath); + if (relatedDocumentId is null) + continue; + + if (!changedDocumentIds.Contains(relatedDocumentId)) + relatedDocumentIdsAndStates[relatedDocumentId] = changedDocument; + } + } + } + + if (relatedDocumentIdsAndStates.Count == 0) + return solution; + + var relatedDocumentIdsAndStatesArray = relatedDocumentIdsAndStates.SelectAsArray(static kvp => (kvp.Key, kvp.Value)); + + return solution.WithDocumentContentsFrom(relatedDocumentIdsAndStatesArray, forceEvenIfTreesWouldDiffer: false); + } + } + + private static Solution UnifyLinkedDocumentContentsAcrossEntireSolution(Solution newSolution, ArrayBuilder addedDocumentIds, PooledHashSet changedDocumentIds) + { + newSolution = UpdateAddedDocumentToExistingContentsInSolution(newSolution, addedDocumentIds); + + // now, for any changed document, ensure we go and make all links to it have the same text/tree. + newSolution = UpdateExistingDocumentsToChangedDocumentContents(newSolution, changedDocumentIds); + + return newSolution; + static Solution UpdateAddedDocumentToExistingContentsInSolution( Solution solution, ArrayBuilder addedDocumentIds) {