Skip to content

Commit

Permalink
Add textDocument/inlayHints support (#9904)
Browse files Browse the repository at this point in the history
Adds inlay hint support for Razor in VS and VS Code, cohosting and
regular.

Goes with dotnet/vscode-csharp#6857 for the VS
Code side.
  • Loading branch information
davidwengier authored Feb 6, 2024
2 parents f8ce66b + e5268fc commit 4ab2609
Show file tree
Hide file tree
Showing 20 changed files with 671 additions and 53 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,16 @@ internal record DelegatedPositionParams(
Position ProjectedPosition,
RazorLanguageKind ProjectedKind) : IDelegatedParams;

internal record DelegatedInlayHintParams(
TextDocumentIdentifierAndVersion Identifier,
Range ProjectedRange,
RazorLanguageKind ProjectedKind) : IDelegatedParams;

internal record DelegatedInlayHintResolveParams(
TextDocumentIdentifierAndVersion Identifier,
InlayHint InlayHint,
RazorLanguageKind ProjectedKind) : IDelegatedParams;

internal record DelegatedValidateBreakpointRangeParams(
TextDocumentIdentifierAndVersion Identifier,
Range ProjectedRange,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,8 @@ internal static class CustomMessageNames
public const string RazorHtmlOnTypeFormattingEndpoint = "razor/htmlOnTypeFormatting";
public const string RazorSimplifyMethodEndpointName = "razor/simplifyMethod";
public const string RazorFormatNewFileEndpointName = "razor/formatNewFile";
public const string RazorInlayHintEndpoint = "razor/inlayHint";
public const string RazorInlayHintResolveEndpoint = "razor/inlayHintResolve";

// VS Windows only at the moment, but could/should be migrated
public const string RazorDocumentSymbolEndpoint = "razor/documentSymbol";
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,15 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Common;

internal static class VSInternalServerCapabilitiesExtensions
{
public static void EnableInlayHints(this VSInternalServerCapabilities serverCapabilities)
{
serverCapabilities.InlayHintOptions = new InlayHintOptions
{
ResolveProvider = true,
WorkDoneProgress = false
};
}

public static void EnableDocumentColorProvider(this VSInternalServerCapabilities serverCapabilities)
{
serverCapabilities.DocumentColorProvider = new DocumentColorOptions();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,8 +1,12 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Diagnostics;
using System.Diagnostics.CodeAnalysis;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.Protocol;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.Workspaces.Extensions;

namespace Microsoft.AspNetCore.Razor.LanguageServer.Extensions;

Expand All @@ -15,4 +19,50 @@ public static IRazorGeneratedDocument GetGeneratedDocument(this RazorCodeDocumen
RazorLanguageKind.Html => document.GetHtmlDocument(),
_ => throw new System.InvalidOperationException(),
};

public static bool TryGetMinimalCSharpRange(this RazorCodeDocument codeDocument, Range razorRange, [NotNullWhen(true)] out Range? csharpRange)
{
SourceSpan? minGeneratedSpan = null;
SourceSpan? maxGeneratedSpan = null;

var sourceText = codeDocument.GetSourceText();
var textSpan = razorRange.ToTextSpan(sourceText);
var csharpDoc = codeDocument.GetCSharpDocument();

// We want to find the min and max C# source mapping that corresponds with our Razor range.
foreach (var mapping in csharpDoc.SourceMappings)
{
var mappedTextSpan = mapping.OriginalSpan.AsTextSpan();

if (textSpan.OverlapsWith(mappedTextSpan))
{
if (minGeneratedSpan is null || mapping.GeneratedSpan.AbsoluteIndex < minGeneratedSpan.Value.AbsoluteIndex)
{
minGeneratedSpan = mapping.GeneratedSpan;
}

var mappingEndIndex = mapping.GeneratedSpan.AbsoluteIndex + mapping.GeneratedSpan.Length;
if (maxGeneratedSpan is null || mappingEndIndex > maxGeneratedSpan.Value.AbsoluteIndex + maxGeneratedSpan.Value.Length)
{
maxGeneratedSpan = mapping.GeneratedSpan;
}
}
}

// Create a new projected range based on our calculated min/max source spans.
if (minGeneratedSpan is not null && maxGeneratedSpan is not null)
{
var csharpSourceText = codeDocument.GetCSharpSourceText();
var startRange = minGeneratedSpan.Value.ToRange(csharpSourceText);
var endRange = maxGeneratedSpan.Value.ToRange(csharpSourceText);

csharpRange = new Range { Start = startRange.Start, End = endRange.End };
Debug.Assert(csharpRange.Start.CompareTo(csharpRange.End) <= 0, "Range.Start should not be larger than Range.End");

return true;
}

csharpRange = null;
return false;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Threading;
using System.Threading.Tasks;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.AspNetCore.Razor.LanguageServer.InlayHints;

internal interface IInlayHintService
{
Task<InlayHint[]?> GetInlayHintsAsync(IClientConnection clientConnection, VersionedDocumentContext documentContext, Range range, CancellationToken cancellationToken);

Task<InlayHint?> ResolveInlayHintAsync(IClientConnection clientConnection, InlayHint inlayHint, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.AspNetCore.Razor.LanguageServer.InlayHints;

[LanguageServerEndpoint(Methods.TextDocumentInlayHintName)]
internal sealed class InlayHintEndpoint(LanguageServerFeatureOptions featureOptions, IInlayHintService inlayHintService, IClientConnection clientConnection)
: IRazorRequestHandler<InlayHintParams, InlayHint[]?>, ICapabilitiesProvider
{
private readonly LanguageServerFeatureOptions _featureOptions = featureOptions;
private readonly IInlayHintService _inlayHintService = inlayHintService;
private readonly IClientConnection _clientConnection = clientConnection;

public bool MutatesSolutionState => false;

public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, VSInternalClientCapabilities clientCapabilities)
{
serverCapabilities.EnableInlayHints();
}

public TextDocumentIdentifier GetTextDocumentIdentifier(InlayHintParams request)
=> request.TextDocument;

public Task<InlayHint[]?> HandleRequestAsync(InlayHintParams request, RazorRequestContext context, CancellationToken cancellationToken)
{
var documentContext = context.GetRequiredDocumentContext();
return _inlayHintService.GetInlayHintsAsync(_clientConnection, documentContext, request.Range, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
using Microsoft.CommonLanguageServerProtocol.Framework;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.AspNetCore.Razor.LanguageServer.InlayHints;

[LanguageServerEndpoint(Methods.InlayHintResolveName)]
internal sealed class InlayHintResolveEndpoint(IInlayHintService inlayHintService, IClientConnection clientConnection)
: IRazorDocumentlessRequestHandler<InlayHint, InlayHint?>
{
private readonly IInlayHintService _inlayHintService = inlayHintService;
private readonly IClientConnection _clientConnection = clientConnection;

public bool MutatesSolutionState => false;

public Task<InlayHint?> HandleRequestAsync(InlayHint request, RazorRequestContext context, CancellationToken cancellationToken)
{
return _inlayHintService.ResolveInlayHintAsync(_clientConnection, request, cancellationToken);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,125 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.ComponentModel.Composition;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
using Microsoft.AspNetCore.Razor.LanguageServer.Protocol;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.Razor.Workspaces.Extensions;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Newtonsoft.Json.Linq;

namespace Microsoft.AspNetCore.Razor.LanguageServer.InlayHints;

[Export(typeof(IInlayHintService))]
[method: ImportingConstructor]
internal sealed class InlayHintService(IRazorDocumentMappingService documentMappingService) : IInlayHintService
{
private readonly IRazorDocumentMappingService _documentMappingService = documentMappingService;

public async Task<InlayHint[]?> GetInlayHintsAsync(IClientConnection clientConnection, VersionedDocumentContext documentContext, Range range, CancellationToken cancellationToken)
{
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
var csharpDocument = codeDocument.GetCSharpDocument();

// We are given a range by the client, but our mapping only succeeds if the start and end of the range can both be mapped
// to C#. Since that doesn't logically match what we want from inlay hints, we instead get the minimum range of mappable
// C# to get hints for. We'll filter that later, to remove the sections that can't be mapped back.
if (!_documentMappingService.TryMapToGeneratedDocumentRange(csharpDocument, range, out var projectedRange) &&
!codeDocument.TryGetMinimalCSharpRange(range, out projectedRange))
{
// There's no C# in the range.
return null;
}

// For now we only support C# inlay hints. Once Web Tools adds support we'll need to request from both servers and combine
// the results, much like folding ranges.
var delegatedRequest = new DelegatedInlayHintParams(
Identifier: documentContext.Identifier,
ProjectedRange: projectedRange,
ProjectedKind: RazorLanguageKind.CSharp
);

var inlayHints = await clientConnection.SendRequestAsync<DelegatedInlayHintParams, InlayHint[]?>(
CustomMessageNames.RazorInlayHintEndpoint,
delegatedRequest,
cancellationToken).ConfigureAwait(false);

if (inlayHints is null)
{
return null;
}

var csharpSourceText = codeDocument.GetCSharpSourceText();
using var _1 = ArrayBuilderPool<InlayHint>.GetPooledObject(out var inlayHintsBuilder);
foreach (var hint in inlayHints)
{
if (hint.Position.TryGetAbsoluteIndex(csharpSourceText, null, out var absoluteIndex) &&
_documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteIndex, out Position? hostDocumentPosition, out _))
{
if (hint.TextEdits is not null)
{
hint.TextEdits = _documentMappingService.GetHostDocumentEdits(csharpDocument, hint.TextEdits);
}

hint.Data = new RazorInlayHintWrapper
{
TextDocument = documentContext.Identifier,
OriginalData = hint.Data,
OriginalPosition = hint.Position
};
hint.Position = hostDocumentPosition;
inlayHintsBuilder.Add(hint);
}
}

return inlayHintsBuilder.ToArray();
}

public async Task<InlayHint?> ResolveInlayHintAsync(IClientConnection clientConnection, InlayHint inlayHint, CancellationToken cancellationToken)
{
var inlayHintWrapper = inlayHint.Data as RazorInlayHintWrapper;
if (inlayHintWrapper is null &&
inlayHint.Data is JObject dataObj)
{
inlayHintWrapper = dataObj.ToObject<RazorInlayHintWrapper>();
}

if (inlayHintWrapper is null)
{
return null;
}

var razorPosition = inlayHint.Position;
inlayHint.Position = inlayHintWrapper.OriginalPosition;
inlayHint.Data = inlayHintWrapper.OriginalData;

// For now we only support C# inlay hints. Once Web Tools adds support we'll need to inlayHint from both servers and combine
// the results, much like folding ranges.
var delegatedRequest = new DelegatedInlayHintResolveParams(
Identifier: inlayHintWrapper.TextDocument,
InlayHint: inlayHint,
ProjectedKind: RazorLanguageKind.CSharp
);

var resolvedHint = await clientConnection.SendRequestAsync<DelegatedInlayHintResolveParams, InlayHint?>(
CustomMessageNames.RazorInlayHintResolveEndpoint,
delegatedRequest,
cancellationToken).ConfigureAwait(false);

if (resolvedHint is null)
{
return null;
}

Debug.Assert(inlayHint.Position == resolvedHint.Position, "Resolving inlay hints should not change the position of them.");
resolvedHint.Position = razorPosition;

return resolvedHint;
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using Microsoft.AspNetCore.Razor.LanguageServer.Protocol;
using Microsoft.VisualStudio.LanguageServer.Protocol;

namespace Microsoft.AspNetCore.Razor.LanguageServer.InlayHints;

internal class RazorInlayHintWrapper
{
public required TextDocumentIdentifierAndVersion TextDocument { get; set; }
public required object? OriginalData { get; set; }
public required Position OriginalPosition { get; set; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
using Microsoft.AspNetCore.Razor.LanguageServer.FindAllReferences;
using Microsoft.AspNetCore.Razor.LanguageServer.Folding;
using Microsoft.AspNetCore.Razor.LanguageServer.Implementation;
using Microsoft.AspNetCore.Razor.LanguageServer.InlayHints;
using Microsoft.AspNetCore.Razor.LanguageServer.LinkedEditingRange;
using Microsoft.AspNetCore.Razor.LanguageServer.MapCode;
using Microsoft.AspNetCore.Razor.LanguageServer.ProjectContexts;
Expand Down Expand Up @@ -195,6 +196,14 @@ static void AddHandlers(IServiceCollection services, LanguageServerFeatureOption
services.AddHandlerWithCapabilities<ProjectContextsEndpoint>();
services.AddHandlerWithCapabilities<DocumentSymbolEndpoint>();
services.AddHandler<MapCodeEndpoint>();

if (!featureOptions.UseRazorCohostServer)
{
services.AddSingleton<IInlayHintService, InlayHintService>();

services.AddHandlerWithCapabilities<InlayHintEndpoint>();
services.AddHandler<InlayHintResolveEndpoint>();
}
}
}

Expand Down
Loading

0 comments on commit 4ab2609

Please sign in to comment.