Add element completion API for TagHelpers.

- Added a new `TagHelperCompletionService` which can be queried for information on what completion information should be provided.
- Added tests to validate the completion service's expectations.
- Fixed an issue where `TagHelper`s with output hints that did not exist in a passed in completion would never be highlighted as `TagHelper`'s; even when their primary targeting element was already in the element completion list.

#1181
This commit is contained in:
N. Taylor Mullen 2017-04-06 15:27:59 -07:00
parent 1b8a4e704c
commit a1cfd22a32
7 changed files with 630 additions and 3 deletions

View File

@ -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
{

View File

@ -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<TagHelperDescriptor> _emptyHashSet = new HashSet<TagHelperDescriptor>();
[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<string, HashSet<TagHelperDescriptor>>(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<TagHelperDescriptor>(),
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<TagHelperDescriptor>();
elementCompletions[rule.TagName] = existingRuleDescriptors;
}
existingRuleDescriptors.Add(possibleDescriptor);
}
}
}
var result = ElementCompletionResult.Create(elementCompletions);
return result;
}
private void AddAllowedChildrenCompletions(
ElementCompletionContext completionContext,
Dictionary<string, HashSet<TagHelperDescriptor>> 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<TagHelperDescriptor>();
elementCompletions[prefixedName] = existingRuleDescriptors;
}
existingRuleDescriptors.UnionWith(descriptors);
}
}
}
}
}

View File

@ -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
{

View File

@ -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<string> existingCompletions,
string containingTagName,
IEnumerable<KeyValuePair<string, string>> attributes,
string containingParentTagName,
Func<string, bool> 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<string> ExistingCompletions { get; }
public string ContainingTagName { get; }
public IEnumerable<KeyValuePair<string, string>> Attributes { get; }
public string ContainingParentTagName { get; }
public Func<string, bool> InHTMLSchema { get; }
}
}

View File

@ -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<string, IEnumerable<TagHelperDescriptor>> Completions { get; }
internal static ElementCompletionResult Create(Dictionary<string, HashSet<TagHelperDescriptor>> completions)
{
var readonlyCompletions = completions.ToDictionary(
key => key.Key,
value => (IEnumerable<TagHelperDescriptor>)value.Value,
completions.Comparer);
var result = new DefaultElementCompletionResult(readonlyCompletions);
return result;
}
private class DefaultElementCompletionResult : ElementCompletionResult
{
private readonly IReadOnlyDictionary<string, IEnumerable<TagHelperDescriptor>> _completions;
public DefaultElementCompletionResult(IReadOnlyDictionary<string, IEnumerable<TagHelperDescriptor>> completions)
{
_completions = completions;
}
public override IReadOnlyDictionary<string, IEnumerable<TagHelperDescriptor>> Completions => _completions;
}
}
}

View File

@ -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);
}
}

View File

@ -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<string, HashSet<TagHelperDescriptor>>()
{
["strong"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0], documentDescriptors[1] },
["b"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0] },
["bold"] = new HashSet<TagHelperDescriptor> { 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<string, HashSet<TagHelperDescriptor>>()
{
["li"] = new HashSet<TagHelperDescriptor> { 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<string, HashSet<TagHelperDescriptor>>()
{
["li"] = new HashSet<TagHelperDescriptor> { documentDescriptors[1] },
["superli"] = new HashSet<TagHelperDescriptor> { 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<string, HashSet<TagHelperDescriptor>>()
{
["div"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0] },
["li"] = new HashSet<TagHelperDescriptor> { 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<string, HashSet<TagHelperDescriptor>>()
{
["li"] = new HashSet<TagHelperDescriptor> { 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<string, HashSet<TagHelperDescriptor>>()
{
["p"] = new HashSet<TagHelperDescriptor>(),
["em"] = new HashSet<TagHelperDescriptor>(),
});
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<string, HashSet<TagHelperDescriptor>>()
{
["b"] = new HashSet<TagHelperDescriptor>(),
["bold"] = new HashSet<TagHelperDescriptor>(),
["div"] = new HashSet<TagHelperDescriptor> { 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<string, HashSet<TagHelperDescriptor>>()
{
["b"] = new HashSet<TagHelperDescriptor>(),
["bold"] = new HashSet<TagHelperDescriptor>(),
});
var completionContext = BuildCompletionContext(documentDescriptors, Enumerable.Empty<string>(), 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<string, HashSet<TagHelperDescriptor>>()
{
["b"] = new HashSet<TagHelperDescriptor>(),
["bold"] = new HashSet<TagHelperDescriptor>(),
["div"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0] }
});
var completionContext = BuildCompletionContext(documentDescriptors, Enumerable.Empty<string>(), 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<string, HashSet<TagHelperDescriptor>>()
{
["strong"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0] },
["b"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0] },
["bold"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0] },
["div"] = new HashSet<TagHelperDescriptor> { documentDescriptors[0], documentDescriptors[1] },
});
var completionContext = BuildCompletionContext(documentDescriptors, Enumerable.Empty<string>(), 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<TagHelperDescriptor> descriptors,
IEnumerable<string> existingCompletions,
string containingTagName,
string containingParentTagName = "body")
{
var documentContext = TagHelperDocumentContext.Create(string.Empty, descriptors);
var completionContext = new ElementCompletionContext(
documentContext,
existingCompletions,
containingTagName,
attributes: Enumerable.Empty<KeyValuePair<string, string>>(),
containingParentTagName: containingParentTagName,
inHTMLSchema: (tag) => tag == "strong" || tag == "b" || tag == "bold" || tag == "li" || tag == "div");
return completionContext;
}
}
}