diff --git a/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs b/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs index 2ff49efa5c..445a265592 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/Properties/Resources.Designer.cs @@ -75,19 +75,19 @@ namespace Microsoft.AspNet.Razor.Runtime } /// - /// Tag name cannot be null or whitespace. + /// {0} name cannot be null or whitespace. /// - internal static string HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace + internal static string TargetElementAttribute_NameCannotBeNullOrWhitespace { - get { return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace"); } + get { return GetString("TargetElementAttribute_NameCannotBeNullOrWhitespace"); } } /// - /// Tag name cannot be null or whitespace. + /// {0} name cannot be null or whitespace. /// - internal static string FormatHtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace() + internal static string FormatTargetElementAttribute_NameCannotBeNullOrWhitespace(object p0) { - return GetString("HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace"); + return string.Format(CultureInfo.CurrentCulture, GetString("TargetElementAttribute_NameCannotBeNullOrWhitespace"), p0); } /// @@ -123,19 +123,19 @@ namespace Microsoft.AspNet.Razor.Runtime } /// - /// Tag helpers cannot target element name '{0}' because it contains a '{1}' character. + /// Tag helpers cannot target {0} name '{1}' because it contains a '{2}' character. /// - internal static string HtmlElementNameAttribute_InvalidElementName + internal static string TargetElementAttribute_InvalidName { - get { return GetString("HtmlElementNameAttribute_InvalidElementName"); } + get { return GetString("TargetElementAttribute_InvalidName"); } } /// - /// Tag helpers cannot target element name '{0}' because it contains a '{1}' character. + /// Tag helpers cannot target {0} name '{1}' because it contains a '{2}' character. /// - internal static string FormatHtmlElementNameAttribute_InvalidElementName(object p0, object p1) + internal static string FormatTargetElementAttribute_InvalidName(object p0, object p1, object p2) { - return string.Format(CultureInfo.CurrentCulture, GetString("HtmlElementNameAttribute_InvalidElementName"), p0, p1); + return string.Format(CultureInfo.CurrentCulture, GetString("TargetElementAttribute_InvalidName"), p0, p1, p2); } /// @@ -170,6 +170,38 @@ namespace Microsoft.AspNet.Razor.Runtime return string.Format(CultureInfo.CurrentCulture, GetString("TagHelperDescriptorResolver_InvalidTagHelperPrefixValue"), p0, p1, p2); } + /// + /// Attribute + /// + internal static string TagHelperDescriptorFactory_Attribute + { + get { return GetString("TagHelperDescriptorFactory_Attribute"); } + } + + /// + /// Attribute + /// + internal static string FormatTagHelperDescriptorFactory_Attribute() + { + return GetString("TagHelperDescriptorFactory_Attribute"); + } + + /// + /// Tag + /// + internal static string TagHelperDescriptorFactory_Tag + { + get { return GetString("TagHelperDescriptorFactory_Tag"); } + } + + /// + /// Tag + /// + internal static string FormatTagHelperDescriptorFactory_Tag() + { + return GetString("TagHelperDescriptorFactory_Tag"); + } + private static string GetString(string name, params string[] formatterNames) { var value = _resourceManager.GetString(name); diff --git a/src/Microsoft.AspNet.Razor.Runtime/Resources.resx b/src/Microsoft.AspNet.Razor.Runtime/Resources.resx index 48f5cb1fdd..bb3fdbd8ae 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/Resources.resx +++ b/src/Microsoft.AspNet.Razor.Runtime/Resources.resx @@ -129,8 +129,8 @@ Must call '{2}.{1}' before calling '{2}.{0}'. - - Tag name cannot be null or whitespace. + + {0} name cannot be null or whitespace. The value cannot be null or empty. @@ -138,8 +138,8 @@ Encountered an unexpected error when attempting to resolve tag helper directive '{0}' with value '{1}'. Error: {2} - - Tag helpers cannot target element name '{0}' because it contains a '{1}' character. + + Tag helpers cannot target {0} name '{1}' because it contains a '{2}' character. Invalid tag helper directive '{0}'. Cannot have multiple '{0}' directives on a page. @@ -147,4 +147,10 @@ Invalid tag helper directive '{0}' value. '{1} is not allowed in prefix '{2}'. + + Attribute + + + Tag + \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/HtmlElementNameAttribute.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/HtmlElementNameAttribute.cs deleted file mode 100644 index 4e2227468d..0000000000 --- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/HtmlElementNameAttribute.cs +++ /dev/null @@ -1,70 +0,0 @@ -// Copyright (c) Microsoft Open Technologies, Inc. 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.Framework.Internal; - -namespace Microsoft.AspNet.Razor.Runtime.TagHelpers -{ - /// - /// Used to override a 's default tag name target. - /// - [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] - public sealed class HtmlElementNameAttribute : Attribute - { - /// - /// Instantiates a new instance of the class. - /// - /// The HTML tag name for the to target. - public HtmlElementNameAttribute([NotNull] string tag) - { - ValidateTagName(tag, nameof(tag)); - - Tags = new[] { tag }; - } - - /// - /// Instantiates a new instance of the class. - /// - /// The HTML tag name for the to target. - /// Additional HTML tag names for the to target. - public HtmlElementNameAttribute([NotNull] string tag, [NotNull] params string[] additionalTags) - { - ValidateTagName(tag, nameof(tag)); - - foreach (var tagName in additionalTags) - { - ValidateTagName(tagName, nameof(additionalTags)); - } - - var allTags = new List(additionalTags); - allTags.Add(tag); - - Tags = allTags; - } - - /// - /// An of tag names for the to target. - /// - public IEnumerable Tags { get; } - - private static void ValidateTagName(string tagName, string parameterName) - { - if (string.IsNullOrWhiteSpace(tagName)) - { - throw new ArgumentException( - Resources.HtmlElementNameAttribute_ElementNameCannotBeNullOrWhitespace, - parameterName); - } - - if (tagName.Contains('!')) - { - throw new ArgumentException( - Resources.FormatHtmlElementNameAttribute_InvalidElementName(tagName, '!'), - parameterName); - } - } - } -} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorFactory.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorFactory.cs index 600427cf58..bbaa91b503 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorFactory.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorFactory.cs @@ -6,7 +6,10 @@ using System.Collections.Generic; using System.Linq; using System.Reflection; using System.Text.RegularExpressions; +using Microsoft.AspNet.Razor.Parser; using Microsoft.AspNet.Razor.TagHelpers; +using Microsoft.AspNet.Razor.Text; +using Microsoft.Framework.Internal; namespace Microsoft.AspNet.Razor.Runtime.TagHelpers { @@ -28,34 +31,52 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers // TODO: Investigate if we should cache TagHelperDescriptors for types: // https://github.com/aspnet/Razor/issues/165 + public static ICollection InvalidNonWhitespaceNameCharacters { get; } = new HashSet( + new[] { '@', '!', '<', '/', '?', '[', '>', ']', '=', '"', '\'' }); + /// /// Creates a from the given . /// /// The assembly name that contains . /// The type to create a from. /// A that describes the given . - public static IEnumerable CreateDescriptors(string assemblyName, Type type) + public static IEnumerable CreateDescriptors( + string assemblyName, + [NotNull] Type type, + [NotNull] ParserErrorSink errorSink) { - var tagNames = GetTagNames(type); - var typeName = type.FullName; + var typeInfo = type.GetTypeInfo(); var attributeDescriptors = GetAttributeDescriptors(type); + var targetElementAttributes = GetValidTargetElementAttributes(typeInfo, errorSink); + var tagHelperDescriptors = + BuildTagHelperDescriptors( + typeInfo, + assemblyName, + attributeDescriptors, + targetElementAttributes); - return tagNames.Select(tagName => - new TagHelperDescriptor( - prefix: string.Empty, - tagName: tagName, - typeName: typeName, - assemblyName: assemblyName, - attributes: attributeDescriptors)); + return tagHelperDescriptors.Distinct(TagHelperDescriptorComparer.Default); } - private static IEnumerable GetTagNames(Type tagHelperType) + private static IEnumerable GetValidTargetElementAttributes( + TypeInfo typeInfo, + ParserErrorSink errorSink) { - var typeInfo = tagHelperType.GetTypeInfo(); - var attributes = typeInfo.GetCustomAttributes(inherit: false); + var targetElementAttributes = typeInfo.GetCustomAttributes(inherit: false); + + return targetElementAttributes.Where(attribute => ValidTargetElementAttributeNames(attribute, errorSink)); + } + + private static IEnumerable BuildTagHelperDescriptors( + TypeInfo typeInfo, + string assemblyName, + IEnumerable attributeDescriptors, + IEnumerable targetElementAttributes) + { + var typeName = typeInfo.FullName; // If there isn't an attribute specifying the tag name derive it from the name - if (!attributes.Any()) + if (!targetElementAttributes.Any()) { var name = typeInfo.Name; @@ -64,11 +85,122 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers name = name.Substring(0, name.Length - TagHelperNameEnding.Length); } - return new[] { ToHtmlCase(name) }; + return new[] + { + BuildTagHelperDescriptor( + ToHtmlCase(name), + typeName, + assemblyName, + attributeDescriptors, + requiredAttributes: Enumerable.Empty()) + }; } - // Remove duplicate tag names. - return attributes.SelectMany(attribute => attribute.Tags).Distinct(); + return targetElementAttributes.Select( + attribute => BuildTagHelperDescriptor(typeName, assemblyName, attributeDescriptors, attribute)); + } + + private static TagHelperDescriptor BuildTagHelperDescriptor( + string typeName, + string assemblyName, + IEnumerable attributeDescriptors, + TargetElementAttribute targetElementAttribute) + { + var requiredAttributes = GetCommaSeparatedValues(targetElementAttribute.Attributes); + + return BuildTagHelperDescriptor( + targetElementAttribute.Tag, + typeName, + assemblyName, + attributeDescriptors, + requiredAttributes); + } + + private static TagHelperDescriptor BuildTagHelperDescriptor( + string tagName, + string typeName, + string assemblyName, + IEnumerable attributeDescriptors, + IEnumerable requiredAttributes) + { + return new TagHelperDescriptor( + prefix: string.Empty, + tagName: tagName, + typeName: typeName, + assemblyName: assemblyName, + attributes: attributeDescriptors, + requiredAttributes: requiredAttributes); + } + + /// + /// Internal for testing. + /// + internal static IEnumerable GetCommaSeparatedValues(string text) + { + // We don't want to remove empty entries, need to notify users of invalid values. + return text?.Split(',').Select(tagName => tagName.Trim()) ?? Enumerable.Empty(); + } + + /// + /// Internal for testing. + /// + internal static bool ValidTargetElementAttributeNames( + TargetElementAttribute attribute, + ParserErrorSink errorSink) + { + var validTagName = ValidateName(attribute.Tag, targetingAttributes: false, errorSink: errorSink); + var validAttributeNames = true; + var attributeNames = GetCommaSeparatedValues(attribute.Attributes); + + foreach (var attributeName in attributeNames) + { + if (!ValidateName(attributeName, targetingAttributes: true, errorSink: errorSink)) + { + validAttributeNames = false; + } + } + + return validTagName && validAttributeNames; + } + + private static bool ValidateName( + string name, + bool targetingAttributes, + ParserErrorSink errorSink) + { + var targetName = targetingAttributes ? + Resources.TagHelperDescriptorFactory_Attribute : + Resources.TagHelperDescriptorFactory_Tag; + var validName = true; + + if (string.IsNullOrWhiteSpace(name)) + { + errorSink.OnError( + SourceLocation.Zero, + Resources.FormatTargetElementAttribute_NameCannotBeNullOrWhitespace(targetName)); + + validName = false; + } + else + { + foreach (var character in name) + { + if (char.IsWhiteSpace(character) || + InvalidNonWhitespaceNameCharacters.Contains(character)) + { + errorSink.OnError( + SourceLocation.Zero, + Resources.FormatTargetElementAttribute_InvalidName( + targetName.ToLower(), + name, + character)); + + validName = false; + } + } + } + + return validName; } private static IEnumerable GetAttributeDescriptors(Type type) diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorResolver.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorResolver.cs index 5c956dc409..e7b71d8c67 100644 --- a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorResolver.cs +++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TagHelperDescriptorResolver.cs @@ -25,8 +25,6 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers { TagHelperDirectiveType.RemoveTagHelper, SyntaxConstants.CSharp.RemoveTagHelperKeyword }, { TagHelperDirectiveType.TagHelperPrefix, SyntaxConstants.CSharp.TagHelperPrefixKeyword }, }; - private static readonly HashSet InvalidNonWhitespacePrefixCharacters = - new HashSet(new[] { '@', '!', '<', '!', '/', '?', '[', '>', ']', '=', '"', '\'' }); private readonly TagHelperTypeResolver _typeResolver; @@ -131,7 +129,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers // Convert types to TagHelperDescriptors var descriptors = tagHelperTypes.SelectMany( - type => TagHelperDescriptorFactory.CreateDescriptors(assemblyName, type)); + type => TagHelperDescriptorFactory.CreateDescriptors(assemblyName, type, errorSink)); return descriptors; } @@ -150,7 +148,8 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers descriptor.TagName, descriptor.TypeName, descriptor.AssemblyName, - descriptor.Attributes)); + descriptor.Attributes, + descriptor.RequiredAttributes)); } return descriptors; @@ -198,7 +197,7 @@ namespace Microsoft.AspNet.Razor.Runtime.TagHelpers { // Prefixes are correlated with tag names, tag names cannot have whitespace. if (char.IsWhiteSpace(character) || - InvalidNonWhitespacePrefixCharacters.Contains(character)) + TagHelperDescriptorFactory.InvalidNonWhitespaceNameCharacters.Contains(character)) { errorSink.OnError( directiveLocation, diff --git a/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TargetElementAttribute.cs b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TargetElementAttribute.cs new file mode 100644 index 0000000000..753e5d92aa --- /dev/null +++ b/src/Microsoft.AspNet.Razor.Runtime/TagHelpers/TargetElementAttribute.cs @@ -0,0 +1,54 @@ +// Copyright (c) Microsoft Open Technologies, Inc. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using Microsoft.AspNet.Razor.TagHelpers; + +namespace Microsoft.AspNet.Razor.Runtime.TagHelpers +{ + /// + /// Provides an 's target. + /// + [AttributeUsage(AttributeTargets.Class, AllowMultiple = true, Inherited = false)] + public sealed class TargetElementAttribute : Attribute + { + public const string CatchAllDescriptorTarget = TagHelperDescriptorProvider.CatchAllDescriptorTarget; + + /// + /// Instantiates a new instance of the class with + /// set to *. + /// + /// A * value indicates an + /// that targets all HTML elements with the required . + public TargetElementAttribute() + : this(CatchAllDescriptorTarget) + { + } + + /// + /// Instantiates a new instance of the class. + /// + /// + /// The HTML tag the targets. + /// + /// A * value indicates an + /// that targets all HTML elements with the required . + public TargetElementAttribute(string tag) + { + Tag = tag; + } + + /// + /// The HTML tag the targets. + /// + /// A * value indicates an + /// that targets all HTML elements with the required . + public string Tag { get; } + + /// + /// A comma-separated of attributes the HTML element must contain for the + /// to run. + /// + public string Attributes { get; set; } + } +} \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs index c0696be646..e0039ca7dd 100644 --- a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs +++ b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperBlockRewriter.cs @@ -224,9 +224,16 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal if (name == null) { - errorSink.OnError(span.Start, - RazorResources.TagHelperBlockRewriter_TagHelperAttributesMustBeWelformed, - span.Content.Length); + // We couldn't find a name, if the original span content was whitespace it ultimately means the tag + // that owns this "attribute" is malformed and is expecting a user to type a new attribute. + // ex: ); diff --git a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperParseTreeRewriter.cs b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperParseTreeRewriter.cs index 26ded3556f..e61a86c4e9 100644 --- a/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperParseTreeRewriter.cs +++ b/src/Microsoft.AspNet.Razor/Parser/TagHelpers/TagHelperParseTreeRewriter.cs @@ -14,14 +14,14 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal public class TagHelperParseTreeRewriter : ISyntaxTreeRewriter { private TagHelperDescriptorProvider _provider; - private Stack _tagStack; + private Stack _trackerStack; private Stack _blockStack; private BlockBuilder _currentBlock; public TagHelperParseTreeRewriter(TagHelperDescriptorProvider provider) { _provider = provider; - _tagStack = new Stack(); + _trackerStack = new Stack(); _blockStack = new Stack(); } @@ -41,7 +41,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal CodeGenerator = input.CodeGenerator }); - var activeTagHelpers = _tagStack.Count; + var activeTagHelpers = _trackerStack.Count; foreach (var child in input.Children) { @@ -76,14 +76,14 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal // We captured the number of active tag helpers at the start of our logic, it should be the same. If not // it means that there are malformed tag helpers at the top of our stack. - if (activeTagHelpers != _tagStack.Count) + if (activeTagHelpers != _trackerStack.Count) { // Malformed tag helpers built here will be tag helpers that do not have end tags in the current block // scope. Block scopes are special cases in Razor such as @

would cause an error because there's no // matching end

tag in the template block scope and therefore doesn't make sense as a tag helper. - BuildMalformedTagHelpers(_tagStack.Count - activeTagHelpers, context); + BuildMalformedTagHelpers(_trackerStack.Count - activeTagHelpers, context); - Debug.Assert(activeTagHelpers == _tagStack.Count); + Debug.Assert(activeTagHelpers == _trackerStack.Count); } BuildCurrentlyTrackedBlock(); @@ -91,8 +91,6 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal private bool TryRewriteTagHelper(Block tagBlock, RewritingContext context) { - // TODO: Fully handle malformed tags: https://github.com/aspnet/Razor/issues/104 - // Get tag name of the current block (doesn't matter if it's an end or start tag) var tagName = GetTagName(tagBlock); @@ -104,22 +102,39 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal var descriptors = Enumerable.Empty(); - if (IsPotentialTagHelper(tagName, tagBlock)) - { - descriptors = _provider.GetTagHelpers(tagName); - } - - // If there aren't any TagHelperDescriptors registered then we aren't a TagHelper - if (!descriptors.Any()) + if (!IsPotentialTagHelper(tagName, tagBlock)) { return false; } + var tracker = _trackerStack.Count > 0 ? _trackerStack.Peek() : null; + var tagNameScope = tracker?.Builder.TagName ?? string.Empty; + if (!IsEndTag(tagBlock)) { - // We're in a begin tag helper block + // We're now in a start tag block, we first need to see if the tag block is a tag helper. + var providedAttributes = GetAttributeNames(tagBlock); - var validTagStructure = ValidTagStructure(tagName, tagBlock, context); + descriptors = _provider.GetDescriptors(tagName, providedAttributes); + + // If there aren't any TagHelperDescriptors registered then we aren't a TagHelper + if (!descriptors.Any()) + { + // If the current tag matches the current TagHelper scope it means the parent TagHelper matched + // all the required attributes but the current one did not; therefore, we need to increment the + // OpenMatchingTags counter for current the TagHelperBlock so we don't end it too early. + // ex: We don't want the first myth to close on the inside + // tag. + if (string.Equals(tagNameScope, tagName, StringComparison.OrdinalIgnoreCase)) + { + tracker.OpenMatchingTags++; + } + + return false; + } + + // We're in a start TagHelper block. + var validTagStructure = ValidateTagStructure(tagName, tagBlock, context); var builder = TagHelperBlockRewriter.Rewrite( tagName, @@ -144,32 +159,43 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal } else { - // We're in an end tag helper block. - - var tagNameScope = _tagStack.Count > 0 ? _tagStack.Peek().TagName : string.Empty; - - // Validate that our end tag helper matches the currently scoped tag helper, if not we - // need to error. + // Validate that our end tag matches the currently scoped tag, if not we may need to error. if (tagNameScope.Equals(tagName, StringComparison.OrdinalIgnoreCase)) { - ValidTagStructure(tagName, tagBlock, context); + // If there are additional end tags required before we can build our block it means we're in a + // situation like this: where we're at the inside
. + if (tracker.OpenMatchingTags > 0) + { + tracker.OpenMatchingTags--; + + return false; + } + + ValidateTagStructure(tagName, tagBlock, context); BuildCurrentlyTrackedTagHelperBlock(tagBlock); } else { + // If there are not TagHelperDescriptors associated with the end tag block that also have no + // required attributes then it means we can't be a TagHelper, bail out. + if (!_provider.GetDescriptors(tagName, attributeNames: Enumerable.Empty()).Any()) + { + return false; + } + // Current tag helper scope does not match the end tag. Attempt to recover the tag // helper by looking up the previous tag helper scopes for a matching tag. If we - // can't recover it means there was no corresponding tag helper begin tag. + // can't recover it means there was no corresponding tag helper start tag. if (TryRecoverTagHelper(tagName, tagBlock, context)) { - ValidTagStructure(tagName, tagBlock, context); + ValidateTagStructure(tagName, tagBlock, context); // Successfully recovered, move onto the next element. } else { - // Could not recover, the end tag helper has no corresponding begin tag, create + // Could not recover, the end tag helper has no corresponding start tag, create // an error based on the current childBlock. context.ErrorSink.OnError( tagBlock.Start, @@ -183,13 +209,61 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal return true; } - private static bool ValidTagStructure(string tagName, Block tag, RewritingContext context) + private IEnumerable GetAttributeNames(Block tagBlock) + { + // Need to calculate how many children we should take that represent the attributes. + var childrenOffset = IsPartialTag(tagBlock) ? 1 : 2; + var attributeChildren = tagBlock.Children.Skip(1).Take(tagBlock.Children.Count() - childrenOffset); + var attributeNames = new List(); + + foreach (var child in attributeChildren) + { + Span childSpan; + + if (child.IsBlock) + { + childSpan = ((Block)child).FindFirstDescendentSpan(); + + if (childSpan == null) + { + continue; + } + } + else + { + childSpan = child as Span; + } + + var attributeName = childSpan + .Content + .Split(separator: new[] { '=' }, count: 2)[0] + .TrimStart(); + + attributeNames.Add(attributeName); + } + + return attributeNames; + } + + private static bool ValidateTagStructure(string tagName, Block tag, RewritingContext context) { // We assume an invalid structure until we verify that the tag meets all of our "valid structure" criteria. - var invalidStructure = true; + if (IsPartialTag(tag)) + { + context.ErrorSink.OnError( + tag.Start, + RazorResources.FormatTagHelpersParseTreeRewriter_MissingCloseAngle(tagName)); + return false; + } + + return true; + } + + private static bool IsPartialTag(Block tagBlock) + { // No need to validate the tag end because in order to be a tag block it must start with '<'. - var tagEnd = tag.Children.Last() as Span; + var tagEnd = tagBlock.Children.Last() as Span; // If our tag end is not a markup span it means it's some sort of code SyntaxTreeNode (not a valid format) if (tagEnd != null && tagEnd.Kind == SpanKind.Markup) @@ -198,18 +272,11 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal if (endSymbol != null && endSymbol.Type == HtmlSymbolType.CloseAngle) { - invalidStructure = false; + return false; } } - if (invalidStructure) - { - context.ErrorSink.OnError( - tag.Start, - RazorResources.FormatTagHelpersParseTreeRewriter_MissingCloseAngle(tagName)); - } - - return !invalidStructure; + return true; } private void BuildCurrentlyTrackedBlock() @@ -239,7 +306,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal { // Track the original end tag so the editor knows where each piece of the TagHelperBlock lies // for formatting. - _tagStack.Pop().SourceEndTag = endTag; + _trackerStack.Pop().Builder.SourceEndTag = endTag; BuildCurrentlyTrackedBlock(); } @@ -256,11 +323,6 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal childSpan.Kind != SpanKind.Transition; } - private bool IsRegisteredTagHelper(string tagName) - { - return _provider.GetTagHelpers(tagName).Any(); - } - private void TrackBlock(BlockBuilder builder) { _currentBlock = builder; @@ -270,7 +332,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal private void TrackTagHelperBlock(TagHelperBlockBuilder builder) { - _tagStack.Push(builder); + _trackerStack.Push(new TagHelperBlockTracker(builder)); TrackBlock(builder); } @@ -279,9 +341,9 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal { var malformedTagHelperCount = 0; - foreach (var tag in _tagStack) + foreach (var tracker in _trackerStack) { - if (tag.TagName.Equals(tagName, StringComparison.OrdinalIgnoreCase)) + if (tracker.Builder.TagName.Equals(tagName, StringComparison.OrdinalIgnoreCase)) { break; } @@ -289,9 +351,9 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal malformedTagHelperCount++; } - // If the malformedTagHelperCount == _tagStack.Count it means we couldn't find a begin tag for the tag + // If the malformedTagHelperCount == _tagStack.Count it means we couldn't find a start tag for the tag // helper, can't recover. - if (malformedTagHelperCount != _tagStack.Count) + if (malformedTagHelperCount != _trackerStack.Count) { BuildMalformedTagHelpers(malformedTagHelperCount, context); @@ -302,7 +364,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal return true; } - // Could not recover tag helper. Aka we found a tag helper end tag without a corresponding begin tag. + // Could not recover tag helper. Aka we found a tag helper end tag without a corresponding start tag. return false; } @@ -310,7 +372,7 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal { for (var i = 0; i < count; i++) { - var malformedTagHelper = _tagStack.Peek(); + var malformedTagHelper = _trackerStack.Peek().Builder; context.ErrorSink.OnError( malformedTagHelper.Start, @@ -357,5 +419,17 @@ namespace Microsoft.AspNet.Razor.Parser.TagHelpers.Internal Debug.Assert(tagBlock.Type == BlockType.Tag); Debug.Assert(tagBlock.Children.First() is Span); } + + private class TagHelperBlockTracker + { + public TagHelperBlockTracker(TagHelperBlockBuilder builder) + { + Builder = builder; + } + + public TagHelperBlockBuilder Builder { get; } + + public uint OpenMatchingTags { get; set; } + } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptor.cs b/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptor.cs index 57f4fdb6d3..bd568eeef2 100644 --- a/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptor.cs +++ b/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptor.cs @@ -14,9 +14,10 @@ namespace Microsoft.AspNet.Razor.TagHelpers /// /// Internal for testing. /// - internal TagHelperDescriptor([NotNull] string tagName, - [NotNull] string typeName, - [NotNull] string assemblyName) + internal TagHelperDescriptor( + [NotNull] string tagName, + [NotNull] string typeName, + [NotNull] string assemblyName) : this( tagName, typeName, @@ -33,12 +34,31 @@ namespace Microsoft.AspNet.Razor.TagHelpers [NotNull] string typeName, [NotNull] string assemblyName, [NotNull] IEnumerable attributes) + : this( + tagName, + typeName, + assemblyName, + attributes, + requiredAttributes: Enumerable.Empty()) + { + } + + /// + /// Internal for testing. + /// + internal TagHelperDescriptor( + [NotNull] string tagName, + [NotNull] string typeName, + [NotNull] string assemblyName, + [NotNull] IEnumerable attributes, + [NotNull] IEnumerable requiredAttributes) : this( prefix: string.Empty, - tagName: tagName, - typeName: typeName, - assemblyName: assemblyName, - attributes: attributes) + tagName: tagName, + typeName: typeName, + assemblyName: assemblyName, + attributes: attributes, + requiredAttributes: requiredAttributes) { } @@ -57,12 +77,16 @@ namespace Microsoft.AspNet.Razor.TagHelpers /// /// The s to request from the HTML tag. /// + /// + /// The attribute names required for the tag helper to target the HTML tag. + /// public TagHelperDescriptor( string prefix, [NotNull] string tagName, [NotNull] string typeName, [NotNull] string assemblyName, - [NotNull] IEnumerable attributes) + [NotNull] IEnumerable attributes, + [NotNull] IEnumerable requiredAttributes) { Prefix = prefix ?? string.Empty; TagName = tagName; @@ -70,6 +94,7 @@ namespace Microsoft.AspNet.Razor.TagHelpers TypeName = typeName; AssemblyName = assemblyName; Attributes = new List(attributes); + RequiredAttributes = new List(requiredAttributes); } /// @@ -102,6 +127,11 @@ namespace Microsoft.AspNet.Razor.TagHelpers /// /// The list of attributes the tag helper expects. /// - public virtual List Attributes { get; private set; } + public IList Attributes { get; private set; } + + /// + /// The list of required attribute names the tag helper expects to target an element. + /// + public IList RequiredAttributes { get; private set; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorComparer.cs b/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorComparer.cs index 92eb3742f0..9b59df67e3 100644 --- a/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorComparer.cs +++ b/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorComparer.cs @@ -3,6 +3,7 @@ using System; using System.Collections.Generic; +using System.Linq; using Microsoft.Internal.Web.Utils; namespace Microsoft.AspNet.Razor.TagHelpers @@ -29,15 +30,22 @@ namespace Microsoft.AspNet.Razor.TagHelpers /// false otherwise. /// /// Determines equality based on , - /// , and - /// . + /// , , + /// and . /// public bool Equals(TagHelperDescriptor descriptorX, TagHelperDescriptor descriptorY) { return string.Equals(descriptorX.TypeName, descriptorY.TypeName, StringComparison.Ordinal) && string.Equals(descriptorX.TagName, descriptorY.TagName, StringComparison.OrdinalIgnoreCase) && - string.Equals(descriptorX.Prefix, descriptorY.Prefix, StringComparison.OrdinalIgnoreCase) && - string.Equals(descriptorX.AssemblyName, descriptorY.AssemblyName, StringComparison.Ordinal); + string.Equals(descriptorX.AssemblyName, descriptorY.AssemblyName, StringComparison.Ordinal) && + Enumerable.SequenceEqual( + descriptorX.RequiredAttributes.OrderBy( + attribute => attribute, + StringComparer.OrdinalIgnoreCase), + descriptorY.RequiredAttributes.OrderBy( + attribute => attribute, + StringComparer.OrdinalIgnoreCase), + StringComparer.OrdinalIgnoreCase); } /// @@ -47,11 +55,22 @@ namespace Microsoft.AspNet.Razor.TagHelpers /// An that uniquely identifies the given . public int GetHashCode(TagHelperDescriptor descriptor) { - return HashCodeCombiner.Start() - .Add(descriptor.TagName, StringComparer.OrdinalIgnoreCase) - .Add(descriptor.TypeName, StringComparer.Ordinal) - .Add(descriptor.AssemblyName, StringComparer.Ordinal) - .CombinedHash; + var hashCodeCombiner = HashCodeCombiner + .Start() + .Add(descriptor.TypeName, StringComparer.Ordinal) + .Add(descriptor.TagName, StringComparer.OrdinalIgnoreCase) + .Add(descriptor.AssemblyName, StringComparer.Ordinal); + + var attributes = descriptor.RequiredAttributes.OrderBy( + attribute => attribute, + StringComparer.OrdinalIgnoreCase); + + foreach (var attribute in attributes) + { + hashCodeCombiner.Add(attributes); + } + + return hashCodeCombiner.CombinedHash; } } } \ No newline at end of file diff --git a/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorProvider.cs b/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorProvider.cs index 988998212b..834f402903 100644 --- a/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorProvider.cs +++ b/src/Microsoft.AspNet.Razor/TagHelpers/TagHelperDescriptorProvider.cs @@ -12,7 +12,7 @@ namespace Microsoft.AspNet.Razor.TagHelpers /// public class TagHelperDescriptorProvider { - private const string CatchAllDescriptorTarget = "*"; + public const string CatchAllDescriptorTarget = "*"; private IDictionary> _registrations; private string _tagHelperPrefix; @@ -37,13 +37,12 @@ namespace Microsoft.AspNet.Razor.TagHelpers /// /// The name of the HTML tag to match. Providing a '*' tag name /// retrieves catch-all s (descriptors that target every tag). + /// Attributes the HTML element must contain to match. /// s that apply to the given . /// Will return an empty if no s are /// found. - public IEnumerable GetTagHelpers(string tagName) + public IEnumerable GetDescriptors(string tagName, IEnumerable attributeNames) { - HashSet descriptors; - if (!string.IsNullOrEmpty(_tagHelperPrefix) && (tagName.Length <= _tagHelperPrefix.Length || !tagName.StartsWith(_tagHelperPrefix, StringComparison.OrdinalIgnoreCase))) @@ -52,29 +51,54 @@ namespace Microsoft.AspNet.Razor.TagHelpers return Enumerable.Empty(); } + HashSet catchAllDescriptors; + IEnumerable descriptors; + // Ensure there's a HashSet to use. - if (!_registrations.TryGetValue(CatchAllDescriptorTarget, out descriptors)) + if (!_registrations.TryGetValue(CatchAllDescriptorTarget, out catchAllDescriptors)) { descriptors = new HashSet(TagHelperDescriptorComparer.Default); } - - // If the requested tag name is the catch-all target, we should short circuit. - if (tagName.Equals(CatchAllDescriptorTarget, StringComparison.OrdinalIgnoreCase)) + else { - return descriptors; + descriptors = catchAllDescriptors; } - // If we have a tag name associated with the requested name, return the descriptors + - // all of the catch-all descriptors. - HashSet matchingDescriptors; - if (_registrations.TryGetValue(tagName, out matchingDescriptors)) + // If the requested tag name is the catch-all target, we shouldn't do the work of concatenating extra + // descriptors. + if (!tagName.Equals(CatchAllDescriptorTarget, StringComparison.OrdinalIgnoreCase)) { - return matchingDescriptors.Concat(descriptors); + // If we have a tag name associated with the requested name, we need to combine matchingDescriptors + // with all the catch-all descriptors. + HashSet matchingDescriptors; + if (_registrations.TryGetValue(tagName, out matchingDescriptors)) + { + descriptors = matchingDescriptors.Concat(descriptors); + } } - // We couldn't any descriptors associated with the requested tag name, return all - // of the "catch-all" tag descriptors (there may not be any). - return descriptors; + var applicableDescriptors = ApplyRequiredAttributes(descriptors, attributeNames); + + return applicableDescriptors; + } + + private IEnumerable ApplyRequiredAttributes( + IEnumerable descriptors, + IEnumerable attributeNames) + { + return descriptors.Where( + descriptor => + { + foreach (var requiredAttribute in descriptor.RequiredAttributes) + { + if (!attributeNames.Contains(requiredAttribute, StringComparer.OrdinalIgnoreCase)) + { + return false; + } + } + + return true; + }); } private void Register(TagHelperDescriptor descriptor)