From 22c7c90b5a158e7b38210122c781e95d496542a4 Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Tue, 28 Mar 2017 14:50:29 -0700 Subject: [PATCH] Add `TagHelperFactsService`. - Add API to enable the editor to query information on the state of `TagHelper`s within a Razor document. - Refactored methods from `TagHelperDescriptorProvider` to be in a `TagHelperDescriptorConventions` class so the language service could use them. - Added `DefaultTagHelperFactService` tests. #1120 --- .../DefaultRazorIRLoweringPhase.cs | 6 +- .../ITagHelperDescriptorBuilder.cs | 2 +- .../Legacy/TagHelperBlockRewriter.cs | 6 +- .../Legacy/TagHelperDescriptorProvider.cs | 40 +-- .../TagHelperDescriptorMatchingConventions.cs | 67 ---- .../TagHelperMatchingConventions.cs | 173 ++++++++++ .../TagMatchingRuleBuilder.cs | 2 +- .../DefaultTagHelperDescriptorFactory.cs | 2 +- .../DefaultRazorTagHelperFactsService.cs | 12 - .../DefaultTagHelperFactsService.cs | 170 ++++++++++ .../RazorTagHelperFactsService.cs | 9 - .../TagHelperFactsService.cs | 20 ++ .../Legacy/TagHelperDescriptorProviderTest.cs | 14 +- ...agHelperRequiredAttributeDescriptorTest.cs | 2 +- .../DefaultTagHelperDescriptorFactoryTest.cs | 12 +- .../DefaultTagHelperFactsServiceTest.cs | 302 ++++++++++++++++++ 16 files changed, 692 insertions(+), 147 deletions(-) delete mode 100644 src/Microsoft.AspNetCore.Razor.Evolution/TagHelperDescriptorMatchingConventions.cs create mode 100644 src/Microsoft.AspNetCore.Razor.Evolution/TagHelperMatchingConventions.cs delete mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultRazorTagHelperFactsService.cs create mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs delete mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/RazorTagHelperFactsService.cs create mode 100644 src/Microsoft.VisualStudio.LanguageServices.Razor/TagHelperFactsService.cs create mode 100644 test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultTagHelperFactsServiceTest.cs diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorIRLoweringPhase.cs b/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorIRLoweringPhase.cs index ad49314ecd..d5b2dfeec9 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorIRLoweringPhase.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/DefaultRazorIRLoweringPhase.cs @@ -530,7 +530,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution { var attributeValueNode = attribute.Value; var associatedDescriptors = descriptors.Where(descriptor => - descriptor.BoundAttributes.Any(attributeDescriptor => attributeDescriptor.CanMatchName(attribute.Name))); + descriptor.BoundAttributes.Any(attributeDescriptor => TagHelperMatchingConventions.CanSatisfyBoundAttribute(attribute.Name, attributeDescriptor))); if (associatedDescriptors.Any() && renderedBoundAttributeNames.Add(attribute.Name)) { @@ -544,7 +544,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution foreach (var associatedDescriptor in associatedDescriptors) { var associatedAttributeDescriptor = associatedDescriptor.BoundAttributes.First( - attributeDescriptor => attributeDescriptor.CanMatchName(attribute.Name)); + attributeDescriptor => TagHelperMatchingConventions.CanSatisfyBoundAttribute(attribute.Name, attributeDescriptor)); var tagHelperTypeName = associatedDescriptor.Metadata[ITagHelperDescriptorBuilder.TypeNameKey]; var attributePropertyName = associatedAttributeDescriptor.Metadata[ITagHelperBoundAttributeDescriptorBuilder.PropertyNameKey]; @@ -557,7 +557,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution Binding = tagHelperBinding, ValueStyle = attribute.ValueStyle, Source = BuildSourceSpanFromNode(attributeValueNode), - IsIndexerNameMatch = associatedAttributeDescriptor.IsIndexerNameMatch(attribute.Name), + IsIndexerNameMatch = TagHelperMatchingConventions.SatisfiesBoundAttributeIndexer(attribute.Name, associatedAttributeDescriptor), }; _builder.Push(setTagHelperProperty); diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/ITagHelperDescriptorBuilder.cs b/src/Microsoft.AspNetCore.Razor.Evolution/ITagHelperDescriptorBuilder.cs index 46fced9122..42a1e673f0 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/ITagHelperDescriptorBuilder.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/ITagHelperDescriptorBuilder.cs @@ -181,7 +181,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution yield return diagnostic; } - else if (name != TagHelperDescriptorProvider.ElementCatchAllTarget) + else if (name != TagHelperMatchingConventions.ElementCatchAllName) { foreach (var character in name) { diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs index 6f94ae75a5..0f58f859c5 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperBlockRewriter.cs @@ -655,7 +655,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy private static string GetPropertyType(string name, IEnumerable descriptors) { var firstBoundAttribute = FindFirstBoundAttribute(name, descriptors); - var isBoundToIndexer = firstBoundAttribute.IsIndexerNameMatch(name); + var isBoundToIndexer = TagHelperMatchingConventions.SatisfiesBoundAttributeIndexer(name, firstBoundAttribute); if (isBoundToIndexer) { @@ -674,7 +674,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy var isBoundAttribute = firstBoundAttribute != null; var isBoundNonStringAttribute = isBoundAttribute && !(firstBoundAttribute.IsStringProperty || - (firstBoundAttribute.IsIndexerNameMatch(name) && firstBoundAttribute.IsIndexerStringProperty)); + (TagHelperMatchingConventions.SatisfiesBoundAttributeIndexer(name, firstBoundAttribute) && firstBoundAttribute.IsIndexerStringProperty)); var isMissingDictionaryKey = isBoundAttribute && firstBoundAttribute.IndexerNamePrefix != null && name.Length == firstBoundAttribute.IndexerNamePrefix.Length; @@ -695,7 +695,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy { var firstBoundAttribute = descriptors .SelectMany(descriptor => descriptor.BoundAttributes) - .FirstOrDefault(attributeDescriptor => attributeDescriptor.CanMatchName(name)); + .FirstOrDefault(attributeDescriptor => TagHelperMatchingConventions.CanSatisfyBoundAttribute(name, attributeDescriptor)); return firstBoundAttribute; } diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperDescriptorProvider.cs b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperDescriptorProvider.cs index 2853c3a16b..a1e78bae39 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperDescriptorProvider.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/Legacy/TagHelperDescriptorProvider.cs @@ -12,8 +12,6 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy /// internal class TagHelperDescriptorProvider { - public const string ElementCatchAllTarget = "*"; - private IDictionary> _registrations; private readonly string _tagHelperPrefix; @@ -60,7 +58,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy IEnumerable descriptors; // Ensure there's a HashSet to use. - if (!_registrations.TryGetValue(ElementCatchAllTarget, out HashSet catchAllDescriptors)) + if (!_registrations.TryGetValue(TagHelperMatchingConventions.ElementCatchAllName, out HashSet catchAllDescriptors)) { descriptors = new HashSet(TagHelperDescriptorComparer.Default); } @@ -81,7 +79,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy foreach (var descriptor in descriptors) { var applicableRules = descriptor.TagMatchingRules.Where( - rule => MatchesRule(rule, attributes, tagNameWithoutPrefix, parentTagName)); + rule => TagHelperMatchingConventions.SatisfiesRule(tagNameWithoutPrefix, parentTagName, attributes, rule)); if (applicableRules.Any()) { @@ -104,43 +102,13 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy return tagMappingResult; } - private bool MatchesRule( - TagMatchingRule rule, - IEnumerable> tagAttributes, - string tagNameWithoutPrefix, - string parentTagName) - { - // Verify tag name - if (rule.TagName != ElementCatchAllTarget && - rule.TagName != null && - !string.Equals(tagNameWithoutPrefix, rule.TagName, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - // Verify parent tag - if (rule.ParentTag != null && !string.Equals(parentTagName, rule.ParentTag, StringComparison.OrdinalIgnoreCase)) - { - return false; - } - - if (!rule.Attributes.All( - requiredAttribute => tagAttributes.Any( - attribute => requiredAttribute.IsMatch(attribute.Key, attribute.Value)))) - { - return false; - } - - return true; - } - private void Register(TagHelperDescriptor descriptor) { foreach (var rule in descriptor.TagMatchingRules) { var registrationKey = - string.Equals(rule.TagName, ElementCatchAllTarget, StringComparison.Ordinal) ? - ElementCatchAllTarget : + string.Equals(rule.TagName, TagHelperMatchingConventions.ElementCatchAllName, StringComparison.Ordinal) ? + TagHelperMatchingConventions.ElementCatchAllName : _tagHelperPrefix + rule.TagName; // Ensure there's a HashSet to add the descriptor to. diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/TagHelperDescriptorMatchingConventions.cs b/src/Microsoft.AspNetCore.Razor.Evolution/TagHelperDescriptorMatchingConventions.cs deleted file mode 100644 index b57dc141ce..0000000000 --- a/src/Microsoft.AspNetCore.Razor.Evolution/TagHelperDescriptorMatchingConventions.cs +++ /dev/null @@ -1,67 +0,0 @@ -// 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.Diagnostics; - -namespace Microsoft.AspNetCore.Razor.Evolution -{ - internal static class TagHelperDescriptorMatchingConventions - { - public static bool CanMatchName(this BoundAttributeDescriptor descriptor, string name) - { - return IsFullNameMatch(descriptor, name) || IsIndexerNameMatch(descriptor, name); - } - - public static bool IsFullNameMatch(this BoundAttributeDescriptor descriptor, string name) - { - return string.Equals(descriptor.Name, name, StringComparison.OrdinalIgnoreCase); - } - - public static bool IsIndexerNameMatch(this BoundAttributeDescriptor descriptor, string name) - { - return descriptor.IndexerNamePrefix != null && - !IsFullNameMatch(descriptor, name) && - name.StartsWith(descriptor.IndexerNamePrefix, StringComparison.OrdinalIgnoreCase); - } - - public static bool IsMatch(this RequiredAttributeDescriptor descriptor, string attributeName, string attributeValue) - { - var nameMatches = false; - if (descriptor.NameComparison == RequiredAttributeDescriptor.NameComparisonMode.FullMatch) - { - nameMatches = string.Equals(descriptor.Name, attributeName, StringComparison.OrdinalIgnoreCase); - } - else if (descriptor.NameComparison == RequiredAttributeDescriptor.NameComparisonMode.PrefixMatch) - { - // attributeName cannot equal the Name if comparing as a PrefixMatch. - nameMatches = attributeName.Length != descriptor.Name.Length && - attributeName.StartsWith(descriptor.Name, StringComparison.OrdinalIgnoreCase); - } - else - { - Debug.Assert(false, "Unknown name comparison."); - } - - if (!nameMatches) - { - return false; - } - - switch (descriptor.ValueComparison) - { - case RequiredAttributeDescriptor.ValueComparisonMode.None: - return true; - case RequiredAttributeDescriptor.ValueComparisonMode.PrefixMatch: // Value starts with - return attributeValue.StartsWith(descriptor.Value, StringComparison.Ordinal); - case RequiredAttributeDescriptor.ValueComparisonMode.SuffixMatch: // Value ends with - return attributeValue.EndsWith(descriptor.Value, StringComparison.Ordinal); - case RequiredAttributeDescriptor.ValueComparisonMode.FullMatch: // Value equals - return string.Equals(attributeValue, descriptor.Value, StringComparison.Ordinal); - default: - Debug.Assert(false, "Unknown value comparison."); - return false; - } - } - } -} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/TagHelperMatchingConventions.cs b/src/Microsoft.AspNetCore.Razor.Evolution/TagHelperMatchingConventions.cs new file mode 100644 index 0000000000..9aa0ef8059 --- /dev/null +++ b/src/Microsoft.AspNetCore.Razor.Evolution/TagHelperMatchingConventions.cs @@ -0,0 +1,173 @@ +// 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; + +namespace Microsoft.AspNetCore.Razor.Evolution +{ + internal static class TagHelperMatchingConventions + { + public const string ElementCatchAllName = "*"; + + public static bool SatisfiesRule( + string tagNameWithoutPrefix, + string parentTagName, + IEnumerable> tagAttributes, + TagMatchingRule rule) + { + if (tagNameWithoutPrefix == null) + { + throw new ArgumentNullException(nameof(tagNameWithoutPrefix)); + } + + if (tagAttributes == null) + { + throw new ArgumentNullException(nameof(tagAttributes)); + } + + if (rule == null) + { + throw new ArgumentNullException(nameof(rule)); + } + + var satisfiesTagName = SatisfiesTagName(tagNameWithoutPrefix, rule); + if (!satisfiesTagName) + { + return false; + } + + var satisfiesParentTag = SatisfiesParentTag(parentTagName, rule); + if (!satisfiesParentTag) + { + return false; + } + + var satisfiesAttributes = SatisfiesAttributes(tagAttributes, rule); + if (!satisfiesAttributes) + { + return false; + } + + return true; + } + + public static bool SatisfiesTagName(string tagNameWithoutPrefix, TagMatchingRule rule) + { + if (tagNameWithoutPrefix == null) + { + throw new ArgumentNullException(nameof(tagNameWithoutPrefix)); + } + + if (rule == null) + { + throw new ArgumentNullException(nameof(rule)); + } + + if (rule.TagName != ElementCatchAllName && + rule.TagName != null && + !string.Equals(tagNameWithoutPrefix, rule.TagName, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + public static bool SatisfiesParentTag(string parentTagName, TagMatchingRule rule) + { + if (rule == null) + { + throw new ArgumentNullException(nameof(rule)); + } + + if (rule.ParentTag != null && !string.Equals(parentTagName, rule.ParentTag, StringComparison.OrdinalIgnoreCase)) + { + return false; + } + + return true; + } + + public static bool SatisfiesAttributes(IEnumerable> tagAttributes, TagMatchingRule rule) + { + if (tagAttributes == null) + { + throw new ArgumentNullException(nameof(tagAttributes)); + } + + if (rule == null) + { + throw new ArgumentNullException(nameof(rule)); + } + + if (!rule.Attributes.All( + requiredAttribute => tagAttributes.Any( + attribute => SatisfiesRequiredAttribute(attribute.Key, attribute.Value, requiredAttribute)))) + { + return false; + } + + return true; + } + + public static bool CanSatisfyBoundAttribute(string name, BoundAttributeDescriptor descriptor) + { + return SatisfiesBoundAttributeName(name, descriptor) || SatisfiesBoundAttributeIndexer(name, descriptor); + } + + public static bool SatisfiesBoundAttributeIndexer(string name, BoundAttributeDescriptor descriptor) + { + return descriptor.IndexerNamePrefix != null && + !SatisfiesBoundAttributeName(name, descriptor) && + name.StartsWith(descriptor.IndexerNamePrefix, StringComparison.OrdinalIgnoreCase); + } + + private static bool SatisfiesBoundAttributeName(string name, BoundAttributeDescriptor descriptor) + { + return string.Equals(descriptor.Name, name, StringComparison.OrdinalIgnoreCase); + } + + // Internal for testing + internal static bool SatisfiesRequiredAttribute(string attributeName, string attributeValue, RequiredAttributeDescriptor descriptor) + { + var nameMatches = false; + if (descriptor.NameComparison == RequiredAttributeDescriptor.NameComparisonMode.FullMatch) + { + nameMatches = string.Equals(descriptor.Name, attributeName, StringComparison.OrdinalIgnoreCase); + } + else if (descriptor.NameComparison == RequiredAttributeDescriptor.NameComparisonMode.PrefixMatch) + { + // attributeName cannot equal the Name if comparing as a PrefixMatch. + nameMatches = attributeName.Length != descriptor.Name.Length && + attributeName.StartsWith(descriptor.Name, StringComparison.OrdinalIgnoreCase); + } + else + { + Debug.Assert(false, "Unknown name comparison."); + } + + if (!nameMatches) + { + return false; + } + + switch (descriptor.ValueComparison) + { + case RequiredAttributeDescriptor.ValueComparisonMode.None: + return true; + case RequiredAttributeDescriptor.ValueComparisonMode.PrefixMatch: // Value starts with + return attributeValue.StartsWith(descriptor.Value, StringComparison.Ordinal); + case RequiredAttributeDescriptor.ValueComparisonMode.SuffixMatch: // Value ends with + return attributeValue.EndsWith(descriptor.Value, StringComparison.Ordinal); + case RequiredAttributeDescriptor.ValueComparisonMode.FullMatch: // Value equals + return string.Equals(attributeValue, descriptor.Value, StringComparison.Ordinal); + default: + Debug.Assert(false, "Unknown value comparison."); + return false; + } + } + } +} diff --git a/src/Microsoft.AspNetCore.Razor.Evolution/TagMatchingRuleBuilder.cs b/src/Microsoft.AspNetCore.Razor.Evolution/TagMatchingRuleBuilder.cs index 3d914717c6..b117f6fd52 100644 --- a/src/Microsoft.AspNetCore.Razor.Evolution/TagMatchingRuleBuilder.cs +++ b/src/Microsoft.AspNetCore.Razor.Evolution/TagMatchingRuleBuilder.cs @@ -122,7 +122,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution yield return diagnostic; } - else if (_tagName != TagHelperDescriptorProvider.ElementCatchAllTarget) + else if (_tagName != TagHelperMatchingConventions.ElementCatchAllName) { foreach (var character in _tagName) { diff --git a/src/Microsoft.CodeAnalysis.Razor/DefaultTagHelperDescriptorFactory.cs b/src/Microsoft.CodeAnalysis.Razor/DefaultTagHelperDescriptorFactory.cs index 7804f051a0..dd787d3765 100644 --- a/src/Microsoft.CodeAnalysis.Razor/DefaultTagHelperDescriptorFactory.cs +++ b/src/Microsoft.CodeAnalysis.Razor/DefaultTagHelperDescriptorFactory.cs @@ -371,7 +371,7 @@ namespace Microsoft.CodeAnalysis.Razor { if (attibute.ConstructorArguments.Length == 0) { - return TagHelperDescriptorProvider.ElementCatchAllTarget; + return TagHelperMatchingConventions.ElementCatchAllName; } else { diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultRazorTagHelperFactsService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultRazorTagHelperFactsService.cs deleted file mode 100644 index c4b02bb194..0000000000 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultRazorTagHelperFactsService.cs +++ /dev/null @@ -1,12 +0,0 @@ -// 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.ComponentModel.Composition; - -namespace Microsoft.VisualStudio.LanguageServices.Razor -{ - [Export(typeof(RazorTagHelperFactsService))] - internal class DefaultRazorTagHelperFactsService : RazorTagHelperFactsService - { - } -} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs new file mode 100644 index 0000000000..555b46792c --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs @@ -0,0 +1,170 @@ +// 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; +using Microsoft.AspNetCore.Razor.Evolution.Legacy; +using System; +using System.Collections.Generic; +using System.ComponentModel.Composition; +using System.Linq; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + [Export(typeof(TagHelperFactsService))] + internal class DefaultTagHelperFactsService : TagHelperFactsService + { + public override TagHelperBinding GetTagHelperBinding( + TagHelperDocumentContext documentContext, + string tagName, + IEnumerable> attributes, + string parentTag) + { + if (documentContext == null) + { + throw new ArgumentNullException(nameof(documentContext)); + } + + if (tagName == null) + { + throw new ArgumentNullException(nameof(tagName)); + } + + if (attributes == null) + { + throw new ArgumentNullException(nameof(attributes)); + } + + var descriptors = documentContext.TagHelpers; + if (descriptors == null || descriptors.Count == 0) + { + return null; + } + + var prefix = documentContext.Prefix; + var provider = new TagHelperDescriptorProvider(prefix, descriptors); + var binding = provider.GetTagHelperBinding(tagName, attributes, parentTag); + + return binding; + } + + public override IEnumerable GetBoundTagHelperAttributes( + TagHelperDocumentContext documentContext, + string attributeName, + TagHelperBinding binding) + { + if (documentContext == null) + { + throw new ArgumentNullException(nameof(documentContext)); + } + + if (attributeName == null) + { + throw new ArgumentNullException(nameof(attributeName)); + } + + if (binding == null) + { + throw new ArgumentNullException(nameof(binding)); + } + + var matchingBoundAttributes = new List(); + foreach (var descriptor in binding.Descriptors) + { + foreach (var boundAttributeDescriptor in descriptor.BoundAttributes) + { + if (TagHelperMatchingConventions.CanSatisfyBoundAttribute(attributeName, boundAttributeDescriptor)) + { + matchingBoundAttributes.Add(boundAttributeDescriptor); + + // Only one bound attribute can match an attribute + break; + } + } + } + + return matchingBoundAttributes; + } + + public override IReadOnlyList GetTagHelpersGivenTag( + TagHelperDocumentContext documentContext, + string tagName, + string parentTag) + { + if (documentContext == null) + { + throw new ArgumentNullException(nameof(documentContext)); + } + + if (tagName == null) + { + throw new ArgumentNullException(nameof(tagName)); + } + + var matchingDescriptors = new List(); + var descriptors = documentContext?.TagHelpers; + if (descriptors?.Count == 0) + { + return matchingDescriptors; + } + + var prefix = documentContext.Prefix ?? string.Empty; + if (!tagName.StartsWith(prefix, StringComparison.OrdinalIgnoreCase)) + { + // Can't possibly match TagHelpers, it doesn't start with the TagHelperPrefix. + return matchingDescriptors; + } + + var tagNameWithoutPrefix = tagName.Substring(prefix.Length); + for (var i = 0; i < descriptors.Count; i++) + { + var descriptor = descriptors[i]; + foreach (var rule in descriptor.TagMatchingRules) + { + if (TagHelperMatchingConventions.SatisfiesTagName(tagNameWithoutPrefix, rule) && + TagHelperMatchingConventions.SatisfiesParentTag(parentTag, rule)) + { + matchingDescriptors.Add(descriptor); + break; + } + } + } + + return matchingDescriptors; + } + + public override IReadOnlyList GetTagHelpersGivenParent(TagHelperDocumentContext documentContext, string parentTag) + { + if (documentContext == null) + { + throw new ArgumentNullException(nameof(documentContext)); + } + + if (parentTag == null) + { + throw new ArgumentNullException(nameof(parentTag)); + } + + var matchingDescriptors = new List(); + var descriptors = documentContext?.TagHelpers; + if (descriptors?.Count == 0) + { + return matchingDescriptors; + } + + for (var i = 0; i < descriptors.Count; i++) + { + var descriptor = descriptors[i]; + foreach (var rule in descriptor.TagMatchingRules) + { + if (TagHelperMatchingConventions.SatisfiesParentTag(parentTag, rule)) + { + matchingDescriptors.Add(descriptor); + break; + } + } + } + + return matchingDescriptors; + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/RazorTagHelperFactsService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/RazorTagHelperFactsService.cs deleted file mode 100644 index fa78d3aa78..0000000000 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/RazorTagHelperFactsService.cs +++ /dev/null @@ -1,9 +0,0 @@ -// 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.VisualStudio.LanguageServices.Razor -{ - public abstract class RazorTagHelperFactsService - { - } -} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/TagHelperFactsService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/TagHelperFactsService.cs new file mode 100644 index 0000000000..90c77cb909 --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/TagHelperFactsService.cs @@ -0,0 +1,20 @@ +// 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; +using Microsoft.AspNetCore.Razor.Evolution.Legacy; +using System.Collections.Generic; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + public abstract class TagHelperFactsService + { + public abstract TagHelperBinding GetTagHelperBinding(TagHelperDocumentContext documentContext, string tagName, IEnumerable> attributes, string parentTag); + + public abstract IEnumerable GetBoundTagHelperAttributes(TagHelperDocumentContext documentContext, string attributeName, TagHelperBinding binding); + + public abstract IReadOnlyList GetTagHelpersGivenTag(TagHelperDocumentContext documentContext, string tagName, string parentTag); + + public abstract IReadOnlyList GetTagHelpersGivenParent(TagHelperDocumentContext documentContext, string parentTag); + } +} diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/TagHelperDescriptorProviderTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/TagHelperDescriptorProviderTest.cs index 840c7d02f0..55357aea64 100644 --- a/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/TagHelperDescriptorProviderTest.cs +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/Legacy/TagHelperDescriptorProviderTest.cs @@ -115,20 +115,20 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy var catchAllDescriptor = ITagHelperDescriptorBuilder.Create("CatchAllTagHelper", "SomeAssembly") .TagMatchingRule(rule => rule - .RequireTagName(TagHelperDescriptorProvider.ElementCatchAllTarget) + .RequireTagName(TagHelperMatchingConventions.ElementCatchAllName) .RequireAttribute(attribute => attribute.Name("class"))) .Build(); var catchAllDescriptor2 = ITagHelperDescriptorBuilder.Create("CatchAllTagHelper2", "SomeAssembly") .TagMatchingRule(rule => rule - .RequireTagName(TagHelperDescriptorProvider.ElementCatchAllTarget) + .RequireTagName(TagHelperMatchingConventions.ElementCatchAllName) .RequireAttribute(attribute => attribute.Name("custom")) .RequireAttribute(attribute => attribute.Name("class"))) .Build(); var catchAllWildcardPrefixDescriptor = ITagHelperDescriptorBuilder.Create("CatchAllWildCardAttribute", "SomeAssembly") .TagMatchingRule(rule => rule - .RequireTagName(TagHelperDescriptorProvider.ElementCatchAllTarget) + .RequireTagName(TagHelperMatchingConventions.ElementCatchAllName) .RequireAttribute(attribute => attribute .Name("prefix-") @@ -254,7 +254,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy { // Arrange var catchAllDescriptor = ITagHelperDescriptorBuilder.Create("foo1", "SomeAssembly") - .TagMatchingRule(rule => rule.RequireTagName(TagHelperDescriptorProvider.ElementCatchAllTarget)) + .TagMatchingRule(rule => rule.RequireTagName(TagHelperMatchingConventions.ElementCatchAllName)) .Build(); var descriptors = new[] { catchAllDescriptor }; var provider = new TagHelperDescriptorProvider("th", descriptors); @@ -274,7 +274,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy { // Arrange var catchAllDescriptor = ITagHelperDescriptorBuilder.Create("foo1", "SomeAssembly") - .TagMatchingRule(rule => rule.RequireTagName(TagHelperDescriptorProvider.ElementCatchAllTarget)) + .TagMatchingRule(rule => rule.RequireTagName(TagHelperMatchingConventions.ElementCatchAllName)) .Build(); var descriptors = new[] { catchAllDescriptor }; var provider = new TagHelperDescriptorProvider("th:", descriptors); @@ -373,7 +373,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy .TagMatchingRule(rule => rule.RequireTagName("span")) .Build(); var catchAllDescriptor = ITagHelperDescriptorBuilder.Create("foo3", "SomeAssembly") - .TagMatchingRule(rule => rule.RequireTagName(TagHelperDescriptorProvider.ElementCatchAllTarget)) + .TagMatchingRule(rule => rule.RequireTagName(TagHelperMatchingConventions.ElementCatchAllName)) .Build(); var descriptors = new TagHelperDescriptor[] { divDescriptor, spanDescriptor, catchAllDescriptor }; var provider = new TagHelperDescriptorProvider(null, descriptors); @@ -427,7 +427,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution.Legacy // Arrange var multiRuleDescriptor = ITagHelperDescriptorBuilder.Create("foo", "SomeAssembly") .TagMatchingRule(rule => rule - .RequireTagName(TagHelperDescriptorProvider.ElementCatchAllTarget) + .RequireTagName(TagHelperMatchingConventions.ElementCatchAllName) .RequireParentTag("body")) .TagMatchingRule(rule => rule .RequireTagName("div")) diff --git a/test/Microsoft.AspNetCore.Razor.Evolution.Test/TagHelperRequiredAttributeDescriptorTest.cs b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TagHelperRequiredAttributeDescriptorTest.cs index bcf93f3c4b..9978be9f26 100644 --- a/test/Microsoft.AspNetCore.Razor.Evolution.Test/TagHelperRequiredAttributeDescriptorTest.cs +++ b/test/Microsoft.AspNetCore.Razor.Evolution.Test/TagHelperRequiredAttributeDescriptorTest.cs @@ -150,7 +150,7 @@ namespace Microsoft.AspNetCore.Razor.Evolution bool expectedResult) { // Act - var result = ((RequiredAttributeDescriptor)requiredAttributeDescriptor).IsMatch(attributeName, attributeValue); + var result = TagHelperMatchingConventions.SatisfiesRequiredAttribute(attributeName, attributeValue, (RequiredAttributeDescriptor)requiredAttributeDescriptor); // Assert Assert.Equal(expectedResult, result); diff --git a/test/Microsoft.CodeAnalysis.Razor.Test/DefaultTagHelperDescriptorFactoryTest.cs b/test/Microsoft.CodeAnalysis.Razor.Test/DefaultTagHelperDescriptorFactoryTest.cs index 1ea30a9965..79177a8e6c 100644 --- a/test/Microsoft.CodeAnalysis.Razor.Test/DefaultTagHelperDescriptorFactoryTest.cs +++ b/test/Microsoft.CodeAnalysis.Razor.Test/DefaultTagHelperDescriptorFactoryTest.cs @@ -720,7 +720,7 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces { typeof(AttributeTargetingTagHelper), CreateTagHelperDescriptor( - TagHelperDescriptorProvider.ElementCatchAllTarget, + TagHelperMatchingConventions.ElementCatchAllName, typeof(AttributeTargetingTagHelper).FullName, AssemblyName, ruleBuilders: new Action[] @@ -731,7 +731,7 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces { typeof(MultiAttributeTargetingTagHelper), CreateTagHelperDescriptor( - TagHelperDescriptorProvider.ElementCatchAllTarget, + TagHelperMatchingConventions.ElementCatchAllName, typeof(MultiAttributeTargetingTagHelper).FullName, AssemblyName, ruleBuilders: new Action[] @@ -747,7 +747,7 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces { typeof(MultiAttributeAttributeTargetingTagHelper), CreateTagHelperDescriptor( - TagHelperDescriptorProvider.ElementCatchAllTarget, + TagHelperMatchingConventions.ElementCatchAllName, typeof(MultiAttributeAttributeTargetingTagHelper).FullName, AssemblyName, ruleBuilders: new Action[] @@ -764,7 +764,7 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces { typeof(InheritedAttributeTargetingTagHelper), CreateTagHelperDescriptor( - TagHelperDescriptorProvider.ElementCatchAllTarget, + TagHelperMatchingConventions.ElementCatchAllName, typeof(InheritedAttributeTargetingTagHelper).FullName, AssemblyName, ruleBuilders: new Action[] @@ -856,7 +856,7 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces { typeof(AttributeWildcardTargetingTagHelper), CreateTagHelperDescriptor( - TagHelperDescriptorProvider.ElementCatchAllTarget, + TagHelperMatchingConventions.ElementCatchAllName, typeof(AttributeWildcardTargetingTagHelper).FullName, AssemblyName, ruleBuilders: new Action[] @@ -870,7 +870,7 @@ namespace Microsoft.CodeAnalysis.Razor.Workspaces { typeof(MultiAttributeWildcardTargetingTagHelper), CreateTagHelperDescriptor( - TagHelperDescriptorProvider.ElementCatchAllTarget, + TagHelperMatchingConventions.ElementCatchAllName, typeof(MultiAttributeWildcardTargetingTagHelper).FullName, AssemblyName, ruleBuilders: new Action[] diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultTagHelperFactsServiceTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultTagHelperFactsServiceTest.cs new file mode 100644 index 0000000000..bffe640ab8 --- /dev/null +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultTagHelperFactsServiceTest.cs @@ -0,0 +1,302 @@ +// 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; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + public class DefaultTagHelperFactsServiceTest + { + // Purposefully not thoroughly testing DefaultTagHelperFactsService.GetTagHelperBinding because it's a pass through + // into TagHelperDescriptorProvider.GetTagHelperBinding. + + [Fact] + public void GetTagHelperBinding_WorksAsExpected() + { + // Arrange + var documentDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule(rule => + rule + .RequireTagName("a") + .RequireAttribute(attribute => attribute.Name("asp-for"))) + .BindAttribute(attribute => + attribute + .Name("asp-for") + .TypeName(typeof(string).FullName) + .PropertyName("AspFor")) + .BindAttribute(attribute => + attribute + .Name("asp-route") + .TypeName(typeof(IDictionary).Namespace + "IDictionary") + .PropertyName("AspRoute") + .AsDictionary("asp-route-", typeof(string).FullName)) + .Build(), + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("input")) + .BindAttribute(attribute => + attribute + .Name("asp-for") + .TypeName(typeof(string).FullName) + .PropertyName("AspFor")) + .Build(), + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + var attributes = new[] + { + new KeyValuePair("asp-for", "Name") + }; + + // Act + var binding = service.GetTagHelperBinding(documentContext, "a", attributes, parentTag: "p"); + + // Assert + var descriptor = Assert.Single(binding.Descriptors); + Assert.Equal(documentDescriptors[0], descriptor, TagHelperDescriptorComparer.CaseSensitive); + var boundRule = Assert.Single(binding.GetBoundRules(descriptor)); + Assert.Equal(documentDescriptors[0].TagMatchingRules.First(), boundRule, TagMatchingRuleComparer.CaseSensitive); + } + + [Fact] + public void GetBoundTagHelperAttributes_MatchesPrefixedAttributeName() + { + // Arrange + var documentDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("a")) + .BindAttribute(attribute => + attribute + .Name("asp-for") + .TypeName(typeof(string).FullName) + .PropertyName("AspFor")) + .BindAttribute(attribute => + attribute + .Name("asp-route") + .TypeName(typeof(IDictionary).Namespace + "IDictionary") + .PropertyName("AspRoute") + .AsDictionary("asp-route-", typeof(string).FullName)) + .Build() + }; + var expectedAttributeDescriptors = new[] + { + documentDescriptors[0].BoundAttributes.Last() + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + var binding = service.GetTagHelperBinding(documentContext, "a", Enumerable.Empty>(), parentTag: null); + + // Act + var descriptors = service.GetBoundTagHelperAttributes(documentContext, "asp-route-something", binding); + + // Assert + Assert.Equal(expectedAttributeDescriptors, descriptors, BoundAttributeDescriptorComparer.CaseSensitive); + } + + [Fact] + public void GetBoundTagHelperAttributes_MatchesAttributeName() + { + // Arrange + var documentDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("input")) + .BindAttribute(attribute => + attribute + .Name("asp-for") + .TypeName(typeof(string).FullName) + .PropertyName("AspFor")) + .BindAttribute(attribute => + attribute + .Name("asp-extra") + .TypeName(typeof(string).FullName) + .PropertyName("AspExtra")) + .Build() + }; + var expectedAttributeDescriptors = new[] + { + documentDescriptors[0].BoundAttributes.First() + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + var binding = service.GetTagHelperBinding(documentContext, "input", Enumerable.Empty>(), parentTag: null); + + // Act + var descriptors = service.GetBoundTagHelperAttributes(documentContext, "asp-for", binding); + + // Assert + Assert.Equal(expectedAttributeDescriptors, descriptors, BoundAttributeDescriptorComparer.CaseSensitive); + } + + [Fact] + public void GetTagHelpersGivenTag_RequiresTagName() + { + // Arrange + var documentDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("strong")) + .Build() + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + + // Act + var descriptors = service.GetTagHelpersGivenTag(documentContext, "strong", "p"); + + // Assert + Assert.Equal(documentDescriptors, descriptors, TagHelperDescriptorComparer.CaseSensitive); + } + + [Fact] + public void GetTagHelpersGivenTag_RestrictsTagHelpersBasedOnTagName() + { + // Arrange + var expectedDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule( + rule => rule + .RequireTagName("a") + .RequireParentTag("div")) + .Build() + }; + var documentDescriptors = new[] + { + expectedDescriptors[0], + ITagHelperDescriptorBuilder.Create("TestType2", "TestAssembly") + .TagMatchingRule( + rule => rule + .RequireTagName("strong") + .RequireParentTag("div")) + .Build() + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + + // Act + var descriptors = service.GetTagHelpersGivenTag(documentContext, "a", "div"); + + // Assert + Assert.Equal(expectedDescriptors, descriptors, TagHelperDescriptorComparer.CaseSensitive); + } + + [Fact] + public void GetTagHelpersGivenTag_RestrictsTagHelpersBasedOnTagHelperPrefix() + { + // Arrange + var expectedDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("strong")) + .Build() + }; + var documentDescriptors = new[] + { + expectedDescriptors[0], + ITagHelperDescriptorBuilder.Create("TestType2", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("thstrong")) + .Build() + }; + var documentContext = TagHelperDocumentContext.Create("th", documentDescriptors); + var service = new DefaultTagHelperFactsService(); + + // Act + var descriptors = service.GetTagHelpersGivenTag(documentContext, "thstrong", "div"); + + // Assert + Assert.Equal(expectedDescriptors, descriptors, TagHelperDescriptorComparer.CaseSensitive); + } + + [Fact] + public void GetTagHelpersGivenTag_RestrictsTagHelpersBasedOnParent() + { + // Arrange + var expectedDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule( + rule => rule + .RequireTagName("strong") + .RequireParentTag("div")) + .Build() + }; + var documentDescriptors = new[] + { + expectedDescriptors[0], + ITagHelperDescriptorBuilder.Create("TestType2", "TestAssembly") + .TagMatchingRule( + rule => rule + .RequireTagName("strong") + .RequireParentTag("p")) + .Build() + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + + // Act + var descriptors = service.GetTagHelpersGivenTag(documentContext, "strong", "div"); + + // Assert + Assert.Equal(expectedDescriptors, descriptors, TagHelperDescriptorComparer.CaseSensitive); + } + + [Fact] + public void GetTagHelpersGivenParent_AllowsUnspecifiedParentTagHelpers() + { + // Arrange + var documentDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("div")) + .Build() + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + + // Act + var descriptors = service.GetTagHelpersGivenParent(documentContext, "p"); + + // Assert + Assert.Equal(documentDescriptors, descriptors, TagHelperDescriptorComparer.CaseSensitive); + } + + [Fact] + public void GetTagHelpersGivenParent_RestrictsTagHelpersBasedOnParent() + { + // Arrange + var expectedDescriptors = new[] + { + ITagHelperDescriptorBuilder.Create("TestType", "TestAssembly") + .TagMatchingRule( + rule => rule + .RequireTagName("p") + .RequireParentTag("div")) + .Build() + }; + var documentDescriptors = new[] + { + expectedDescriptors[0], + ITagHelperDescriptorBuilder.Create("TestType2", "TestAssembly") + .TagMatchingRule( + rule => rule + .RequireTagName("strong") + .RequireParentTag("p")) + .Build() + }; + var documentContext = TagHelperDocumentContext.Create(string.Empty, documentDescriptors); + var service = new DefaultTagHelperFactsService(); + + // Act + var descriptors = service.GetTagHelpersGivenParent(documentContext, "div"); + + // Assert + Assert.Equal(expectedDescriptors, descriptors, TagHelperDescriptorComparer.CaseSensitive); + } + } +}