From 70ac333af1f05bd7196bb1b4713c56c071be7b95 Mon Sep 17 00:00:00 2001 From: Sam Harwell Date: Fri, 24 May 2019 10:31:40 -0500 Subject: [PATCH] Support URL navigation for href in Quick Info --- .../DocumentationCommentXmlNames.cs | 1 + .../AsyncCompletion/CompletionSource.cs | 5 +- .../Implementation/IntelliSense/Helpers.cs | 29 ++++- .../QuickInfo/IntellisenseQuickInfoBuilder.cs | 7 +- ...stractIntellisenseQuickInfoBuilderTests.vb | 5 + ...IntellisenseQuickInfoBuilderTests_Links.vb | 106 ++++++++++++++++++ .../Core/Portable/Common/TaggedText.cs | 14 ++- ...ctDocumentationCommentFormattingService.cs | 50 ++++++++- 8 files changed, 201 insertions(+), 16 deletions(-) create mode 100644 src/EditorFeatures/Test2/IntelliSense/IntellisenseQuickInfoBuilderTests_Links.vb diff --git a/src/Compilers/Core/Portable/InternalUtilities/DocumentationCommentXmlNames.cs b/src/Compilers/Core/Portable/InternalUtilities/DocumentationCommentXmlNames.cs index 15a2e232900..6eacb0ee608 100644 --- a/src/Compilers/Core/Portable/InternalUtilities/DocumentationCommentXmlNames.cs +++ b/src/Compilers/Core/Portable/InternalUtilities/DocumentationCommentXmlNames.cs @@ -37,6 +37,7 @@ internal static class DocumentationCommentXmlNames public const string ValueElementName = "value"; public const string CrefAttributeName = "cref"; + public const string HrefAttributeName = "href"; public const string FileAttributeName = "file"; public const string InstanceAttributeName = "instance"; public const string LangwordAttributeName = "langword"; diff --git a/src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs b/src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs index df98d8b31a0..a9735a3f926 100644 --- a/src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs +++ b/src/EditorFeatures/Core/Implementation/IntelliSense/AsyncCompletion/CompletionSource.cs @@ -294,15 +294,16 @@ public async Task GetDescriptionAsync(IAsyncCompletionSession session, V } var service = document.GetLanguageService(); - if (service == null) { return null; } + var navigateToLinkService = document.Project.Solution.Workspace.Services.GetRequiredService(); + var description = await service.GetDescriptionAsync(document, roslynItem, cancellationToken).ConfigureAwait(false); - var elements = IntelliSense.Helpers.BuildClassifiedTextElements(description.TaggedParts).ToArray(); + var elements = IntelliSense.Helpers.BuildClassifiedTextElements(description.TaggedParts, navigateToLinkService).ToArray(); if (elements.Length == 0) { return new ClassifiedTextElement(); diff --git a/src/EditorFeatures/Core/Implementation/IntelliSense/Helpers.cs b/src/EditorFeatures/Core/Implementation/IntelliSense/Helpers.cs index 271481c82c6..7d286e021dd 100644 --- a/src/EditorFeatures/Core/Implementation/IntelliSense/Helpers.cs +++ b/src/EditorFeatures/Core/Implementation/IntelliSense/Helpers.cs @@ -1,8 +1,10 @@ // Copyright (c) Microsoft. 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 Microsoft.CodeAnalysis.Classification; using Microsoft.VisualStudio.Text.Adornments; using Roslyn.Utilities; @@ -11,13 +13,13 @@ namespace Microsoft.CodeAnalysis.Editor.Implementation.IntelliSense { internal static class Helpers { - internal static IEnumerable BuildClassifiedTextElements(ImmutableArray taggedTexts) + internal static IEnumerable BuildClassifiedTextElements(ImmutableArray taggedTexts, INavigateToLinkService navigateToLinkService) { var index = 0; - return BuildClassifiedTextElements(taggedTexts, ref index); + return BuildClassifiedTextElements(taggedTexts, ref index, navigateToLinkService); } - private static IReadOnlyCollection BuildClassifiedTextElements(ImmutableArray taggedTexts, ref int index) + private static IReadOnlyCollection BuildClassifiedTextElements(ImmutableArray taggedTexts, ref int index, INavigateToLinkService navigateToLinkService) { // This method produces a sequence of zero or more paragraphs var paragraphs = new List(); @@ -41,7 +43,7 @@ private static IReadOnlyCollection BuildClassifiedTextElements(Immutable } index++; - var nestedElements = BuildClassifiedTextElements(taggedTexts, ref index); + var nestedElements = BuildClassifiedTextElements(taggedTexts, ref index, navigateToLinkService); if (nestedElements.Count <= 1) { currentParagraph.Add(new ContainerElement( @@ -108,7 +110,15 @@ private static IReadOnlyCollection BuildClassifiedTextElements(Immutable { // This is tagged text getting added to the current line we are building. var style = GetClassifiedTextRunStyle(part.Style); - currentRuns.Add(new ClassifiedTextRun(part.Tag.ToClassificationTypeName(), part.Text, style)); + if (part.NavigationTarget is object) + { + var tooltip = part.NavigationTarget; + currentRuns.Add(new ClassifiedTextRun(part.Tag.ToClassificationTypeName(), part.Text, () => NavigateToQuickInfoTarget(tooltip, navigateToLinkService), tooltip, style)); + } + else + { + currentRuns.Add(new ClassifiedTextRun(part.Tag.ToClassificationTypeName(), part.Text, style)); + } } } @@ -127,6 +137,15 @@ private static IReadOnlyCollection BuildClassifiedTextElements(Immutable return paragraphs; } + private static void NavigateToQuickInfoTarget(string navigationTarget, INavigateToLinkService navigateToLinkService) + { + if (Uri.TryCreate(navigationTarget, UriKind.Absolute, out var absoluteUri)) + { + navigateToLinkService.TryNavigateToLinkAsync(absoluteUri, CancellationToken.None); + return; + } + } + private static ClassifiedTextRunStyle GetClassifiedTextRunStyle(TaggedTextStyle style) { var result = ClassifiedTextRunStyle.Plain; diff --git a/src/EditorFeatures/Core/Implementation/IntelliSense/QuickInfo/IntellisenseQuickInfoBuilder.cs b/src/EditorFeatures/Core/Implementation/IntelliSense/QuickInfo/IntellisenseQuickInfoBuilder.cs index 8a274ebc9b8..85c60da6ad3 100644 --- a/src/EditorFeatures/Core/Implementation/IntelliSense/QuickInfo/IntellisenseQuickInfoBuilder.cs +++ b/src/EditorFeatures/Core/Implementation/IntelliSense/QuickInfo/IntellisenseQuickInfoBuilder.cs @@ -43,10 +43,11 @@ internal static class IntellisenseQuickInfoBuilder var elements = new List(); var descSection = quickInfoItem.Sections.FirstOrDefault(s => s.Kind == QuickInfoSectionKinds.Description); + var navigateToLinkService = document.Project.Solution.Workspace.Services.GetRequiredService(); if (descSection != null) { var isFirstElement = true; - foreach (var element in Helpers.BuildClassifiedTextElements(descSection.TaggedParts)) + foreach (var element in Helpers.BuildClassifiedTextElements(descSection.TaggedParts, navigateToLinkService)) { if (isFirstElement) { @@ -68,7 +69,7 @@ internal static class IntellisenseQuickInfoBuilder if (documentationCommentSection != null) { var isFirstElement = true; - foreach (var element in Helpers.BuildClassifiedTextElements(documentationCommentSection.TaggedParts)) + foreach (var element in Helpers.BuildClassifiedTextElements(documentationCommentSection.TaggedParts, navigateToLinkService)) { if (isFirstElement) { @@ -92,7 +93,7 @@ internal static class IntellisenseQuickInfoBuilder // Add the remaining sections as Stacked style elements.AddRange( quickInfoItem.Sections.Where(s => s.Kind != QuickInfoSectionKinds.Description && s.Kind != QuickInfoSectionKinds.DocumentationComments) - .SelectMany(s => Helpers.BuildClassifiedTextElements(s.TaggedParts))); + .SelectMany(s => Helpers.BuildClassifiedTextElements(s.TaggedParts, navigateToLinkService))); // build text for RelatedSpan if (quickInfoItem.RelatedSpans.Any()) diff --git a/src/EditorFeatures/Test2/IntelliSense/AbstractIntellisenseQuickInfoBuilderTests.vb b/src/EditorFeatures/Test2/IntelliSense/AbstractIntellisenseQuickInfoBuilderTests.vb index 5e2606d3f78..9395d4734f7 100644 --- a/src/EditorFeatures/Test2/IntelliSense/AbstractIntellisenseQuickInfoBuilderTests.vb +++ b/src/EditorFeatures/Test2/IntelliSense/AbstractIntellisenseQuickInfoBuilderTests.vb @@ -189,6 +189,11 @@ Namespace Microsoft.CodeAnalysis.Editor.UnitTests.IntelliSense If classifiedTextRun IsNot Nothing Then Dim classification = GetKnownClassification(classifiedTextRun.ClassificationTypeName) result.Append($"{classification}, ""{classifiedTextRun.Text.Replace("""", """""")}""") + If classifiedTextRun.NavigationAction IsNot Nothing OrElse Not String.IsNullOrEmpty(classifiedTextRun.Tooltip) Then + Dim tooltip = If(classifiedTextRun.Tooltip IsNot Nothing, $"""{classifiedTextRun.Tooltip.Replace("""", """""")}""", "Nothing") + result.Append($", navigationAction:=Sub() Return, {tooltip}") + End If + If classifiedTextRun.Style <> ClassifiedTextRunStyle.Plain Then result.Append($", {TextRunStyleToString(classifiedTextRun.Style)}") End If diff --git a/src/EditorFeatures/Test2/IntelliSense/IntellisenseQuickInfoBuilderTests_Links.vb b/src/EditorFeatures/Test2/IntelliSense/IntellisenseQuickInfoBuilderTests_Links.vb new file mode 100644 index 00000000000..1de716c51a6 --- /dev/null +++ b/src/EditorFeatures/Test2/IntelliSense/IntellisenseQuickInfoBuilderTests_Links.vb @@ -0,0 +1,106 @@ +' Copyright (c) Microsoft. All Rights Reserved. Licensed under the Apache License, Version 2.0. See License.txt in the project root for license information. + +Imports Microsoft.CodeAnalysis.Classification +Imports Microsoft.VisualStudio.Core.Imaging +Imports Microsoft.VisualStudio.Imaging +Imports Microsoft.VisualStudio.Text.Adornments + +Namespace Microsoft.CodeAnalysis.Editor.UnitTests.IntelliSense + Public Class IntellisenseQuickInfoBuilderTests_Links + Inherits AbstractIntellisenseQuickInfoBuilderTests + + + + + Public Async Sub QuickInfoForPlainHyperlink(tag As String) + Dim workspace = + + + + using System.Threading; + class MyClass { + /// <summary> + /// This contains a link to <<%= tag %> href="https://github.com/dotnet/roslyn"/>. + /// </summary> + void MyMethod() { + MyM$$ethod(); + } + } + + + + + Dim intellisenseQuickInfo = Await GetQuickInfoItemAsync(workspace, LanguageNames.CSharp) + + Dim expected = New ContainerElement( + ContainerElementStyle.Stacked Or ContainerElementStyle.VerticalPadding, + New ContainerElement( + ContainerElementStyle.Stacked, + New ContainerElement( + ContainerElementStyle.Wrapped, + New ImageElement(New ImageId(KnownImageIds.ImageCatalogGuid, KnownImageIds.MethodPrivate)), + New ClassifiedTextElement( + New ClassifiedTextRun(ClassificationTypeNames.Keyword, "void"), + New ClassifiedTextRun(ClassificationTypeNames.WhiteSpace, " "), + New ClassifiedTextRun(ClassificationTypeNames.ClassName, "MyClass"), + New ClassifiedTextRun(ClassificationTypeNames.Punctuation, "."), + New ClassifiedTextRun(ClassificationTypeNames.MethodName, "MyMethod"), + New ClassifiedTextRun(ClassificationTypeNames.Punctuation, "("), + New ClassifiedTextRun(ClassificationTypeNames.Punctuation, ")"))), + New ClassifiedTextElement( + New ClassifiedTextRun(ClassificationTypeNames.Text, "This contains a link to"), + New ClassifiedTextRun(ClassificationTypeNames.WhiteSpace, " "), + New ClassifiedTextRun(ClassificationTypeNames.Text, "https://github.com/dotnet/roslyn", navigationAction:=Sub() Return, "https://github.com/dotnet/roslyn"), + New ClassifiedTextRun(ClassificationTypeNames.Text, ".")))) + + AssertEqualAdornments(expected, intellisenseQuickInfo.Item) + End Sub + + + + + Public Async Sub QuickInfoForHyperlinkWithText(tag As String) + Dim workspace = + + + + using System.Threading; + class MyClass { + /// <summary> + /// This contains a link to <<%= tag %> href="https://github.com/dotnet/roslyn">dotnet/roslyn</<%= tag %>>. + /// </summary> + void MyMethod() { + MyM$$ethod(); + } + } + + + + + Dim intellisenseQuickInfo = Await GetQuickInfoItemAsync(workspace, LanguageNames.CSharp) + + Dim expected = New ContainerElement( + ContainerElementStyle.Stacked Or ContainerElementStyle.VerticalPadding, + New ContainerElement( + ContainerElementStyle.Stacked, + New ContainerElement( + ContainerElementStyle.Wrapped, + New ImageElement(New ImageId(KnownImageIds.ImageCatalogGuid, KnownImageIds.MethodPrivate)), + New ClassifiedTextElement( + New ClassifiedTextRun(ClassificationTypeNames.Keyword, "void"), + New ClassifiedTextRun(ClassificationTypeNames.WhiteSpace, " "), + New ClassifiedTextRun(ClassificationTypeNames.ClassName, "MyClass"), + New ClassifiedTextRun(ClassificationTypeNames.Punctuation, "."), + New ClassifiedTextRun(ClassificationTypeNames.MethodName, "MyMethod"), + New ClassifiedTextRun(ClassificationTypeNames.Punctuation, "("), + New ClassifiedTextRun(ClassificationTypeNames.Punctuation, ")"))), + New ClassifiedTextElement( + New ClassifiedTextRun(ClassificationTypeNames.Text, "This contains a link to"), + New ClassifiedTextRun(ClassificationTypeNames.WhiteSpace, " "), + New ClassifiedTextRun(ClassificationTypeNames.Text, "dotnet/roslyn", navigationAction:=Sub() Return, "https://github.com/dotnet/roslyn"), + New ClassifiedTextRun(ClassificationTypeNames.Text, ".")))) + + AssertEqualAdornments(expected, intellisenseQuickInfo.Item) + End Sub + End Class +End Namespace diff --git a/src/Features/Core/Portable/Common/TaggedText.cs b/src/Features/Core/Portable/Common/TaggedText.cs index d03941482ca..8d7144e0b61 100644 --- a/src/Features/Core/Portable/Common/TaggedText.cs +++ b/src/Features/Core/Portable/Common/TaggedText.cs @@ -31,13 +31,19 @@ namespace Microsoft.CodeAnalysis /// internal TaggedTextStyle Style { get; } + /// + /// Gets the navigation target for the text, or if the text does not have a navigation + /// target. + /// + internal string NavigationTarget { get; } + /// /// Creates a new instance of /// /// A descriptive tag from . /// The actual text to be displayed. public TaggedText(string tag, string text) - : this(tag, text, TaggedTextStyle.None) + : this(tag, text, TaggedTextStyle.None, navigationTarget: null) { } @@ -47,11 +53,13 @@ public TaggedText(string tag, string text) /// A descriptive tag from . /// The actual text to be displayed. /// The style(s) to apply to the text. - internal TaggedText(string tag, string text, TaggedTextStyle style) + /// The navigation target for the text, or if the text does not have a navigation target. + internal TaggedText(string tag, string text, TaggedTextStyle style, string navigationTarget) { Tag = tag ?? throw new ArgumentNullException(nameof(tag)); Text = text ?? throw new ArgumentNullException(nameof(text)); Style = style; + NavigationTarget = navigationTarget; } public override string ToString() @@ -73,7 +81,7 @@ public static ImmutableArray ToTaggedText(this IEnumerable - new TaggedText(SymbolDisplayPartKindTags.GetTag(d.Kind), d.ToString(), style)); + new TaggedText(SymbolDisplayPartKindTags.GetTag(d.Kind), d.ToString(), style, navigationTarget: null)); } public static string JoinText(this ImmutableArray values) diff --git a/src/Features/Core/Portable/DocumentationComments/AbstractDocumentationCommentFormattingService.cs b/src/Features/Core/Portable/DocumentationComments/AbstractDocumentationCommentFormattingService.cs index b1a5b2239f3..f7ee3890af3 100644 --- a/src/Features/Core/Portable/DocumentationComments/AbstractDocumentationCommentFormattingService.cs +++ b/src/Features/Core/Portable/DocumentationComments/AbstractDocumentationCommentFormattingService.cs @@ -31,10 +31,12 @@ private class FormatterState internal readonly List Builder = new List(); private readonly List<(DocumentationCommentListType type, int index, bool renderedItem)> _listStack = new List<(DocumentationCommentListType type, int index, bool renderedItem)>(); + private readonly Stack _navigationTargetStack = new Stack(); private readonly Stack _styleStack = new Stack(); public FormatterState() { + _navigationTargetStack.Push(null); _styleStack.Push(TaggedTextStyle.None); } @@ -51,6 +53,7 @@ public bool AtBeginning public SymbolDisplayFormat Format { get; internal set; } + internal string NavigationTarget => _navigationTargetStack.Peek(); internal TaggedTextStyle Style => _styleStack.Peek(); public void AppendSingleSpace() @@ -62,7 +65,7 @@ public void AppendString(string s) { EmitPendingChars(); - Builder.Add(new TaggedText(TextTags.Text, s, Style)); + Builder.Add(new TaggedText(TextTags.Text, s, Style, NavigationTarget)); _anyNonWhitespaceSinceLastPara = true; } @@ -115,6 +118,16 @@ public void PopList() MarkBeginOrEndPara(); } + public void PushNavigationTarget(string navigationTarget) + { + _navigationTargetStack.Push(navigationTarget); + } + + public void PopNavigationTarget() + { + _navigationTargetStack.Pop(); + } + public void PushStyle(TaggedTextStyle style) { _styleStack.Push(_styleStack.Peek() | style); @@ -272,9 +285,11 @@ private static void AppendTextFromNode(FormatterState state, XNode node, Compila var name = element.Name.LocalName; var needPopStyle = false; + string navigationTarget = null; if (name == DocumentationCommentXmlNames.SeeElementName || - name == DocumentationCommentXmlNames.SeeAlsoElementName) + name == DocumentationCommentXmlNames.SeeAlsoElementName || + name == "a") { if (element.IsEmpty || element.FirstNode == null) { @@ -285,6 +300,14 @@ private static void AppendTextFromNode(FormatterState state, XNode node, Compila return; } + else + { + navigationTarget = GetNavigationTarget(element); + if (navigationTarget is object) + { + state.PushNavigationTarget(navigationTarget); + } + } } else if (name == DocumentationCommentXmlNames.ParameterReferenceElementName || name == DocumentationCommentXmlNames.TypeParameterReferenceElementName) @@ -381,6 +404,11 @@ private static void AppendTextFromNode(FormatterState state, XNode node, Compila state.PopStyle(); } + if (navigationTarget is object) + { + state.PopNavigationTarget(); + } + if (name == DocumentationCommentXmlNames.TermElementName) { state.AppendSingleSpace(); @@ -388,6 +416,17 @@ private static void AppendTextFromNode(FormatterState state, XNode node, Compila } } + private static string GetNavigationTarget(XElement element) + { + var hrefAttribute = element.Attribute(DocumentationCommentXmlNames.HrefAttributeName); + if (hrefAttribute is object) + { + return hrefAttribute.Value; + } + + return null; + } + private static void AppendTextFromAttribute(FormatterState state, XElement element, XAttribute attribute, string attributeNameToParse, SymbolDisplayPartKind kind) { var attributeName = attribute.Name.LocalName; @@ -401,7 +440,12 @@ private static void AppendTextFromAttribute(FormatterState state, XElement eleme var displayKind = attributeName == DocumentationCommentXmlNames.LangwordAttributeName ? TextTags.Keyword : TextTags.Text; - state.AppendParts(SpecializedCollections.SingletonEnumerable(new TaggedText(displayKind, attribute.Value, state.Style))); + var text = attribute.Value; + var style = state.Style; + var navigationTarget = attributeName == DocumentationCommentXmlNames.HrefAttributeName + ? attribute.Value + : null; + state.AppendParts(SpecializedCollections.SingletonEnumerable(new TaggedText(displayKind, text, style, navigationTarget))); } } -- GitLab