Add Razor document tracking to FallbackRazorProjectHost.

- VisualStudio defaults to adding a `<none>` link item when right click -> add existing item for Razor files; therefore, this also includes the knowledge of the "None" item group when finding Razor files.
- Added unit and functional tests to verify the new `FallbackRazorProjectHost` behavior.
- Added new schema items to represent the `Content` and `None` item type information (pulled from the project system repo).

#2373
This commit is contained in:
N. Taylor Mullen 2018-05-29 18:21:21 -07:00
parent 607812f97f
commit 56d69db0fa
4 changed files with 386 additions and 13 deletions

View File

@ -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))

View File

@ -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<HostDocument>();
// 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<HostDocument>();
// 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<string, string> 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

View File

@ -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";
}
}
}

View File

@ -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<string, string>()
{
[ItemReference.LinkPropertyName] = "NewIndex.cshtml",
[ItemReference.FullPathPropertyName] = "C:\\From\\Index.cshtml",
});
var afterChangeNoneItems = new ItemCollection(ManageProjectSystemSchema.NoneItem.SchemaName);
NoneItems.Item("About.cshtml", new Dictionary<string, string>()
{
[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<string, string>()
{
[ItemReference.LinkPropertyName] = "NewIndex.cshtml",
[ItemReference.FullPathPropertyName] = "C:\\From\\Index.cshtml",
});
NoneItems.Item("About.cshtml", new Dictionary<string, string>()
{
[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<string, string>()
{
[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<string, string>()
{
[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<string, string>()
{
[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<string, string>()
{
[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<string, string>()
{
[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<string, string>()
{
[ItemReference.FullPathPropertyName] = "C:\\Path\\Index.cshtml",
});
NoneItems.Item("About.cshtml", new Dictionary<string, string>()
{
[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<string, string>()
{
[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<string, string>()
{
[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)
{
}