From a289e04cb4a2d56b668df62e8260f5bb54944fb1 Mon Sep 17 00:00:00 2001 From: Ryan Nowak Date: Wed, 23 Nov 2016 11:35:18 -0800 Subject: [PATCH] Code dump of TagHelper discovery prototype Adds a new project for design time Razor analysis --- NuGet.config | 1 + Razor.sln | 17 +- .../Properties/AssemblyInfo.cs | 2 +- .../DefaultTagHelperDescriptorFactory.cs | 1054 +++++++++++++++++ .../DefaultTagHelperResolver.cs | 80 ++ .../DefaultTagHelperResolverFactory.cs | 17 + ...osoft.CodeAnalysis.Razor.Workspaces.csproj | 30 + .../RazorLanguage.cs | 10 + .../TagHelperResolver.cs | 18 + .../TagHelperTypes.cs | 34 + 10 files changed, 1261 insertions(+), 2 deletions(-) create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperDescriptorFactory.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolverFactory.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/Microsoft.CodeAnalysis.Razor.Workspaces.csproj create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorLanguage.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperResolver.cs create mode 100644 src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperTypes.cs diff --git a/NuGet.config b/NuGet.config index 826a1f9035..ddceba3fa4 100644 --- a/NuGet.config +++ b/NuGet.config @@ -1,6 +1,7 @@  + diff --git a/Razor.sln b/Razor.sln index d75d12a28b..0af37dded2 100644 --- a/Razor.sln +++ b/Razor.sln @@ -1,6 +1,6 @@ Microsoft Visual Studio Solution File, Format Version 12.00 # Visual Studio 15 -VisualStudioVersion = 15.0.26009.0 +VisualStudioVersion = 15.0.26014.0 MinimumVisualStudioVersion = 10.0.40219.1 Project("{2150E333-8FDC-42A3-9474-1A3956D46DE8}") = "src", "src", "{3C0D6505-79B3-49D0-B4C3-176F0F1836ED}" EndProject @@ -26,6 +26,8 @@ Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor. EndProject Project("{9A19103F-16F7-4668-BE54-9A1E7A4F7556}") = "Microsoft.AspNetCore.Razor.Runtime.Test", "test\Microsoft.AspNetCore.Razor.Runtime.Test\Microsoft.AspNetCore.Razor.Runtime.Test.csproj", "{277AB67E-9C8D-4799-A18C-C628E70A8664}" EndProject +Project("{FAE04EC0-301F-11D3-BF4B-00C04F79EFBC}") = "Microsoft.CodeAnalysis.Razor.Workspaces", "src\Microsoft.CodeAnalysis.Razor.Workspaces\Microsoft.CodeAnalysis.Razor.Workspaces.csproj", "{0F265874-C592-448B-BC4F-3430AB03E0DC}" +EndProject Global GlobalSection(SolutionConfigurationPlatforms) = preSolution Debug|Any CPU = Debug|Any CPU @@ -120,6 +122,18 @@ Global {277AB67E-9C8D-4799-A18C-C628E70A8664}.Release|x64.Build.0 = Release|x64 {277AB67E-9C8D-4799-A18C-C628E70A8664}.Release|x86.ActiveCfg = Release|x86 {277AB67E-9C8D-4799-A18C-C628E70A8664}.Release|x86.Build.0 = Release|x86 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Debug|Any CPU.ActiveCfg = Debug|Any CPU + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Debug|Any CPU.Build.0 = Debug|Any CPU + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Debug|x64.ActiveCfg = Debug|x64 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Debug|x64.Build.0 = Debug|x64 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Debug|x86.ActiveCfg = Debug|x86 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Debug|x86.Build.0 = Debug|x86 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Release|Any CPU.ActiveCfg = Release|Any CPU + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Release|Any CPU.Build.0 = Release|Any CPU + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Release|x64.ActiveCfg = Release|x64 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Release|x64.Build.0 = Release|x64 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Release|x86.ActiveCfg = Release|x86 + {0F265874-C592-448B-BC4F-3430AB03E0DC}.Release|x86.Build.0 = Release|x86 EndGlobalSection GlobalSection(SolutionProperties) = preSolution HideSolutionNode = FALSE @@ -132,5 +146,6 @@ Global {932F3C9C-A6C0-40D3-BA50-9309886242FC} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED} {969357A4-CCF1-46D9-B002-9AA072AFC75C} = {92463391-81BE-462B-AC3C-78C6C760741F} {277AB67E-9C8D-4799-A18C-C628E70A8664} = {92463391-81BE-462B-AC3C-78C6C760741F} + {0F265874-C592-448B-BC4F-3430AB03E0DC} = {3C0D6505-79B3-49D0-B4C3-176F0F1836ED} EndGlobalSection EndGlobal diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Properties/AssemblyInfo.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Properties/AssemblyInfo.cs index e053d27350..7d35a13254 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/Properties/AssemblyInfo.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Properties/AssemblyInfo.cs @@ -2,6 +2,6 @@ // Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. using System.Runtime.CompilerServices; - +[assembly: InternalsVisibleTo("Microsoft.CodeAnalysis.Razor.Workspaces, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("Microsoft.AspNetCore.Razor.Evolution.Test, PublicKey=0024000004800000940000000602000000240000525341310004000001000100f33a29044fa9d740c9b3213a93e57c84b472c84e0b8a0e1ae48e67a9f8f6de9d5f7f3d52ac23e48ac51801f1dc950abe901da34d2a9e3baadb141a17c77ef3c565dd5ee5054b91cf63bb3c6ab83f72ab3aafe93d0fc3c2348b764fafb0b1c0733de51459aeab46580384bf9d74c4e28164b7cde247f891ba07891c9d872ad2bb")] [assembly: InternalsVisibleTo("DynamicProxyGenAssembly2, PublicKey=0024000004800000940000000602000000240000525341310004000001000100c547cac37abd99c8db225ef2f6c8a3602f3b3606cc9891605d02baa56104f4cfc0734aa39b93bf7852f7d9266654753cc297e7d2edfe0bac1cdcf9f717241550e0a7b191195b7667bb4f64bcb8e2121380fd1d9d46ad2d92d2d15605093924cceaf74c4861eff62abf69b9291ed0a340e113be11e6a7d3113e92484cf7045cc7")] diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperDescriptorFactory.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperDescriptorFactory.cs new file mode 100644 index 0000000000..6c61d06b54 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperDescriptorFactory.cs @@ -0,0 +1,1054 @@ +// 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.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using Microsoft.CodeAnalysis; +using Microsoft.AspNetCore.Razor.Evolution.Legacy; +using Microsoft.AspNetCore.Razor.Evolution; + +namespace Microsoft.CodeAnalysis.Razor +{ + internal class DefaultTagHelperDescriptorFactory + { + private const string DataDashPrefix = "data-"; + private const string TagHelperNameEnding = "TagHelper"; + private const string HtmlCaseRegexReplacement = "-$1$2"; + private const char RequiredAttributeWildcardSuffix = '*'; + + // This matches the following AFTER the start of the input string (MATCH). + // Any letter/number followed by an uppercase letter then lowercase letter: 1(Aa), a(Aa), A(Aa) + // Any lowercase letter followed by an uppercase letter: a(A) + // Each match is then prefixed by a "-" via the ToHtmlCase method. + private static readonly Regex HtmlCaseRegex = + new Regex( + "(? InvalidNonWhitespaceNameCharacters { get; } = new HashSet( + new[] { '@', '!', '<', '/', '?', '[', '>', ']', '=', '"', '\'', '*' }); + + public DefaultTagHelperDescriptorFactory(Compilation compilation) + { + Compilation = compilation; + + _htmlAttributeNameAttributeSymbol = compilation.GetTypeByMetadataName(TagHelperTypes.HtmlAttributeNameAttribute); + _htmlAttributeNotBoundAttributeSymbol = compilation.GetTypeByMetadataName(TagHelperTypes.HtmlAttributeNotBoundAttribute); + _htmlTargetElementAttributeSymbol = compilation.GetTypeByMetadataName(TagHelperTypes.HtmlTargetElementAttribute); + _restrictChildrenAttributeSymbol = compilation.GetTypeByMetadataName(TagHelperTypes.RestrictChildrenAttribute); + + _iDictionarySymbol = Compilation.GetTypeByMetadataName(TagHelperTypes.IDictionary); + } + + protected Compilation Compilation { get; } + + /// + public virtual IEnumerable CreateDescriptors( + string assemblyName, + INamedTypeSymbol type, + ErrorSink errorSink) + { + if (type == null) + { + throw new ArgumentNullException(nameof(type)); + } + + if (errorSink == null) + { + throw new ArgumentNullException(nameof(errorSink)); + } + + var attributeDescriptors = GetAttributeDescriptors(type, errorSink); + var targetElementAttributes = GetValidHtmlTargetElementAttributes(type, errorSink); + var allowedChildren = GetAllowedChildren(type, errorSink); + + var tagHelperDescriptors = + BuildTagHelperDescriptors( + type, + assemblyName, + attributeDescriptors, + targetElementAttributes, + allowedChildren); + + return tagHelperDescriptors.Distinct(TagHelperDescriptorComparer.Default); + } + + private IEnumerable GetValidHtmlTargetElementAttributes( + INamedTypeSymbol typeSymbol, + ErrorSink errorSink) + { + var targetElementAttributes = typeSymbol.GetAttributes().Where(a => a.AttributeClass == _htmlTargetElementAttributeSymbol); + return targetElementAttributes.Where(a => ValidHtmlTargetElementAttributeNames(a, errorSink)); + } + + private IEnumerable BuildTagHelperDescriptors( + INamedTypeSymbol type, + string assemblyName, + IEnumerable attributeDescriptors, + IEnumerable targetElementAttributes, + IEnumerable allowedChildren) + { + TagHelperDesignTimeDescriptor typeDesignTimeDescriptor = null; + + var typeName = GetFullName(type); + + // If there isn't an attribute specifying the tag name derive it from the name + if (!targetElementAttributes.Any()) + { + var name = type.Name; + + if (name.EndsWith(TagHelperNameEnding, StringComparison.OrdinalIgnoreCase)) + { + name = name.Substring(0, name.Length - TagHelperNameEnding.Length); + } + + return new[] + { + BuildTagHelperDescriptor( + ToHtmlCase(name), + typeName, + assemblyName, + attributeDescriptors, + requiredAttributeDescriptors: Enumerable.Empty(), + allowedChildren: allowedChildren, + tagStructure: default(TagStructure), + parentTag: null, + designTimeDescriptor: typeDesignTimeDescriptor) + }; + } + + return targetElementAttributes.Select( + attribute => + BuildTagHelperDescriptor( + typeName, + assemblyName, + attributeDescriptors, + attribute, + allowedChildren, + typeDesignTimeDescriptor)); + } + + private IEnumerable GetAllowedChildren(INamedTypeSymbol type, ErrorSink errorSink) + { + var restrictChildrenAttribute = type.GetAttributes().Where(a => a.AttributeClass == _restrictChildrenAttributeSymbol).FirstOrDefault(); + if (restrictChildrenAttribute == null) + { + return null; + } + + var allowedChildren = new List(); + allowedChildren.Add((string)restrictChildrenAttribute.ConstructorArguments[0].Value); + + if (restrictChildrenAttribute.ConstructorArguments.Length == 2) + { + foreach (var value in restrictChildrenAttribute.ConstructorArguments[1].Values) + { + allowedChildren.Add((string)value.Value); + } + } + + var validAllowedChildren = GetValidAllowedChildren(allowedChildren, GetFullName(type), errorSink); + + if (validAllowedChildren.Any()) + { + return validAllowedChildren; + } + else + { + // All allowed children were invalid, return null to indicate that any child is acceptable. + return null; + } + } + + // Internal for unit testing + internal static IEnumerable GetValidAllowedChildren( + IEnumerable allowedChildren, + string tagHelperName, + ErrorSink errorSink) + { + var validAllowedChildren = new List(); + + foreach (var name in allowedChildren) + { + var valid = TryValidateName( + name, + whitespaceError: "invalid", + characterErrorBuilder: (invalidCharacter) => "invalid", + errorSink: errorSink); + + if (valid) + { + validAllowedChildren.Add(name); + } + } + + return validAllowedChildren; + } + + private static TagHelperDescriptor BuildTagHelperDescriptor( + string typeName, + string assemblyName, + IEnumerable attributeDescriptors, + AttributeData targetElementAttribute, + IEnumerable allowedChildren, + TagHelperDesignTimeDescriptor designTimeDescriptor) + { + IEnumerable requiredAttributeDescriptors; + TryGetRequiredAttributeDescriptors( + HtmlTargetElementAttribute_Attributes(targetElementAttribute), + errorSink: null, + descriptors: out requiredAttributeDescriptors); + + return BuildTagHelperDescriptor( + HtmlTargetElementAttribute_Tag(targetElementAttribute), + typeName, + assemblyName, + attributeDescriptors, + requiredAttributeDescriptors, + allowedChildren, + HtmlTargetElementAttribute_ParentTag(targetElementAttribute), + HtmlTargetElementAttribute_TagStructure(targetElementAttribute), + designTimeDescriptor); + } + + private static string HtmlTargetElementAttribute_Attributes(AttributeData attibute) + { + foreach (var kvp in attibute.NamedArguments) + { + if (kvp.Key == TagHelperTypes.HtmlTargetElement.Attributes) + { + return (string)kvp.Value.Value; + } + } + + return null; + } + + private static string HtmlTargetElementAttribute_ParentTag(AttributeData attibute) + { + foreach (var kvp in attibute.NamedArguments) + { + if (kvp.Key == TagHelperTypes.HtmlTargetElement.ParentTag) + { + return (string)kvp.Value.Value; + } + } + + return null; + } + + private static string HtmlTargetElementAttribute_Tag(AttributeData attibute) + { + if (attibute.ConstructorArguments.Length == 0) + { + return null; + } + else + { + return (string)attibute.ConstructorArguments[0].Value; + } + } + + private static TagStructure HtmlTargetElementAttribute_TagStructure(AttributeData attibute) + { + foreach (var kvp in attibute.NamedArguments) + { + if (kvp.Key == TagHelperTypes.HtmlTargetElement.TagStructure) + { + return (TagStructure)kvp.Value.Value; + } + } + + return TagStructure.Unspecified; + } + + private static TagHelperDescriptor BuildTagHelperDescriptor( + string tagName, + string typeName, + string assemblyName, + IEnumerable attributeDescriptors, + IEnumerable requiredAttributeDescriptors, + IEnumerable allowedChildren, + string parentTag, + TagStructure tagStructure, + TagHelperDesignTimeDescriptor designTimeDescriptor) + { + return new TagHelperDescriptor + { + TagName = tagName, + TypeName = typeName, + AssemblyName = assemblyName, + Attributes = attributeDescriptors, + RequiredAttributes = requiredAttributeDescriptors, + AllowedChildren = allowedChildren, + RequiredParent = parentTag, + TagStructure = tagStructure, + DesignTimeDescriptor = designTimeDescriptor + }; + } + + /// + /// Internal for testing. + /// + internal static bool ValidHtmlTargetElementAttributeNames( + AttributeData attribute, + ErrorSink errorSink) + { + var validTagName = ValidateName(HtmlTargetElementAttribute_Tag(attribute), targetingAttributes: false, errorSink: errorSink); + IEnumerable requiredAttributeDescriptors; + var validRequiredAttributes = TryGetRequiredAttributeDescriptors(HtmlTargetElementAttribute_Attributes(attribute), errorSink, out requiredAttributeDescriptors); + var validParentTagName = ValidateParentTagName(HtmlTargetElementAttribute_ParentTag(attribute), errorSink); + + return validTagName && validRequiredAttributes && validParentTagName; + } + + /// + /// Internal for unit testing. + /// + internal static bool ValidateParentTagName(string parentTag, ErrorSink errorSink) + { + return parentTag == null || + TryValidateName( + parentTag, + "invalid", + characterErrorBuilder: (invalidCharacter) => "invalid", + errorSink: errorSink); + } + + private static bool TryGetRequiredAttributeDescriptors( + string requiredAttributes, + ErrorSink errorSink, + out IEnumerable descriptors) + { + var parser = new RequiredAttributeParser(requiredAttributes); + + return parser.TryParse(errorSink, out descriptors); + } + + private static bool ValidateName(string name, bool targetingAttributes, ErrorSink errorSink) + { + if (!targetingAttributes && + string.Equals( + name, + "*", + StringComparison.OrdinalIgnoreCase)) + { + // '*' as the entire name is OK in the HtmlTargetElement catch-all case. + return true; + } + + var targetName = targetingAttributes ? "invalid" : "invalid"; + + var validName = TryValidateName( + name, + whitespaceError: "invalid", + characterErrorBuilder: (invalidCharacter) => "invalid", + errorSink: errorSink); + + return validName; + } + + private static bool TryValidateName( + string name, + string whitespaceError, + Func characterErrorBuilder, + ErrorSink errorSink) + { + var validName = true; + + if (string.IsNullOrWhiteSpace(name)) + { + errorSink.OnError(SourceLocation.Zero, whitespaceError, length: 0); + + validName = false; + } + else + { + foreach (var character in name) + { + if (char.IsWhiteSpace(character) || + InvalidNonWhitespaceNameCharacters.Contains(character)) + { + var error = characterErrorBuilder(character); + errorSink.OnError(SourceLocation.Zero, error, length: 0); + + validName = false; + } + } + } + + return validName; + } + + private IEnumerable GetAttributeDescriptors(INamedTypeSymbol type, ErrorSink errorSink) + { + + var attributeDescriptors = new List(); + + // Keep indexer descriptors separate to avoid sorting the combined list later. + var indexerDescriptors = new List(); + + var accessibleProperties = type.GetMembers().OfType().Where(IsAccessibleProperty); + foreach (var property in accessibleProperties) + { + var attributeNameAttribute = property + .GetAttributes() + .Where(a => a.AttributeClass == _htmlAttributeNameAttributeSymbol) + .FirstOrDefault(); + + bool hasExplicitName; + string attributeName; + if (attributeNameAttribute == null || + attributeNameAttribute.ConstructorArguments.Length == 0 || + string.IsNullOrEmpty((string)attributeNameAttribute.ConstructorArguments[0].Value)) + { + hasExplicitName = false; + attributeName = ToHtmlCase(property.Name); + } + else + { + hasExplicitName = true; + attributeName = (string)attributeNameAttribute.ConstructorArguments[0].Value; + } + + TagHelperAttributeDescriptor mainDescriptor = null; + if (property.SetMethod != null && property.SetMethod.DeclaredAccessibility == Accessibility.Public) + { + mainDescriptor = ToAttributeDescriptor(property, attributeName); + if (!ValidateTagHelperAttributeDescriptor(mainDescriptor, type, errorSink)) + { + // HtmlAttributeNameAttribute.Name is invalid. Ignore this property completely. + continue; + } + } + else if (hasExplicitName) + { + // Specified HtmlAttributeNameAttribute.Name though property has no public setter. + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + continue; + } + + bool isInvalid; + var indexerDescriptor = ToIndexerAttributeDescriptor( + property, + attributeNameAttribute, + parentType: type, + errorSink: errorSink, + defaultPrefix: attributeName + "-", + isInvalid: out isInvalid); + if (indexerDescriptor != null && + !ValidateTagHelperAttributeDescriptor(indexerDescriptor, type, errorSink)) + { + isInvalid = true; + } + + if (isInvalid) + { + // The property type or HtmlAttributeNameAttribute.DictionaryAttributePrefix (or perhaps the + // HTML-casing of the property name) is invalid. Ignore this property completely. + continue; + } + + if (mainDescriptor != null) + { + attributeDescriptors.Add(mainDescriptor); + } + + if (indexerDescriptor != null) + { + indexerDescriptors.Add(indexerDescriptor); + } + } + + attributeDescriptors.AddRange(indexerDescriptors); + + return attributeDescriptors; + } + + // Internal for testing. + internal static bool ValidateTagHelperAttributeDescriptor( + TagHelperAttributeDescriptor attributeDescriptor, + INamedTypeSymbol parentType, + ErrorSink errorSink) + { + string nameOrPrefix; + if (attributeDescriptor.IsIndexer) + { + nameOrPrefix = "invalid"; + } + else if (string.IsNullOrEmpty(attributeDescriptor.Name)) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + + return false; + } + else + { + nameOrPrefix = "invalid"; + } + + return ValidateTagHelperAttributeNameOrPrefix( + attributeDescriptor.Name, + parentType, + attributeDescriptor.PropertyName, + errorSink, + nameOrPrefix); + } + + private static bool ValidateTagHelperAttributeNameOrPrefix( + string attributeNameOrPrefix, + INamedTypeSymbol parentType, + string propertyName, + ErrorSink errorSink, + string nameOrPrefix) + { + if (string.IsNullOrEmpty(attributeNameOrPrefix)) + { + // ValidateTagHelperAttributeDescriptor validates Name is non-null and non-empty. The empty string is + // valid for DictionaryAttributePrefix and null is impossible at this point because it means "don't + // create a descriptor". (Empty DictionaryAttributePrefix is a corner case which would bind every + // attribute of a target element. Likely not particularly useful but unclear what minimum length + // should be required and what scenarios a minimum length would break.) + return true; + } + + if (string.IsNullOrWhiteSpace(attributeNameOrPrefix)) + { + // Provide a single error if the entire name is whitespace, not an error per character. + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + + return false; + } + + // data-* attributes are explicitly not implemented by user agents and are not intended for use on + // the server; therefore it's invalid for TagHelpers to bind to them. + if (attributeNameOrPrefix.StartsWith(DataDashPrefix, StringComparison.OrdinalIgnoreCase)) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + + return false; + } + + var isValid = true; + foreach (var character in attributeNameOrPrefix) + { + if (char.IsWhiteSpace(character) || InvalidNonWhitespaceNameCharacters.Contains(character)) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + + isValid = false; + } + } + + return isValid; + } + + private TagHelperAttributeDescriptor ToAttributeDescriptor(IPropertySymbol property, string attributeName) + { + return ToAttributeDescriptor( + property, + attributeName, + GetFullName(property.Type), + isIndexer: false, + isStringProperty: property.Type.SpecialType == SpecialType.System_String); + } + + private TagHelperAttributeDescriptor ToIndexerAttributeDescriptor( + IPropertySymbol property, + AttributeData attributeNameAttribute, + INamedTypeSymbol parentType, + ErrorSink errorSink, + string defaultPrefix, + out bool isInvalid) + { + isInvalid = false; + var hasPublicSetter = property.SetMethod != null && property.SetMethod.DeclaredAccessibility == Accessibility.Public; + + + string dictionaryAttributePrefix = null; + bool dictionaryAttributePrefixSet = false; + + if (attributeNameAttribute != null) + { + foreach (var argument in attributeNameAttribute.NamedArguments) + { + if (argument.Key == TagHelperTypes.HtmlAttributeName.DictionaryAttributePrefix) + { + dictionaryAttributePrefix = (string)argument.Value.Value; + dictionaryAttributePrefixSet = true; + break; + } + } + } + + INamedTypeSymbol dictionaryType; + if ((property.Type as INamedTypeSymbol)?.ConstructedFrom == _iDictionarySymbol) + { + dictionaryType = (INamedTypeSymbol)property.Type; + } + else if (property.Type.AllInterfaces.Any(s => s.ConstructedFrom == _iDictionarySymbol)) + { + dictionaryType = property.Type.AllInterfaces.First(s => s.ConstructedFrom == _iDictionarySymbol); + } + else + { + dictionaryType = null; + } + + if (dictionaryType == null || + dictionaryType.TypeArguments[0].SpecialType != SpecialType.System_String) + { + if (dictionaryAttributePrefix != null) + { + // DictionaryAttributePrefix is not supported unless associated with an + // IDictionary property. + isInvalid = true; + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + } + else if (attributeNameAttribute != null && !hasPublicSetter) + { + // Associated an HtmlAttributeNameAttribute with a non-dictionary property that lacks a public + // setter. + isInvalid = true; + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + } + + return null; + } + else if ( + !hasPublicSetter && + attributeNameAttribute != null && + !dictionaryAttributePrefixSet) + { + // Must set DictionaryAttributePrefix when using HtmlAttributeNameAttribute with a dictionary property + // that lacks a public setter. + isInvalid = true; + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + + return null; + } + + // Potential prefix case. Use default prefix (based on name)? + var useDefault = attributeNameAttribute == null || !dictionaryAttributePrefixSet; + + var prefix = useDefault ? defaultPrefix : dictionaryAttributePrefix; + if (prefix == null) + { + // DictionaryAttributePrefix explicitly set to null. Ignore. + return null; + } + + return ToAttributeDescriptor( + property, + attributeName: prefix, + typeName: dictionaryType == null ? null : GetFullName(dictionaryType.TypeArguments[1]), + isIndexer: true, + isStringProperty: dictionaryType == null ? false : dictionaryType.TypeArguments[1].SpecialType == SpecialType.System_String); + } + + private TagHelperAttributeDescriptor ToAttributeDescriptor( + IPropertySymbol property, + string attributeName, + string typeName, + bool isIndexer, + bool isStringProperty) + { + return new TagHelperAttributeDescriptor + { + Name = attributeName, + PropertyName = property.Name, + IsEnum = property.Type.TypeKind == TypeKind.Enum, + TypeName = typeName, + IsStringProperty = isStringProperty, + IsIndexer = isIndexer, + }; + } + + private bool IsAccessibleProperty(IPropertySymbol property) + { + // Accessible properties are those with public getters and without [HtmlAttributeNotBound]. + return property.Parameters.Length == 0 && + property.GetMethod != null && + property.GetMethod.DeclaredAccessibility == Accessibility.Public && + property.GetAttributes().Where(a => a.AttributeClass == _htmlAttributeNotBoundAttributeSymbol).FirstOrDefault() == null; + } + + /// + /// Converts from pascal/camel case to lower kebab-case. + /// + /// + /// SomeThing => some-thing + /// capsONInside => caps-on-inside + /// CAPSOnOUTSIDE => caps-on-outside + /// ALLCAPS => allcaps + /// One1Two2Three3 => one1-two2-three3 + /// ONE1TWO2THREE3 => one1two2three3 + /// First_Second_ThirdHi => first_second_third-hi + /// + private static string ToHtmlCase(string name) + { + return HtmlCaseRegex.Replace(name, HtmlCaseRegexReplacement).ToLowerInvariant(); + } + + private static string GetFullName(ITypeSymbol type) + { + return type.ContainingNamespace.ToDisplayString() + "." + type.Name; + } + + // Internal for testing + internal class RequiredAttributeParser + { + private static readonly IReadOnlyDictionary CssValueComparisons = + new Dictionary + { + { '=', TagHelperRequiredAttributeValueComparison.FullMatch }, + { '^', TagHelperRequiredAttributeValueComparison.PrefixMatch }, + { '$', TagHelperRequiredAttributeValueComparison.SuffixMatch } + }; + private static readonly char[] InvalidPlainAttributeNameCharacters = { ' ', '\t', ',', RequiredAttributeWildcardSuffix }; + private static readonly char[] InvalidCssAttributeNameCharacters = (new[] { ' ', '\t', ',', ']' }) + .Concat(CssValueComparisons.Keys) + .ToArray(); + private static readonly char[] InvalidCssQuotelessValueCharacters = { ' ', '\t', ']' }; + + private int _index; + private string _requiredAttributes; + + public RequiredAttributeParser(string requiredAttributes) + { + _requiredAttributes = requiredAttributes; + } + + private char Current => _requiredAttributes[_index]; + + private bool AtEnd => _index >= _requiredAttributes.Length; + + public bool TryParse( + ErrorSink errorSink, + out IEnumerable requiredAttributeDescriptors) + { + if (string.IsNullOrEmpty(_requiredAttributes)) + { + requiredAttributeDescriptors = Enumerable.Empty(); + return true; + } + + requiredAttributeDescriptors = null; + var descriptors = new List(); + + PassOptionalWhitespace(); + + do + { + TagHelperRequiredAttributeDescriptor descriptor; + if (At('[')) + { + descriptor = ParseCssSelector(errorSink); + } + else + { + descriptor = ParsePlainSelector(errorSink); + } + + if (descriptor == null) + { + // Failed to create the descriptor due to an invalid required attribute. + return false; + } + else + { + descriptors.Add(descriptor); + } + + PassOptionalWhitespace(); + + if (At(',')) + { + _index++; + + if (!EnsureNotAtEnd(errorSink)) + { + return false; + } + } + else if (!AtEnd) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + return false; + } + + PassOptionalWhitespace(); + } + while (!AtEnd); + + requiredAttributeDescriptors = descriptors; + return true; + } + + private TagHelperRequiredAttributeDescriptor ParsePlainSelector(ErrorSink errorSink) + { + var nameEndIndex = _requiredAttributes.IndexOfAny(InvalidPlainAttributeNameCharacters, _index); + string attributeName; + + var nameComparison = TagHelperRequiredAttributeNameComparison.FullMatch; + if (nameEndIndex == -1) + { + attributeName = _requiredAttributes.Substring(_index); + _index = _requiredAttributes.Length; + } + else + { + attributeName = _requiredAttributes.Substring(_index, nameEndIndex - _index); + _index = nameEndIndex; + + if (_requiredAttributes[nameEndIndex] == RequiredAttributeWildcardSuffix) + { + nameComparison = TagHelperRequiredAttributeNameComparison.PrefixMatch; + + // Move past wild card + _index++; + } + } + + TagHelperRequiredAttributeDescriptor descriptor = null; + if (ValidateName(attributeName, targetingAttributes: true, errorSink: errorSink)) + { + descriptor = new TagHelperRequiredAttributeDescriptor + { + Name = attributeName, + NameComparison = nameComparison + }; + } + + return descriptor; + } + + private string ParseCssAttributeName(ErrorSink errorSink) + { + var nameStartIndex = _index; + var nameEndIndex = _requiredAttributes.IndexOfAny(InvalidCssAttributeNameCharacters, _index); + nameEndIndex = nameEndIndex == -1 ? _requiredAttributes.Length : nameEndIndex; + _index = nameEndIndex; + + var attributeName = _requiredAttributes.Substring(nameStartIndex, nameEndIndex - nameStartIndex); + + return attributeName; + } + + private TagHelperRequiredAttributeValueComparison? ParseCssValueComparison(ErrorSink errorSink) + { + Debug.Assert(!AtEnd); + TagHelperRequiredAttributeValueComparison valueComparison; + + if (CssValueComparisons.TryGetValue(Current, out valueComparison)) + { + var op = Current; + _index++; + + if (op != '=' && At('=')) + { + // Two length operator (ex: ^=). Move past the second piece + _index++; + } + else if (op != '=') // We're at an incomplete operator (ex: [foo^] + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + return null; + } + } + else if (!At(']')) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + return null; + } + + return valueComparison; + } + + private string ParseCssValue(ErrorSink errorSink) + { + int valueStart; + int valueEnd; + if (At('\'') || At('"')) + { + var quote = Current; + + // Move past the quote + _index++; + + valueStart = _index; + valueEnd = _requiredAttributes.IndexOf(quote, _index); + if (valueEnd == -1) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + return null; + } + _index = valueEnd + 1; + } + else + { + valueStart = _index; + var valueEndIndex = _requiredAttributes.IndexOfAny(InvalidCssQuotelessValueCharacters, _index); + valueEnd = valueEndIndex == -1 ? _requiredAttributes.Length : valueEndIndex; + _index = valueEnd; + } + + var value = _requiredAttributes.Substring(valueStart, valueEnd - valueStart); + + return value; + } + + private TagHelperRequiredAttributeDescriptor ParseCssSelector(ErrorSink errorSink) + { + Debug.Assert(At('[')); + + // Move past '['. + _index++; + PassOptionalWhitespace(); + + var attributeName = ParseCssAttributeName(errorSink); + + PassOptionalWhitespace(); + + if (!EnsureNotAtEnd(errorSink)) + { + return null; + } + + if (!ValidateName(attributeName, targetingAttributes: true, errorSink: errorSink)) + { + // Couldn't parse a valid attribute name. + return null; + } + + var valueComparison = ParseCssValueComparison(errorSink); + + if (!valueComparison.HasValue) + { + return null; + } + + PassOptionalWhitespace(); + + if (!EnsureNotAtEnd(errorSink)) + { + return null; + } + + var value = ParseCssValue(errorSink); + + if (value == null) + { + // Couldn't parse value + return null; + } + + PassOptionalWhitespace(); + + if (At(']')) + { + // Move past the ending bracket. + _index++; + } + else if (AtEnd) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + return null; + } + else + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + return null; + } + + return new TagHelperRequiredAttributeDescriptor + { + Name = attributeName, + NameComparison = TagHelperRequiredAttributeNameComparison.FullMatch, + Value = value, + ValueComparison = valueComparison.Value, + }; + } + + private bool EnsureNotAtEnd(ErrorSink errorSink) + { + if (AtEnd) + { + errorSink.OnError( + SourceLocation.Zero, + "invalid", + length: 0); + + return false; + } + + return true; + } + + private bool At(char c) + { + return !AtEnd && Current == c; + } + + private void PassOptionalWhitespace() + { + while (!AtEnd && (Current == ' ' || Current == '\t')) + { + _index++; + } + } + } + } +} \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs new file mode 100644 index 0000000000..14bab8704d --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolver.cs @@ -0,0 +1,80 @@ +// 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 Microsoft.AspNetCore.Razor.Evolution.Legacy; +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; + +namespace Microsoft.CodeAnalysis.Razor +{ + internal class DefaultTagHelperResolver : TagHelperResolver + { + public override async Task> GetTagHelpersAsync(Project project, CancellationToken cancellationToken = default(CancellationToken)) + { + var compilation = await project.GetCompilationAsync(cancellationToken).ConfigureAwait(false); + + var results = new List(); + + // If ITagHelper isn't defined, then we couldn't possibly find anything. + var @interface = compilation.GetTypeByMetadataName(TagHelperTypes.ITagHelper); + if (@interface == null) + { + return results; + } + + var types = new List(); + var visitor = new Visitor(@interface, types); + + visitor.Visit(compilation.Assembly.GlobalNamespace); + + foreach (var reference in compilation.References) + { + var assembly = compilation.GetAssemblyOrModuleSymbol(reference) as IAssemblySymbol; + if (assembly != null) + { + visitor.Visit(compilation.Assembly.GlobalNamespace); + } + } + + var errors = new ErrorSink(); + var factory = new DefaultTagHelperDescriptorFactory(compilation); + + foreach (var type in types) + { + results.AddRange(factory.CreateDescriptors(type.ContainingAssembly.Identity.GetDisplayName(), type, errors)); + } + + return results; + } + + // Visits top-level types and finds interface implementations. + private class Visitor : SymbolVisitor + { + private INamedTypeSymbol _interface; + private List _results; + + public Visitor(INamedTypeSymbol @interface, List results) + { + _interface = @interface; + _results = results; + } + + public override void VisitNamedType(INamedTypeSymbol symbol) + { + if (symbol.AllInterfaces.Contains(_interface)) + { + _results.Add(symbol); + } + } + + public override void VisitNamespace(INamespaceSymbol symbol) + { + foreach (var member in symbol.GetMembers()) + { + Visit(member); + } + } + } + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolverFactory.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolverFactory.cs new file mode 100644 index 0000000000..38e091cd0f --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/DefaultTagHelperResolverFactory.cs @@ -0,0 +1,17 @@ +// 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 Microsoft.CodeAnalysis.Host; +using Microsoft.CodeAnalysis.Host.Mef; + +namespace Microsoft.CodeAnalysis.Razor +{ + [ExportLanguageServiceFactory(typeof(TagHelperResolver), RazorLanguage.Name, ServiceLayer.Default)] + internal class DefaultTagHelperResolverFactory : ILanguageServiceFactory + { + public ILanguageService CreateLanguageService(HostLanguageServices languageServices) + { + return new DefaultTagHelperResolver(); + } + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/Microsoft.CodeAnalysis.Razor.Workspaces.csproj b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Microsoft.CodeAnalysis.Razor.Workspaces.csproj new file mode 100644 index 0000000000..f6c1a47d9f --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/Microsoft.CodeAnalysis.Razor.Workspaces.csproj @@ -0,0 +1,30 @@ + + + + Razor is a markup syntax for adding server-side logic to web pages. This package contains the Razor design-time infrastructure. + net46 + $(NoWarn);CS1591 + true + aspnetcore;cshtml;razor + + + Microsoft.CodeAnalysis.Razor + + + + + + + $(PackageTargetFallback);portable-net45+win8+wp8+wpa81; + + + + + + + + + + + + \ No newline at end of file diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorLanguage.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorLanguage.cs new file mode 100644 index 0000000000..5c993368cc --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorLanguage.cs @@ -0,0 +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. + +namespace Microsoft.CodeAnalysis.Razor +{ + public static class RazorLanguage + { + public const string Name = "Razor"; + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperResolver.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperResolver.cs new file mode 100644 index 0000000000..77bf153a40 --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperResolver.cs @@ -0,0 +1,18 @@ +// 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 System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Host; +using Microsoft.AspNetCore.Razor.Evolution.Legacy; + +namespace Microsoft.CodeAnalysis.Razor +{ + internal abstract class TagHelperResolver : ILanguageService + { + public abstract Task> GetTagHelpersAsync( + Project project, + CancellationToken cancellationToken = default(CancellationToken)); + } +} diff --git a/src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperTypes.cs b/src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperTypes.cs new file mode 100644 index 0000000000..4a65817e7e --- /dev/null +++ b/src/Microsoft.CodeAnalysis.Razor.Workspaces/TagHelperTypes.cs @@ -0,0 +1,34 @@ +// 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 TagHelperTypes + { + public const string ITagHelper = "Microsoft.AspNetCore.Razor.TagHelpers.ITagHelper"; + + public const string IDictionary = "System.Collections.Generic.IDictionary`2"; + + public const string HtmlAttributeNameAttribute = "Microsoft.AspNetCore.Razor.TagHelpers.HtmlAttributeNameAttribute"; + + public const string HtmlAttributeNotBoundAttribute = "Microsoft.AspNetCore.Razor.TagHelpers.HtmlAttributeNotBoundAttribute"; + + public const string HtmlTargetElementAttribute = "Microsoft.AspNetCore.Razor.TagHelpers.HtmlTargetElementAttribute"; + + public const string RestrictChildrenAttribute = "Microsoft.AspNetCore.Razor.TagHelpers.RestrictChildrenAttribute"; + + public static class HtmlAttributeName + { + public const string DictionaryAttributePrefix = "DictionaryAttributePrefix"; + } + + public static class HtmlTargetElement + { + public const string Attributes = "Attributes"; + + public const string ParentTag = "ParentTag"; + + public const string TagStructure = "TagStructure"; + } + } +}