From 6e647854faa7e28f1a4b3b96e1e6147a72c8cf68 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Tue, 17 Jan 2017 17:02:15 -0800 Subject: [PATCH] Add support for `ViewComponentTagHelpers`. - Hardcoded `ViewComponent` discovery. - Hardcoded `ViewComponentTagHelperDescriptor` creation. - Added test to validate that ViewComponents are discovered and transitioned into TagHelpers properly. - Avoided adding a reference to MVC to prevent circular references. This resulted in custom marker attributes to represent `ViewComponent`s. Also made a lot of use of `ViewComponent` conventions (ending in "ViewComponent"). #932 --- .../DefaultTagHelperResolver.cs | 166 ++++++++++-- .../ViewComponentResources.Designer.cs | 110 ++++++++ .../ViewComponentResources.resx | 135 ++++++++++ ...ViewComponentTagHelperDescriptorFactory.cs | 238 ++++++++++++++++++ .../ViewComponentTypes.cs | 31 +++ .../DefaultTagHelperResolverTest.cs | 223 +++++++++++++++- ...ComponentTagHelperDescriptorFactoryTest.cs | 200 +++++++++++++++ 7 files changed, 1079 insertions(+), 24 deletions(-) create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Properties/ViewComponentResources.Designer.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentResources.resx create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTagHelperDescriptorFactory.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTypes.cs create mode 100644 test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ViewComponentTagHelperDescriptorFactoryTest.cs diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs index 642b287881..3dfacaca05 100644 --- a/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs @@ -1,7 +1,9 @@ // 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 System.Linq; using Microsoft.AspNetCore.Razor.Evolution; using Microsoft.AspNetCore.Razor.Evolution.Legacy; @@ -9,9 +11,18 @@ namespace Microsoft.CodeAnalysis.Razor { internal class DefaultTagHelperResolver : TagHelperResolver { - public DefaultTagHelperResolver(bool designTime) + private static readonly Version SupportedVCTHMvcVersion = new Version(1, 1); + private readonly string ViewComponentAssembly; + + public DefaultTagHelperResolver(bool designTime) : this(designTime, ViewComponentTypes.Assembly) + { + } + + // Internal for testing + internal DefaultTagHelperResolver(bool designTime, string viewComponentAssembly) { DesignTime = designTime; + ViewComponentAssembly = viewComponentAssembly; } public bool DesignTime { get; } @@ -19,17 +30,75 @@ namespace Microsoft.CodeAnalysis.Razor public override IReadOnlyList GetTagHelpers(Compilation compilation) { var results = new List(); + var errors = new ErrorSink(); - // If ITagHelper isn't defined, then we couldn't possibly find anything. + VisitTagHelpers(compilation, results, errors); + VisitViewComponents(compilation, results, errors); + + return results; + } + + private void VisitTagHelpers(Compilation compilation, List results, ErrorSink errors) + { var @interface = compilation.GetTypeByMetadataName(TagHelperTypes.ITagHelper); if (@interface == null) { - return results; + // If ITagHelper isn't defined, then we couldn't possibly find anything. + return; } var types = new List(); - var visitor = new Visitor(@interface, types); + var visitor = new TagHelperVisitor(@interface, types); + VisitCompilation(visitor, compilation); + + var factory = new DefaultTagHelperDescriptorFactory(compilation, DesignTime); + + foreach (var type in types) + { + var descriptors = factory.CreateDescriptors(type, errors); + results.AddRange(descriptors); + } + } + + private void VisitViewComponents(Compilation compilation, List results, ErrorSink errors) + { + var mvcViewFeaturesAssembly = compilation.References + .Select(reference => compilation.GetAssemblyOrModuleSymbol(reference)) + .OfType() + .FirstOrDefault(assembly => string.Equals(assembly.Identity.Name, ViewComponentAssembly, StringComparison.Ordinal)); + + if (mvcViewFeaturesAssembly == null || mvcViewFeaturesAssembly.Identity.Version < SupportedVCTHMvcVersion) + { + return; + } + + var viewComponentAttributeSymbol = compilation.GetTypeByMetadataName(ViewComponentTypes.ViewComponentAttribute); + var nonViewComponentAttributeSymbol = compilation.GetTypeByMetadataName(ViewComponentTypes.NonViewComponentAttribute); + var types = new List(); + var visitor = new ViewComponentVisitor(viewComponentAttributeSymbol, viewComponentAttributeSymbol, types); + + VisitCompilation(visitor, compilation); + + var factory = new ViewComponentTagHelperDescriptorFactory(compilation); + + foreach (var type in types) + { + try + { + var descriptor = factory.CreateDescriptor(type); + + results.Add(descriptor); + } + catch (Exception ex) + { + errors.OnError(SourceLocation.Zero, ex.Message, length: 0); + } + } + } + + private static void VisitCompilation(SymbolVisitor visitor, Compilation compilation) + { visitor.Visit(compilation.Assembly.GlobalNamespace); foreach (var reference in compilation.References) @@ -39,26 +108,15 @@ namespace Microsoft.CodeAnalysis.Razor visitor.Visit(assembly.GlobalNamespace); } } - - var errors = new ErrorSink(); - var factory = new DefaultTagHelperDescriptorFactory(compilation, DesignTime); - - foreach (var type in types) - { - var descriptors = factory.CreateDescriptors(type, errors); - results.AddRange(descriptors); - } - - return results; } // Visits top-level types and finds interface implementations. - internal class Visitor : SymbolVisitor + internal class TagHelperVisitor : SymbolVisitor { private INamedTypeSymbol _interface; private List _results; - public Visitor(INamedTypeSymbol @interface, List results) + public TagHelperVisitor(INamedTypeSymbol @interface, List results) { _interface = @interface; _results = results; @@ -88,5 +146,79 @@ namespace Microsoft.CodeAnalysis.Razor symbol.AllInterfaces.Contains(_interface); } } + + internal class ViewComponentVisitor : SymbolVisitor + { + private INamedTypeSymbol _viewComponentAttribute; + private INamedTypeSymbol _nonViewComponentAttribute; + private List _results; + + public ViewComponentVisitor( + INamedTypeSymbol viewComponentAttribute, + INamedTypeSymbol nonViewComponentAttribute, + List results) + { + _viewComponentAttribute = viewComponentAttribute; + _nonViewComponentAttribute = nonViewComponentAttribute; + _results = results; + } + + public override void VisitNamedType(INamedTypeSymbol symbol) + { + if (IsViewComponent(symbol)) + { + _results.Add(symbol); + } + + if (symbol.DeclaredAccessibility != Accessibility.Public) + { + return; + } + + foreach (var member in symbol.GetTypeMembers()) + { + Visit(member); + } + } + + public override void VisitNamespace(INamespaceSymbol symbol) + { + foreach (var member in symbol.GetMembers()) + { + Visit(member); + } + } + + internal bool IsViewComponent(INamedTypeSymbol symbol) + { + if (symbol.DeclaredAccessibility != Accessibility.Public || + symbol.IsAbstract || + symbol.IsGenericType || + AttributeIsDefined(symbol, _nonViewComponentAttribute)) + { + return false; + } + + return symbol.Name.EndsWith(ViewComponentTypes.ViewComponentSuffix) || + AttributeIsDefined(symbol, _viewComponentAttribute); + } + + private static bool AttributeIsDefined(INamedTypeSymbol type, INamedTypeSymbol queryAttribute) + { + if (type == null) + { + return false; + } + + var attribute = type.GetAttributes().Where(a => a.AttributeClass == queryAttribute).FirstOrDefault(); + + if (attribute != null) + { + return true; + } + + return AttributeIsDefined(type.BaseType, queryAttribute); + } + } } } \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Properties/ViewComponentResources.Designer.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Properties/ViewComponentResources.Designer.cs new file mode 100644 index 0000000000..47b2e198ce --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Properties/ViewComponentResources.Designer.cs @@ -0,0 +1,110 @@ +// +namespace Microsoft.CodeAnalysis.Razor.Workspaces +{ + using System.Globalization; + using System.Reflection; + using System.Resources; + + internal static class ViewComponentResources + { + private static readonly ResourceManager _resourceManager + = new ResourceManager("Microsoft.CodeAnalysis.Razor.Workspaces.ViewComponentResources", typeof(ViewComponentResources).GetTypeInfo().Assembly); + + /// + /// View component '{0}' must have exactly one public method named '{1}' or '{2}'. + /// + internal static string ViewComponent_AmbiguousMethods + { + get { return GetString("ViewComponent_AmbiguousMethods"); } + } + + /// + /// View component '{0}' must have exactly one public method named '{1}' or '{2}'. + /// + internal static string FormatViewComponent_AmbiguousMethods(object p0, object p1, object p2) + { + return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_AmbiguousMethods"), p0, p1, p2); + } + + /// + /// Method '{0}' of view component '{1}' should be declared to return {2}&lt;T&gt;. + /// + internal static string ViewComponent_AsyncMethod_ShouldReturnTask + { + get { return GetString("ViewComponent_AsyncMethod_ShouldReturnTask"); } + } + + /// + /// Method '{0}' of view component '{1}' should be declared to return {2}&lt;T&gt;. + /// + internal static string FormatViewComponent_AsyncMethod_ShouldReturnTask(object p0, object p1, object p2) + { + return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_AsyncMethod_ShouldReturnTask"), p0, p1, p2); + } + + /// + /// Could not find an '{0}' or '{1}' method for the view component '{2}'. + /// + internal static string ViewComponent_CannotFindMethod + { + get { return GetString("ViewComponent_CannotFindMethod"); } + } + + /// + /// Could not find an '{0}' or '{1}' method for the view component '{2}'. + /// + internal static string FormatViewComponent_CannotFindMethod(object p0, object p1, object p2) + { + return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_CannotFindMethod"), p0, p1, p2); + } + + /// + /// Method '{0}' of view component '{1}' cannot return a {2}. + /// + internal static string ViewComponent_SyncMethod_CannotReturnTask + { + get { return GetString("ViewComponent_SyncMethod_CannotReturnTask"); } + } + + /// + /// Method '{0}' of view component '{1}' cannot return a {2}. + /// + internal static string FormatViewComponent_SyncMethod_CannotReturnTask(object p0, object p1, object p2) + { + return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_SyncMethod_CannotReturnTask"), p0, p1, p2); + } + + /// + /// Method '{0}' of view component '{1}' should be declared to return a value. + /// + internal static string ViewComponent_SyncMethod_ShouldReturnValue + { + get { return GetString("ViewComponent_SyncMethod_ShouldReturnValue"); } + } + + /// + /// Method '{0}' of view component '{1}' should be declared to return a value. + /// + internal static string FormatViewComponent_SyncMethod_ShouldReturnValue(object p0, object p1) + { + return string.Format(CultureInfo.CurrentCulture, GetString("ViewComponent_SyncMethod_ShouldReturnValue"), p0, p1); + } + + private static string GetString(string name, params string[] formatterNames) + { + var value = _resourceManager.GetString(name); + + System.Diagnostics.Debug.Assert(value != null); + + if (formatterNames != null) + { + for (var i = 0; i < formatterNames.Length; i++) + { + value = value.Replace("{" + formatterNames[i] + "}", "{" + i + "}"); + } + } + + return value; + } + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentResources.resx b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentResources.resx new file mode 100644 index 0000000000..d6aad4ff3f --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentResources.resx @@ -0,0 +1,135 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + text/microsoft-resx + + + 2.0 + + + System.Resources.ResXResourceReader, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + System.Resources.ResXResourceWriter, System.Windows.Forms, Version=4.0.0.0, Culture=neutral, PublicKeyToken=b77a5c561934e089 + + + View component '{0}' must have exactly one public method named '{1}' or '{2}'. + + + Method '{0}' of view component '{1}' should be declared to return {2}&lt;T&gt;. + + + Could not find an '{0}' or '{1}' method for the view component '{2}'. + + + Method '{0}' of view component '{1}' cannot return a {2}. + + + Method '{0}' of view component '{1}' should be declared to return a value. + + \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTagHelperDescriptorFactory.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTagHelperDescriptorFactory.cs new file mode 100644 index 0000000000..de1a3c2bed --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTagHelperDescriptorFactory.cs @@ -0,0 +1,238 @@ +// 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 System.Collections.Immutable; +using System.Linq; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Evolution; +using Microsoft.CodeAnalysis.Razor.Workspaces; + +namespace Microsoft.CodeAnalysis.Razor +{ + internal class ViewComponentTagHelperDescriptorFactory + { + private readonly INamedTypeSymbol _viewComponentAttributeSymbol; + private readonly INamedTypeSymbol _genericTaskSymbol; + private readonly INamedTypeSymbol _taskSymbol; + private readonly INamedTypeSymbol _iDictionarySymbol; + + private static readonly SymbolDisplayFormat FullNameTypeDisplayFormat = + SymbolDisplayFormat.FullyQualifiedFormat + .WithGlobalNamespaceStyle(SymbolDisplayGlobalNamespaceStyle.Omitted) + .WithMiscellaneousOptions(SymbolDisplayFormat.FullyQualifiedFormat.MiscellaneousOptions & (~SymbolDisplayMiscellaneousOptions.UseSpecialTypes)); + + public ViewComponentTagHelperDescriptorFactory(Compilation compilation) + { + _viewComponentAttributeSymbol = compilation.GetTypeByMetadataName(ViewComponentTypes.ViewComponentAttribute); + _genericTaskSymbol = compilation.GetTypeByMetadataName(ViewComponentTypes.GenericTask); + _taskSymbol = compilation.GetTypeByMetadataName(ViewComponentTypes.Task); + _iDictionarySymbol = compilation.GetTypeByMetadataName(TagHelperTypes.IDictionary); + } + + public virtual TagHelperDescriptor CreateDescriptor(INamedTypeSymbol type) + { + var assemblyName = type.ContainingAssembly.Name; + var shortName = GetShortName(type); + var tagName = $"vc:{DefaultTagHelperDescriptorFactory.ToHtmlCase(shortName)}"; + var typeName = $"__Generated__{shortName}ViewComponentTagHelper"; + + var descriptor = new TagHelperDescriptor + { + TagName = tagName, + TypeName = typeName, + AssemblyName = assemblyName + }; + + SetAttributeDescriptors(type, descriptor); + + descriptor.PropertyBag.Add(ViewComponentTypes.ViewComponentNameKey, shortName); + + return descriptor; + } + + private void SetAttributeDescriptors(INamedTypeSymbol type, TagHelperDescriptor descriptor) + { + var methodParameters = GetInvokeMethodParameters(type); + var attributeDescriptors = new List(); + var indexerDescriptors = new List(); + var requiredAttributeDescriptors = new List(); + + foreach (var parameter in methodParameters) + { + var lowerKebabName = DefaultTagHelperDescriptorFactory.ToHtmlCase(parameter.Name); + var typeName = parameter.Type.ToDisplayString(FullNameTypeDisplayFormat); + var attributeDescriptor = new TagHelperAttributeDescriptor + { + Name = lowerKebabName, + PropertyName = parameter.Name, + TypeName = typeName + }; + + attributeDescriptor.IsEnum = parameter.Type.TypeKind == TypeKind.Enum; + attributeDescriptor.IsIndexer = false; + + attributeDescriptors.Add(attributeDescriptor); + + var indexerDescriptor = GetIndexerAttributeDescriptor(parameter, lowerKebabName); + if (indexerDescriptor != null) + { + indexerDescriptors.Add(indexerDescriptor); + } + else + { + // Set required attributes only for non-indexer attributes. Indexer attributes can't be required attributes + // because there are two ways of setting values for the attribute. + requiredAttributeDescriptors.Add(new TagHelperRequiredAttributeDescriptor + { + Name = lowerKebabName + }); + } + } + + attributeDescriptors.AddRange(indexerDescriptors); + descriptor.Attributes = attributeDescriptors; + descriptor.RequiredAttributes = requiredAttributeDescriptors; + } + + private TagHelperAttributeDescriptor GetIndexerAttributeDescriptor(IParameterSymbol parameter, string name) + { + INamedTypeSymbol dictionaryType; + if ((parameter.Type as INamedTypeSymbol)?.ConstructedFrom == _iDictionarySymbol) + { + dictionaryType = (INamedTypeSymbol)parameter.Type; + } + else if (parameter.Type.AllInterfaces.Any(s => s.ConstructedFrom == _iDictionarySymbol)) + { + dictionaryType = parameter.Type.AllInterfaces.First(s => s.ConstructedFrom == _iDictionarySymbol); + } + else + { + dictionaryType = null; + } + + if (dictionaryType == null || dictionaryType.TypeArguments[0].SpecialType != SpecialType.System_String) + { + return null; + } + + var type = dictionaryType.TypeArguments[1]; + var descriptor = new TagHelperAttributeDescriptor + { + Name = name + "-", + PropertyName = parameter.Name, + TypeName = type.ToDisplayString(FullNameTypeDisplayFormat), + IsEnum = type.TypeKind == TypeKind.Enum, + IsIndexer = true + }; + + return descriptor; + } + + private ImmutableArray GetInvokeMethodParameters(INamedTypeSymbol componentType) + { + var methods = componentType.GetMembers() + .OfType() + .Where(method => + method.DeclaredAccessibility == Accessibility.Public && + (string.Equals(method.Name, ViewComponentTypes.AsyncMethodName, StringComparison.Ordinal) || + string.Equals(method.Name, ViewComponentTypes.SyncMethodName, StringComparison.Ordinal))) + .ToArray(); + + if (methods.Length == 0) + { + throw new InvalidOperationException( + ViewComponentResources.FormatViewComponent_CannotFindMethod(ViewComponentTypes.SyncMethodName, ViewComponentTypes.AsyncMethodName, componentType.ToDisplayString(FullNameTypeDisplayFormat))); + } + else if (methods.Length > 1) + { + throw new InvalidOperationException( + ViewComponentResources.FormatViewComponent_AmbiguousMethods(componentType.ToDisplayString(FullNameTypeDisplayFormat), ViewComponentTypes.AsyncMethodName, ViewComponentTypes.SyncMethodName)); + } + + var selectedMethod = methods[0]; + var returnType = selectedMethod.ReturnType as INamedTypeSymbol; + if (string.Equals(selectedMethod.Name, ViewComponentTypes.AsyncMethodName, StringComparison.Ordinal) && returnType != null) + { + if (!returnType.IsGenericType == true || + returnType.ConstructedFrom == _genericTaskSymbol) + { + throw new InvalidOperationException(ViewComponentResources.FormatViewComponent_AsyncMethod_ShouldReturnTask( + ViewComponentTypes.AsyncMethodName, + componentType.ToDisplayString(FullNameTypeDisplayFormat), + nameof(Task))); + } + } + else if (returnType != null) + { + // Will invoke synchronously. Method must not return void, Task or Task. + if (returnType.SpecialType == SpecialType.System_Void) + { + throw new InvalidOperationException(ViewComponentResources.FormatViewComponent_SyncMethod_ShouldReturnValue( + ViewComponentTypes.SyncMethodName, + componentType.ToDisplayString(FullNameTypeDisplayFormat))); + } + + var inheritsFromTask = false; + var currentType = returnType; + while (currentType != null) + { + if (currentType == _taskSymbol) + { + inheritsFromTask = true; + break; + } + + currentType = currentType.BaseType; + } + + if (inheritsFromTask) + { + throw new InvalidOperationException(ViewComponentResources.FormatViewComponent_SyncMethod_CannotReturnTask( + ViewComponentTypes.SyncMethodName, + componentType.ToDisplayString(FullNameTypeDisplayFormat), + nameof(Task))); + } + } + + var methodParameters = selectedMethod.Parameters; + + return methodParameters; + } + + private string GetShortName(INamedTypeSymbol componentType) + { + var viewComponentAttribute = componentType.GetAttributes().Where(a => a.AttributeClass == _viewComponentAttributeSymbol).FirstOrDefault(); + var name = viewComponentAttribute + ?.NamedArguments + .Where(namedArgument => string.Equals(namedArgument.Key, ViewComponentTypes.ViewComponent.Name, StringComparison.Ordinal)) + .FirstOrDefault() + .Value + .Value as string; + + if (!string.IsNullOrEmpty(name)) + { + var separatorIndex = name.LastIndexOf('.'); + if (separatorIndex >= 0) + { + return name.Substring(separatorIndex + 1); + } + else + { + return name; + } + } + + // Get name by convention + if (componentType.Name.EndsWith(ViewComponentTypes.ViewComponentSuffix, StringComparison.OrdinalIgnoreCase)) + { + return componentType.Name.Substring(0, componentType.Name.Length - ViewComponentTypes.ViewComponentSuffix.Length); + } + else + { + return componentType.Name; + } + } + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTypes.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTypes.cs new file mode 100644 index 0000000000..93a5e93231 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/ViewComponentTypes.cs @@ -0,0 +1,31 @@ +// 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. + +namespace Microsoft.CodeAnalysis.Razor +{ + internal static class ViewComponentTypes + { + public const string Assembly = "Microsoft.AspNetCore.Mvc.ViewFeatures"; + + public const string ViewComponentSuffix = "ViewComponent"; + + public const string ViewComponentAttribute = "Microsoft.AspNetCore.Mvc.ViewComponentAttribute"; + + public const string NonViewComponentAttribute = "Microsoft.AspNetCore.Mvc.NonViewComponentAttribute"; + + public const string GenericTask = "System.Threading.Tasks.Task`1"; + + public const string Task = "System.Threading.Tasks.Task"; + + public const string ViewComponentNameKey = "ViewComponentName"; + + public const string AsyncMethodName = "InvokeAsync"; + + public const string SyncMethodName = "Invoke"; + + public static class ViewComponent + { + public const string Name = "Name"; + } + } +} diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/DefaultTagHelperResolverTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/DefaultTagHelperResolverTest.cs index 32295caa52..8c2ef107ac 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/DefaultTagHelperResolverTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/DefaultTagHelperResolverTest.cs @@ -16,16 +16,20 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces private static INamedTypeSymbol ITagHelperSymbol { get; } = Compilation.GetTypeByMetadataName(TagHelperTypes.ITagHelper); - private DefaultTagHelperResolver.Visitor TestVisitor => new DefaultTagHelperResolver.Visitor(ITagHelperSymbol, new List()); + // In practice MVC will provide a marker attribute for ViewComponents. To prevent a circular reference between MVC and Razor + // we can use a test class as a marker. + private static INamedTypeSymbol TestViewComponentAttributeSymbol { get; } = Compilation.GetTypeByMetadataName(typeof(TestViewComponentAttribute).FullName); + private static INamedTypeSymbol TestNonViewComponentAttributeSymbol { get; } = Compilation.GetTypeByMetadataName(typeof(TestNonViewComponentAttribute).FullName); [Fact] public void IsTagHelper_PlainTagHelper_ReturnsTrue() { // Arrange + var testVisitor = new DefaultTagHelperResolver.TagHelperVisitor(ITagHelperSymbol, new List()); var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Valid_PlainTagHelper).FullName); // Act - var isTagHelper = TestVisitor.IsTagHelper(tagHelperSymbol); + var isTagHelper = testVisitor.IsTagHelper(tagHelperSymbol); // Assert Assert.True(isTagHelper); @@ -35,10 +39,11 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces public void IsTagHelper_InheritedTagHelper_ReturnsTrue() { // Arrange + var testVisitor = new DefaultTagHelperResolver.TagHelperVisitor(ITagHelperSymbol, new List()); var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Valid_InheritedTagHelper).FullName); // Act - var isTagHelper = TestVisitor.IsTagHelper(tagHelperSymbol); + var isTagHelper = testVisitor.IsTagHelper(tagHelperSymbol); // Assert Assert.True(isTagHelper); @@ -48,10 +53,11 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces public void IsTagHelper_AbstractTagHelper_ReturnsFalse() { // Arrange + var testVisitor = new DefaultTagHelperResolver.TagHelperVisitor(ITagHelperSymbol, new List()); var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_AbstractTagHelper).FullName); // Act - var isTagHelper = TestVisitor.IsTagHelper(tagHelperSymbol); + var isTagHelper = testVisitor.IsTagHelper(tagHelperSymbol); // Assert Assert.False(isTagHelper); @@ -61,10 +67,11 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces public void IsTagHelper_GenericTagHelper_ReturnsFalse() { // Arrange + var testVisitor = new DefaultTagHelperResolver.TagHelperVisitor(ITagHelperSymbol, new List()); var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_GenericTagHelper<>).FullName); // Act - var isTagHelper = TestVisitor.IsTagHelper(tagHelperSymbol); + var isTagHelper = testVisitor.IsTagHelper(tagHelperSymbol); // Assert Assert.False(isTagHelper); @@ -74,10 +81,11 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces public void IsTagHelper_InternalTagHelper_ReturnsFalse() { // Arrange + var testVisitor = new DefaultTagHelperResolver.TagHelperVisitor(ITagHelperSymbol, new List()); var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_InternalTagHelper).FullName); // Act - var isTagHelper = TestVisitor.IsTagHelper(tagHelperSymbol); + var isTagHelper = testVisitor.IsTagHelper(tagHelperSymbol); // Assert Assert.False(isTagHelper); @@ -88,19 +96,177 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces { // Arrange var resolver = new DefaultTagHelperResolver(designTime: false); + var expectedTypeName = typeof(DefaultTagHelperResolver).FullName + "." + nameof(Invalid_NestedPublicTagHelper); // Act var descriptors = resolver.GetTagHelpers(Compilation); // Assert var matchingDescriptors = descriptors - .Where(descriptor => string.Equals(descriptor.TypeName, typeof(Invalid_NestedPublicTagHelper).FullName, StringComparison.Ordinal)); + .Where(descriptor => string.Equals(descriptor.TypeName, expectedTypeName, StringComparison.Ordinal)); Assert.Empty(matchingDescriptors); } + [Fact] + public void IsViewComponent_PlainViewComponent_ReturnsTrue() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Valid_PlainViewComponent).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.True(isViewComponent); + } + + [Fact] + public void IsViewComponent_DecoratedViewComponent_ReturnsTrue() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Valid_DecoratedVC).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.True(isViewComponent); + } + + [Fact] + public void IsViewComponent_InheritedViewComponent_ReturnsTrue() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Valid_InheritedVC).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.True(isViewComponent); + } + + [Fact] + public void IsViewComponent_AbstractViewComponent_ReturnsFalse() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_AbstractViewComponent).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.False(isViewComponent); + } + + [Fact] + public void IsViewComponent_GenericViewComponent_ReturnsFalse() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_GenericViewComponent<>).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.False(isViewComponent); + } + + [Fact] + public void IsViewComponent_InternalViewComponent_ReturnsFalse() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_InternalViewComponent).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.False(isViewComponent); + } + + [Fact] + public void GetTagHelpers_NestedViewComponentTagHelpersAreFound() + { + // Arrange + var resolver = new DefaultTagHelperResolver( + designTime: false, + viewComponentAssembly: typeof(DefaultTagHelperResolverTest).Assembly.GetName().Name); + var expectedTypeName = "__Generated__" + nameof(Valid_NestedPublicViewComponent) + "TagHelper"; + + // Act + var descriptors = resolver.GetTagHelpers(Compilation); + + // Assert + Assert.Single(descriptors, descriptor => string.Equals(descriptor.TypeName, expectedTypeName, StringComparison.Ordinal)); + } + + [Fact] + public void IsViewComponent_DecoratedNonViewComponent_ReturnsFalse() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_DecoratedViewComponent).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.False(isViewComponent); + } + + [Fact] + public void IsViewComponent_InheritedNonViewComponent_ReturnsFalse() + { + // Arrange + var testVisitor = new DefaultTagHelperResolver.ViewComponentVisitor( + TestViewComponentAttributeSymbol, + TestNonViewComponentAttributeSymbol, + new List()); + var tagHelperSymbol = Compilation.GetTypeByMetadataName(typeof(Invalid_InheritedViewComponent).FullName); + + // Act + var isViewComponent = testVisitor.IsViewComponent(tagHelperSymbol); + + // Assert + Assert.False(isViewComponent); + } + public class Invalid_NestedPublicTagHelper : TagHelper { } + + public class Valid_NestedPublicViewComponent + { + public string Invoke(string foo) => null; + } } public abstract class Invalid_AbstractTagHelper : TagHelper @@ -122,4 +288,47 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces public class Valid_InheritedTagHelper : Valid_PlainTagHelper { } + + public abstract class Invalid_AbstractViewComponent + { + } + + public class Invalid_GenericViewComponent + { + } + + internal class Invalid_InternalViewComponent + { + } + + public class Valid_PlainViewComponent + { + } + + [TestViewComponent] + public class Valid_DecoratedVC + { + } + + public class Valid_InheritedVC : Valid_DecoratedVC + { + } + + [TestNonViewComponent] + public class Invalid_DecoratedViewComponent + { + } + + [TestViewComponent] + public class Invalid_InheritedViewComponent : Invalid_DecoratedViewComponent + { + } + + public class TestViewComponentAttribute : Attribute + { + } + + public class TestNonViewComponentAttribute : Attribute + { + } } diff --git a/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ViewComponentTagHelperDescriptorFactoryTest.cs b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ViewComponentTagHelperDescriptorFactoryTest.cs new file mode 100644 index 0000000000..7a72acef90 --- /dev/null +++ b/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/ViewComponentTagHelperDescriptorFactoryTest.cs @@ -0,0 +1,200 @@ +// 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.Collections.Generic; +using Microsoft.AspNetCore.Razor.Evolution; +using Microsoft.CodeAnalysis.Razor.Workspaces.Test; +using Microsoft.CodeAnalysis.Razor.Workspaces.Test.Comparers; +using Xunit; + +namespace Microsoft.CodeAnalysis.Razor.Workspaces +{ + public class ViewComponentTagHelperDescriptorFactoryTest + { + [Fact] + public void CreateDescriptor_UnderstandsStringParameters() + { + // Arrange + var testCompilation = TestCompilation.Create(); + var viewComponent = testCompilation.GetTypeByMetadataName(typeof(StringParameterViewComponent).FullName); + var factory = new ViewComponentTagHelperDescriptorFactory(testCompilation); + var expectedDescriptor = new TagHelperDescriptor + { + TagName = "vc:string-parameter", + TypeName = "__Generated__StringParameterViewComponentTagHelper", + AssemblyName = typeof(StringParameterViewComponent).Assembly.GetName().Name, + Attributes = new List + { + new TagHelperAttributeDescriptor + { + Name = "foo", + PropertyName = "foo", + TypeName = typeof(string).FullName + }, + new TagHelperAttributeDescriptor + { + Name = "bar", + PropertyName = "bar", + TypeName = typeof(string).FullName + } + }, + RequiredAttributes = new List + { + new TagHelperRequiredAttributeDescriptor + { + Name = "foo" + }, + new TagHelperRequiredAttributeDescriptor + { + Name = "bar" + } + } + }; + expectedDescriptor.PropertyBag.Add(ViewComponentTypes.ViewComponentNameKey, "StringParameter"); + + // Act + var descriptor = factory.CreateDescriptor(viewComponent); + + // Assert + Assert.Equal(expectedDescriptor, descriptor, CaseSensitiveTagHelperDescriptorComparer.Default); + } + + [Fact] + public void CreateDescriptor_UnderstandsVariousParameterTypes() + { + // Arrange + var testCompilation = TestCompilation.Create(); + var viewComponent = testCompilation.GetTypeByMetadataName(typeof(VariousParameterViewComponent).FullName); + var factory = new ViewComponentTagHelperDescriptorFactory(testCompilation); + var expectedDescriptor = new TagHelperDescriptor + { + TagName = "vc:various-parameter", + TypeName = "__Generated__VariousParameterViewComponentTagHelper", + AssemblyName = typeof(VariousParameterViewComponent).Assembly.GetName().Name, + Attributes = new List + { + new TagHelperAttributeDescriptor + { + Name = "test-enum", + PropertyName = "testEnum", + TypeName = typeof(VariousParameterViewComponent).FullName + "." + nameof(VariousParameterViewComponent.TestEnum), + IsEnum = true + }, + + new TagHelperAttributeDescriptor + { + Name = "test-string", + PropertyName = "testString", + TypeName = typeof(string).FullName + }, + + new TagHelperAttributeDescriptor + { + Name = "baz", + PropertyName = "baz", + TypeName = typeof(int).FullName + } + }, + RequiredAttributes = new List + { + new TagHelperRequiredAttributeDescriptor + { + Name = "test-enum" + }, + + new TagHelperRequiredAttributeDescriptor + { + Name = "test-string" + }, + + new TagHelperRequiredAttributeDescriptor + { + Name = "baz" + } + } + }; + expectedDescriptor.PropertyBag.Add(ViewComponentTypes.ViewComponentNameKey, "VariousParameter"); + + // Act + var descriptor = factory.CreateDescriptor(viewComponent); + + // Assert + Assert.Equal(expectedDescriptor, descriptor, CaseSensitiveTagHelperDescriptorComparer.Default); + } + + [Fact] + public void CreateDescriptor_UnderstandsGenericParameters() + { + // Arrange + var testCompilation = TestCompilation.Create(); + var viewComponent = testCompilation.GetTypeByMetadataName(typeof(GenericParameterViewComponent).FullName); + var factory = new ViewComponentTagHelperDescriptorFactory(testCompilation); + var expectedDescriptor = new TagHelperDescriptor + { + TagName = "vc:generic-parameter", + TypeName = "__Generated__GenericParameterViewComponentTagHelper", + AssemblyName = typeof(GenericParameterViewComponent).Assembly.GetName().Name, + Attributes = new List + { + new TagHelperAttributeDescriptor + { + Name = "foo", + PropertyName = "Foo", + TypeName = "System.Collections.Generic.List" + }, + + new TagHelperAttributeDescriptor + { + Name = "bar", + PropertyName = "Bar", + TypeName = "System.Collections.Generic.Dictionary" + }, + + new TagHelperAttributeDescriptor + { + Name = "bar-", + PropertyName = "Bar", + TypeName = typeof(int).FullName, + IsIndexer = true + } + }, + RequiredAttributes = new List + { + new TagHelperRequiredAttributeDescriptor + { + Name = "foo" + } + } + }; + expectedDescriptor.PropertyBag.Add(ViewComponentTypes.ViewComponentNameKey, "GenericParameter"); + + // Act + var descriptor = factory.CreateDescriptor(viewComponent); + + // Assert + Assert.Equal(expectedDescriptor, descriptor, CaseSensitiveTagHelperDescriptorComparer.Default); + } + } + + public class StringParameterViewComponent + { + public string Invoke(string foo, string bar) => null; + } + + public class VariousParameterViewComponent + { + public string Invoke(TestEnum testEnum, string testString, int baz = 5) => null; + + public enum TestEnum + { + A = 1, + B = 2, + C = 3 + } + } + + public class GenericParameterViewComponent + { + public string Invoke(List Foo, Dictionary Bar) => null; + } +} \ No newline at end of file