Skip to content

Commit

Permalink
Introduces a new hover provider, under V2 of the protocol, that uses …
Browse files Browse the repository at this point in the history
…Roslyn's QuickInfoService

The existing provider uses a custom handler, which this replaces. Among other benefits, this brings nullability display when available, and ensures that any new additions to roslyn's info get propagated to users of this service. Unfortunately, however, TaggedText in VS is significantly more powerful than vscode's hover renderer: that simply uses markdown. Their implementation does not support any extensions to enable C# formatting of code inline, I created a poor-man's substitute: for the description line, we treat the whole line as C#. It does mean there can be a bit of odd formatting with `(parameter)` or similar, but this exactly mirrors what typescript does so I don't think it's a big deal. For other sections, I picked sections that looked ok when formatted as C# code, and I otherwise did a simple conversion, as best I could, from tagged text to inline markdown.
  • Loading branch information
333fred committed Jul 24, 2020
1 parent af15858 commit 017a909
Show file tree
Hide file tree
Showing 5 changed files with 1,016 additions and 0 deletions.
9 changes: 9 additions & 0 deletions src/OmniSharp.Abstractions/Models/v2/QuickInfoRequest.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
using OmniSharp.Mef;

namespace OmniSharp.Models.v2
{
[OmniSharpEndpoint(OmniSharpEndpoints.V2.QuickInfo, typeof(QuickInfoRequest), typeof(QuickInfoResponse))]
public class QuickInfoRequest : Request
{
}
}
35 changes: 35 additions & 0 deletions src/OmniSharp.Abstractions/Models/v2/QuickInfoResponse.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
#nullable enable
namespace OmniSharp.Models.v2
{
public class QuickInfoResponse
{
/// <summary>
/// Description of the symbol under the cursor. This is expected to be rendered as a C# codeblock
/// </summary>
public string? Description { get; set; }

/// <summary>
/// Documentation of the symbol under the cursor, if present. It is expected to be rendered as markdown.
/// </summary>
public string? Summary { get; set; }

/// <summary>
/// Other relevant information to the symbol under the cursor.
/// </summary>
public QuickInfoResponseSection[]? RemainingSections { get; set; }
}

public struct QuickInfoResponseSection
{
/// <summary>
/// If true, the text should be rendered as C# code. If false, the text should be rendered as markdown.
/// </summary>
public bool IsCSharpCode { get; set; }
public string Text { get; set; }

public override string ToString()
{
return $@"{{ IsCSharpCode = {IsCSharpCode}, Text = ""{Text}"" }}";
}
}
}
2 changes: 2 additions & 0 deletions src/OmniSharp.Abstractions/OmniSharpEndpoints.cs
Original file line number Diff line number Diff line change
Expand Up @@ -65,6 +65,8 @@ public static class V2
public const string CodeStructure = "/v2/codestructure";

public const string Highlight = "/v2/highlight";

public const string QuickInfo = "/v2/quickinfo";
}
}
}
197 changes: 197 additions & 0 deletions src/OmniSharp.Roslyn.CSharp/Services/QuickInfoProvider.cs
Original file line number Diff line number Diff line change
@@ -0,0 +1,197 @@
using System.Collections.Immutable;
using System.Composition;
using System.Linq;
using System.Text;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.QuickInfo;
using Microsoft.CodeAnalysis.Text;
using OmniSharp.Mef;
using OmniSharp.Models.v2;
using OmniSharp.Options;

#nullable enable

namespace OmniSharp.Roslyn.CSharp.Services
{
[OmniSharpHandler(OmniSharpEndpoints.V2.QuickInfo, LanguageNames.CSharp)]
public class QuickInfoProvider : IRequestHandler<QuickInfoRequest, QuickInfoResponse>
{
// Based on https://github.com/dotnet/roslyn/blob/master/src/Features/LanguageServer/Protocol/Handler/Hover/HoverHandler.cs

// These are internal tag values taken from https://github.com/dotnet/roslyn/blob/master/src/Features/Core/Portable/Common/TextTags.cs
// They're copied here so that we can ensure we render blocks correctly in the markdown

/// <summary>
/// Indicates the start of a text container. The elements after <see cref="ContainerStart"/> through (but not
/// including) the matching <see cref="ContainerEnd"/> are rendered in a rectangular block which is positioned
/// as an inline element relative to surrounding elements. The text of the <see cref="ContainerStart"/> element
/// itself precedes the content of the container, and is typically a bullet or number header for an item in a
/// list.
/// </summary>
private const string ContainerStart = nameof(ContainerStart);
/// <summary>
/// Indicates the end of a text container. See <see cref="ContainerStart"/>.
/// </summary>
private const string ContainerEnd = nameof(ContainerEnd);

private readonly OmniSharpWorkspace _workspace;
private readonly FormattingOptions _formattingOptions;

[ImportingConstructor]
public QuickInfoProvider(OmniSharpWorkspace workspace, FormattingOptions formattingOptions)
{
_workspace = workspace;
_formattingOptions = formattingOptions;
}

public async Task<QuickInfoResponse> Handle(QuickInfoRequest request)
{
var document = _workspace.GetDocument(request.FileName);
var response = new QuickInfoResponse();

if (document is null)
{
return response;
}

var quickInfoService = QuickInfoService.GetService(document);
if (quickInfoService is null)
{
return response;
}

var sourceText = await document.GetTextAsync();
var position = sourceText.Lines.GetPosition(new LinePosition(request.Line, request.Column));

var quickInfo = await quickInfoService.GetQuickInfoAsync(document, position);
if (quickInfo is null)
{
return response;
}


var sb = new StringBuilder();
response.Description = quickInfo.Sections.FirstOrDefault(s => s.Kind == QuickInfoSectionKinds.Description)?.Text;

var documentation = quickInfo.Sections.FirstOrDefault(s => s.Kind == QuickInfoSectionKinds.DocumentationComments);
if (documentation is object)
{
response.Summary = getMarkdown(documentation.TaggedParts);
}

response.RemainingSections = quickInfo.Sections
.Where(s => s.Kind != QuickInfoSectionKinds.Description && s.Kind != QuickInfoSectionKinds.DocumentationComments)
.Select(s =>
{
switch (s.Kind)
{
case QuickInfoSectionKinds.AnonymousTypes:
case QuickInfoSectionKinds.TypeParameters:
return new QuickInfoResponseSection { IsCSharpCode = true, Text = s.Text };

default:
return new QuickInfoResponseSection { IsCSharpCode = false, Text = getMarkdown(s.TaggedParts) };
}
})
.ToArray();

return response;

string getMarkdown(ImmutableArray<TaggedText> taggedTexts)
{
bool isInCodeBlock = false;
var sb = new StringBuilder();
for (int i = 0; i < taggedTexts.Length; i++)
{
var current = taggedTexts[i];

switch (current.Tag)
{
case TextTags.Text when !isInCodeBlock:
sb.Append(current.Text);
break;

case TextTags.Text:
endBlock();
sb.Append(current.Text);
break;

case TextTags.Space when isInCodeBlock:
if (nextIsTag(TextTags.Text, i))
{
endBlock();
}

sb.Append(current.Text);
break;

case TextTags.Space:
case TextTags.Punctuation:
sb.Append(current.Text);
break;

case ContainerStart:
// Markdown needs 2 linebreaks to make a new paragraph
addNewline();
addNewline();
sb.Append(current.Text);
break;

case ContainerEnd:
// Markdown needs 2 linebreaks to make a new paragraph
addNewline();
addNewline();
break;

case TextTags.LineBreak:
if (!nextIsTag(ContainerStart, i) && !nextIsTag(ContainerEnd, i))
{
addNewline();
addNewline();
}
break;

default:
if (!isInCodeBlock)
{
isInCodeBlock = true;
sb.Append('`');
}
sb.Append(current.Text);
break;
}
}

if (isInCodeBlock)
{
endBlock();
}

return sb.ToString().Trim();

void addNewline()
{
if (isInCodeBlock)
{
endBlock();
}

sb.Append(_formattingOptions.NewLine);
}

void endBlock()
{
sb.Append('`');
isInCodeBlock = false;
}

bool nextIsTag(string tag, int i)
{
int nextI = i + 1;
return nextI < taggedTexts.Length && taggedTexts[nextI].Tag == tag;
}
}
}
}
}
Loading

3 comments on commit 017a909

@s-KaiNet
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@333fred Hi Fred,

I try to reuse QuickInfoProvider in my code and it works, however, the return value contains only one section. I.e. when I hover over Console in the source code, it returns back the markdown class System.Console without documentation section. I've tried with native Roslyn's QuickInfoService and had the same result.

You added this functionality, could you give me some hint, what might be wrong? Two days in a row I'm trying to understand why it doesn't work and still without success. I really appreciate any help from your side.

My code is fairly simple:

var quick = new QuickInfoProvider(workspace, new OmniSharp.Options.FormattingOptions(), loggerFactory);
var res = await quick.Handle(new OmniSharp.Models.QuickInfoRequest
{
	Line = 8,
	Column = 25,
	FileName = FileName
});

_logger.LogInformation("Quick info:");
_logger.LogInformation(res.Markdown);

This is how I create workspace (Consts.App is just a simple console code block):

public static OmniSharpWorkspace CreateWorkspace(ILoggerFactory loggerFactory)
{
	var workspace = new OmniSharpWorkspace(new HostServicesAggregator(Enumerable.Empty<IHostServicesProvider>(), loggerFactory), loggerFactory, new EmptyFileSystemWatcher());

	var projectId = ProjectId.CreateNewId();
	var versionStamp = VersionStamp.Create();

	var project = ProjectInfo.Create(projectId, VersionStamp.Default, "MyProj", "MyProjAssembly", LanguageNames.CSharp, "foo.csproj")
		.WithMetadataReferences(new[] {
			MetadataReference.CreateFromFile(typeof(Console).Assembly.Location),
			MetadataReference.CreateFromFile(typeof(object).Assembly.Location),
		});
	workspace.AddProject(project);
	workspace.AddDocument(DocumentInfo.Create(DocumentId.CreateNewId(projectId), FileName, Enumerable.Empty<string>(), SourceCodeKind.Regular, TextLoader.From(TextAndVersion.Create(SourceText.From(Consts.App), versionStamp)), FileName));

	return workspace;
}

As said, it returns only title section without documentation.

@333fred
Copy link
Contributor Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@s-KaiNet I'm not sure what part of the MSBuild toolset finds doc comments, but I'd bet you're not replicating it with those metadata references. I'd suggest looking through the Omnisharp project loader code that interacts with msbuild.

@s-KaiNet
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Thank you! Will take a look at the MSBuild option.

Please sign in to comment.