Skip to content

Commit

Permalink
Add support for partial component class editing -> refresh components.
Browse files Browse the repository at this point in the history
- We now do aggressive detection on the type of C# class that's being edited. In order to not impact C# scenarios we only do work if C# assets are available to us. Meaning, we inspect the old document and if that document has its' semantic model available we spend cycles to determine if it's a component. In the case that we find a C# component class that wasn't previously caught we enqueue an update.
- Our IComponent detection logic has to be "fuzzy" because we don't have access to the C# compilation at the workspace change detection layer. Therefore we need to look at type names and do string comparisons vs. looking up specific types in the compilation.
- Added several tests to ensure we enqueue and that we properly detect component classes.

dotnet/aspnetcore#14646
  • Loading branch information
NTaylorMullen committed Oct 15, 2019
1 parent 2d18ba2 commit bdb33c8
Show file tree
Hide file tree
Showing 2 changed files with 296 additions and 2 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.CodeAnalysis.CSharp.Syntax;

namespace Microsoft.CodeAnalysis.Razor.ProjectSystem
{
Expand Down Expand Up @@ -116,13 +118,21 @@ internal virtual void Workspace_WorkspaceChanged(object sender, WorkspaceChangeE

if (document.FilePath == null)
{
break;
return;
}

// Using EndsWith because Path.GetExtension will ignore everything before .cs
// Using Ordinal because the SDK generates these filenames.
// Stll have .cshtml.g.cs and .razor.g.cs for Razor.VSCode scenarios.
if (document.FilePath.EndsWith(".cshtml.g.cs", StringComparison.Ordinal) || document.FilePath.EndsWith(".razor.g.cs", StringComparison.Ordinal) || document.FilePath.EndsWith(".razor", StringComparison.Ordinal))
{
EnqueueUpdate(e.ProjectId);
return;
}

// We now know we're not operating directly on a Razor file. However, it's possible the user is operating on a partial class that is associated with a Razor file.

if (IsPartialComponentClass(document))
{
EnqueueUpdate(e.ProjectId);
}
Expand Down Expand Up @@ -153,6 +163,49 @@ internal virtual void Workspace_WorkspaceChanged(object sender, WorkspaceChangeE
}
}

// Internal for testing
internal bool IsPartialComponentClass(Document document)
{
if (!document.TryGetSyntaxRoot(out var root))
{
return false;
}

var classDeclarations = root.DescendantNodes().OfType<ClassDeclarationSyntax>();
if (!classDeclarations.Any())
{
return false;
}

if (!document.TryGetSemanticModel(out var semanticModel))
{
// This will occasionally return false resulting in us not refreshing TagHelpers for component partial classes. This means there are situations when a users'
// TagHelper definitions will not immediately update but we will eventually acheive omniscience.
return false;
}

foreach (var classDeclaration in classDeclarations)
{
var classSymbol = semanticModel.GetDeclaredSymbol(classDeclaration) as INamedTypeSymbol;
if (classSymbol == null)
{
continue;
}

// We can't access the compilation at this point so we need to do a "fuzzy" match for our IComponent base type. In the worst case scenario
// we'll end up triggering extra TagHelper updates for situations where a class implements some other IComponent type that looks like ours.
var implementsIComponent = classSymbol.AllInterfaces.FirstOrDefault(@interface => string.Equals(@interface.ToDisplayString(), ComponentsApi.IComponent.MetadataName)) != null;
if (!implementsIComponent)
{
continue;
}

return true;
}

return false;
}

// Virtual for temporary VSCode workaround
protected virtual void InitializeSolution(Solution solution)
{
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,8 @@
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language.Components;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Text;
using Microsoft.VisualStudio.LanguageServices.Razor.Test;
using Moq;
Expand All @@ -26,6 +28,8 @@ public WorkspaceProjectStateChangeDetectorTest()
var cshtmlDocumentInfo = DocumentInfo.Create(CshtmlDocumentId, "Test", filePath: "file.cshtml.g.cs");
RazorDocumentId = DocumentId.CreateNewId(projectId1);
var razorDocumentInfo = DocumentInfo.Create(RazorDocumentId, "Test", filePath: "file.razor.g.cs");
PartialComponentClassDocumentId = DocumentId.CreateNewId(projectId1);
var partialComponentClassDocumentInfo = DocumentInfo.Create(PartialComponentClassDocumentId, "Test", filePath: "file.razor.cs");

SolutionWithTwoProjects = Workspace.CurrentSolution
.AddProject(ProjectInfo.Create(
Expand All @@ -35,7 +39,7 @@ public WorkspaceProjectStateChangeDetectorTest()
"One",
LanguageNames.CSharp,
filePath: "One.csproj",
documents: new[] { cshtmlDocumentInfo, razorDocumentInfo }))
documents: new[] { cshtmlDocumentInfo, razorDocumentInfo, partialComponentClassDocumentInfo }))
.AddProject(ProjectInfo.Create(
projectId2,
VersionStamp.Default,
Expand Down Expand Up @@ -84,6 +88,8 @@ public WorkspaceProjectStateChangeDetectorTest()

public DocumentId RazorDocumentId { get; }

public DocumentId PartialComponentClassDocumentId { get; }

[ForegroundTheory]
[InlineData(WorkspaceChangeKind.SolutionAdded)]
[InlineData(WorkspaceChangeKind.SolutionChanged)]
Expand Down Expand Up @@ -253,6 +259,59 @@ public async Task WorkspaceChanged_DocumentChanged_Razor_UpdatesProjectState_Aft
Assert.Equal(update.projectSnapshot.FilePath, HostProjectOne.FilePath);
}

[ForegroundFact]
public async Task WorkspaceChanged_DocumentChanged_PartialComponent_UpdatesProjectState_AfterDelay()
{
// Arrange
var workspaceStateGenerator = new TestProjectWorkspaceStateGenerator();
var detector = new WorkspaceProjectStateChangeDetector(workspaceStateGenerator)
{
EnqueueDelay = 1,
BlockDelayedUpdateWorkEnqueue = new ManualResetEventSlim(initialState: false),
};

Workspace.TryApplyChanges(SolutionWithTwoProjects);
var projectManager = new TestProjectSnapshotManager(new[] { detector }, Workspace);
projectManager.ProjectAdded(HostProjectOne);
workspaceStateGenerator.ClearQueue();

var sourceText = SourceText.From(
$@"
public partial class TestComponent : {ComponentsApi.IComponent.MetadataName} {{}}
namespace Microsoft.AspNetCore.Components
{{
public interface IComponent {{}}
}}
");
var syntaxTreeRoot = CSharpSyntaxTree.ParseText(sourceText).GetRoot();
var solution = SolutionWithTwoProjects
.WithDocumentText(PartialComponentClassDocumentId, sourceText)
.WithDocumentSyntaxRoot(PartialComponentClassDocumentId, syntaxTreeRoot, PreservationMode.PreserveIdentity);
var document = solution.GetDocument(PartialComponentClassDocumentId);

// The change detector only operates when a semantic model / syntax tree is available.
await document.GetSyntaxRootAsync();
await document.GetSemanticModelAsync();

var e = new WorkspaceChangeEventArgs(WorkspaceChangeKind.DocumentChanged, oldSolution: solution, newSolution: solution, projectId: ProjectNumberOne.Id, PartialComponentClassDocumentId);

// Act
detector.Workspace_WorkspaceChanged(Workspace, e);

// Assert
//
// The change hasn't come through yet.
Assert.Empty(workspaceStateGenerator.UpdateQueue);

detector.BlockDelayedUpdateWorkEnqueue.Set();

await detector._deferredUpdates.Single().Value;

var update = Assert.Single(workspaceStateGenerator.UpdateQueue);
Assert.Equal(update.workspaceProject.Id, ProjectNumberOne.Id);
Assert.Equal(update.projectSnapshot.FilePath, HostProjectOne.FilePath);
}

[ForegroundFact]
public void WorkspaceChanged_ProjectRemovedEvent_QueuesProjectStateRemoval()
{
Expand Down Expand Up @@ -296,6 +355,188 @@ public void WorkspaceChanged_ProjectAddedEvent_AddsProject()
p => Assert.Equal(ProjectNumberThree.Id, p.workspaceProject.Id));
}

[Fact]
public async Task IsPartialComponentClass_InitializedDocument_ReturnsTrue()
{
// Arrange
var workspaceStateGenerator = new TestProjectWorkspaceStateGenerator();
var detector = new WorkspaceProjectStateChangeDetector(workspaceStateGenerator);
var sourceText = SourceText.From(
$@"
public partial class TestComponent : {ComponentsApi.IComponent.MetadataName} {{}}
namespace Microsoft.AspNetCore.Components
{{
public interface IComponent {{}}
}}
");
var syntaxTreeRoot = CSharpSyntaxTree.ParseText(sourceText).GetRoot();
var solution = SolutionWithTwoProjects
.WithDocumentText(PartialComponentClassDocumentId, sourceText)
.WithDocumentSyntaxRoot(PartialComponentClassDocumentId, syntaxTreeRoot, PreservationMode.PreserveIdentity);
var document = solution.GetDocument(PartialComponentClassDocumentId);

// Initialize document
await document.GetSyntaxRootAsync();
await document.GetSemanticModelAsync();

// Act
var result = detector.IsPartialComponentClass(document);

// Assert
Assert.True(result);
}

[Fact]
public void IsPartialComponentClass_Uninitialized_ReturnsFalse()
{
// Arrange
var workspaceStateGenerator = new TestProjectWorkspaceStateGenerator();
var detector = new WorkspaceProjectStateChangeDetector(workspaceStateGenerator);
var sourceText = SourceText.From(
$@"
public partial class TestComponent : {ComponentsApi.IComponent.MetadataName} {{}}
namespace Microsoft.AspNetCore.Components
{{
public interface IComponent {{}}
}}
");
var syntaxTreeRoot = CSharpSyntaxTree.ParseText(sourceText).GetRoot();
var solution = SolutionWithTwoProjects
.WithDocumentText(PartialComponentClassDocumentId, sourceText)
.WithDocumentSyntaxRoot(PartialComponentClassDocumentId, syntaxTreeRoot, PreservationMode.PreserveIdentity);
var document = solution.GetDocument(PartialComponentClassDocumentId);

// Act
var result = detector.IsPartialComponentClass(document);

// Assert
Assert.False(result);
}

[Fact]
public async Task IsPartialComponentClass_UninitializedSemanticModel_ReturnsFalse()
{
// Arrange
var workspaceStateGenerator = new TestProjectWorkspaceStateGenerator();
var detector = new WorkspaceProjectStateChangeDetector(workspaceStateGenerator);
var sourceText = SourceText.From(
$@"
public partial class TestComponent : {ComponentsApi.IComponent.MetadataName} {{}}
namespace Microsoft.AspNetCore.Components
{{
public interface IComponent {{}}
}}
");
var syntaxTreeRoot = CSharpSyntaxTree.ParseText(sourceText).GetRoot();
var solution = SolutionWithTwoProjects
.WithDocumentText(PartialComponentClassDocumentId, sourceText)
.WithDocumentSyntaxRoot(PartialComponentClassDocumentId, syntaxTreeRoot, PreservationMode.PreserveIdentity);
var document = solution.GetDocument(PartialComponentClassDocumentId);

await document.GetSyntaxRootAsync();

// Act
var result = detector.IsPartialComponentClass(document);

// Assert
Assert.False(result);
}

[Fact]
public async Task IsPartialComponentClass_NonClass_ReturnsFalse()
{
// Arrange
var workspaceStateGenerator = new TestProjectWorkspaceStateGenerator();
var detector = new WorkspaceProjectStateChangeDetector(workspaceStateGenerator);
var sourceText = SourceText.From(string.Empty);
var syntaxTreeRoot = CSharpSyntaxTree.ParseText(sourceText).GetRoot();
var solution = SolutionWithTwoProjects
.WithDocumentText(PartialComponentClassDocumentId, sourceText)
.WithDocumentSyntaxRoot(PartialComponentClassDocumentId, syntaxTreeRoot, PreservationMode.PreserveIdentity);
var document = solution.GetDocument(PartialComponentClassDocumentId);

// Initialize document
await document.GetSyntaxRootAsync();
await document.GetSemanticModelAsync();

// Act
var result = detector.IsPartialComponentClass(document);

// Assert
Assert.False(result);
}

[Fact]
public async Task IsPartialComponentClass_MultipleClassesOneComponentPartial_ReturnsTrue()
{

// Arrange
var workspaceStateGenerator = new TestProjectWorkspaceStateGenerator();
var detector = new WorkspaceProjectStateChangeDetector(workspaceStateGenerator);
var sourceText = SourceText.From(
$@"
public partial class NonComponent1 {{}}
public class NonComponent2 {{}}
public partial class TestComponent : {ComponentsApi.IComponent.MetadataName} {{}}
public partial class NonComponent3 {{}}
public class NonComponent4 {{}}
namespace Microsoft.AspNetCore.Components
{{
public interface IComponent {{}}
}}
");
var syntaxTreeRoot = CSharpSyntaxTree.ParseText(sourceText).GetRoot();
var solution = SolutionWithTwoProjects
.WithDocumentText(PartialComponentClassDocumentId, sourceText)
.WithDocumentSyntaxRoot(PartialComponentClassDocumentId, syntaxTreeRoot, PreservationMode.PreserveIdentity);
var document = solution.GetDocument(PartialComponentClassDocumentId);

// Initialize document
await document.GetSyntaxRootAsync();
await document.GetSemanticModelAsync();

// Act
var result = detector.IsPartialComponentClass(document);

// Assert
Assert.True(result);
}

[Fact]
public async Task IsPartialComponentClass_NonComponents_ReturnsFalse()
{

// Arrange
var workspaceStateGenerator = new TestProjectWorkspaceStateGenerator();
var detector = new WorkspaceProjectStateChangeDetector(workspaceStateGenerator);
var sourceText = SourceText.From(
$@"
public partial class NonComponent1 {{}}
public class NonComponent2 {{}}
public partial class NonComponent3 {{}}
public class NonComponent4 {{}}
namespace Microsoft.AspNetCore.Components
{{
public interface IComponent {{}}
}}
");
var syntaxTreeRoot = CSharpSyntaxTree.ParseText(sourceText).GetRoot();
var solution = SolutionWithTwoProjects
.WithDocumentText(PartialComponentClassDocumentId, sourceText)
.WithDocumentSyntaxRoot(PartialComponentClassDocumentId, syntaxTreeRoot, PreservationMode.PreserveIdentity);
var document = solution.GetDocument(PartialComponentClassDocumentId);

// Initialize document
await document.GetSyntaxRootAsync();
await document.GetSemanticModelAsync();

// Act
var result = detector.IsPartialComponentClass(document);

// Assert
Assert.False(result);
}

private class TestProjectSnapshotManager : DefaultProjectSnapshotManager
{
public TestProjectSnapshotManager(IEnumerable<ProjectSnapshotChangeTrigger> triggers, Workspace workspace)
Expand Down

0 comments on commit bdb33c8

Please sign in to comment.