Implement basic Component discovery

This is the basics of component discovery along with some tests.

The next set of changes will integrate it into the compilation process.
This commit is contained in:
Ryan Nowak 2018-02-28 20:16:15 -08:00 committed by Steve Sanderson
parent 2a2a045863
commit daf6a404f9
8 changed files with 603 additions and 2 deletions

View File

@ -87,6 +87,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "BlazorHosted.CSharp.Server"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "BlazorHosted.CSharp.Shared", "src\Microsoft.AspNetCore.Blazor.Templates\content\BlazorHosted.CSharp\BlazorHosted.CSharp.Shared\BlazorHosted.CSharp.Shared.csproj", "{F3E02B21-1127-431A-B832-0E53CB72097B}"
EndProject
Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.AspNetCore.Blazor.Razor.Extensions.Test", "test\Microsoft.AspNetCore.Blazor.Razor.Extensions.Test\Microsoft.AspNetCore.Blazor.Razor.Extensions.Test.csproj", "{FF25111E-5A3E-48A3-96D8-08A2C5A2A91C}"
EndProject
Global
GlobalSection(SolutionConfigurationPlatforms) = preSolution
Debug|Any CPU = Debug|Any CPU
@ -209,6 +211,10 @@ Global
{F3E02B21-1127-431A-B832-0E53CB72097B}.Debug|Any CPU.Build.0 = Debug|Any CPU
{F3E02B21-1127-431A-B832-0E53CB72097B}.Release|Any CPU.ActiveCfg = Release|Any CPU
{F3E02B21-1127-431A-B832-0E53CB72097B}.Release|Any CPU.Build.0 = Release|Any CPU
{FF25111E-5A3E-48A3-96D8-08A2C5A2A91C}.Debug|Any CPU.ActiveCfg = Debug|Any CPU
{FF25111E-5A3E-48A3-96D8-08A2C5A2A91C}.Debug|Any CPU.Build.0 = Debug|Any CPU
{FF25111E-5A3E-48A3-96D8-08A2C5A2A91C}.Release|Any CPU.ActiveCfg = Release|Any CPU
{FF25111E-5A3E-48A3-96D8-08A2C5A2A91C}.Release|Any CPU.Build.0 = Release|Any CPU
EndGlobalSection
GlobalSection(SolutionProperties) = preSolution
HideSolutionNode = FALSE
@ -249,6 +255,7 @@ Global
{7549444A-9C81-44DE-AD0D-2C55501EAAC7} = {73DA1DFD-79F0-4BA2-B0B6-4F3A21D2C3F8}
{78ED9932-0912-4F36-8F82-33DE850E7A33} = {73DA1DFD-79F0-4BA2-B0B6-4F3A21D2C3F8}
{F3E02B21-1127-431A-B832-0E53CB72097B} = {73DA1DFD-79F0-4BA2-B0B6-4F3A21D2C3F8}
{FF25111E-5A3E-48A3-96D8-08A2C5A2A91C} = {ADA3AE29-F6DE-49F6-8C7C-B321508CAE8E}
EndGlobalSection
GlobalSection(ExtensibilityGlobals) = postSolution
SolutionGuid = {504DA352-6788-4DC0-8705-82167E72A4D3}

View File

@ -19,6 +19,18 @@ namespace Microsoft.AspNetCore.Blazor.Razor
public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Layouts.LayoutAttribute";
}
public static class IComponent
{
public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.Components.IComponent";
public static readonly string MetadataName = FullTypeName;
}
public static class IDictionary
{
public static readonly string MetadataName = "System.Collection.IDictionary`2";
}
public static class RenderFragment
{
public static readonly string FullTypeName = "Microsoft.AspNetCore.Blazor.RenderFragment";

View File

@ -12,7 +12,9 @@ namespace Microsoft.AspNetCore.Blazor.Razor
{
internal class ComponentDocumentClassifierPass : DocumentClassifierPassBase, IRazorDocumentClassifierPass
{
protected override string DocumentKind => "Blazor.Component-0.1";
public static readonly string ComponentDocumentKind = "Blazor.Component-0.1";
protected override string DocumentKind => ComponentDocumentKind;
protected override bool IsMatch(RazorCodeDocument codeDocument, DocumentIntermediateNode documentNode)
{

View File

@ -0,0 +1,247 @@
// Copyright (c) .NET Foundation. All rights reserved.
// 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 Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.Razor;
namespace Microsoft.AspNetCore.Blazor.Razor
{
internal class ComponentTagHelperDescriptorProvider : RazorEngineFeatureBase, ITagHelperDescriptorProvider
{
public readonly static string ComponentTagHelperKind = ComponentDocumentClassifierPass.ComponentDocumentKind;
private static readonly SymbolDisplayFormat FullNameTypeDisplayFormat =
SymbolDisplayFormat.FullyQualifiedFormat
.WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted)
.WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions & (~SymbolDisplayMiscellaneousOptions.UseSpecialTypes));
public bool IncludeDocumentation { get; set; }
public int Order { get; set; }
public void Execute(TagHelperDescriptorProviderContext context)
{
if (context == null)
{
throw new ArgumentNullException(nameof(context));
}
var compilation = context.GetCompilation();
if (compilation == null)
{
// No compilation, nothing to do.
return;
}
var componentSymbol = compilation.GetTypeByMetadataName(BlazorApi.IComponent.MetadataName);
if (componentSymbol == null)
{
// No definition for IComponent, nothing to do.
return;
}
var types = new List<INamedTypeSymbol>();
var visitor = new ComponentTypeVisitor(componentSymbol, types);
// Visit the primary output of this compilation, as well as all references.
visitor.Visit(compilation.Assembly);
foreach (var reference in compilation.References)
{
// We ignore .netmodules here - there really isn't a case where they are used by user code
// even though the Roslyn APIs all support them.
if (compilation.GetAssemblyOrModuleSymbol(reference) is IAssemblySymbol assembly)
{
visitor.Visit(assembly);
}
}
for (var i = 0; i < types.Count; i++)
{
var type = types[i];
context.Results.Add(CreateDescriptor(type));
}
}
private TagHelperDescriptor CreateDescriptor(INamedTypeSymbol type)
{
if (type == null)
{
throw new ArgumentNullException(nameof(type));
}
var typeName = type.ToDisplayString(FullNameTypeDisplayFormat);
var assemblyName = type.ContainingAssembly.Identity.Name;
var builder = TagHelperDescriptorBuilder.Create(ComponentTagHelperKind, typeName, assemblyName);
builder.SetTypeName(typeName);
// This opts out this 'component' tag helper for any processing that's specific to the default
// Razor ITagHelper runtime.
builder.Metadata[TagHelperMetadata.Runtime.Name] = "Blazor.IComponent";
var xml = type.GetDocumentationCommentXml();
if (!string.IsNullOrEmpty(xml))
{
builder.Documentation = xml;
}
// Components have very simple matching rules. The type name (short) matches the tag name.
builder.TagMatchingRule(r => r.TagName = type.Name);
foreach (var property in GetVisibleProperties(type))
{
if (property.kind == PropertyKind.Ignored)
{
continue;
}
builder.BindAttribute(pb =>
{
pb.Name = property.property.Name;
pb.TypeName = property.property.Type.ToDisplayString(FullNameTypeDisplayFormat);
pb.SetPropertyName(property.property.Name);
if (property.kind == PropertyKind.Enum)
{
pb.IsEnum = true;
}
xml = property.property.GetDocumentationCommentXml();
if (!string.IsNullOrEmpty(xml))
{
pb.Documentation = xml;
}
});
}
var descriptor = builder.Build();
return descriptor;
}
// Does a walk up the inheritance chain to determine the set of 'visible' properties by using
// a dictionary keyed on property name.
//
// Note that we're only interested in a property if all of the above are true:
// - visible (not shadowed)
// - has public getter
// - has public setter
// - is not an indexer
private IEnumerable<(IPropertySymbol property, PropertyKind kind)> GetVisibleProperties(INamedTypeSymbol type)
{
var properties = new Dictionary<string, (IPropertySymbol, PropertyKind)>(StringComparer.Ordinal);
do
{
var members = type.GetMembers();
for (var i = 0; i < members.Length; i++)
{
var property = members[i] as IPropertySymbol;
if (property == null)
{
// Not a property
continue;
}
var kind = PropertyKind.Default;
if (properties.ContainsKey(property.Name))
{
// Not visible
kind = PropertyKind.Ignored;
}
if (property.Parameters.Length != 0)
{
// Indexer
kind = PropertyKind.Ignored;
}
if (property.GetMethod?.DeclaredAccessibility != Accessibility.Public)
{
// Non-public getter or no getter
kind = PropertyKind.Ignored;
}
if (property.SetMethod?.DeclaredAccessibility != Accessibility.Public)
{
// Non-public setter or no setter
kind = PropertyKind.Ignored;
}
if (kind == PropertyKind.Default && property.Type.TypeKind == TypeKind.Enum)
{
kind = PropertyKind.Enum;
}
properties.Add(property.Name, (property, kind));
}
type = type.BaseType;
}
while (type != null);
return properties.Values;
}
private enum PropertyKind
{
Ignored,
Default,
Enum,
}
private class ComponentTypeVisitor : SymbolVisitor
{
private INamedTypeSymbol _interface;
private List<INamedTypeSymbol> _results;
public ComponentTypeVisitor(INamedTypeSymbol @interface, List<INamedTypeSymbol> results)
{
_interface = @interface;
_results = results;
}
public override void VisitNamedType(INamedTypeSymbol symbol)
{
if (IsComponent(symbol))
{
_results.Add(symbol);
}
}
public override void VisitNamespace(INamespaceSymbol symbol)
{
foreach (var member in symbol.GetMembers())
{
Visit(member);
}
}
public override void VisitAssembly(IAssemblySymbol symbol)
{
// This as a simple yet high-value optimization that excludes the vast majority of
// assemblies that (by definition) can't contain a component.
if (symbol.Name != null && !symbol.Name.StartsWith("System.", StringComparison.Ordinal))
{
Visit(symbol.GlobalNamespace);
}
}
internal bool IsComponent(INamedTypeSymbol symbol)
{
if (_interface == null)
{
return false;
}
return
symbol.DeclaredAccessibility == Accessibility.Public &&
!symbol.IsAbstract &&
!symbol.IsGenericType &&
symbol.AllInterfaces.Contains(_interface);
}
}
}
}

View File

@ -1,7 +1,10 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System.Runtime.CompilerServices;
using Microsoft.AspNetCore.Blazor.Razor;
using Microsoft.AspNetCore.Razor.Language;
[assembly: ProvideRazorExtensionInitializer("Blazor-0.1", typeof(BlazorExtensionInitializer))]
[assembly: ProvideRazorExtensionInitializer("Blazor-0.1", typeof(BlazorExtensionInitializer))]
[assembly: InternalsVisibleTo("Microsoft.AspNetCore.Blazor.Razor.Extensions.Test")]

View File

@ -0,0 +1,295 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information.
using System;
using System.Linq;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.Extensions.DependencyModel;
using Xunit;
namespace Microsoft.AspNetCore.Blazor.Razor.Extensions
{
public class ComponentTagHelperDescriptorProviderTest
{
static ComponentTagHelperDescriptorProviderTest()
{
var dependencyContext = DependencyContext.Load(typeof(ComponentTagHelperDescriptorProviderTest).Assembly);
var metadataReferences = dependencyContext.CompileLibraries
.SelectMany(l => l.ResolveReferencePaths())
.Select(assemblyPath => MetadataReference.CreateFromFile(assemblyPath))
.ToArray();
BaseCompilation = CSharpCompilation.Create(
"TestAssembly",
Array.Empty<SyntaxTree>(),
metadataReferences,
new CSharpCompilationOptions(OutputKind.DynamicallyLinkedLibrary));
}
private static Compilation BaseCompilation { get; }
[Fact]
public void Excecute_FindsIComponentType_CreatesDescriptor()
{
// Arrange
var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@"
using Microsoft.AspNetCore.Blazor.Components;
namespace Test
{
public class MyComponent : IComponent
{
public void Init(RenderHandle renderHandle) { }
public void SetParameters(ParameterCollection parameters) { }
public string MyProperty { get; set; }
}
}
"));
Assert.Empty(compilation.GetDiagnostics());
var context = TagHelperDescriptorProviderContext.Create();
context.SetCompilation(compilation);
var provider = new ComponentTagHelperDescriptorProvider();
// Act
provider.Execute(context);
// Assert
var components = ExcludeBuiltInComponents(context);
var component = Assert.Single(components);
// These are features Components don't use. Verifying them once here and
// then ignoring them.
Assert.Empty(component.AllowedChildTags);
Assert.Null(component.TagOutputHint);
// These are features that are invariants of all Components. Verifying them once
// here and then ignoring them.
Assert.Empty(component.Diagnostics);
Assert.False(component.HasErrors);
Assert.Equal(ComponentTagHelperDescriptorProvider.ComponentTagHelperKind, component.Kind);
Assert.Equal("Blazor.Component-0.1", component.Kind);
Assert.False(component.IsDefaultKind());
Assert.False(component.KindUsesDefaultTagHelperRuntime());
// No documentation in this test
Assert.Null(component.Documentation);
// These are all trivally derived from the assembly/namespace/type name
Assert.Equal("TestAssembly", component.AssemblyName);
Assert.Equal("Test.MyComponent", component.Name);
Assert.Equal("Test.MyComponent", component.DisplayName);
Assert.Equal("Test.MyComponent", component.GetTypeName());
// Our use of matching rules is also very simple, and derived from the name. Veriying
// it once in detail here and then ignoring it.
var rule = Assert.Single(component.TagMatchingRules);
Assert.Empty(rule.Attributes);
Assert.Empty(rule.Diagnostics);
Assert.False(rule.HasErrors);
Assert.Null(rule.ParentTag);
Assert.Equal("MyComponent", rule.TagName);
Assert.Equal(TagStructure.Unspecified, rule.TagStructure);
// Our use of metadata is also (for now) an invariant for all Components - other than the type name
// which is trivial. Verifying it once in detail and then ignoring it.
Assert.Collection(
component.Metadata.OrderBy(kvp => kvp.Key),
kvp => { Assert.Equal(TagHelperMetadata.Common.TypeName, kvp.Key); Assert.Equal("Test.MyComponent", kvp.Value); },
kvp => { Assert.Equal(TagHelperMetadata.Runtime.Name, kvp.Key); Assert.Equal("Blazor.IComponent", kvp.Value); });
// Our use of bound attributes is what tests will focus on. As you might expect right now, this test
// is going to cover a lot of trivial stuff that will be true for all components/component-properties.
var attribute = Assert.Single(component.BoundAttributes);
// Invariants
Assert.Empty(attribute.Diagnostics);
Assert.False(attribute.HasErrors);
Assert.Equal("Blazor.Component-0.1", attribute.Kind);
Assert.False(attribute.IsDefaultKind());
// Related to dictionaries/indexers, not supported currently, not sure if we ever will
Assert.False(attribute.HasIndexer);
Assert.Null(attribute.IndexerNamePrefix);
Assert.Null(attribute.IndexerTypeName);
Assert.False(attribute.IsIndexerBooleanProperty);
Assert.False(attribute.IsIndexerStringProperty);
// No documentation in this test
Assert.Null(attribute.Documentation);
// Names are trivially derived from the property name
Assert.Equal("MyProperty", attribute.Name);
Assert.Equal("MyProperty", attribute.GetPropertyName());
Assert.Equal("string Test.MyComponent.MyProperty", attribute.DisplayName);
// Defined from the property type
Assert.Equal("System.String", attribute.TypeName);
Assert.True(attribute.IsStringProperty);
Assert.False(attribute.IsBooleanProperty);
Assert.False(attribute.IsEnum);
// Our use of metadata is also (for now) an invariant for all Component properties - other than the type name
// which is trivial. Verifying it once in detail and then ignoring it.
Assert.Collection(
attribute.Metadata.OrderBy(kvp => kvp.Key),
kvp => { Assert.Equal(TagHelperMetadata.Common.PropertyName, kvp.Key); Assert.Equal("MyProperty", kvp.Value); });
}
[Fact]
public void Excecute_FindsBlazorComponentType_CreatesDescriptor()
{
// Arrange
var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@"
using Microsoft.AspNetCore.Blazor.Components;
namespace Test
{
public class MyComponent : BlazorComponent
{
public string MyProperty { get; set; }
}
}
"));
Assert.Empty(compilation.GetDiagnostics());
var context = TagHelperDescriptorProviderContext.Create();
context.SetCompilation(compilation);
var provider = new ComponentTagHelperDescriptorProvider();
// Act
provider.Execute(context);
// Assert
var components = ExcludeBuiltInComponents(context);
var component = Assert.Single(components);
Assert.Equal("TestAssembly", component.AssemblyName);
Assert.Equal("Test.MyComponent", component.Name);
var attribute = Assert.Single(component.BoundAttributes);
Assert.Equal("MyProperty", attribute.Name);
Assert.Equal("System.String", attribute.TypeName);
}
[Fact] // bool properties support minimized attributes
public void Excecute_BoolProperty_CreatesDescriptor()
{
// Arrange
var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@"
using Microsoft.AspNetCore.Blazor.Components;
namespace Test
{
public class MyComponent : BlazorComponent
{
public bool MyProperty { get; set; }
}
}
"));
Assert.Empty(compilation.GetDiagnostics());
var context = TagHelperDescriptorProviderContext.Create();
context.SetCompilation(compilation);
var provider = new ComponentTagHelperDescriptorProvider();
// Act
provider.Execute(context);
// Assert
var components = ExcludeBuiltInComponents(context);
var component = Assert.Single(components);
Assert.Equal("TestAssembly", component.AssemblyName);
Assert.Equal("Test.MyComponent", component.Name);
var attribute = Assert.Single(component.BoundAttributes);
Assert.Equal("MyProperty", attribute.Name);
Assert.Equal("System.Boolean", attribute.TypeName);
Assert.False(attribute.HasIndexer);
Assert.True(attribute.IsBooleanProperty);
Assert.False(attribute.IsEnum);
Assert.False(attribute.IsStringProperty);
}
[Fact] // enum properties have some special intellisense behavior
public void Excecute_EnumProperty_CreatesDescriptor()
{
// Arrange
var compilation = BaseCompilation.AddSyntaxTrees(CSharpSyntaxTree.ParseText(@"
using Microsoft.AspNetCore.Blazor.Components;
namespace Test
{
public enum MyEnum
{
One,
Two
}
public class MyComponent : BlazorComponent
{
public MyEnum MyProperty { get; set; }
}
}
"));
Assert.Empty(compilation.GetDiagnostics());
var context = TagHelperDescriptorProviderContext.Create();
context.SetCompilation(compilation);
var provider = new ComponentTagHelperDescriptorProvider();
// Act
provider.Execute(context);
// Assert
var components = ExcludeBuiltInComponents(context);
var component = Assert.Single(components);
Assert.Equal("TestAssembly", component.AssemblyName);
Assert.Equal("Test.MyComponent", component.Name);
var attribute = Assert.Single(component.BoundAttributes);
Assert.Equal("MyProperty", attribute.Name);
Assert.Equal("Test.MyEnum", attribute.TypeName);
Assert.False(attribute.HasIndexer);
Assert.False(attribute.IsBooleanProperty);
Assert.True(attribute.IsEnum);
Assert.False(attribute.IsStringProperty);
}
// For simplicity in testing, exlude the built-in components. We'll add more and we
// don't want to update the tests when that happens.
private TagHelperDescriptor[] ExcludeBuiltInComponents(TagHelperDescriptorProviderContext context)
{
return context.Results
.Where(c => c.AssemblyName == "TestAssembly")
.OrderBy(c => c.Name)
.ToArray();
}
}
}

View File

@ -0,0 +1,32 @@
<Project Sdk="Microsoft.NET.Sdk">
<PropertyGroup>
<TargetFramework>netcoreapp2.0</TargetFramework>
<IsPackable>false</IsPackable>
<!--
Retains compilation settings so we can create a compilation during tests with the same data
used to compile this assembly.
-->
<PreserveCompilationContext>true</PreserveCompilationContext>
</PropertyGroup>
<ItemGroup>
<None Include="xunit.runner.json" CopyToOutputDirectory="PreserveNewest" />
</ItemGroup>
<ItemGroup>
<PackageReference Include="Microsoft.CodeAnalysis.CSharp" Version="2.6.1" />
<PackageReference Include="Microsoft.Extensions.DependencyModel" Version="2.0.4" />
<PackageReference Include="Microsoft.NET.Test.Sdk" Version="15.5.0" />
<PackageReference Include="xunit" Version="2.3.1" />
<PackageReference Include="xunit.runner.visualstudio" Version="2.3.1" />
</ItemGroup>
<ItemGroup>
<ProjectReference Include="..\..\src\Microsoft.AspNetCore.Blazor\Microsoft.AspNetCore.Blazor.csproj" />
<ProjectReference Include="..\..\src\Microsoft.AspNetCore.Blazor.Razor.Extensions\Microsoft.AspNetCore.Blazor.Razor.Extensions.csproj" />
</ItemGroup>
</Project>

View File

@ -0,0 +1,3 @@
{
"shadowCopy": false
}