Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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 @@ -7,6 +7,7 @@
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
using Microsoft.AspNetCore.Razor.LanguageServer.Protocol;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.Extensions.Logging;
Expand Down Expand Up @@ -46,6 +47,17 @@ protected AbstractRazorDelegatingEndpoint(
/// </summary>
protected virtual bool PreferCSharpOverHtmlIfPossible { get; } = false;

/// <summary>
/// When <see langword="true"/>, we'll adjust the position of the caret to the name portion of an attribute if it is in the
/// attribute, but not the name. eg. if we get @bi$$nd-Value we'll act as though we got @bind-$$Value
/// </summary>
protected virtual bool TreatAnyAttributePositionAsAttributeName { get; } = false;

/// <summary>
/// If <see cref="TreatAnyAttributePositionAsAttributeName "/> is <see langword="true"/>, returns the range of the full attribute
/// </summary>
protected Range? OriginalAttributeRange { get; private set; }

/// <summary>
/// The name of the endpoint to delegate to, from <see cref="RazorLanguageServerCustomMessageTargets"/>. This is the
/// custom endpoint that is sent via <see cref="ClientNotifierServiceBase"/> which returns
Expand Down Expand Up @@ -105,7 +117,28 @@ protected virtual Task<TResponse> HandleDelegatedResponseAsync(TResponse delegat
return default;
}

var projection = await _documentMappingService.TryGetProjectionAsync(documentContext, request.Position, requestContext.Logger, cancellationToken).ConfigureAwait(false);
var position = request.Position;
if (TreatAnyAttributePositionAsAttributeName)
{
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
var sourceText = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
if (request.Position.TryGetAbsoluteIndex(sourceText, Logger, out var absoluteIndex))
{
// First, lets see if we should adjust the location to get a better result from C#. For example given <Component @bi|nd-Value="Pants" />
// where | is the cursor, we would be unable to map that location to C#. If we pretend the caret was 3 characters to the right though,
// in the actual component property name, then the C# server would give us a result, so we fake it.
if (RazorSyntaxFacts.TryGetAttributeNameAbsoluteIndex(codeDocument, absoluteIndex, out var attributeNameIndex, out var attributeSpan))
{
sourceText.GetLineAndOffset(attributeNameIndex, out var line, out var offset);

OriginalAttributeRange = attributeSpan.AsRange(sourceText);

position = new Position(line, offset);
}
}
}

var projection = await _documentMappingService.TryGetProjectionAsync(documentContext, position, requestContext.Logger, cancellationToken).ConfigureAwait(false);
if (projection is null)
{
return default;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,8 @@ public DefinitionEndpoint(

protected override bool PreferCSharpOverHtmlIfPossible => true;

protected override bool TreatAnyAttributePositionAsAttributeName => true;

protected override string CustomMessageTarget => RazorLanguageServerCustomMessageTargets.RazorDefinitionEndpointName;

public RegistrationExtensionResult GetRegistration(VSInternalClientCapabilities clientCapabilities)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,4 +26,7 @@ public static Range AsRange(this TextSpan span, SourceText sourceText)

return range;
}

public static Range AsRange(this Language.Syntax.TextSpan span, SourceText sourceText)
=> new TextSpan(span.Start, span.Length).AsRange(sourceText);
}
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,8 @@ public RegistrationExtensionResult GetRegistration(VSInternalClientCapabilities

protected override bool PreferCSharpOverHtmlIfPossible => true;

protected override bool TreatAnyAttributePositionAsAttributeName => true;

protected override Task<IDelegatedParams?> CreateDelegatedParamsAsync(ReferenceParams request, RazorRequestContext requestContext, Projection projection, CancellationToken cancellationToken)
{
// HTML doesn't need to do FAR
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -49,6 +49,8 @@ public RegistrationExtensionResult GetRegistration(VSInternalClientCapabilities

protected override bool PreferCSharpOverHtmlIfPossible => true;

protected override bool TreatAnyAttributePositionAsAttributeName => true;

protected override string CustomMessageTarget => RazorLanguageServerCustomMessageTargets.RazorHoverEndpointName;

protected override Task<IDelegatedParams?> CreateDelegatedParamsAsync(TextDocumentPositionParams request, RazorRequestContext requestContext, Projection projection, CancellationToken cancellationToken)
Expand Down Expand Up @@ -93,7 +95,14 @@ public RegistrationExtensionResult GetRegistration(VSInternalClientCapabilities
var documentContext = requestContext.GetRequiredDocumentContext();
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);

if (_documentMappingService.TryMapFromProjectedDocumentRange(codeDocument.GetCSharpDocument(), response.Range, out var projectedRange))
// If we don't include the originally requested position in our response, the client may not show it, so we extend the range to ensure it is in there.
// eg for hovering at @bind-Value:af$$ter, we want to show people the hover for the Value property, so Roslyn will return to us the range for just the
// portion of the attribute that says "Value".
if (OriginalAttributeRange is not null)
{
response.Range = OriginalAttributeRange;
}
else if (_documentMappingService.TryMapFromProjectedDocumentRange(codeDocument.GetCSharpDocument(), response.Range, out var projectedRange))
{
response.Range = projectedRange;
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -34,6 +34,10 @@ public ImplementationEndpoint(

protected override string CustomMessageTarget => RazorLanguageServerCustomMessageTargets.RazorImplementationEndpointName;

protected override bool PreferCSharpOverHtmlIfPossible => true;
Copy link
Member Author

Choose a reason for hiding this comment

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

This strictly out of scope, and was an oversight from a previous PR that turned this on for Go To Def that I just noticed, but seemed simple enough to fix.


protected override bool TreatAnyAttributePositionAsAttributeName => true;

public RegistrationExtensionResult GetRegistration(VSInternalClientCapabilities clientCapabilities)
{
const string ServerCapability = "implementationProvider";
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;

namespace Microsoft.AspNetCore.Razor.LanguageServer;

internal static class RazorSyntaxFacts
{
/// <summary>
/// Given an absolute index positioned in an attribute, finds the absolute index of the part of the
/// attribute that represents the attribute name. eg. for @bi$$nd-Value it will find the absolute index
/// of "Value"
/// </summary>
public static bool TryGetAttributeNameAbsoluteIndex(RazorCodeDocument codeDocument, int absoluteIndex, out int attributeNameAbsoluteIndex, out TextSpan fullAttributeNameSpan)
{
attributeNameAbsoluteIndex = 0;
fullAttributeNameSpan = default;

var tree = codeDocument.GetSyntaxTree();
var owner = tree.GetOwner(absoluteIndex);

var attributeName = owner?.Parent switch
{
MarkupTagHelperAttributeSyntax att => att.Name,
MarkupMinimizedTagHelperAttributeSyntax att => att.Name,
MarkupTagHelperDirectiveAttributeSyntax att => att.Name,
MarkupMinimizedTagHelperDirectiveAttributeSyntax att => att.Name,
_ => null
};

if (attributeName is null)
{
return false;
}

// Can't get to this point if owner was null, but the compiler doesn't know that
Assumes.NotNull(owner);

fullAttributeNameSpan = GetFullAttributeNameSpan(owner.Parent);

// The GetOwner method can be surprising, eg. Foo="$$Bar" will return the starting quote of the attribute value,
// but its parent is the attribute name. Easy enough to filter that sort of thing out by just requiring
// the caret position to be somewhere within the attribute name.
if (!fullAttributeNameSpan.Contains(absoluteIndex))
{
return false;
}

if (attributeName.LiteralTokens is [{ } name])
{
var attribute = name.Content;
if (attribute.StartsWith("bind-"))
{
attributeNameAbsoluteIndex = attributeName.SpanStart + 5;
}
else
{
attributeNameAbsoluteIndex = attributeName.SpanStart;
}

return true;
}

return false;
}

private static TextSpan GetFullAttributeNameSpan(SyntaxNode node)
{
return node switch
{
MarkupTagHelperAttributeSyntax att => att.Name.Span,
MarkupMinimizedTagHelperAttributeSyntax att => att.Name.Span,
MarkupTagHelperDirectiveAttributeSyntax att => CalculateFullSpan(att.Name, att.ParameterName, att.Transition),
MarkupMinimizedTagHelperDirectiveAttributeSyntax att => CalculateFullSpan(att.Name, att.ParameterName, att.Transition),
_ => Assumed.Unreachable<TextSpan>(),
};

static TextSpan CalculateFullSpan(MarkupTextLiteralSyntax attributeName, MarkupTextLiteralSyntax? parameterName, RazorMetaCodeSyntax? transition)
{
var start = attributeName.SpanStart;
var length = attributeName.Span.Length;

// The transition is the "@" if its present
if (transition is not null)
{
start -= 1;
length += 1;
}

// The parameter is, for example, the ":after" but does not include the colon, so we have to account for it
if (parameterName is not null)
{
length += 1 + parameterName.Span.Length;
}

return new TextSpan(start, length);
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -99,11 +99,37 @@ public async Task Handle_SingleServer_CSharp_MetadataReference()
Assert.Equal(21, location.Range.Start.Line);
}

[Fact]
public async Task Handle_SingleServer_ComponentAttribute_OtherRazorFile()
[Theory]
[InlineData("$$IncrementCount")]
[InlineData("In$$crementCount")]
[InlineData("IncrementCount$$")]
public async Task Handle_SingleServer_Attribute_SameFile(string method)
{
var input = """
<SurveyPrompt @bind-Ti$$tle="InputValue" @bind-Value:after="BindAfter" />
var input = $$"""
<button @onclick="{{method}}"></div>

@code
{
void [|IncrementCount|]()
{
}
}
""";

await VerifyCSharpGoToDefinitionAsync(input, "test.razor");
}

[Theory]
[InlineData("Ti$$tle")]
[InlineData("$$@bind-Title")]
[InlineData("@$$bind-Title")]
[InlineData("@bi$$nd-Title")]
[InlineData("@bind$$-Title")]
[InlineData("@bind-Ti$$tle")]
public async Task Handle_SingleServer_ComponentAttribute_OtherRazorFile(string attribute)
{
var input = $$"""
<SurveyPrompt {{attribute}}="InputValue" />

@code
{
Expand Down Expand Up @@ -158,12 +184,12 @@ @namespace BlazorServer_31.Shared
Assert.Equal(range.Start.Character, location.Range.Start.Character);
}

private async Task VerifyCSharpGoToDefinitionAsync(string input)
private async Task VerifyCSharpGoToDefinitionAsync(string input, string filePath = null)
{
// Arrange
TestFileMarkupParser.GetPositionAndSpan(input, out var output, out var cursorPosition, out var expectedSpan);

var codeDocument = CreateCodeDocument(output);
var codeDocument = CreateCodeDocument(output, filePath: filePath);
var razorFilePath = "C:/path/to/file.razor";

// Act
Expand Down