Skip to content
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using Microsoft.AspNetCore.Razor.Language.Syntax;

namespace Microsoft.AspNetCore.Razor.Language;

internal static partial class RazorCodeDocumentExtensions
{
/// <summary>
/// Adjusts the position if it's on a component end tag to use the corresponding start tag position.
/// This ensures that hover, go to definition, and find all references work consistently for both
/// start and end tags, since only start tags have source mappings.
/// </summary>
/// <param name="codeDocument">The code document.</param>
/// <param name="hostDocumentIndex">The position in the host document.</param>
/// <returns>
/// The adjusted position if on a component end tag's name, otherwise the original position.
/// </returns>
public static int AdjustPositionForComponentEndTag(this RazorCodeDocument codeDocument, int hostDocumentIndex)
{
var root = codeDocument.GetRequiredSyntaxRoot();
var owner = root.FindInnermostNode(hostDocumentIndex, includeWhitespace: false);
if (owner is null)
{
return hostDocumentIndex;
}

// Check if we're on a component end tag and the position is within the tag name
if (owner.FirstAncestorOrSelf<MarkupTagHelperEndTagSyntax>() is { } endTag &&
endTag.Name.Span.IntersectsWith(hostDocumentIndex) &&
endTag.GetStartTag() is MarkupTagHelperStartTagSyntax tagHelperStartTag)
{
// Calculate the offset within the end tag name
var offsetInEndTag = hostDocumentIndex - endTag.Name.SpanStart;

// Apply the same offset to the start tag name
// This preserves the relative position within the tag name
return tagHelperStartTag.Name.SpanStart + offsetInEndTag;
Copy link
Contributor

Choose a reason for hiding this comment

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

tagHelperStartTag.Name.SpanStart + offsetInEndTag

@davidwengier -- There can't be anything funny like a fully qualified start tag and a not fully qualified end name, can there?

}

return hostDocumentIndex;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
Expand Down Expand Up @@ -55,6 +56,9 @@ protected override IRemoteFindAllReferencesService CreateService(in ServiceArgs
return NoFurtherHandling;
}

// Adjust position if on a component end tag to use the start tag position
hostDocumentIndex = codeDocument.AdjustPositionForComponentEndTag(hostDocumentIndex);

var positionInfo = GetPositionInfo(codeDocument, hostDocumentIndex, preferCSharpOverHtml: true);

if (positionInfo.LanguageKind is not RazorLanguageKind.CSharp)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@

using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
Expand Down Expand Up @@ -54,6 +55,9 @@ protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs arg
return NoFurtherHandling;
}

// Adjust position if on a component end tag to use the start tag position
hostDocumentIndex = codeDocument.AdjustPositionForComponentEndTag(hostDocumentIndex);

var positionInfo = GetPositionInfo(codeDocument, hostDocumentIndex, preferCSharpOverHtml: true);

// First, see if this is a tag helper. We ignore component attributes here, because they're better served by the C# handler.
Expand Down
Original file line number Diff line number Diff line change
@@ -1,10 +1,12 @@
// Licensed to the .NET Foundation under one or more agreements.
// The .NET Foundation licenses this file to you under the MIT license.

using System;
using System.Diagnostics;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor.DocumentMapping;
using Microsoft.CodeAnalysis.Razor.Hover;
Expand Down Expand Up @@ -46,11 +48,17 @@ protected override IRemoteHoverService CreateService(in ServiceArgs args)
{
var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);

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

var originalHostDocumentIndex = hostDocumentIndex;

// Adjust position if on a component end tag to use the start tag position
hostDocumentIndex = codeDocument.AdjustPositionForComponentEndTag(hostDocumentIndex);

var clientCapabilities = _clientCapabilitiesService.ClientCapabilities;
var positionInfo = GetPositionInfo(codeDocument, hostDocumentIndex, preferCSharpOverHtml: true);

Expand Down Expand Up @@ -82,6 +90,27 @@ protected override IRemoteHoverService CreateService(in ServiceArgs args)
csharpHover.Range = LspFactory.CreateRange(hostDocumentSpan);
}

// If we adjusted from an end tag to a start tag, we need to make sure the range covers the end tag,
// not just the start tag, or VS won't show the hover info
if (originalHostDocumentIndex > hostDocumentIndex &&
csharpHover.Range is not null)
{
// We were originally on the end tag somewhere, then redirected to the start tag to get the hover.
// We now need to translate the range we got back, and mapped, over to the end tag again. This is as
// easy as just offsetting the range by the difference between our original and adjusted index.
if (sourceText.TryGetAbsoluteIndex(csharpHover.Range.Start, out var adjustedStart) &&
sourceText.TryGetAbsoluteIndex(csharpHover.Range.End, out var adjustedEnd))
{
var offset = originalHostDocumentIndex - hostDocumentIndex;

// Make sure we don't fall off the end of the document, though it should be impossible
adjustedStart = Math.Min(adjustedStart + offset, sourceText.Length - 1);
adjustedEnd = Math.Min(adjustedEnd + offset, sourceText.Length - 1);

csharpHover.Range = sourceText.GetRange(adjustedStart, adjustedEnd);
}
}

return Results(csharpHover);
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,26 @@ public async Task Html()
await VerifyHoverAsync(code, htmlResponse, h => Assert.Same(htmlResponse, h));
}

[Fact]
public async Task Html_EndTag()
{
TestCode code = """
<PageTitle></PageTitle>
<div></d$$iv>

@{
var myVariable = "Hello";

var length = myVariable.Length;
}
""";

// This simply verifies that Hover will call into HTML.
var htmlResponse = new VSInternalHover();

await VerifyHoverAsync(code, htmlResponse, h => Assert.Same(htmlResponse, h));
}

[Fact]
public async Task CSharp()
{
Expand Down Expand Up @@ -185,6 +205,111 @@ await VerifyHoverAsync(code, async (hover, document) =>
});
}

[Fact]
public async Task ComponentEndTag()
{
TestCode code = """
<PageTitle></[|Pa$$geTitle|]>
<div></div>

@{
var myVariable = "Hello";

var length = myVariable.Length;
}
""";

await VerifyHoverAsync(code, async (hover, document) =>
{
await hover.VerifyRangeAsync(code.Span, document);

hover.VerifyRawContent(
Container(
Container(
Image,
ClassifiedText( // class Microsoft.AspNetCore.Components.Web.PageTitle
Keyword("class"),
WhiteSpace(" "),
Namespace("Microsoft"),
Punctuation("."),
Namespace("AspNetCore"),
Punctuation("."),
Namespace("Components"),
Punctuation("."),
Namespace("Web"),
Punctuation("."),
ClassName("PageTitle")))));
});
}

[Fact]
public async Task ComponentEndTag_FullyQualified()
{
TestCode code = """
<Microsoft.AspNetCore.Components.Web.PageTitle></Microsoft.AspNetCore.Components.Web.[|Pa$$geTitle|]>
<div></div>

@{
var myVariable = "Hello";

var length = myVariable.Length;
}
""";

await VerifyHoverAsync(code, async (hover, document) =>
{
await hover.VerifyRangeAsync(code.Span, document);

hover.VerifyRawContent(
Container(
Container(
Image,
ClassifiedText( // class Microsoft.AspNetCore.Components.Web.PageTitle
Keyword("class"),
WhiteSpace(" "),
Namespace("Microsoft"),
Punctuation("."),
Namespace("AspNetCore"),
Punctuation("."),
Namespace("Components"),
Punctuation("."),
Namespace("Web"),
Punctuation("."),
ClassName("PageTitle")))));
});
}

[Fact]
public async Task ComponentEndTag_FullyQualified_Namespace()
{
TestCode code = """
<Microsoft.AspNetCore.Components.Web.PageTitle></Microsoft.[|AspNe$$tCore|].Components.Web.PageTitle>
<div></div>

@{
var myVariable = "Hello";

var length = myVariable.Length;
}
""";

await VerifyHoverAsync(code, async (hover, document) =>
{
await hover.VerifyRangeAsync(code.Span, document);

hover.VerifyRawContent(
Container(
Container(
Image,
ClassifiedText( // namespace Microsoft.AspNetCore
Keyword("namespace"),
WhiteSpace(" "),
Namespace("Microsoft"),
Punctuation("."),
Namespace("AspNetCore")))));
});
}

private async Task VerifyHoverAsync(TestCode input, Func<Hover, TextDocument, Task> verifyHover)
{
var document = CreateProjectAndRazorDocument(input.Text);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,38 @@ await VerifyFindAllReferencesAsync(input,
(FilePath("SurveyPrompt.cs"), surveyPrompt));
}

[Fact]
public async Task ComponentEndTag_DefinedInCSharp()
{
TestCode input = """
<[|SurveyPrompt|] Title="InputValue"></Surv$$eyPrompt>
""";

// lang=c#-test
TestCode surveyPrompt = """
using Microsoft.AspNetCore.Components;
using Microsoft.AspNetCore.Components.Rendering;

namespace SomeProject;

public class [|SurveyPrompt|] : ComponentBase
{
[Parameter]
public string Title { get; set; } = "Hello";

protected override void BuildRenderTree(RenderTreeBuilder builder)
{
builder.OpenElement(0, "div");
builder.AddContent(1, Title + " from a C#-defined component!");
builder.CloseElement();
}
}
""";

await VerifyFindAllReferencesAsync(input,
(FilePath("SurveyPrompt.cs"), surveyPrompt));
}

private async Task VerifyFindAllReferencesAsync(TestCode input, params (string fileName, TestCode testCode)[] additionalFiles)
{
var document = CreateProjectAndRazorDocument(input.Text, additionalFiles: [.. additionalFiles.Select(f => (f.fileName, f.testCode.Text))]);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -156,6 +156,38 @@ public async Task Component()
Assert.Equal(range, location.Range);
}

[Fact]
public async Task ComponentEndTag()
{
TestCode input = """
<SurveyPrompt Title="InputValue"></Surv$$eyPrompt>
""";

TestCode surveyPrompt = """
[||]@namespace SomeProject

<div></div>

@code
{
[Parameter]
public string Title { get; set; }
}
""";

var result = await GetGoToDefinitionResultAsync(input, RazorFileKind.Component,
additionalFiles: (FileName("SurveyPrompt.razor"), surveyPrompt.Text));

Assert.NotNull(result.Value.Second);
var locations = result.Value.Second;
var location = Assert.Single(locations);

Assert.Equal(FileUri("SurveyPrompt.razor"), location.DocumentUri.GetRequiredParsedUri());
var text = SourceText.From(surveyPrompt.Text);
var range = text.GetRange(surveyPrompt.Span);
Assert.Equal(range, location.Range);
}

[Fact]
public async Task ComponentAttribute()
{
Expand Down