diff --git a/src/Workspaces/Core/Portable/Workspace/Solution/ProjectState.cs b/src/Workspaces/Core/Portable/Workspace/Solution/ProjectState.cs index 50c5adf2f2523..768f609ee9fa3 100644 --- a/src/Workspaces/Core/Portable/Workspace/Solution/ProjectState.cs +++ b/src/Workspaces/Core/Portable/Workspace/Solution/ProjectState.cs @@ -1127,11 +1127,13 @@ public void AddDocumentIdsWithFilePath(ref TemporaryArray temporaryA this.AnalyzerConfigDocumentStates.AddDocumentIdsWithFilePath(ref temporaryArray, filePath); } - public DocumentId? GetFirstDocumentIdWithFilePath(string filePath) + /// + /// Returns the first from this project that matches this path. This only checks for regular documents, + /// and does not check for additional documents or .editorconfig documents. + /// + public DocumentId? GetFirstSourceDocumentIdWithFilePath(string filePath) { - return this.DocumentStates.GetFirstDocumentIdWithFilePath(filePath) ?? - this.AdditionalDocumentStates.GetFirstDocumentIdWithFilePath(filePath) ?? - this.AnalyzerConfigDocumentStates.GetFirstDocumentIdWithFilePath(filePath); + return this.DocumentStates.GetFirstDocumentIdWithFilePath(filePath); } public int CompareTo(ProjectState? other) diff --git a/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs b/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs index 5ce65e7a6c802..712d6cf569b64 100644 --- a/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs +++ b/src/Workspaces/Core/Portable/Workspace/Solution/SolutionState.cs @@ -67,7 +67,9 @@ internal sealed partial class SolutionState // holds on data calculated based on the AnalyzerReferences list private readonly Lazy _lazyAnalyzers; - // Mapping from file path to the set of documents that are related to it. + /// + /// Mapping from file path to the set of documents that are related to it. Includes all types of documents. + /// private readonly ConcurrentDictionary> _lazyFilePathToRelatedDocumentIds = new(FilePathComparer); private SolutionState( @@ -1272,6 +1274,10 @@ public SolutionState WithAnalyzerReferences(IReadOnlyList ana return Branch(analyzerReferences: analyzerReferences); } + /// + /// Returns a for a document in another project that has the same file path. Only + /// works for regular source documents, additional documents or .editorconfig files will return null. + /// public DocumentId? GetFirstRelatedDocumentId(DocumentId documentId, ProjectId? relatedProjectIdHint) { Contract.ThrowIfTrue(documentId.ProjectId == relatedProjectIdHint); @@ -1295,7 +1301,7 @@ public SolutionState WithAnalyzerReferences(IReadOnlyList ana foreach (var relatedDocumentId in relatedDocumentIds) { // Match the linear search behavior below and do not return documents from the same project. - if (relatedDocumentId != documentId && relatedDocumentId.ProjectId != documentId.ProjectId) + if (relatedDocumentId != documentId && relatedDocumentId.ProjectId != documentId.ProjectId && this.ContainsDocument(relatedDocumentId)) return relatedDocumentId; } @@ -1306,7 +1312,7 @@ public SolutionState WithAnalyzerReferences(IReadOnlyList ana Contract.ThrowIfTrue(relatedProject == projectState); if (relatedProject != null) { - var siblingDocumentId = relatedProject.GetFirstDocumentIdWithFilePath(filePath); + var siblingDocumentId = relatedProject.GetFirstSourceDocumentIdWithFilePath(filePath); if (siblingDocumentId is not null) return siblingDocumentId; } @@ -1318,7 +1324,7 @@ public SolutionState WithAnalyzerReferences(IReadOnlyList ana if (siblingProjectState == projectState || siblingProjectState == relatedProject) continue; - var siblingDocumentId = siblingProjectState.GetFirstDocumentIdWithFilePath(filePath); + var siblingDocumentId = siblingProjectState.GetFirstSourceDocumentIdWithFilePath(filePath); if (siblingDocumentId is not null) return siblingDocumentId; } diff --git a/src/Workspaces/CoreTest/SolutionTests/SolutionTests.cs b/src/Workspaces/CoreTest/SolutionTests/SolutionTests.cs index 24710b4d76266..857e6f44c49e0 100644 --- a/src/Workspaces/CoreTest/SolutionTests/SolutionTests.cs +++ b/src/Workspaces/CoreTest/SolutionTests/SolutionTests.cs @@ -2483,8 +2483,9 @@ public void AddDocument_SourceText() Assert.Throws(() => solution.AddDocument(documentId: DocumentId.CreateNewId(ProjectId.CreateNewId()), "name", sourceText)); } - [Fact] - public async Task GetFirstRelatedDocumentIdWithDuplicatedDocuments() + [Theory] + [CombinatorialData] + public async Task GetFirstRelatedDocumentIdWithDuplicatedDocuments(bool populateCacheFirst) { using var workspace = CreateWorkspaceWithProjectAndDocuments(); var origSolution = workspace.CurrentSolution; @@ -2498,14 +2499,119 @@ public async Task GetFirstRelatedDocumentIdWithDuplicatedDocuments() var newSolution = origSolution.AddDocument(newDocumentId, document.Name, sourceText, filePath: document.FilePath!); - // Populate the SolutionState cache for this document id - _ = newSolution.GetRelatedDocumentIds(origDocumentId); + if (populateCacheFirst) + { + // Populate the SolutionState cache for this document id + _ = newSolution.GetRelatedDocumentIds(origDocumentId); + } - // Ensure a GetFirstRelatedDocumentId call with a poulated cache doesn't return newDocumentId + // Ensure a GetFirstRelatedDocumentId call doesn't return an ID, since the document is from the same project var relatedDocument = newSolution.GetFirstRelatedDocumentId(origDocumentId, relatedProjectIdHint: null); Assert.Null(relatedDocument); } + [Theory] + [CombinatorialData] + public async Task GetFirstRelatedDocumentIdWithAdditionalDocumentHavingSamePath(bool populateCacheFirst, bool passRelatedProjectHint) + { + const string LinkedFileName = @"Z:\Linked.cs"; + + using var workspace = CreateWorkspace(); + + var additionalFileProjectId = ProjectId.CreateNewId("AdditionalFileProject"); + var sourceFileProjectId = ProjectId.CreateNewId("SourceFileProject"); + + var solution = workspace.CurrentSolution + .AddProject(additionalFileProjectId, "AdditionalFileProject", "AdditionalFileProject.dll", LanguageNames.CSharp) + .AddAdditionalDocument(DocumentId.CreateNewId(additionalFileProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName) + .AddProject(sourceFileProjectId, "MainProject", "MainProject.dll", LanguageNames.CSharp) + .AddDocument(DocumentId.CreateNewId(sourceFileProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName); + + var sourceFileDocumentId = solution.GetRequiredProject(sourceFileProjectId).DocumentIds.Single(); + + if (populateCacheFirst) + { + // Populate the SolutionState cache for this document id + _ = solution.GetRelatedDocumentIds(sourceFileDocumentId); + } + + // Ensure a GetFirstRelatedDocumentId call doesn't return an ID, since the related document is an additional document, not a regular document + var relatedDocument = solution.GetFirstRelatedDocumentId(sourceFileDocumentId, relatedProjectIdHint: passRelatedProjectHint ? additionalFileProjectId : null); + Assert.Null(relatedDocument); + } + + [Theory] + [CombinatorialData] + public async Task GetFirstRelatedDocumentIdWithAnalyzerConfigDocumentHavingSamePath(bool populateCacheFirst, bool passRelatedProjectHint) + { + const string LinkedFileName = @"Z:\Linked.cs"; + + using var workspace = CreateWorkspace(); + + var analyzerConfigProjectId = ProjectId.CreateNewId("AnalyzerConfigProject"); + var sourceFileProjectId = ProjectId.CreateNewId("SourceFileProject"); + + var solution = workspace.CurrentSolution + .AddProject(analyzerConfigProjectId, "AnalyzerConfigProject", "AnalyzerConfigProject.dll", LanguageNames.CSharp) + .AddAnalyzerConfigDocument(DocumentId.CreateNewId(analyzerConfigProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName) + .AddProject(sourceFileProjectId, "MainProject", "MainProject.dll", LanguageNames.CSharp) + .AddDocument(DocumentId.CreateNewId(sourceFileProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName); + + var sourceFileDocumentId = solution.GetRequiredProject(sourceFileProjectId).DocumentIds.Single(); + + if (populateCacheFirst) + { + // Populate the SolutionState cache for this document id + _ = solution.GetRelatedDocumentIds(sourceFileDocumentId); + } + + // Ensure a GetFirstRelatedDocumentId call doesn't return an ID, since the related document is an additional document, not a regular document + var relatedDocument = solution.GetFirstRelatedDocumentId(sourceFileDocumentId, relatedProjectIdHint: passRelatedProjectHint ? analyzerConfigProjectId : null); + Assert.Null(relatedDocument); + } + + [Fact] + public async Task SetCurrentSolutionWithRegularAndAdditionalFileUsingSamePathDoesNotThrow() + { + const string LinkedFileName = @"Z:\Linked.cs"; + + using var workspace = CreateWorkspace(); + + var additionalFileProjectId = ProjectId.CreateNewId("AdditionalFileProject"); + var sourceFileProjectId = ProjectId.CreateNewId("SourceFileProject"); + + var solution = workspace.CurrentSolution + .AddProject(additionalFileProjectId, "AdditionalFileProject", "AdditionalFileProject.dll", LanguageNames.CSharp) + .AddAdditionalDocument(DocumentId.CreateNewId(additionalFileProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName) + .AddProject(sourceFileProjectId, "MainProject", "MainProject.dll", LanguageNames.CSharp) + .AddDocument(DocumentId.CreateNewId(sourceFileProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName); + + // Ensure we don't accidentally try to unify the two documents's syntax trees, since one of them won't even have one. When we had this bug + // SetCurrentSolution would throw, so the validation here is simply that this doesn't crash. + workspace.SetCurrentSolution(_ => solution, WorkspaceChangeKind.SolutionAdded); + } + + [Fact] + public async Task SetCurrentSolutionWithRegularAndAnalyzerConfigFileUsingSamePathDoesNotThrow() + { + const string LinkedFileName = @"Z:\Linked.cs"; + + using var workspace = CreateWorkspace(); + + var analyzerConfigProjectId = ProjectId.CreateNewId("AnalyzerConfigProject"); + var sourceFileProjectId = ProjectId.CreateNewId("SourceFileProject"); + + var solution = workspace.CurrentSolution + .AddProject(analyzerConfigProjectId, "AnalyzerConfigProject", "AnalyzerConfigProject.dll", LanguageNames.CSharp) + .AddAnalyzerConfigDocument(DocumentId.CreateNewId(analyzerConfigProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName) + .AddProject(sourceFileProjectId, "MainProject", "MainProject.dll", LanguageNames.CSharp) + .AddDocument(DocumentId.CreateNewId(sourceFileProjectId), "Linked.cs", SourceText.From("class C {}"), filePath: LinkedFileName); + + // Ensure we don't accidentally try to unify the two documents's syntax trees, since one of them won't even have one. When we had this bug + // SetCurrentSolution would throw, so the validation here is simply that this doesn't crash. + workspace.SetCurrentSolution(_ => solution, WorkspaceChangeKind.SolutionAdded); + } + [Fact] public void AddDocument_SyntaxRoot() {