diff --git a/src/Microsoft.AspNetCore.Razor.Language/CodeGeneration/CSharpRenderingContext.cs b/src/Microsoft.AspNetCore.Razor.Language/CodeGeneration/CSharpRenderingContext.cs index 52150b11d0..8a91ec03b3 100644 --- a/src/Microsoft.AspNetCore.Razor.Language/CodeGeneration/CSharpRenderingContext.cs +++ b/src/Microsoft.AspNetCore.Razor.Language/CodeGeneration/CSharpRenderingContext.cs @@ -3,8 +3,8 @@ using System; using System.Collections.Generic; -using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.AspNetCore.Razor.Language.Intermediate; +using Microsoft.AspNetCore.Razor.Language.Legacy; namespace Microsoft.AspNetCore.Razor.Language.CodeGeneration { diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperCompletionService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperCompletionService.cs new file mode 100644 index 0000000000..9fb24c0dcb --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperCompletionService.cs @@ -0,0 +1,151 @@ +// 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.ComponentModel.Composition; +using System.Linq; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + [Export(typeof(TagHelperCompletionService))] + internal class DefaultTagHelperCompletionService : TagHelperCompletionService + { + private readonly TagHelperFactsService _tagHelperFactsService; + private static readonly HashSet _emptyHashSet = new HashSet(); + + [ImportingConstructor] + public DefaultTagHelperCompletionService(TagHelperFactsService tagHelperFactsService) + { + _tagHelperFactsService = tagHelperFactsService; + } + + public override ElementCompletionResult GetElementCompletions(ElementCompletionContext completionContext) + { + if (completionContext == null) + { + throw new ArgumentNullException(nameof(completionContext)); + } + + var elementCompletions = new Dictionary>(StringComparer.OrdinalIgnoreCase); + + AddAllowedChildrenCompletions(completionContext, elementCompletions); + + if (elementCompletions.Count > 0) + { + // If the containing element is already a TagHelper and only allows certain children. + var emptyResult = ElementCompletionResult.Create(elementCompletions); + return emptyResult; + } + + elementCompletions = completionContext.ExistingCompletions.ToDictionary( + completion => completion, + _ => new HashSet(), + StringComparer.OrdinalIgnoreCase); + + var possibleChildDescriptors = _tagHelperFactsService.GetTagHelpersGivenParent(completionContext.DocumentContext, completionContext.ContainingTagName); + foreach (var possibleDescriptor in possibleChildDescriptors) + { + var addRuleCompletions = false; + var outputHint = possibleDescriptor.TagOutputHint; + + // Filter out catch-all rules because TagHelpers that target attributes only would light up every child tag otherwise. Force those TagHelpers + // to have additional requirements before showing them in the element completion list. + var nonCatchAllRules = possibleDescriptor.TagMatchingRules.Where(rule => rule.TagName != TagHelperMatchingConventions.ElementCatchAllName); + foreach (var rule in nonCatchAllRules) + { + if (elementCompletions.ContainsKey(rule.TagName)) + { + addRuleCompletions = true; + } + else if (outputHint != null && elementCompletions.ContainsKey(outputHint)) + { + // If the possible descriptors final output tag already exists in our list of completions, we should add every representation + // of that descriptor to the possible element completions. + addRuleCompletions = true; + } + else if (!completionContext.InHTMLSchema(rule.TagName)) + { + // If there is an unknown HTML schema tag that doesn't exist in the current completion we should add it. This happens for + // TagHelpers that target non-schema oriented tags. + addRuleCompletions = true; + } + + if (addRuleCompletions) + { + if (!elementCompletions.TryGetValue(rule.TagName, out var existingRuleDescriptors)) + { + existingRuleDescriptors = new HashSet(); + elementCompletions[rule.TagName] = existingRuleDescriptors; + } + + existingRuleDescriptors.Add(possibleDescriptor); + } + } + } + + var result = ElementCompletionResult.Create(elementCompletions); + return result; + } + + private void AddAllowedChildrenCompletions( + ElementCompletionContext completionContext, + Dictionary> elementCompletions) + { + if (completionContext.ContainingTagName == null) + { + // If we're at the root then there's no containing TagHelper to specify allowed children. + return; + } + + var prefix = completionContext.DocumentContext.Prefix ?? string.Empty; + var binding = _tagHelperFactsService.GetTagHelperBinding( + completionContext.DocumentContext, + completionContext.ContainingTagName, + completionContext.Attributes, + completionContext.ContainingParentTagName); + + if (binding == null) + { + // Containing tag is not a TagHelper; therefore, it allows any children. + return; + } + + foreach (var descriptor in binding.Descriptors) + { + if (descriptor.AllowedChildTags == null) + { + continue; + } + + foreach (var childTag in descriptor.AllowedChildTags) + { + var prefixedName = string.Concat(prefix, childTag); + var descriptors = _tagHelperFactsService.GetTagHelpersGivenTag( + completionContext.DocumentContext, + prefixedName, + completionContext.ContainingTagName); + + if (descriptors.Count == 0) + { + if (!elementCompletions.ContainsKey(prefixedName)) + { + elementCompletions[prefixedName] = _emptyHashSet; + } + + continue; + } + + if (!elementCompletions.TryGetValue(prefixedName, out var existingRuleDescriptors)) + { + existingRuleDescriptors = new HashSet(); + elementCompletions[prefixedName] = existingRuleDescriptors; + } + + existingRuleDescriptors.UnionWith(descriptors); + } + } + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs index a8a53fd811..93fc5e7988 100644 --- a/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/DefaultTagHelperFactsService.cs @@ -1,11 +1,11 @@ // 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.Language; -using Microsoft.AspNetCore.Razor.Language.Legacy; using System; using System.Collections.Generic; using System.ComponentModel.Composition; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Legacy; namespace Microsoft.VisualStudio.LanguageServices.Razor { diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ElementCompletionContext.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ElementCompletionContext.cs new file mode 100644 index 0000000000..f7a0e20016 --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ElementCompletionContext.cs @@ -0,0 +1,55 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + public sealed class ElementCompletionContext + { + public ElementCompletionContext( + TagHelperDocumentContext documentContext, + IEnumerable existingCompletions, + string containingTagName, + IEnumerable> attributes, + string containingParentTagName, + Func inHTMLSchema) + { + if (documentContext == null) + { + throw new ArgumentNullException(nameof(documentContext)); + } + + if (existingCompletions == null) + { + throw new ArgumentNullException(nameof(existingCompletions)); + } + + if (inHTMLSchema == null) + { + throw new ArgumentNullException(nameof(inHTMLSchema)); + } + + DocumentContext = documentContext; + ExistingCompletions = existingCompletions; + ContainingTagName = containingTagName; + Attributes = attributes; + ContainingParentTagName = containingParentTagName; + InHTMLSchema = inHTMLSchema; + } + + public TagHelperDocumentContext DocumentContext { get; } + + public IEnumerable ExistingCompletions { get; } + + public string ContainingTagName { get; } + + public IEnumerable> Attributes { get; } + + public string ContainingParentTagName { get; } + + public Func InHTMLSchema { get; } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/ElementCompletionResult.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/ElementCompletionResult.cs new file mode 100644 index 0000000000..4088662ffb --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/ElementCompletionResult.cs @@ -0,0 +1,41 @@ +// 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.Linq; +using Microsoft.AspNetCore.Razor.Language; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + public abstract class ElementCompletionResult + { + private ElementCompletionResult() + { + } + + public abstract IReadOnlyDictionary> Completions { get; } + + internal static ElementCompletionResult Create(Dictionary> completions) + { + var readonlyCompletions = completions.ToDictionary( + key => key.Key, + value => (IEnumerable)value.Value, + completions.Comparer); + var result = new DefaultElementCompletionResult(readonlyCompletions); + + return result; + } + + private class DefaultElementCompletionResult : ElementCompletionResult + { + private readonly IReadOnlyDictionary> _completions; + + public DefaultElementCompletionResult(IReadOnlyDictionary> completions) + { + _completions = completions; + } + + public override IReadOnlyDictionary> Completions => _completions; + } + } +} diff --git a/src/Microsoft.VisualStudio.LanguageServices.Razor/TagHelperCompletionService.cs b/src/Microsoft.VisualStudio.LanguageServices.Razor/TagHelperCompletionService.cs new file mode 100644 index 0000000000..a2673f705c --- /dev/null +++ b/src/Microsoft.VisualStudio.LanguageServices.Razor/TagHelperCompletionService.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.VisualStudio.LanguageServices.Razor +{ + public abstract class TagHelperCompletionService + { + public abstract ElementCompletionResult GetElementCompletions(ElementCompletionContext completionContext); + } +} diff --git a/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultTagHelperCompletionServiceTest.cs b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultTagHelperCompletionServiceTest.cs new file mode 100644 index 0000000000..fd01c2e5c6 --- /dev/null +++ b/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/DefaultTagHelperCompletionServiceTest.cs @@ -0,0 +1,370 @@ +// 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.Language; +using System.Collections.Generic; +using System.Linq; +using Xunit; + +namespace Microsoft.VisualStudio.LanguageServices.Razor +{ + public class DefaultTagHelperCompletionServiceTest + { + [Fact] + public void GetElementCompletions_AllowsMultiTargetingTagHelpers() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("BoldTagHelper1", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("strong")) + .TagMatchingRule(rule => rule.RequireTagName("b")) + .TagMatchingRule(rule => rule.RequireTagName("bold")) + .Build(), + TagHelperDescriptorBuilder.Create("BoldTagHelper2", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("strong")) + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["strong"] = new HashSet { documentDescriptors[0], documentDescriptors[1] }, + ["b"] = new HashSet { documentDescriptors[0] }, + ["bold"] = new HashSet { documentDescriptors[0] }, + }); + + var existingCompletions = new[] { "strong", "b", "bold" }; + var completionContext = BuildCompletionContext( + documentDescriptors, + existingCompletions, + containingTagName: "ul"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_CombinesDescriptorsOnExistingCompletions() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("LiTagHelper1", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("li")) + .Build(), + TagHelperDescriptorBuilder.Create("LiTagHelper2", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("li")) + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["li"] = new HashSet { documentDescriptors[0], documentDescriptors[1] }, + }); + + var existingCompletions = new[] { "li" }; + var completionContext = BuildCompletionContext(documentDescriptors, existingCompletions, containingTagName: "ul"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_NewCompletionsForSchemaTagsNotInExistingCompletionsAreIgnored() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("SuperLiTagHelper", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("superli")) + .Build(), + TagHelperDescriptorBuilder.Create("LiTagHelper", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("li")) + .TagOutputHint("strong") + .Build(), + TagHelperDescriptorBuilder.Create("DivTagHelper", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("div")) + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["li"] = new HashSet { documentDescriptors[1] }, + ["superli"] = new HashSet { documentDescriptors[0] }, + }); + + var existingCompletions = new[] { "li" }; + var completionContext = BuildCompletionContext(documentDescriptors, existingCompletions, containingTagName: "ul"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_OutputHintIsCrossReferencedWithExistingCompletions() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("DivTagHelper", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("div")) + .TagOutputHint("li") + .Build(), + TagHelperDescriptorBuilder.Create("LiTagHelper", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("li")) + .TagOutputHint("strong") + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["div"] = new HashSet { documentDescriptors[0] }, + ["li"] = new HashSet { documentDescriptors[1] }, + }); + + var existingCompletions = new[] { "li" }; + var completionContext = BuildCompletionContext(documentDescriptors, existingCompletions, containingTagName: "ul"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_EnsuresDescriptorsHaveSatisfiedParent() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("LiTagHelper1", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("li")) + .Build(), + TagHelperDescriptorBuilder.Create("LiTagHelper2", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("li").RequireParentTag("ol")) + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["li"] = new HashSet { documentDescriptors[0] }, + }); + + var existingCompletions = new[] { "li" }; + var completionContext = BuildCompletionContext(documentDescriptors, existingCompletions, containingTagName: "ul"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_AllowedChildrenAreIgnoredWhenAtRoot() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("CatchAll", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("*")) + .AllowChildTag("b") + .AllowChildTag("bold") + .AllowChildTag("div") + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["p"] = new HashSet(), + ["em"] = new HashSet(), + }); + + var existingCompletions = new[] { "p", "em" }; + var completionContext = BuildCompletionContext( + documentDescriptors, + existingCompletions, + containingTagName: null, + containingParentTagName: null); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_DoesNotReturnExistingCompletionsWhenAllowedChildren() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("BoldParent", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("div")) + .AllowChildTag("b") + .AllowChildTag("bold") + .AllowChildTag("div") + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["b"] = new HashSet(), + ["bold"] = new HashSet(), + ["div"] = new HashSet { documentDescriptors[0] } + }); + + var existingCompletions = new[] { "p", "em" }; + var completionContext = BuildCompletionContext(documentDescriptors, existingCompletions, containingTagName: "div"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_CapturesAllAllowedChildTagsFromParentTagHelpers_NoneTagHelpers() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("BoldParent", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("div")) + .AllowChildTag("b") + .AllowChildTag("bold") + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["b"] = new HashSet(), + ["bold"] = new HashSet(), + }); + + var completionContext = BuildCompletionContext(documentDescriptors, Enumerable.Empty(), containingTagName: "div"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_CapturesAllAllowedChildTagsFromParentTagHelpers_SomeTagHelpers() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("BoldParent", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("div")) + .AllowChildTag("b") + .AllowChildTag("bold") + .AllowChildTag("div") + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["b"] = new HashSet(), + ["bold"] = new HashSet(), + ["div"] = new HashSet { documentDescriptors[0] } + }); + + var completionContext = BuildCompletionContext(documentDescriptors, Enumerable.Empty(), containingTagName: "div"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + [Fact] + public void GetElementCompletions_CapturesAllAllowedChildTagsFromParentTagHelpers_AllTagHelpers() + { + // Arrange + var documentDescriptors = new[] + { + TagHelperDescriptorBuilder.Create("BoldParentCatchAll", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("*")) + .AllowChildTag("strong") + .AllowChildTag("div") + .AllowChildTag("b") + .Build(), + TagHelperDescriptorBuilder.Create("BoldParent", "TestAssembly") + .TagMatchingRule(rule => rule.RequireTagName("div")) + .AllowChildTag("b") + .AllowChildTag("bold") + .Build(), + }; + var expectedCompletions = ElementCompletionResult.Create(new Dictionary>() + { + ["strong"] = new HashSet { documentDescriptors[0] }, + ["b"] = new HashSet { documentDescriptors[0] }, + ["bold"] = new HashSet { documentDescriptors[0] }, + ["div"] = new HashSet { documentDescriptors[0], documentDescriptors[1] }, + }); + + var completionContext = BuildCompletionContext(documentDescriptors, Enumerable.Empty(), containingTagName: "div"); + var service = CreateTagHelperCompletionFactsService(); + + // Act + var completions = service.GetElementCompletions(completionContext); + + // Assert + AssertCompletionsAreEquivalent(expectedCompletions, completions); + } + + private static DefaultTagHelperCompletionService CreateTagHelperCompletionFactsService() + { + var tagHelperFactService = new DefaultTagHelperFactsService(); + var completionFactService = new DefaultTagHelperCompletionService(tagHelperFactService); + + return completionFactService; + } + + private static void AssertCompletionsAreEquivalent(ElementCompletionResult expected, ElementCompletionResult actual) + { + Assert.Equal(expected.Completions.Count, actual.Completions.Count); + + foreach (var expectedCompletion in expected.Completions) + { + var actualValue = actual.Completions[expectedCompletion.Key]; + Assert.NotNull(actualValue); + Assert.Equal(expectedCompletion.Value, actualValue, TagHelperDescriptorComparer.CaseSensitive); + } + } + + private static ElementCompletionContext BuildCompletionContext( + IEnumerable descriptors, + IEnumerable existingCompletions, + string containingTagName, + string containingParentTagName = "body") + { + var documentContext = TagHelperDocumentContext.Create(string.Empty, descriptors); + var completionContext = new ElementCompletionContext( + documentContext, + existingCompletions, + containingTagName, + attributes: Enumerable.Empty>(), + containingParentTagName: containingParentTagName, + inHTMLSchema: (tag) => tag == "strong" || tag == "b" || tag == "bold" || tag == "li" || tag == "div"); + + return completionContext; + } + } +}