From aec88e3eba339f91f75a4d1aa68ff7736aee34ee Mon Sep 17 00:00:00 2001 From: "N. Taylor Mullen" Date: Wed, 6 Jun 2018 14:30:42 -0700 Subject: [PATCH] Change Razor directive completions to use new completions API. - Kept the same behavior as we previously had with Razor directive completions. - Attempted adding additional functionalities such as lighting up Razor directive completion when completion was invoked on top of Razor directives (non-C#) but ran into issues involving the core HTML editor not consuming the new completion APIs yet. That's something we'll have to re-visit once they move to the new completion APIs. - Added tests to validate all aspects of new completion APIs. - Made completion provider turn on and off based off of feature flag. #1743 #1813 --- build/dependencies.props | 3 +- ...Microsoft.VisualStudio.Editor.Razor.csproj | 4 +- .../RazorDirectiveCompletionProvider.cs | 28 +- .../RazorDirectiveCompletionSource.cs | 167 ++++++++ .../RazorDirectiveCompletionSourceProvider.cs | 64 +++ .../RazorDirectiveCompletionProviderTest.cs | 73 +++- ...orDirectiveCompletionSourceProviderTest.cs | 99 +++++ .../RazorDirectiveCompletionSourceTest.cs | 369 ++++++++++++++++++ 8 files changed, 786 insertions(+), 21 deletions(-) create mode 100644 src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSource.cs create mode 100644 src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSourceProvider.cs create mode 100644 test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceProviderTest.cs create mode 100644 test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceTest.cs diff --git a/build/dependencies.props b/build/dependencies.props index 910334101b..3668a75360 100644 --- a/build/dependencies.props +++ b/build/dependencies.props @@ -25,6 +25,7 @@ 15.0.26606 15.6.161-preview 15.6.161-preview + 15.8.519 7.10.6070 15.3.224 2.0.6142705 @@ -42,7 +43,7 @@ 4.7.49 2.0.3 11.0.2 - 1.1.92 + 1.3.23 4.6.0-preview1-26727-04 4.3.0 4.6.0-preview1-26727-04 diff --git a/src/Microsoft.VisualStudio.Editor.Razor/Microsoft.VisualStudio.Editor.Razor.csproj b/src/Microsoft.VisualStudio.Editor.Razor/Microsoft.VisualStudio.Editor.Razor.csproj index aa50d39965..0022ff4c94 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/Microsoft.VisualStudio.Editor.Razor.csproj +++ b/src/Microsoft.VisualStudio.Editor.Razor/Microsoft.VisualStudio.Editor.Razor.csproj @@ -9,7 +9,9 @@ - + + + diff --git a/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionProvider.cs b/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionProvider.cs index 6a2d017716..fc3a4b9ed3 100644 --- a/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionProvider.cs +++ b/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionProvider.cs @@ -13,9 +13,9 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Completion; -using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Tags; using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion; using Microsoft.VisualStudio.Text; using Microsoft.VisualStudio.Text.Projection; @@ -36,16 +36,33 @@ namespace Microsoft.VisualStudio.Editor.Razor CSharpCodeParser.TagHelperPrefixDirectiveDescriptor, }; private readonly Lazy _codeDocumentProvider; + private readonly IAsyncCompletionBroker _asyncCompletionBroker; + private readonly RazorTextBufferProvider _textBufferProvider; [ImportingConstructor] - public RazorDirectiveCompletionProvider([Import(typeof(RazorCodeDocumentProvider))] Lazy codeDocumentProvider) + public RazorDirectiveCompletionProvider( + [Import(typeof(RazorCodeDocumentProvider))] Lazy codeDocumentProvider, + IAsyncCompletionBroker asyncCompletionBroker, + RazorTextBufferProvider textBufferProvider) { if (codeDocumentProvider == null) { throw new ArgumentNullException(nameof(codeDocumentProvider)); } + if (asyncCompletionBroker == null) + { + throw new ArgumentNullException(nameof(asyncCompletionBroker)); + } + + if (textBufferProvider == null) + { + throw new ArgumentNullException(nameof(textBufferProvider)); + } + _codeDocumentProvider = codeDocumentProvider; + _asyncCompletionBroker = asyncCompletionBroker; + _textBufferProvider = textBufferProvider; } public override Task GetDescriptionAsync(Document document, CompletionItem item, CancellationToken cancellationToken) @@ -86,6 +103,13 @@ namespace Microsoft.VisualStudio.Editor.Razor return Task.CompletedTask; } + if (!_textBufferProvider.TryGetFromDocument(context.Document, out var textBuffer) || + !_asyncCompletionBroker.IsCompletionSupported(textBuffer.ContentType)) + { + // Completion is not supported. + return Task.CompletedTask; + } + var result = AddCompletionItems(context); return result; diff --git a/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSource.cs b/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSource.cs new file mode 100644 index 0000000000..06677a843c --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSource.cs @@ -0,0 +1,167 @@ +// 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.Collections.Immutable; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Core.Imaging; +using Microsoft.VisualStudio.Imaging; +using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion; +using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Adornments; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + internal class RazorDirectiveCompletionSource : IAsyncCompletionSource + { + // Internal for testing + internal static readonly object DescriptionKey = new object(); + internal static readonly ImageElement DirectiveImageGlyph = new ImageElement( + new ImageId(KnownImageIds.ImageCatalogGuid, KnownImageIds.Type), + "Razor Directive."); + internal static readonly ImmutableArray DirectiveCompletionFilters = new[] { + new CompletionFilter("Razor Directive", "r", DirectiveImageGlyph) + }.ToImmutableArray(); + private static readonly IEnumerable DefaultDirectives = new[] + { + CSharpCodeParser.AddTagHelperDirectiveDescriptor, + CSharpCodeParser.RemoveTagHelperDirectiveDescriptor, + CSharpCodeParser.TagHelperPrefixDirectiveDescriptor, + }; + + // Internal for testing + internal readonly VisualStudioRazorParser _parser; + private readonly ForegroundDispatcher _foregroundDispatcher; + + public RazorDirectiveCompletionSource( + VisualStudioRazorParser parser, + ForegroundDispatcher foregroundDispatcher) + { + if (parser == null) + { + throw new ArgumentNullException(nameof(parser)); + } + + if (foregroundDispatcher == null) + { + throw new ArgumentNullException(nameof(foregroundDispatcher)); + } + + _parser = parser; + _foregroundDispatcher = foregroundDispatcher; + } + + public Task GetCompletionContextAsync( + InitialTrigger trigger, + SnapshotPoint triggerLocation, + SnapshotSpan applicableSpan, + CancellationToken token) + { + _foregroundDispatcher.AssertBackgroundThread(); + + var syntaxTree = _parser.CodeDocument?.GetSyntaxTree(); + if (!AtDirectiveCompletionPoint(syntaxTree, triggerLocation)) + { + return Task.FromResult(CompletionContext.Empty); + } + + var completionItems = GetCompletionItems(syntaxTree); + var context = new CompletionContext(completionItems.ToImmutableArray()); + return Task.FromResult(context); + } + + public Task GetDescriptionAsync(CompletionItem item, CancellationToken token) + { + if (!item.Properties.TryGetProperty(DescriptionKey, out var directiveDescription)) + { + directiveDescription = string.Empty; + } + + return Task.FromResult(directiveDescription); + } + + public bool TryGetApplicableToSpan(char typeChar, SnapshotPoint triggerLocation, out SnapshotSpan applicableToSpan, CancellationToken token) + { + // The applicable span for completion is the piece of text a completion is for. For example: + // @Date|Time.Now + // If you trigger completion at the | then the applicable span is the region of 'DateTime'; however, Razor + // doesn't know this information so we rely on Roslyn to define what the applicable span for a completion is. + applicableToSpan = default(SnapshotSpan); + return false; + } + + // Internal for testing + internal List GetCompletionItems(RazorSyntaxTree syntaxTree) + { + var directives = syntaxTree.Options.Directives.Concat(DefaultDirectives); + var completionItems = new List(); + foreach (var directive in directives) + { + var completionDisplayText = directive.DisplayName ?? directive.Directive; + var completionItem = new CompletionItem( + displayText: completionDisplayText, + filterText: completionDisplayText, + insertText: directive.Directive, + source: this, + icon: DirectiveImageGlyph, + filters: DirectiveCompletionFilters, + suffix: string.Empty, + sortText: completionDisplayText, + attributeIcons: ImmutableArray.Empty); + completionItem.Properties.AddProperty(DescriptionKey, directive.Description); + completionItems.Add(completionItem); + } + + return completionItems; + } + + // Internal for testing + internal static bool AtDirectiveCompletionPoint(RazorSyntaxTree syntaxTree, SnapshotPoint location) + { + if (syntaxTree == null) + { + return false; + } + + var change = new SourceChange(location.Position, 0, string.Empty); + var owner = syntaxTree.Root.LocateOwner(change); + + if (owner == null) + { + return false; + } + + if (owner.ChunkGenerator is ExpressionChunkGenerator && + owner.Tokens.All(IsDirectiveCompletableToken) && + // Do not provide IntelliSense for explicit expressions. Explicit expressions will usually look like: + // [@] [(] [DateTime.Now] [)] + owner.Parent?.Children.Count > 1 && + owner.Parent.Children[1] == owner) + { + return true; + } + + return false; + } + + // Internal for testing + internal static bool IsDirectiveCompletableToken(IToken token) + { + if (!(token is CSharpToken csharpToken)) + { + return false; + } + + return csharpToken.Type == CSharpTokenType.Identifier || + // Marker symbol + csharpToken.Type == CSharpTokenType.Unknown; + } + } +} diff --git a/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSourceProvider.cs b/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSourceProvider.cs new file mode 100644 index 0000000000..343bfba36f --- /dev/null +++ b/src/Microsoft.VisualStudio.Editor.Razor/RazorDirectiveCompletionSourceProvider.cs @@ -0,0 +1,64 @@ +// 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.ComponentModel.Composition; +using System.Linq; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Utilities; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + [System.Composition.Shared] + [Export(typeof(IAsyncCompletionSourceProvider))] + [Name("Razor directive completion provider.")] + [ContentType(RazorLanguage.CoreContentType)] + internal class RazorDirectiveCompletionSourceProvider : IAsyncCompletionSourceProvider + { + private readonly ForegroundDispatcher _foregroundDispatcher; + + [ImportingConstructor] + public RazorDirectiveCompletionSourceProvider(ForegroundDispatcher foregroundDispatcher) + { + if (foregroundDispatcher == null) + { + throw new ArgumentNullException(nameof(foregroundDispatcher)); + } + + _foregroundDispatcher = foregroundDispatcher; + } + + public IAsyncCompletionSource GetOrCreate(ITextView textView) + { + if (textView == null) + { + throw new ArgumentNullException(nameof(textView)); + } + + var razorBuffer = textView.BufferGraph.GetRazorBuffers().FirstOrDefault(); + if (!razorBuffer.Properties.TryGetProperty(typeof(RazorDirectiveCompletionSource), out IAsyncCompletionSource completionSource)) + { + completionSource = CreateCompletionSource(razorBuffer); + razorBuffer.Properties.AddProperty(typeof(RazorDirectiveCompletionSource), completionSource); + } + + return completionSource; + } + + // Internal for testing + internal IAsyncCompletionSource CreateCompletionSource(ITextBuffer razorBuffer) + { + if (!razorBuffer.Properties.TryGetProperty(typeof(VisualStudioRazorParser), out VisualStudioRazorParser parser)) + { + // Parser hasn't been associated with the text buffer yet. + return null; + } + + var completionSource = new RazorDirectiveCompletionSource(parser, _foregroundDispatcher); + return completionSource; + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionProviderTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionProviderTest.cs index 1b76f772b6..d6e5ff747d 100644 --- a/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionProviderTest.cs +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionProviderTest.cs @@ -4,7 +4,6 @@ using System; using System.Collections.Generic; using System.Collections.Immutable; -using System.ComponentModel.Composition; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -16,9 +15,12 @@ using Microsoft.CodeAnalysis.Completion; using Microsoft.CodeAnalysis.Options; using Microsoft.CodeAnalysis.Tags; using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion; using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Utilities; using Moq; using Xunit; +using ITextBuffer = Microsoft.VisualStudio.Text.ITextBuffer; namespace Microsoft.VisualStudio.Editor.Razor { @@ -31,12 +33,23 @@ namespace Microsoft.VisualStudio.Editor.Razor CSharpCodeParser.TagHelperPrefixDirectiveDescriptor, }; + public RazorDirectiveCompletionProviderTest() + { + CompletionBroker = Mock.Of(broker => broker.IsCompletionSupported(It.IsAny()) == true); + var razorBuffer = Mock.Of(buffer => buffer.ContentType == Mock.Of()); + TextBufferProvider = Mock.Of(provider => provider.TryGetFromDocument(It.IsAny(), out razorBuffer) == true); + } + + private IAsyncCompletionBroker CompletionBroker { get; } + + private RazorTextBufferProvider TextBufferProvider { get; } + [Fact] public void AtDirectiveCompletionPoint_ReturnsFalseIfChangeHasNoOwner() { // Arrange var codeDocumentProvider = CreateCodeDocumentProvider("@", Enumerable.Empty()); - var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider); + var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider, CompletionBroker, TextBufferProvider); var document = CreateDocument(); codeDocumentProvider.Value.TryGetFromDocument(document, out var codeDocument); var syntaxTree = codeDocument.GetSyntaxTree(); @@ -61,7 +74,10 @@ namespace Microsoft.VisualStudio.Editor.Razor [RazorDirectiveCompletionProvider.DescriptionKey] = expectedDescription, }).ToImmutableDictionary()); var codeDocumentProvider = new Mock(); - var completionProvider = new RazorDirectiveCompletionProvider(new Lazy(() => codeDocumentProvider.Object)); + var completionProvider = new RazorDirectiveCompletionProvider( + new Lazy(() => codeDocumentProvider.Object), + CompletionBroker, + TextBufferProvider); // Act var description = await completionProvider.GetDescriptionAsync(document, item, CancellationToken.None); @@ -80,7 +96,10 @@ namespace Microsoft.VisualStudio.Editor.Razor var document = CreateDocument(); var item = CompletionItem.Create("TestDirective"); var codeDocumentProvider = new Mock(); - var completionProvider = new RazorDirectiveCompletionProvider(new Lazy(() => codeDocumentProvider.Object)); + var completionProvider = new RazorDirectiveCompletionProvider( + new Lazy(() => codeDocumentProvider.Object), + CompletionBroker, + TextBufferProvider); // Act var description = await completionProvider.GetDescriptionAsync(document, item, CancellationToken.None); @@ -95,7 +114,10 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Arrange var codeDocumentProvider = new Mock(MockBehavior.Strict); - var completionProvider = new FailOnGetCompletionsProvider(new Lazy(() => codeDocumentProvider.Object)); + var completionProvider = new FailOnGetCompletionsProvider( + new Lazy(() => codeDocumentProvider.Object), + CompletionBroker, + TextBufferProvider); var document = CreateDocument(); document = document.WithFilePath("NotRazor.cs"); var context = CreateContext(1, completionProvider, document); @@ -121,7 +143,10 @@ namespace Microsoft.VisualStudio.Editor.Razor }); var codeDocumentProvider = new Mock(MockBehavior.Strict); - var completionProvider = new FailOnGetCompletionsProvider(new Lazy(() => codeDocumentProvider.Object)); + var completionProvider = new FailOnGetCompletionsProvider( + new Lazy(() => codeDocumentProvider.Object), + CompletionBroker, + TextBufferProvider); var context = CreateContext(1, completionProvider, document); // Act & Assert @@ -136,7 +161,10 @@ namespace Microsoft.VisualStudio.Editor.Razor var codeDocumentProvider = new Mock(); codeDocumentProvider.Setup(provider => provider.TryGetFromDocument(It.IsAny(), out codeDocument)) .Returns(false); - var completionProvider = new FailOnGetCompletionsProvider(new Lazy(() => codeDocumentProvider.Object)); + var completionProvider = new FailOnGetCompletionsProvider( + new Lazy(() => codeDocumentProvider.Object), + CompletionBroker, + TextBufferProvider); var document = CreateDocument(); var context = CreateContext(1, completionProvider, document); @@ -149,7 +177,11 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Arrange var codeDocumentProvider = CreateCodeDocumentProvider("@", Enumerable.Empty()); - var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider, false); + var completionProvider = new FailOnGetCompletionsProvider( + codeDocumentProvider, + CompletionBroker, + TextBufferProvider, + canGetSnapshotPoint: false); var document = CreateDocument(); var context = CreateContext(0, completionProvider, document); @@ -162,7 +194,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Arrange var codeDocumentProvider = CreateCodeDocumentProvider("@", Enumerable.Empty()); - var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider); + var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider, CompletionBroker, TextBufferProvider); var document = CreateDocument(); var context = CreateContext(0, completionProvider, document); @@ -177,7 +209,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Arrange var codeDocumentProvider = CreateCodeDocumentProvider("@" + content, Enumerable.Empty()); - var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider); + var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider, CompletionBroker, TextBufferProvider); var document = CreateDocument(); var context = CreateContext(1, completionProvider, document); @@ -190,7 +222,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Arrange var codeDocumentProvider = CreateCodeDocumentProvider("@()", Enumerable.Empty()); - var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider); + var completionProvider = new FailOnGetCompletionsProvider(codeDocumentProvider, CompletionBroker, TextBufferProvider); var document = CreateDocument(); var context = CreateContext(2, completionProvider, document); @@ -206,7 +238,10 @@ namespace Microsoft.VisualStudio.Editor.Razor var codeDocument = TestRazorCodeDocument.CreateEmpty(); codeDocumentProvider.Setup(provider => provider.TryGetFromDocument(It.IsAny(), out codeDocument)) .Returns(true); - var completionProvider = new FailOnGetCompletionsProvider(new Lazy(() => codeDocumentProvider.Object)); + var completionProvider = new FailOnGetCompletionsProvider( + new Lazy(() => codeDocumentProvider.Object), + CompletionBroker, + TextBufferProvider); var document = CreateDocument(); var context = CreateContext(2, completionProvider, document); @@ -219,7 +254,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Arrange var codeDocumentProvider = CreateCodeDocumentProvider("@", Enumerable.Empty()); - var completionProvider = new RazorDirectiveCompletionProvider(codeDocumentProvider); + var completionProvider = new RazorDirectiveCompletionProvider(codeDocumentProvider, CompletionBroker, TextBufferProvider); var document = CreateDocument(); codeDocumentProvider.Value.TryGetFromDocument(document, out var codeDocument); var syntaxTree = codeDocument.GetSyntaxTree(); @@ -240,7 +275,7 @@ namespace Microsoft.VisualStudio.Editor.Razor { // Arrange var codeDocumentProvider = CreateCodeDocumentProvider("@", new[] { SectionDirective.Directive }); - var completionProvider = new RazorDirectiveCompletionProvider(codeDocumentProvider); + var completionProvider = new RazorDirectiveCompletionProvider(codeDocumentProvider, CompletionBroker, TextBufferProvider); var document = CreateDocument(); codeDocumentProvider.Value.TryGetFromDocument(document, out var codeDocument); var syntaxTree = codeDocument.GetSyntaxTree(); @@ -263,7 +298,7 @@ namespace Microsoft.VisualStudio.Editor.Razor // Arrange var customDirective = DirectiveDescriptor.CreateSingleLineDirective("custom"); var codeDocumentProvider = CreateCodeDocumentProvider("@", new[] { customDirective }); - var completionProvider = new RazorDirectiveCompletionProvider(codeDocumentProvider); + var completionProvider = new RazorDirectiveCompletionProvider(codeDocumentProvider, CompletionBroker, TextBufferProvider); var document = CreateDocument(); codeDocumentProvider.Value.TryGetFromDocument(document, out var codeDocument); var syntaxTree = codeDocument.GetSyntaxTree(); @@ -348,8 +383,12 @@ namespace Microsoft.VisualStudio.Editor.Razor { private readonly bool _canGetSnapshotPoint; - public FailOnGetCompletionsProvider(Lazy codeDocumentProvider, bool canGetSnapshotPoint = true) - : base(codeDocumentProvider) + public FailOnGetCompletionsProvider( + Lazy codeDocumentProvider, + IAsyncCompletionBroker asyncCompletionBroker, + RazorTextBufferProvider textBufferProvider, + bool canGetSnapshotPoint = true) + : base(codeDocumentProvider, asyncCompletionBroker, textBufferProvider) { _canGetSnapshotPoint = canGetSnapshotPoint; } diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceProviderTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceProviderTest.cs new file mode 100644 index 0000000000..5cffff529b --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceProviderTest.cs @@ -0,0 +1,99 @@ +// 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.ObjectModel; +using Microsoft.CodeAnalysis.Razor; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Text.Editor; +using Microsoft.VisualStudio.Text.Projection; +using Microsoft.VisualStudio.Utilities; +using Moq; +using Xunit; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + public class RazorDirectiveCompletionSourceProviderTest : ForegroundDispatcherTestBase + { + private IContentType RazorContentType { get; } = Mock.Of(c => c.IsOfType(RazorLanguage.ContentType) == true); + + private IContentType NonRazorContentType { get; } = Mock.Of(c => c.IsOfType(It.IsAny()) == false); + + [Fact] + public void CreateCompletionSource_ReturnsNullIfParserHasNotBeenAssocitedWithRazorBuffer() + { + // Arrange + var expectedParser = Mock.Of(); + var properties = new PropertyCollection(); + properties.AddProperty(typeof(VisualStudioRazorParser), expectedParser); + var razorBuffer = Mock.Of(buffer => buffer.ContentType == RazorContentType && buffer.Properties == properties); + var completionSourceProvider = new RazorDirectiveCompletionSourceProvider(Dispatcher); + + // Act + var completionSource = completionSourceProvider.CreateCompletionSource(razorBuffer); + + // Assert + var completionSourceImpl = Assert.IsType(completionSource); + Assert.Same(expectedParser, completionSourceImpl._parser); + } + + [Fact] + public void CreateCompletionSource_CreatesACompletionSourceWithTextBuffersParser() + { + // Arrange + var razorBuffer = Mock.Of(buffer => buffer.ContentType == RazorContentType && buffer.Properties == new PropertyCollection()); + var completionSourceProvider = new RazorDirectiveCompletionSourceProvider(Dispatcher); + + // Act + var completionSource = completionSourceProvider.CreateCompletionSource(razorBuffer); + + // Assert + Assert.Null(completionSource); + } + + [Fact] + public void GetOrCreate_ReturnsNullIfRazorBufferHasNotBeenAssociatedWithTextView() + { + // Arrange + var textView = CreateTextView(NonRazorContentType, new PropertyCollection()); + var completionSourceProvider = new RazorDirectiveCompletionSourceProvider(Dispatcher); + + // Act + var completionSource = completionSourceProvider.GetOrCreate(textView); + + // Assert + Assert.Null(completionSource); + } + + [Fact] + public void GetOrCreate_CachesCompletionSource() + { + // Arrange + var expectedParser = Mock.Of(); + var properties = new PropertyCollection(); + properties.AddProperty(typeof(VisualStudioRazorParser), expectedParser); + var textView = CreateTextView(RazorContentType, properties); + var completionSourceProvider = new RazorDirectiveCompletionSourceProvider(Dispatcher); + + // Act + var completionSource1 = completionSourceProvider.GetOrCreate(textView); + var completionSource2 = completionSourceProvider.GetOrCreate(textView); + + // Assert + Assert.Same(completionSource1, completionSource2); + } + + private static ITextView CreateTextView(IContentType contentType, PropertyCollection properties) + { + var bufferGraph = new Mock(); + bufferGraph.Setup(graph => graph.GetTextBuffers(It.IsAny>())) + .Returns(new Collection() + { + Mock.Of(buffer => buffer.ContentType == contentType && buffer.Properties == properties) + }); + var textView = Mock.Of(view => view.BufferGraph == bufferGraph.Object); + + return textView; + } + } +} diff --git a/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceTest.cs b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceTest.cs new file mode 100644 index 0000000000..3e51fbf0fe --- /dev/null +++ b/test/Microsoft.VisualStudio.Editor.Razor.Test/RazorDirectiveCompletionSourceTest.cs @@ -0,0 +1,369 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +using System.Collections.Generic; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion; +using Microsoft.VisualStudio.Language.Intellisense.AsyncCompletion.Data; +using Microsoft.VisualStudio.Text; +using Moq; +using Xunit; +using Span = Microsoft.VisualStudio.Text.Span; + +namespace Microsoft.VisualStudio.Editor.Razor +{ + public class RazorDirectiveCompletionSourceTest : ForegroundDispatcherTestBase + { + private static readonly IReadOnlyList DefaultDirectives = new[] + { + CSharpCodeParser.AddTagHelperDirectiveDescriptor, + CSharpCodeParser.RemoveTagHelperDirectiveDescriptor, + CSharpCodeParser.TagHelperPrefixDirectiveDescriptor, + }; + + [ForegroundFact] + public async Task GetCompletionContextAsync_DoesNotProvideCompletionsPriorToParseResults() + { + // Arrange + var text = "@validCompletion"; + var parser = Mock.Of(); // CodeDocument will be null faking a parser without a parse. + var completionSource = new RazorDirectiveCompletionSource(parser, Dispatcher); + var documentSnapshot = new StringTextSnapshot(text); + var triggerLocation = new SnapshotPoint(documentSnapshot, 4); + var applicableSpan = new SnapshotSpan(documentSnapshot, new Span(1, text.Length - 1 /* @ */)); + + // Act + var completionContext = await Task.Run( + async () => await completionSource.GetCompletionContextAsync(new InitialTrigger(), triggerLocation, applicableSpan, CancellationToken.None)); + + // Assert + Assert.Empty(completionContext.Items); + } + + [ForegroundFact] + public async Task GetCompletionContextAsync_DoesNotProvideCompletionsWhenNotAtCompletionPoint() + { + // Arrange + var text = "@(NotValidCompletionLocation)"; + var parser = CreateParser(text); + var completionSource = new RazorDirectiveCompletionSource(parser, Dispatcher); + var documentSnapshot = new StringTextSnapshot(text); + var triggerLocation = new SnapshotPoint(documentSnapshot, 4); + var applicableSpan = new SnapshotSpan(documentSnapshot, new Span(2, text.Length - 3 /* @() */)); + + // Act + var completionContext = await Task.Run( + async () => await completionSource.GetCompletionContextAsync(new InitialTrigger(), triggerLocation, applicableSpan, CancellationToken.None)); + + // Assert + Assert.Empty(completionContext.Items); + } + + // This is more of an integration level test validating the end-to-end completion flow. + [ForegroundFact] + public async Task GetCompletionContextAsync_ProvidesCompletionsWhenAtCompletionPoint() + { + // Arrange + var text = "@addTag"; + var parser = CreateParser(text, SectionDirective.Directive); + var completionSource = new RazorDirectiveCompletionSource(parser, Dispatcher); + var documentSnapshot = new StringTextSnapshot(text); + var triggerLocation = new SnapshotPoint(documentSnapshot, 4); + var applicableSpan = new SnapshotSpan(documentSnapshot, new Span(1, text.Length - 1 /* @ */)); + + // Act + var completionContext = await Task.Run( + async () => await completionSource.GetCompletionContextAsync(new InitialTrigger(), triggerLocation, applicableSpan, CancellationToken.None)); + + // Assert + Assert.Collection( + completionContext.Items, + item => AssertRazorCompletionItem(SectionDirective.Directive, item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[0], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[1], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[2], item, completionSource)); + } + + [Fact] + public void GetCompletionItems_ReturnsDefaultDirectivesAsCompletionItems() + { + // Arrange + var syntaxTree = CreateSyntaxTree("@addTag"); + var completionSource = new RazorDirectiveCompletionSource(Mock.Of(), Dispatcher); + + // Act + var completionItems = completionSource.GetCompletionItems(syntaxTree); + + // Assert + Assert.Collection( + completionItems, + item => AssertRazorCompletionItem(DefaultDirectives[0], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[1], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[2], item, completionSource)); + } + + [Fact] + public void GetCompletionItems_ReturnsCustomDirectivesAsCompletionItems() + { + // Arrange + var customDirective = DirectiveDescriptor.CreateSingleLineDirective("custom", builder => + { + builder.Description = "My Custom Directive."; + }); + var syntaxTree = CreateSyntaxTree("@addTag", customDirective); + var completionSource = new RazorDirectiveCompletionSource(Mock.Of(), Dispatcher); + + // Act + var completionItems = completionSource.GetCompletionItems(syntaxTree); + + // Assert + Assert.Collection( + completionItems, + item => AssertRazorCompletionItem(customDirective, item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[0], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[1], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[2], item, completionSource)); + } + + [Fact] + public void GetCompletionItems_UsesDisplayNamesWhenNotNull() + { + // Arrange + var customDirective = DirectiveDescriptor.CreateSingleLineDirective("custom", builder => + { + builder.DisplayName = "different"; + builder.Description = "My Custom Directive."; + }); + var syntaxTree = CreateSyntaxTree("@addTag", customDirective); + var completionSource = new RazorDirectiveCompletionSource(Mock.Of(), Dispatcher); + + // Act + var completionItems = completionSource.GetCompletionItems(syntaxTree); + + // Assert + Assert.Collection( + completionItems, + item => AssertRazorCompletionItem("different", customDirective, item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[0], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[1], item, completionSource), + item => AssertRazorCompletionItem(DefaultDirectives[2], item, completionSource)); + } + + [Fact] + public async Task GetDescriptionAsync_AddsDirectiveDescriptionIfPropertyExists() + { + // Arrange + var completionItem = new CompletionItem("TestDirective", Mock.Of()); + var expectedDescription = "The expected description"; + completionItem.Properties.AddProperty(RazorDirectiveCompletionSource.DescriptionKey, expectedDescription); + var completionSource = new RazorDirectiveCompletionSource(Mock.Of(), Dispatcher); + + // Act + var descriptionObject = await completionSource.GetDescriptionAsync(completionItem, CancellationToken.None); + + // Assert + var description = Assert.IsType(descriptionObject); + Assert.Equal(expectedDescription, descriptionObject); + } + + [Fact] + public async Task GetDescriptionAsync_DoesNotAddDescriptionWhenPropertyAbsent() + { + // Arrange + var completionItem = new CompletionItem("TestDirective", Mock.Of()); + var completionSource = new RazorDirectiveCompletionSource(Mock.Of(), Dispatcher); + + // Act + var descriptionObject = await completionSource.GetDescriptionAsync(completionItem, CancellationToken.None); + + // Assert + var description = Assert.IsType(descriptionObject); + Assert.Equal(string.Empty, description); + } + + [Fact] + public void AtDirectiveCompletionPoint_ReturnsFalseIfSyntaxTreeNull() + { + // Act + var result = RazorDirectiveCompletionSource.AtDirectiveCompletionPoint(syntaxTree: null, location: new SnapshotPoint()); + + // Assert + Assert.False(result); + } + + [Fact] + public void AtDirectiveCompletionPoint_ReturnsFalseIfNoOwner() + { + // Arrange + var syntaxTree = CreateSyntaxTree("@"); + var snapshotPoint = new SnapshotPoint(new StringTextSnapshot("@ text"), 2); + + // Act + var result = RazorDirectiveCompletionSource.AtDirectiveCompletionPoint(syntaxTree, snapshotPoint); + + // Assert + Assert.False(result); + } + + [Fact] + public void AtDirectiveCompletionPoint_ReturnsFalseWhenOwnerIsNotExpression() + { + // Arrange + var syntaxTree = CreateSyntaxTree("@{"); + var snapshotPoint = new SnapshotPoint(new StringTextSnapshot("@{"), 2); + + // Act + var result = RazorDirectiveCompletionSource.AtDirectiveCompletionPoint(syntaxTree, snapshotPoint); + + // Assert + Assert.False(result); + } + + [Fact] + public void AtDirectiveCompletionPoint_ReturnsFalseWhenOwnerIsComplexExpression() + { + // Arrange + var syntaxTree = CreateSyntaxTree("@DateTime.Now"); + var snapshotPoint = new SnapshotPoint(new StringTextSnapshot("@DateTime.Now"), 2); + + // Act + var result = RazorDirectiveCompletionSource.AtDirectiveCompletionPoint(syntaxTree, snapshotPoint); + + // Assert + Assert.False(result); + } + + [Fact] + public void AtDirectiveCompletionPoint_ReturnsFalseWhenOwnerIsExplicitExpression() + { + // Arrange + var syntaxTree = CreateSyntaxTree("@(something)"); + var snapshotPoint = new SnapshotPoint(new StringTextSnapshot("@(something)"), 4); + + // Act + var result = RazorDirectiveCompletionSource.AtDirectiveCompletionPoint(syntaxTree, snapshotPoint); + + // Assert + Assert.False(result); + } + + [Fact] + public void AtDirectiveCompletionPoint_ReturnsTrueForSimpleImplicitExpressions() + { + // Arrange + var syntaxTree = CreateSyntaxTree("@mod"); + var snapshotPoint = new SnapshotPoint(new StringTextSnapshot("@mod"), 2); + + // Act + var result = RazorDirectiveCompletionSource.AtDirectiveCompletionPoint(syntaxTree, snapshotPoint); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsDirectiveCompletableToken_ReturnsTrueForCSharpIdentifiers() + { + // Arrange + var csharpToken = new CSharpToken("model", CSharpTokenType.Identifier); + + // Act + var result = RazorDirectiveCompletionSource.IsDirectiveCompletableToken(csharpToken); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsDirectiveCompletableToken_ReturnsTrueForCSharpMarkerTokens() + { + // Arrange + var csharpToken = new CSharpToken(string.Empty, CSharpTokenType.Unknown); + + // Act + var result = RazorDirectiveCompletionSource.IsDirectiveCompletableToken(csharpToken); + + // Assert + Assert.True(result); + } + + [Fact] + public void IsDirectiveCompletableToken_ReturnsFalseForNonCSharpTokens() + { + // Arrange + var token = Mock.Of(); + + // Act + var result = RazorDirectiveCompletionSource.IsDirectiveCompletableToken(token); + + // Assert + Assert.False(result); + } + + [Fact] + public void IsDirectiveCompletableToken_ReturnsFalseForInvalidCSharpTokens() + { + // Arrange + var csharpToken = new CSharpToken("~", CSharpTokenType.Tilde); + + // Act + var result = RazorDirectiveCompletionSource.IsDirectiveCompletableToken(csharpToken); + + // Assert + Assert.False(result); + } + + private static void AssertRazorCompletionItem(string completionDisplayText, DirectiveDescriptor directive, CompletionItem item, IAsyncCompletionSource source) + { + Assert.Equal(item.DisplayText, completionDisplayText); + Assert.Equal(item.FilterText, completionDisplayText); + Assert.Equal(item.InsertText, directive.Directive); + Assert.Same(item.Source, source); + Assert.True(item.Properties.TryGetProperty(RazorDirectiveCompletionSource.DescriptionKey, out var actualDescription)); + Assert.Equal(directive.Description, actualDescription); + + AssertRazorCompletionItemDefaults(item); + } + + private static void AssertRazorCompletionItem(DirectiveDescriptor directive, CompletionItem item, IAsyncCompletionSource source) => + AssertRazorCompletionItem(directive.Directive, directive, item, source); + + private static void AssertRazorCompletionItemDefaults(CompletionItem item) + { + Assert.Equal(item.Icon.ImageId.Guid, RazorDirectiveCompletionSource.DirectiveImageGlyph.ImageId.Guid); + var filter = Assert.Single(item.Filters); + Assert.Same(RazorDirectiveCompletionSource.DirectiveCompletionFilters[0], filter); + Assert.Equal(string.Empty, item.Suffix); + Assert.Equal(item.DisplayText, item.SortText); + Assert.Empty(item.AttributeIcons); + } + + private static VisualStudioRazorParser CreateParser(string text, params DirectiveDescriptor[] directives) + { + var syntaxTree = CreateSyntaxTree(text, directives); + var codeDocument = TestRazorCodeDocument.Create(text); + codeDocument.SetSyntaxTree(syntaxTree); + var parser = Mock.Of(p => p.CodeDocument == codeDocument); + + return parser; + } + + private static RazorSyntaxTree CreateSyntaxTree(string text, params DirectiveDescriptor[] directives) + { + var sourceDocument = TestRazorSourceDocument.Create(text); + var options = RazorParserOptions.Create(builder => + { + foreach (var directive in directives) + { + builder.Directives.Add(directive); + } + }); + var syntaxTree = RazorSyntaxTree.Parse(sourceDocument, options); + return syntaxTree; + } + } +}