Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -9,11 +9,14 @@
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
using Microsoft.CodeAnalysis.MetadataAsSource;
using Microsoft.CodeAnalysis.Options;
using Microsoft.CodeAnalysis.Razor.Cohost;
using Microsoft.CodeAnalysis.Razor.CohostingShared;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Razor.Workspaces.Extensions;
using Response = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse<Microsoft.CodeAnalysis.Razor.Remote.GoToDefinitionResponse?>;

namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost;

Expand Down Expand Up @@ -59,24 +62,71 @@ public ImmutableArray<Registration> GetRegistrations(VSInternalClientCapabilitie
var position = LspFactory.CreatePosition(request.Position.ToLinePosition());

var response = await _remoteServiceInvoker
.TryInvokeAsync<IRemoteGoToDefinitionService, RemoteResponse<LspLocation[]?>>(
.TryInvokeAsync<IRemoteGoToDefinitionService, Response>(
razorDocument.Project.Solution,
(service, solutionInfo, cancellationToken) =>
service.GetDefinitionsAsync(solutionInfo, razorDocument.Id, position, cancellationToken),
cancellationToken)
.ConfigureAwait(false);

if (response.Result is LspLocation[] locations)
if (response == Response.NoFurtherHandling)
{
return null;
}

if (response == Response.CallHtml)
{
return await GetHtmlDefinitionsAsync(request, razorDocument, cancellationToken).ConfigureAwait(false);
}

// Razor OOP found definition locations it could return directly.
if (response is { StopHandling: false, Result: { Locations: { } locations, CSharpRequest: null } })
{
return locations;
}

if (response.StopHandling)
// Razor OOP found a navigable metadata symbol that must be resolved in the host.
if (response is { StopHandling: false, Result: { Locations: null, CSharpRequest: { } csharpRequest } })
{
return await GetCSharpDefinitionsAsync(razorDocument, csharpRequest, cancellationToken).ConfigureAwait(false);
}

// Any other combination represents a malformed response.
throw new InvalidOperationException($"Invalid go-to-definition response: {response}");
Comment on lines +94 to +95
}

private static async Task<LspLocation[]?> GetCSharpDefinitionsAsync(
TextDocument razorDocument,
TextDocumentPositionParams request,
CancellationToken cancellationToken)
{
var generatedDocument = await razorDocument.Project.Solution
.TryGetSourceGeneratedDocumentAsync(request.TextDocument.DocumentUri, cancellationToken)
.ConfigureAwait(false);

if (generatedDocument is null)
{
return null;
}

return await GetHtmlDefinitionsAsync(request, razorDocument, cancellationToken).ConfigureAwait(false);
var solution = generatedDocument.Project.Solution;
var globalOptions = solution.Services.ExportProvider.GetService<IGlobalOptionService>();
var metadataAsSourceFileService = solution.Services.ExportProvider.GetService<IMetadataAsSourceFileService>();

// OOP already ran this helper with metadata-as-source disabled. If it found a source location,
// it returned that result directly and we would not get this far. Repeat the lookup in the host
// workspace with metadata-as-source enabled so the remaining metadata symbol can use host-only
// services such as SourceLink.
var locations = await AbstractGoToDefinitionHandler.GetDefinitionsAsync(
globalOptions,
metadataAsSourceFileService,
solution.Workspace,
generatedDocument,
forSymbolType: false,
request.Position.ToLinePosition(),
cancellationToken).ConfigureAwait(false);

return locations;
}

private async Task<SumType<LspLocation, LspLocation[], DocumentLink[]>?> GetHtmlDefinitionsAsync(TextDocumentPositionParams request, TextDocument razorDocument, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System.Text.Json.Serialization;

namespace Microsoft.CodeAnalysis.Razor.Remote;

internal sealed record GoToDefinitionResponse(
[property: JsonPropertyName("locations")] LspLocation[]? Locations,
[property: JsonPropertyName("csharpRequest")] TextDocumentPositionParams? CSharpRequest)
{
public static GoToDefinitionResponse FromLocations(LspLocation[] locations)
=> new(locations, CSharpRequest: null);

public static GoToDefinitionResponse FromCSharpRequest(TextDocumentPositionParams request)
=> new(Locations: null, request);
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ namespace Microsoft.CodeAnalysis.Razor.Remote;

internal interface IRemoteGoToDefinitionService : IRemoteJsonService
{
ValueTask<RemoteResponse<LspLocation[]?>> GetDefinitionsAsync(
ValueTask<RemoteResponse<GoToDefinitionResponse?>> GetDefinitionsAsync(
JsonSerializableRazorSolutionWrapper solutionInfo,
JsonSerializableDocumentId razorDocumentId,
Position position,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,8 @@
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.FindSymbols;
using Microsoft.CodeAnalysis.LanguageServer;
using Microsoft.CodeAnalysis.LanguageServer.Handler;
using Microsoft.CodeAnalysis.MetadataAsSource;
using Microsoft.CodeAnalysis.Options;
Expand All @@ -15,8 +17,9 @@
using Microsoft.CodeAnalysis.Remote.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Remote.Razor.GoToDefinition;
using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Shared.Extensions;
using Microsoft.CodeAnalysis.Text;
using static Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse<Roslyn.LanguageServer.Protocol.Location[]?>;
using Response = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse<Microsoft.CodeAnalysis.Razor.Remote.GoToDefinitionResponse?>;

namespace Microsoft.CodeAnalysis.Remote.Razor;

Expand All @@ -33,26 +36,28 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg

protected override IDocumentPositionInfoStrategy DocumentPositionInfoStrategy => PreferAttributeNameDocumentPositionInfoStrategy.Instance;

private static Task<LspLocation[]?> GetDefinitionsAsync(
private static Task<LspLocation[]?> GetSourceDefinitionsAsync(
Workspace workspace,
Document document,
bool typeOnly,
LinePosition linePosition,
CancellationToken cancellationToken)
{
var globalOptions = document.Project.Solution.Services.ExportProvider.GetService<IGlobalOptionService>();
var metadataAsSourceFileService = document.Project.Solution.Services.ExportProvider.GetService<IMetadataAsSourceFileService>();

// Metadata-as-source relies on host-only services such as SourceLink. Passing null keeps
// this lookup source-only; navigable metadata symbols are sent back to the cohost endpoint below.
return AbstractGoToDefinitionHandler.GetDefinitionsAsync(
globalOptions,
metadataAsSourceFileService,
metadataAsSourceFileService: null,
workspace,
document,
typeOnly,
linePosition,
cancellationToken);
}

public ValueTask<RemoteResponse<LspLocation[]?>> GetDefinitionsAsync(
public ValueTask<Response> GetDefinitionsAsync(
JsonSerializableRazorSolutionWrapper solutionInfo,
JsonSerializableDocumentId documentId,
Position position,
Expand All @@ -63,7 +68,7 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg
snapshot => GetDefinitionsAsync(snapshot, position, cancellationToken),
cancellationToken);

private async ValueTask<RemoteResponse<LspLocation[]?>> GetDefinitionsAsync(
private async ValueTask<Response> GetDefinitionsAsync(
RemoteDocumentSnapshot snapshot,
Position position,
CancellationToken cancellationToken)
Expand All @@ -72,7 +77,7 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg

if (!codeDocument.Source.Text.TryGetAbsoluteIndex(position, out var hostDocumentIndex))
{
return NoFurtherHandling;
return Response.NoFurtherHandling;
}

// Adjust position if on a component end tag to use the start tag position
Expand All @@ -91,7 +96,7 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg

if (componentLocations is { Length: > 0 })
{
return Results(componentLocations);
return Response.Results(GoToDefinitionResponse.FromLocations(componentLocations));
}

// Check if we're in a string literal with a file path (before calling C# which would navigate to String class)
Expand All @@ -106,32 +111,50 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg

if (stringLiteralLocations is { Length: > 0 })
{
return Results(stringLiteralLocations);
return Response.Results(GoToDefinitionResponse.FromLocations(stringLiteralLocations));
}
}

if (positionInfo.LanguageKind is RazorLanguageKind.Html or RazorLanguageKind.Razor)
{
// If it isn't a Razor construct, and it isn't C#, let the server know to delegate to HTML.
return CallHtml;
return Response.CallHtml;
}

// Finally, call into C#.
var generatedDocument = await snapshot
.GetGeneratedDocumentAsync(positionInfo.InDeclDocument, cancellationToken)
.ConfigureAwait(false);

var locations = await GetDefinitionsAsync(
var projectedPosition = positionInfo.Position.ToLinePosition();
var locations = await GetSourceDefinitionsAsync(
_workspaceProvider.GetWorkspace(),
generatedDocument,
typeOnly: false,
positionInfo.Position.ToLinePosition(),
projectedPosition,
cancellationToken).ConfigureAwait(false);

if (locations is null and not [])
if (locations is null)
{
// C# didn't return anything, so we're done.
return NoFurtherHandling;
return Response.NoFurtherHandling;
}

if (locations.Length == 0)
{
// Resolving the symbol requires a semantic model and SymbolFinder, so keep this fallback
// after source lookup rather than adding that work to every direct-source navigation.
if (!await IsNavigableMetadataSymbolAsync(generatedDocument, projectedPosition, cancellationToken).ConfigureAwait(false))
{
return Response.NoFurtherHandling;
}

return Response.Results(GoToDefinitionResponse.FromCSharpRequest(
new TextDocumentPositionParams
{
TextDocument = new TextDocumentIdentifier { DocumentUri = generatedDocument.GetURI() },
Position = positionInfo.Position,
}));
}

// Map the C# locations back to the Razor file.
Expand All @@ -157,7 +180,30 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg
mappedLocations.Add(mappedLocation);
}

return Results(mappedLocations.ToArray());
return Response.Results(GoToDefinitionResponse.FromLocations(mappedLocations.ToArray()));
}

private static async Task<bool> IsNavigableMetadataSymbolAsync(
Document document,
LinePosition linePosition,
CancellationToken cancellationToken)
{
var metadataAsSourceFileService = document.Project.Solution.Services.ExportProvider.GetService<IMetadataAsSourceFileService>();
if (metadataAsSourceFileService is null)
{
return false;
}

var position = await document.GetPositionFromLinePositionAsync(linePosition, cancellationToken).ConfigureAwait(false);
var semanticModel = await document.GetRequiredSemanticModelAsync(cancellationToken).ConfigureAwait(false);
var symbol = await SymbolFinder.FindSymbolAtPositionAsync(
semanticModel,
position,
document.Project.Solution.Services,
includeType: true,
cancellationToken).ConfigureAwait(false);

return symbol is not null && metadataAsSourceFileService.IsNavigableMetadataSymbol(symbol);
}

internal static class TestAccessor
Expand All @@ -168,6 +214,6 @@ internal static class TestAccessor
bool typeOnly,
LinePosition linePosition,
CancellationToken cancellationToken)
=> RemoteGoToDefinitionService.GetDefinitionsAsync(workspace, document, typeOnly, linePosition, cancellationToken);
=> GetSourceDefinitionsAsync(workspace, document, typeOnly, linePosition, cancellationToken);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@
using Microsoft.CodeAnalysis.LanguageServer;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Text;
using Xunit;
using Xunit.Abstractions;
Expand All @@ -22,7 +23,7 @@ public class CohostGoToDefinitionEndpointTest(ITestOutputHelper testOutputHelper
[Fact]
public async Task CSharp_Method()
{
var input = """
TestCode input = """
<div></div>
@{
var x = Ge$$tX();
Expand All @@ -36,7 +37,15 @@ public async Task CSharp_Method()
}
""";

await VerifyGoToDefinitionAsync(input);
var document = CreateProjectAndRazorDocument(input.Text);
var response = await GetRemoteGoToDefinitionResponseAsync(document, input);

Assert.False(response.StopHandling);
Assumes.NotNull(response.Result);
Assert.NotNull(response.Result.Locations);
Assert.Null(response.Result.CSharpRequest);

await VerifyGoToDefinitionAsync(input, razorDocument: document);
}

[Fact]
Expand All @@ -63,15 +72,23 @@ string GetX()
[Fact]
public async Task CSharp_MetadataReference()
{
var input = """
TestCode input = """
<div></div>
@functions
{
private stri$$ng _name;
}
""";

var result = await GetGoToDefinitionResultAsync(input);
var document = CreateProjectAndRazorDocument(input.Text);
var response = await GetRemoteGoToDefinitionResponseAsync(document, input);

Assert.False(response.StopHandling);
Assumes.NotNull(response.Result);
Assert.Null(response.Result.Locations);
Assert.NotNull(response.Result.CSharpRequest);

var result = await GetGoToDefinitionResultCoreAsync(document, input, htmlResponse: null);

Assumes.NotNull(result);
Assert.NotNull(result.Value.Second);
Expand Down Expand Up @@ -1086,4 +1103,16 @@ htmlResponse is null

return await endpoint.GetTestAccessor().HandleRequestAsync(textDocumentPositionParams, document, DisposalToken);
}

private async Task<RemoteResponse<GoToDefinitionResponse?>> GetRemoteGoToDefinitionResponseAsync(TextDocument document, TestCode input)
{
var inputText = await document.GetTextAsync(DisposalToken);
var position = inputText.GetPosition(input.Position);

return await RemoteServiceInvoker.TryInvokeAsync<IRemoteGoToDefinitionService, RemoteResponse<GoToDefinitionResponse?>>(
document.Project.Solution,
(service, solutionInfo, cancellationToken) =>
service.GetDefinitionsAsync(solutionInfo, document.Id, position, cancellationToken),
DisposalToken);
}
}