diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultRazorProjectHost.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultRazorProjectHost.cs index daefe2320a..68dfc4312a 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultRazorProjectHost.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/DefaultRazorProjectHost.cs @@ -302,7 +302,6 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem return true; } - private HostDocument[] GetCurrentDocuments(IProjectSubscriptionUpdate update) { if (!update.CurrentState.TryGetValue(Rules.RazorGenerateWithTargetPath.SchemaName, out var rule)) diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackRazorProjectHost.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackRazorProjectHost.cs index 75d877577b..9ca90249cd 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackRazorProjectHost.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/FallbackRazorProjectHost.cs @@ -2,8 +2,11 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System; +using System.Collections.Generic; +using System.Collections.Immutable; using System.ComponentModel.Composition; using System.IO; +using System.Linq; using System.Reflection.Metadata; using System.Reflection.PortableExecutable; using System.Threading; @@ -11,6 +14,9 @@ using System.Threading.Tasks; using System.Threading.Tasks.Dataflow; using Microsoft.VisualStudio.LanguageServices; using Microsoft.VisualStudio.ProjectSystem; +using ContentItem = Microsoft.CodeAnalysis.Razor.ProjectSystem.ManageProjectSystemSchema.ContentItem; +using ItemReference = Microsoft.CodeAnalysis.Razor.ProjectSystem.ManageProjectSystemSchema.ItemReference; +using NoneItem = Microsoft.CodeAnalysis.Razor.ProjectSystem.ManageProjectSystemSchema.NoneItem; using ResolvedCompilationReference = Microsoft.CodeAnalysis.Razor.ProjectSystem.ManageProjectSystemSchema.ResolvedCompilationReference; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem @@ -58,7 +64,12 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem receiver, initialDataAsNew: true, suppressVersionOnlyUpdates: true, - ruleNames: new string[] { ResolvedCompilationReference.SchemaName }, + ruleNames: new string[] + { + ResolvedCompilationReference.SchemaName, + ContentItem.SchemaName, + NoneItem.SchemaName, + }, linkOptions: new DataflowLinkOptions() { PropagateCompletion = true }); } @@ -115,9 +126,30 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem var configuration = FallbackRazorConfiguration.SelectConfiguration(version); var hostProject = new HostProject(CommonServices.UnconfiguredProject.FullPath, configuration); + + // We need to deal with the case where the project was uninitialized, but now + // is valid for Razor. In that case we might have previously seen all of the documents + // but ignored them because the project wasn't active. + // + // So what we do to deal with this, is that we 'remove' all changed and removed items + // and then we 'add' all current items. This allows minimal churn to the PSM, but still + // makes us up-to-date. + var documents = GetCurrentDocuments(update.Value); + var changedDocuments = GetChangedAndRemovedDocuments(update.Value); + await UpdateAsync(() => { UpdateProjectUnsafe(hostProject); + + for (var i = 0; i < changedDocuments.Length; i++) + { + RemoveDocumentUnsafe(changedDocuments[i]); + } + + for (var i = 0; i < documents.Length; i++) + { + AddDocumentUnsafe(documents[i]); + } }).ConfigureAwait(false); }); }, registerFaultHandler: true); @@ -129,6 +161,97 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem return ReadAssemblyVersion(filePath); } + // Internal for testing + internal HostDocument[] GetCurrentDocuments(IProjectSubscriptionUpdate update) + { + var documents = new List(); + + // Content Razor files + if (update.CurrentState.TryGetValue(ContentItem.SchemaName, out var rule)) + { + foreach (var kvp in rule.Items) + { + if (TryGetRazorDocument(kvp.Value, out var document)) + { + documents.Add(document); + } + } + } + + // None Razor files, these are typically included when a user links a file in Visual Studio. + if (update.CurrentState.TryGetValue(NoneItem.SchemaName, out var nonRule)) + { + foreach (var kvp in nonRule.Items) + { + if (TryGetRazorDocument(kvp.Value, out var document)) + { + documents.Add(document); + } + } + } + + return documents.ToArray(); + } + + // Internal for testing + internal HostDocument[] GetChangedAndRemovedDocuments(IProjectSubscriptionUpdate update) + { + var documents = new List(); + + // Content Razor files + if (update.ProjectChanges.TryGetValue(ContentItem.SchemaName, out var rule)) + { + foreach (var key in rule.Difference.RemovedItems.Concat(rule.Difference.ChangedItems)) + { + if (rule.Before.Items.TryGetValue(key, out var value) && + TryGetRazorDocument(value, out var document)) + { + documents.Add(document); + } + } + } + + // None Razor files, these are typically included when a user links a file in Visual Studio. + if (update.ProjectChanges.TryGetValue(NoneItem.SchemaName, out var nonRule)) + { + foreach (var key in nonRule.Difference.RemovedItems.Concat(nonRule.Difference.ChangedItems)) + { + if (nonRule.Before.Items.TryGetValue(key, out var value) && + TryGetRazorDocument(value, out var document)) + { + documents.Add(document); + } + } + } + + return documents.ToArray(); + } + + // Internal for testing + internal bool TryGetRazorDocument(IImmutableDictionary itemState, out HostDocument razorDocument) + { + if (itemState.TryGetValue(ItemReference.FullPathPropertyName, out var filePath)) + { + // If there's no target path then we normalize the target path to the file path. In the end, all we care about + // is that the file being included in the primary project ends in .cshtml. + itemState.TryGetValue(ItemReference.LinkPropertyName, out var targetPath); + if (string.IsNullOrEmpty(targetPath)) + { + targetPath = filePath; + } + + if (targetPath.EndsWith(".cshtml")) + { + targetPath = CommonServices.UnconfiguredProject.MakeRooted(targetPath); + razorDocument = new HostDocument(filePath, targetPath); + return true; + } + } + + razorDocument = null; + return false; + } + private static Version ReadAssemblyVersion(string filePath) { try diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/ManageProjectSystemSchema.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/ManageProjectSystemSchema.cs index 79138c8ac6..65f3f9da1d 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/ManageProjectSystemSchema.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ProjectSystem/ManageProjectSystemSchema.cs @@ -12,5 +12,26 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem public static readonly string ItemName = "ResolvedCompilationReference"; } + + public static class ContentItem + { + public static readonly string SchemaName = "Content"; + + public static readonly string ItemName = "Content"; + } + + public static class NoneItem + { + public static readonly string SchemaName = "None"; + + public static readonly string ItemName = "None"; + } + + public static class ItemReference + { + public static readonly string FullPathPropertyName = "FullPath"; + + public static readonly string LinkPropertyName = "Link"; + } } } diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackRazorProjectHostTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackRazorProjectHostTest.cs index ee94223733..78b634c287 100644 --- a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackRazorProjectHostTest.cs +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/ProjectSystem/FallbackRazorProjectHostTest.cs @@ -3,10 +3,12 @@ using System; using System.Collections.Generic; +using System.Collections.Immutable; using System.Threading.Tasks; using Microsoft.VisualStudio.ProjectSystem; using Moq; using Xunit; +using ItemReference = Microsoft.CodeAnalysis.Razor.ProjectSystem.ManageProjectSystemSchema.ItemReference; namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { @@ -18,19 +20,211 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem ProjectManager = new TestProjectSnapshotManager(Dispatcher, Workspace); ReferenceItems = new ItemCollection(ManageProjectSystemSchema.ResolvedCompilationReference.SchemaName); + ContentItems = new ItemCollection(ManageProjectSystemSchema.ContentItem.SchemaName); + NoneItems = new ItemCollection(ManageProjectSystemSchema.NoneItem.SchemaName); } private ItemCollection ReferenceItems { get; } private TestProjectSnapshotManager ProjectManager { get; } + private ItemCollection ContentItems { get; } + + private ItemCollection NoneItems { get; } + private Workspace Workspace { get; } + [Fact] + public void GetChangedAndRemovedDocuments_ReturnsChangedContentAndNoneItems() + { + // Arrange + var afterChangeContentItems = new ItemCollection(ManageProjectSystemSchema.ContentItem.SchemaName); + ContentItems.Item("Index.cshtml", new Dictionary() + { + [ItemReference.LinkPropertyName] = "NewIndex.cshtml", + [ItemReference.FullPathPropertyName] = "C:\\From\\Index.cshtml", + }); + var afterChangeNoneItems = new ItemCollection(ManageProjectSystemSchema.NoneItem.SchemaName); + NoneItems.Item("About.cshtml", new Dictionary() + { + [ItemReference.LinkPropertyName] = "NewAbout.cshtml", + [ItemReference.FullPathPropertyName] = "C:\\From\\About.cshtml", + }); + var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); + var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); + var changes = new TestProjectChangeDescription[] + { + afterChangeContentItems.ToChange(ContentItems.ToSnapshot()), + afterChangeNoneItems.ToChange(NoneItems.ToSnapshot()), + }; + var update = services.CreateUpdate(changes).Value; + + // Act + var result = host.GetChangedAndRemovedDocuments(update); + + // Assert + Assert.Collection( + result, + document => + { + Assert.Equal("C:\\From\\Index.cshtml", document.FilePath); + Assert.Equal("C:\\To\\NewIndex.cshtml", document.TargetPath); + }, + document => + { + Assert.Equal("C:\\From\\About.cshtml", document.FilePath); + Assert.Equal("C:\\To\\NewAbout.cshtml", document.TargetPath); + }); + } + + [Fact] + public void GetCurrentDocuments_ReturnsContentAndNoneItems() + { + // Arrange + ContentItems.Item("Index.cshtml", new Dictionary() + { + [ItemReference.LinkPropertyName] = "NewIndex.cshtml", + [ItemReference.FullPathPropertyName] = "C:\\From\\Index.cshtml", + }); + NoneItems.Item("About.cshtml", new Dictionary() + { + [ItemReference.LinkPropertyName] = "NewAbout.cshtml", + [ItemReference.FullPathPropertyName] = "C:\\From\\About.cshtml", + }); + var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); + var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); + var changes = new TestProjectChangeDescription[] + { + ContentItems.ToChange(), + NoneItems.ToChange(), + }; + var update = services.CreateUpdate(changes).Value; + + // Act + var result = host.GetCurrentDocuments(update); + + // Assert + Assert.Collection( + result, + document => + { + Assert.Equal("C:\\From\\Index.cshtml", document.FilePath); + Assert.Equal("C:\\To\\NewIndex.cshtml", document.TargetPath); + }, + document => + { + Assert.Equal("C:\\From\\About.cshtml", document.FilePath); + Assert.Equal("C:\\To\\NewAbout.cshtml", document.TargetPath); + }); + } + + [Fact] + public void TryGetRazorDocument_NoFilePath_ReturnsFalse() + { + // Arrange + var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); + var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); + var itemState = new Dictionary() + { + [ItemReference.LinkPropertyName] = "Index.cshtml", + }.ToImmutableDictionary(); + + // Act + var result = host.TryGetRazorDocument(itemState, out var document); + + // Assert + Assert.False(result); + Assert.Null(document); + } + + [Fact] + public void TryGetRazorDocument_NonRazorFilePath_ReturnsFalse() + { + // Arrange + var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); + var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); + var itemState = new Dictionary() + { + [ItemReference.FullPathPropertyName] = "C:\\Path\\site.css", + }.ToImmutableDictionary(); + + // Act + var result = host.TryGetRazorDocument(itemState, out var document); + + // Assert + Assert.False(result); + Assert.Null(document); + } + + [Fact] + public void TryGetRazorDocument_NonRazorTargetPath_ReturnsFalse() + { + // Arrange + var services = new TestProjectSystemServices("C:\\Path\\To\\Test.csproj"); + var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); + var itemState = new Dictionary() + { + [ItemReference.LinkPropertyName] = "site.html", + [ItemReference.FullPathPropertyName] = "C:\\Path\\From\\Index.cshtml", + }.ToImmutableDictionary(); + + // Act + var result = host.TryGetRazorDocument(itemState, out var document); + + // Assert + Assert.False(result); + Assert.Null(document); + } + + [Fact] + public void TryGetRazorDocument_JustFilePath_ReturnsTrue() + { + // Arrange + var expectedPath = "C:\\Path\\Index.cshtml"; + var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); + var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); + var itemState = new Dictionary() + { + [ItemReference.FullPathPropertyName] = expectedPath, + }.ToImmutableDictionary(); + + // Act + var result = host.TryGetRazorDocument(itemState, out var document); + + // Assert + Assert.True(result); + Assert.Equal(expectedPath, document.FilePath); + Assert.Equal(expectedPath, document.TargetPath); + } + + [Fact] + public void TryGetRazorDocument_LinkedFilepath_ReturnsTrue() + { + // Arrange + var expectedFullPath = "C:\\Path\\From\\Index.cshtml"; + var expectedTargetPath = "C:\\Path\\To\\Index.cshtml"; + var services = new TestProjectSystemServices("C:\\Path\\To\\Test.csproj"); + var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); + var itemState = new Dictionary() + { + [ItemReference.LinkPropertyName] = "Index.cshtml", + [ItemReference.FullPathPropertyName] = expectedFullPath, + }.ToImmutableDictionary(); + + // Act + var result = host.TryGetRazorDocument(itemState, out var document); + + // Assert + Assert.True(result); + Assert.Equal(expectedFullPath, document.FilePath); + Assert.Equal(expectedTargetPath, document.TargetPath); + } + [ForegroundFact] public async Task FallbackRazorProjectHost_ForegroundThread_CreateAndDispose_Succeeds() { // Arrange - var services = new TestProjectSystemServices("Test.csproj"); + var services = new TestProjectSystemServices("C:\\To\\Test.csproj"); var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager); // Act & Assert @@ -83,13 +277,23 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { // Arrange ReferenceItems.Item("c:\\nuget\\Microsoft.AspNetCore.Mvc.razor.dll"); + ContentItems.Item("Index.cshtml", new Dictionary() + { + [ItemReference.FullPathPropertyName] = "C:\\Path\\Index.cshtml", + }); + NoneItems.Item("About.cshtml", new Dictionary() + { + [ItemReference.FullPathPropertyName] = "C:\\Path\\About.cshtml", + }); var changes = new TestProjectChangeDescription[] { ReferenceItems.ToChange(), + ContentItems.ToChange(), + NoneItems.ToChange(), }; - var services = new TestProjectSystemServices("Test.csproj"); + var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager) { @@ -104,9 +308,14 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem // Assert var snapshot = Assert.Single(ProjectManager.Projects); - Assert.Equal("Test.csproj", snapshot.FilePath); + Assert.Equal("C:\\Path\\Test.csproj", snapshot.FilePath); Assert.Same(FallbackRazorConfiguration.MVC_2_0, snapshot.Configuration); + Assert.Collection( + snapshot.DocumentFilePaths, + filePath => Assert.Equal("C:\\Path\\Index.cshtml", filePath), + filePath => Assert.Equal("C:\\Path\\About.cshtml", filePath)); + await Task.Run(async () => await host.DisposeAsync()); Assert.Empty(ProjectManager.Projects); } @@ -169,13 +378,24 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { // Arrange ReferenceItems.Item("c:\\nuget\\Microsoft.AspNetCore.Mvc.razor.dll"); + var afterChangeContentItems = new ItemCollection(ManageProjectSystemSchema.ContentItem.SchemaName); + ContentItems.Item("Index.cshtml", new Dictionary() + { + [ItemReference.FullPathPropertyName] = "C:\\Path\\Index.cshtml", + }); + var initialChanges = new TestProjectChangeDescription[] + { + ReferenceItems.ToChange(), + ContentItems.ToChange(), + }; var changes = new TestProjectChangeDescription[] { ReferenceItems.ToChange(), + afterChangeContentItems.ToChange(ContentItems.ToSnapshot()), }; - var services = new TestProjectSystemServices("Test.csproj"); + var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager) { @@ -186,12 +406,14 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.Empty(ProjectManager.Projects); // Act - 1 - await Task.Run(async () => await host.OnProjectChanged(services.CreateUpdate(changes))); + await Task.Run(async () => await host.OnProjectChanged(services.CreateUpdate(initialChanges))); // Assert - 1 var snapshot = Assert.Single(ProjectManager.Projects); - Assert.Equal("Test.csproj", snapshot.FilePath); + Assert.Equal("C:\\Path\\Test.csproj", snapshot.FilePath); Assert.Same(FallbackRazorConfiguration.MVC_2_0, snapshot.Configuration); + var filePath = Assert.Single(snapshot.DocumentFilePaths); + Assert.Equal("C:\\Path\\Index.cshtml", filePath); // Act - 2 host.AssemblyVersion = new Version(1, 0); @@ -199,8 +421,9 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem // Assert - 2 snapshot = Assert.Single(ProjectManager.Projects); - Assert.Equal("Test.csproj", snapshot.FilePath); + Assert.Equal("C:\\Path\\Test.csproj", snapshot.FilePath); Assert.Same(FallbackRazorConfiguration.MVC_1_0, snapshot.Configuration); + Assert.Empty(snapshot.DocumentFilePaths); await Task.Run(async () => await host.DisposeAsync()); Assert.Empty(ProjectManager.Projects); @@ -236,7 +459,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem Assert.Same(FallbackRazorConfiguration.MVC_2_0, snapshot.Configuration); // Act - 2 - host.AssemblyVersion= null; + host.AssemblyVersion = null; await Task.Run(async () => await host.OnProjectChanged(services.CreateUpdate(changes))); // Assert - 2 @@ -251,13 +474,18 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem { // Arrange ReferenceItems.Item("c:\\nuget\\Microsoft.AspNetCore.Mvc.razor.dll"); + ContentItems.Item("Index.cshtml", new Dictionary() + { + [ItemReference.FullPathPropertyName] = "C:\\Path\\Index.cshtml", + }); var changes = new TestProjectChangeDescription[] { ReferenceItems.ToChange(), + ContentItems.ToChange(), }; - var services = new TestProjectSystemServices("Test.csproj"); + var services = new TestProjectSystemServices("C:\\Path\\Test.csproj"); var host = new TestFallbackRazorProjectHost(services, Workspace, ProjectManager) { @@ -272,8 +500,10 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem // Assert - 1 var snapshot = Assert.Single(ProjectManager.Projects); - Assert.Equal("Test.csproj", snapshot.FilePath); + Assert.Equal("C:\\Path\\Test.csproj", snapshot.FilePath); Assert.Same(FallbackRazorConfiguration.MVC_2_0, snapshot.Configuration); + var filePath = Assert.Single(snapshot.DocumentFilePaths); + Assert.Equal("C:\\Path\\Index.cshtml", filePath); // Act - 2 await Task.Run(async () => await host.DisposeAsync()); @@ -333,7 +563,7 @@ namespace Microsoft.CodeAnalysis.Razor.ProjectSystem private class TestFallbackRazorProjectHost : FallbackRazorProjectHost { - internal TestFallbackRazorProjectHost(IUnconfiguredProjectCommonServices commonServices, Workspace workspace, ProjectSnapshotManagerBase projectManager) + internal TestFallbackRazorProjectHost(IUnconfiguredProjectCommonServices commonServices, Workspace workspace, ProjectSnapshotManagerBase projectManager) : base(commonServices, workspace, projectManager) { }