diff --git a/eng/targets/Services.props b/eng/targets/Services.props index e4ce786f254..61ba0e77547 100644 --- a/eng/targets/Services.props +++ b/eng/targets/Services.props @@ -25,6 +25,7 @@ + diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AbstractRazorDelegatingEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AbstractRazorDelegatingEndpoint.cs index dfef778fe71..c84789d378c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AbstractRazorDelegatingEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AbstractRazorDelegatingEndpoint.cs @@ -113,12 +113,14 @@ protected virtual Task HandleDelegatedResponseAsync(TResponse delegat return default; } - var positionInfo = await DocumentPositionInfoStrategy.TryGetPositionInfoAsync(_documentMappingService, documentContext, request.Position, cancellationToken).ConfigureAwait(false); - if (positionInfo is null) + var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + if (!codeDocument.Source.Text.TryGetAbsoluteIndex(request.Position, out var absoluteIndex)) { return default; } + var positionInfo = DocumentPositionInfoStrategy.GetPositionInfo(_documentMappingService, codeDocument, absoluteIndex); + var response = await TryHandleAsync(request, requestContext, positionInfo, cancellationToken).ConfigureAwait(false); if (response is not null && response is not ISumType { Value: null }) { @@ -141,7 +143,6 @@ protected virtual Task HandleDelegatedResponseAsync(TResponse delegat // Sometimes Html can actually be mapped to C#, like for example component attributes, which map to // C# properties, even though they appear entirely in a Html context. Since remapping is pretty cheap // it's easier to just try mapping, and see what happens, rather than checking for specific syntax nodes. - var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); if (_documentMappingService.TryMapToGeneratedDocumentPosition(codeDocument.GetCSharpDocument(), positionInfo.HostDocumentIndex, out Position? csharpPosition, out _)) { // We're just gonna pretend this mapped perfectly normally onto C#. Moving this logic to the actual position info diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AutoInsert/PreferHtmlInAttributeValuesDocumentPositionStrategy.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AutoInsert/PreferHtmlInAttributeValuesDocumentPositionStrategy.cs deleted file mode 100644 index 8df88d48145..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AutoInsert/PreferHtmlInAttributeValuesDocumentPositionStrategy.cs +++ /dev/null @@ -1,50 +0,0 @@ -// 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.Language.Syntax; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.AutoInsert; - -// The main reason for this service is auto-insert of empty double quotes when a user types -// equals "=" after Blazor component attribute. We think this is Razor (correctly I guess) -// and wouldn't forward auto-insert request to HTML in this case. By essentially overriding -// language info here we allow the request to be sent over to HTML where it will insert empty -// double-quotes as it would for any other attribute value -internal class PreferHtmlInAttributeValuesDocumentPositionInfoStrategy : IDocumentPositionInfoStrategy -{ - public static IDocumentPositionInfoStrategy Instance { get; } = new PreferHtmlInAttributeValuesDocumentPositionInfoStrategy(); - - public async Task TryGetPositionInfoAsync(IDocumentMappingService documentMappingService, DocumentContext documentContext, Position position, CancellationToken cancellationToken) - { - var defaultDocumentPositionInfo = await DefaultDocumentPositionInfoStrategy.Instance.TryGetPositionInfoAsync(documentMappingService, documentContext, position, cancellationToken).ConfigureAwait(false); - if (defaultDocumentPositionInfo is null) - { - return null; - } - - var absolutePosition = defaultDocumentPositionInfo.HostDocumentIndex; - if (defaultDocumentPositionInfo.LanguageKind != RazorLanguageKind.Razor || - absolutePosition < 1) - { - return defaultDocumentPositionInfo; - } - - // Get the node at previous position to see if we are after markup tag helper attribute, - // and more specifically after the EqualsToken of it - var previousPosition = absolutePosition - 1; - var owner = await documentContext.GetSyntaxNodeAsync(previousPosition, cancellationToken).ConfigureAwait(false); - if (owner is MarkupTagHelperAttributeSyntax { EqualsToken: { IsMissing: false } equalsToken } && - equalsToken.EndPosition == defaultDocumentPositionInfo.HostDocumentIndex) - { - return new DocumentPositionInfo(RazorLanguageKind.Html, defaultDocumentPositionInfo.Position, defaultDocumentPositionInfo.HostDocumentIndex); - } - - return defaultDocumentPositionInfo; - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/CodeBlockService.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/CodeBlockService.cs index 9cda5df9204..a01cc5ba960 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/CodeBlockService.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/CodeBlockService.cs @@ -8,6 +8,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionListProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionListProvider.cs index 2c8e023b46d..163ec5dad71 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionListProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionListProvider.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultDocumentPositionInfoStrategy.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultDocumentPositionInfoStrategy.cs deleted file mode 100644 index 976829713ad..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultDocumentPositionInfoStrategy.cs +++ /dev/null @@ -1,31 +0,0 @@ -// 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.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal class DefaultDocumentPositionInfoStrategy : IDocumentPositionInfoStrategy -{ - public static IDocumentPositionInfoStrategy Instance { get; } = new DefaultDocumentPositionInfoStrategy(); - - public async Task TryGetPositionInfoAsync( - IDocumentMappingService documentMappingService, - DocumentContext documentContext, - Position position, - CancellationToken cancellationToken) - { - var sourceText = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); - if (!sourceText.TryGetAbsoluteIndex(position, out var absoluteIndex)) - { - return null; - } - - return await documentMappingService.GetPositionInfoAsync(documentContext, absoluteIndex, cancellationToken).ConfigureAwait(false); - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Definition/DefinitionEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Definition/DefinitionEndpoint.cs index 304c7729526..dd22142c0ef 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Definition/DefinitionEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Definition/DefinitionEndpoint.cs @@ -1,42 +1,38 @@ // 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 System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.AspNetCore.Razor.Threading; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.GoToDefinition; using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.VisualStudio.LanguageServer.Protocol; using DefinitionResult = Microsoft.VisualStudio.LanguageServer.Protocol.SumType< - Microsoft.VisualStudio.LanguageServer.Protocol.VSInternalLocation, - Microsoft.VisualStudio.LanguageServer.Protocol.VSInternalLocation[], + Microsoft.VisualStudio.LanguageServer.Protocol.Location, + Microsoft.VisualStudio.LanguageServer.Protocol.Location[], Microsoft.VisualStudio.LanguageServer.Protocol.DocumentLink[]>; -using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; -using SyntaxKind = Microsoft.AspNetCore.Razor.Language.SyntaxKind; namespace Microsoft.AspNetCore.Razor.LanguageServer.Definition; [RazorLanguageServerEndpoint(Methods.TextDocumentDefinitionName)] internal sealed class DefinitionEndpoint( - IRazorComponentSearchEngine componentSearchEngine, + IRazorComponentDefinitionService componentDefinitionService, IDocumentMappingService documentMappingService, LanguageServerFeatureOptions languageServerFeatureOptions, IClientConnection clientConnection, ILoggerFactory loggerFactory) - : AbstractRazorDelegatingEndpoint(languageServerFeatureOptions, documentMappingService, clientConnection, loggerFactory.GetOrCreateLogger()), ICapabilitiesProvider + : AbstractRazorDelegatingEndpoint( + languageServerFeatureOptions, + documentMappingService, + clientConnection, + loggerFactory.GetOrCreateLogger()), ICapabilitiesProvider { - private readonly IRazorComponentSearchEngine _componentSearchEngine = componentSearchEngine; + private readonly IRazorComponentDefinitionService _componentDefinitionService = componentDefinitionService; private readonly IDocumentMappingService _documentMappingService = documentMappingService; protected override bool PreferCSharpOverHtmlIfPossible => true; @@ -50,60 +46,31 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V serverCapabilities.DefinitionProvider = new DefinitionOptions(); } - protected async override Task TryHandleAsync(TextDocumentPositionParams request, RazorRequestContext requestContext, DocumentPositionInfo positionInfo, CancellationToken cancellationToken) + protected async override Task TryHandleAsync( + TextDocumentPositionParams request, + RazorRequestContext requestContext, + DocumentPositionInfo positionInfo, + CancellationToken cancellationToken) { Logger.LogInformation($"Starting go-to-def endpoint request."); + var documentContext = requestContext.DocumentContext; if (documentContext is null) { return null; } - if (!FileKinds.IsComponent(documentContext.FileKind)) - { - Logger.LogInformation($"FileKind '{documentContext.FileKind}' is not a component type."); - return default; - } - // If single server support is on, then we ignore attributes, as they are better handled by delegating to Roslyn - var (originTagDescriptor, attributeDescriptor) = await GetOriginTagHelperBindingAsync(documentContext, positionInfo.HostDocumentIndex, SingleServerSupport, Logger, cancellationToken).ConfigureAwait(false); - if (originTagDescriptor is null) - { - Logger.LogInformation($"Origin TagHelper descriptor is null."); - return default; - } - - var originComponentDocumentSnapshot = await _componentSearchEngine.TryLocateComponentAsync(documentContext.Snapshot, originTagDescriptor).ConfigureAwait(false); - if (originComponentDocumentSnapshot is null) - { - Logger.LogInformation($"Origin TagHelper document snapshot is null."); - return default; - } - - var originComponentDocumentFilePath = originComponentDocumentSnapshot.FilePath.AssumeNotNull(); - - Logger.LogInformation($"Definition found at file path: {originComponentDocumentFilePath}"); - - var range = await GetNavigateRangeAsync(originComponentDocumentSnapshot, attributeDescriptor, cancellationToken).ConfigureAwait(false); - - var originComponentUri = new UriBuilder - { - Path = originComponentDocumentFilePath, - Scheme = Uri.UriSchemeFile, - Host = string.Empty, - }.Uri; - - return new[] - { - new VSInternalLocation - { - Uri = originComponentUri, - Range = range, - }, - }; + return await _componentDefinitionService + .GetDefinitionAsync(documentContext.Snapshot, positionInfo, ignoreAttributes: SingleServerSupport, cancellationToken) + .ConfigureAwait(false); } - protected override Task CreateDelegatedParamsAsync(TextDocumentPositionParams request, RazorRequestContext requestContext, DocumentPositionInfo positionInfo, CancellationToken cancellationToken) + protected override Task CreateDelegatedParamsAsync( + TextDocumentPositionParams request, + RazorRequestContext requestContext, + DocumentPositionInfo positionInfo, + CancellationToken cancellationToken) { var documentContext = requestContext.DocumentContext; if (documentContext is null) @@ -117,25 +84,30 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V positionInfo.LanguageKind)); } - protected async override Task HandleDelegatedResponseAsync(DefinitionResult? response, TextDocumentPositionParams originalRequest, RazorRequestContext requestContext, DocumentPositionInfo positionInfo, CancellationToken cancellationToken) + protected async override Task HandleDelegatedResponseAsync( + DefinitionResult? response, + TextDocumentPositionParams originalRequest, + RazorRequestContext requestContext, + DocumentPositionInfo positionInfo, + CancellationToken cancellationToken) { - if (response is null) + if (response is not DefinitionResult result) { return null; } - if (response.Value.TryGetFirst(out var location)) + if (result.TryGetFirst(out var location)) { (location.Uri, location.Range) = await _documentMappingService.MapToHostDocumentUriAndRangeAsync(location.Uri, location.Range, cancellationToken).ConfigureAwait(false); } - else if (response.Value.TryGetSecond(out var locations)) + else if (result.TryGetSecond(out var locations)) { foreach (var loc in locations) { (loc.Uri, loc.Range) = await _documentMappingService.MapToHostDocumentUriAndRangeAsync(loc.Uri, loc.Range, cancellationToken).ConfigureAwait(false); } } - else if (response.Value.TryGetThird(out var links)) + else if (result.TryGetThird(out var links)) { foreach (var link in links) { @@ -146,187 +118,6 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V } } - return response; - } - - internal static async Task<(TagHelperDescriptor?, BoundAttributeDescriptor?)> GetOriginTagHelperBindingAsync( - DocumentContext documentContext, - int absoluteIndex, - bool ignoreAttributes, - ILogger logger, - CancellationToken cancellationToken) - { - var owner = await documentContext.GetSyntaxNodeAsync(absoluteIndex, cancellationToken).ConfigureAwait(false); - if (owner is null) - { - logger.LogInformation($"Could not locate owner."); - return (null, null); - } - - var node = owner.FirstAncestorOrSelf(n => - n.Kind == SyntaxKind.MarkupTagHelperStartTag || - n.Kind == SyntaxKind.MarkupTagHelperEndTag); - if (node is null) - { - logger.LogInformation($"Could not locate ancestor of type MarkupTagHelperStartTag or MarkupTagHelperEndTag."); - return (null, null); - } - - var name = GetStartOrEndTagName(node); - if (name is null) - { - logger.LogInformation($"Could not retrieve name of start or end tag."); - return (null, null); - } - - string? propertyName = null; - - if (!ignoreAttributes && node is MarkupTagHelperStartTagSyntax startTag) - { - // Include attributes where the end index also matches, since GetSyntaxNodeAsync will consider that the start tag but we behave - // as if the user wants to go to the attribute definition. - // ie: - var selectedAttribute = startTag.Attributes.FirstOrDefault(a => a.Span.Contains(absoluteIndex) || a.Span.End == absoluteIndex); - - // If we're on an attribute then just validate against the attribute name - if (selectedAttribute is MarkupTagHelperAttributeSyntax attribute) - { - // Normal attribute, ie - name = attribute.Name; - propertyName = attribute.TagHelperAttributeInfo.Name; - } - else if (selectedAttribute is MarkupMinimizedTagHelperAttributeSyntax minimizedAttribute) - { - // Minimized attribute, ie - name = minimizedAttribute.Name; - propertyName = minimizedAttribute.TagHelperAttributeInfo.Name; - } - } - - if (!name.Span.IntersectsWith(absoluteIndex)) - { - logger.LogInformation($"Tag name or attributes' span does not intersect with location's absolute index ({absoluteIndex})."); - return (null, null); - } - - if (node.Parent is not MarkupTagHelperElementSyntax tagHelperElement) - { - logger.LogInformation($"Parent of start or end tag is not a MarkupTagHelperElement."); - return (null, null); - } - - if (tagHelperElement.TagHelperInfo?.BindingResult is not TagHelperBinding binding) - { - logger.LogInformation($"MarkupTagHelperElement does not contain TagHelperInfo."); - return (null, null); - } - - var originTagDescriptor = binding.Descriptors.FirstOrDefault(static d => !d.IsAttributeDescriptor()); - if (originTagDescriptor is null) - { - logger.LogInformation($"Origin TagHelper descriptor is null."); - return (null, null); - } - - var attributeDescriptor = (propertyName is not null) - ? originTagDescriptor.BoundAttributes.FirstOrDefault(a => a.Name?.Equals(propertyName, StringComparison.Ordinal) == true) - : null; - - return (originTagDescriptor, attributeDescriptor); - } - - private static SyntaxNode? GetStartOrEndTagName(SyntaxNode node) - { - return node switch - { - MarkupTagHelperStartTagSyntax tagHelperStartTag => tagHelperStartTag.Name, - MarkupTagHelperEndTagSyntax tagHelperEndTag => tagHelperEndTag.Name, - _ => null - }; - } - - private async Task GetNavigateRangeAsync(IDocumentSnapshot documentSnapshot, BoundAttributeDescriptor? attributeDescriptor, CancellationToken cancellationToken) - { - if (attributeDescriptor is not null) - { - Logger.LogInformation($"Attempting to get definition from an attribute directly."); - - var originCodeDocument = await documentSnapshot.GetGeneratedOutputAsync().ConfigureAwait(false); - var range = await TryGetPropertyRangeAsync(originCodeDocument, attributeDescriptor.GetPropertyName(), _documentMappingService, Logger, cancellationToken).ConfigureAwait(false); - - if (range is not null) - { - return range; - } - } - - // When navigating from a start or end tag, we just take the user to the top of the file. - // If we were trying to navigate to a property, and we couldn't find it, we can at least take - // them to the file for the component. If the property was defined in a partial class they can - // at least then press F7 to go there. - return VsLspFactory.DefaultRange; - } - - internal static async Task TryGetPropertyRangeAsync(RazorCodeDocument codeDocument, string propertyName, IDocumentMappingService documentMappingService, ILogger logger, CancellationToken cancellationToken) - { - // Parse the C# file and find the property that matches the name. - // We don't worry about parameter attributes here for two main reasons: - // 1. We don't have symbolic information, so the best we could do would be checking for any - // attribute named Parameter, regardless of which namespace. It also means we would have - // to do more checks for all of the various ways that the attribute could be specified - // (eg fully qualified, aliased, etc.) - // 2. Since C# doesn't allow multiple properties with the same name, and we're doing a case - // sensitive search, we know the property we find is the one the user is trying to encode in a - // tag helper attribute. If they don't have the [Parameter] attribute then the Razor compiler - // will error, but allowing them to Go To Def on that property regardless, actually helps - // them fix the error. - var csharpText = codeDocument.GetCSharpSourceText(); - var syntaxTree = CSharpSyntaxTree.ParseText(csharpText, cancellationToken: cancellationToken); - var root = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); - - // Since we know how the compiler generates the C# source we can be a little specific here, and avoid - // long tree walks. If the compiler ever changes how they generate their code, the tests for this will break - // so we'll know about it. - if (GetClassDeclaration(root) is { } classDeclaration) - { - var property = classDeclaration - .Members - .OfType() - .Where(p => p.Identifier.ValueText.Equals(propertyName, StringComparison.Ordinal)) - .FirstOrDefault(); - - if (property is null) - { - // The property probably exists in a partial class - logger.LogInformation($"Could not find property in the generated source. Comes from partial?"); - return null; - } - - var range = csharpText.GetRange(property.Identifier.Span); - if (documentMappingService.TryMapToHostDocumentRange(codeDocument.GetCSharpDocument(), range, out var originalRange)) - { - return originalRange; - } - - logger.LogInformation($"Property found but couldn't map its location."); - } - - logger.LogInformation($"Generated C# was not in expected shape (CompilationUnit [-> Namespace] -> Class)"); - - return null; - - static ClassDeclarationSyntax? GetClassDeclaration(CodeAnalysis.SyntaxNode root) - { - return root switch - { - CompilationUnitSyntax unit => unit switch - { - { Members: [NamespaceDeclarationSyntax { Members: [ClassDeclarationSyntax c, ..] }, ..] } => c, - { Members: [ClassDeclarationSyntax c, ..] } => c, - _ => null, - }, - _ => null, - }; - } + return result; } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Definition/RazorComponentDefinitionService.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Definition/RazorComponentDefinitionService.cs new file mode 100644 index 00000000000..9a330263563 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Definition/RazorComponentDefinitionService.cs @@ -0,0 +1,22 @@ +// 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 System.Collections.Generic; +using System.Linq; +using System.Text; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.GoToDefinition; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Workspaces; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Definition; + +internal sealed class RazorComponentDefinitionService( + IRazorComponentSearchEngine componentSearchEngine, + IDocumentMappingService documentMappingService, + ILoggerFactory loggerFactory) + : AbstractRazorComponentDefinitionService(componentSearchEngine, documentMappingService, loggerFactory.GetOrCreateLogger()) +{ +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hover/HoverService.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hover/HoverService.cs index 4fb546a09d6..963f3d96454 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hover/HoverService.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hover/HoverService.cs @@ -12,6 +12,7 @@ using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.Tooltip; +using Microsoft.CodeAnalysis.Razor; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IDocumentPositionInfoStrategy.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IDocumentPositionInfoStrategy.cs deleted file mode 100644 index dc7b13c4880..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/IDocumentPositionInfoStrategy.cs +++ /dev/null @@ -1,19 +0,0 @@ -// 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.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -internal interface IDocumentPositionInfoStrategy -{ - Task TryGetPositionInfoAsync( - IDocumentMappingService documentMappingService, - DocumentContext documentContext, - Position position, - CancellationToken cancellationToken); -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/PreferAttributeNameDocumentPositionInfoStrategy.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/PreferAttributeNameDocumentPositionInfoStrategy.cs deleted file mode 100644 index 98b8fcac5d1..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/PreferAttributeNameDocumentPositionInfoStrategy.cs +++ /dev/null @@ -1,46 +0,0 @@ -// 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.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer; - -/// -/// A projection strategy that, when given a position that occurs anywhere in an attribute name, will return the projection -/// for the position at the start of the attribute name, ignoring any prefix or suffix. eg given any location within the -/// attribute "@bind-Value:after", it will return the projection at the point of the word "Value" therein. -/// -internal class PreferAttributeNameDocumentPositionInfoStrategy : IDocumentPositionInfoStrategy -{ - public static IDocumentPositionInfoStrategy Instance { get; } = new PreferAttributeNameDocumentPositionInfoStrategy(); - - public async Task TryGetPositionInfoAsync( - IDocumentMappingService documentMappingService, - DocumentContext documentContext, - Position position, - CancellationToken cancellationToken) - { - var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); - var sourceText = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); - if (sourceText.TryGetAbsoluteIndex(position, out var absoluteIndex)) - { - // First, lets see if we should adjust the location to get a better result from C#. For example given - // 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)) - { - position = sourceText.GetPosition(attributeNameIndex); - } - } - - // We actually don't need a different projection strategy, we just wanted to move the caret position - return await DefaultDocumentPositionInfoStrategy.Instance - .TryGetPositionInfoAsync(documentMappingService, documentContext, position, cancellationToken) - .ConfigureAwait(false); - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs index 601df17f06c..535501e83b2 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs @@ -25,6 +25,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.WrapWithTag; using Microsoft.AspNetCore.Razor.Telemetry; using Microsoft.CodeAnalysis.Razor.FoldingRanges; +using Microsoft.CodeAnalysis.Razor.GoToDefinition; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.Rename; using Microsoft.CodeAnalysis.Razor.Workspaces; @@ -178,10 +179,11 @@ static void AddHandlers(IServiceCollection services, LanguageServerFeatureOption services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); - services.AddHandlerWithCapabilities(); - if (!featureOptions.UseRazorCohostServer) { + services.AddSingleton(); + services.AddHandlerWithCapabilities(); + services.AddSingleton(); services.AddHandlerWithCapabilities(); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/DefaultDocumentPositionInfoStrategy.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/DefaultDocumentPositionInfoStrategy.cs new file mode 100644 index 00000000000..c126e2656f1 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/DefaultDocumentPositionInfoStrategy.cs @@ -0,0 +1,18 @@ +// 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.Language; + +namespace Microsoft.CodeAnalysis.Razor.DocumentMapping; + +internal sealed class DefaultDocumentPositionInfoStrategy : IDocumentPositionInfoStrategy +{ + public static IDocumentPositionInfoStrategy Instance { get; } = new DefaultDocumentPositionInfoStrategy(); + + private DefaultDocumentPositionInfoStrategy() + { + } + + public DocumentPositionInfo GetPositionInfo(IDocumentMappingService mappingService, RazorCodeDocument codeDocument, int hostDocumentIndex) + => mappingService.GetPositionInfo(codeDocument, hostDocumentIndex); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/DocumentPositionInfo.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/DocumentPositionInfo.cs index cfc2ad81629..bbb745aea2c 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/DocumentPositionInfo.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/DocumentPositionInfo.cs @@ -10,4 +10,4 @@ namespace Microsoft.CodeAnalysis.Razor.DocumentMapping; /// Represents a position in a document. If is Razor then the position will be /// in the host document, otherwise it will be in the corresponding generated document. /// -internal record DocumentPositionInfo(RazorLanguageKind LanguageKind, Position Position, int HostDocumentIndex); +internal readonly record struct DocumentPositionInfo(RazorLanguageKind LanguageKind, Position Position, int HostDocumentIndex); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/IDocumentPositionInfoStrategy.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/IDocumentPositionInfoStrategy.cs new file mode 100644 index 00000000000..9e257a712a8 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/IDocumentPositionInfoStrategy.cs @@ -0,0 +1,11 @@ +// 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.Language; + +namespace Microsoft.CodeAnalysis.Razor.DocumentMapping; + +internal interface IDocumentPositionInfoStrategy +{ + DocumentPositionInfo GetPositionInfo(IDocumentMappingService mappingService, RazorCodeDocument codeDocument, int hostDocumentIndex); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/PreferAttributeNameDocumentPositionInfoStrategy.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/PreferAttributeNameDocumentPositionInfoStrategy.cs new file mode 100644 index 00000000000..86c1cf5017a --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/PreferAttributeNameDocumentPositionInfoStrategy.cs @@ -0,0 +1,33 @@ +// 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.Language; + +namespace Microsoft.CodeAnalysis.Razor.DocumentMapping; + +/// +/// A projection strategy that, when given a position that occurs anywhere in an attribute name, will return the projection +/// for the position at the start of the attribute name, ignoring any prefix or suffix. eg given any location within the +/// attribute "@bind-Value:after", it will return the projection at the point of the word "Value" therein. +/// +internal sealed class PreferAttributeNameDocumentPositionInfoStrategy : IDocumentPositionInfoStrategy +{ + public static IDocumentPositionInfoStrategy Instance { get; } = new PreferAttributeNameDocumentPositionInfoStrategy(); + + private PreferAttributeNameDocumentPositionInfoStrategy() + { + } + + public DocumentPositionInfo GetPositionInfo(IDocumentMappingService mappingService, RazorCodeDocument codeDocument, int hostDocumentIndex) + { + // First, lets see if we should adjust the location to get a better result from C#. For example given + // 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, hostDocumentIndex, out var attributeNameIndex)) + { + hostDocumentIndex = attributeNameIndex; + } + + return DefaultDocumentPositionInfoStrategy.Instance.GetPositionInfo(mappingService, codeDocument, hostDocumentIndex); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/PreferHtmlInAttributeValuesDocumentPositionStrategy.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/PreferHtmlInAttributeValuesDocumentPositionStrategy.cs new file mode 100644 index 00000000000..7de3f5f7612 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/DocumentMapping/PreferHtmlInAttributeValuesDocumentPositionStrategy.cs @@ -0,0 +1,54 @@ +// 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; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Syntax; +using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.Razor.DocumentMapping; + +// The main reason for this service is auto-insert of empty double quotes when a user types +// equals "=" after Blazor component attribute. We think this is Razor (correctly I guess) +// and wouldn't forward auto-insert request to HTML in this case. By essentially overriding +// language info here we allow the request to be sent over to HTML where it will insert empty +// double-quotes as it would for any other attribute value +internal sealed class PreferHtmlInAttributeValuesDocumentPositionInfoStrategy : IDocumentPositionInfoStrategy +{ + public static IDocumentPositionInfoStrategy Instance { get; } = new PreferHtmlInAttributeValuesDocumentPositionInfoStrategy(); + + private PreferHtmlInAttributeValuesDocumentPositionInfoStrategy() + { + } + + public DocumentPositionInfo GetPositionInfo(IDocumentMappingService mappingService, RazorCodeDocument codeDocument, int hostDocumentIndex) + { + var positionInfo = DefaultDocumentPositionInfoStrategy.Instance.GetPositionInfo(mappingService, codeDocument, hostDocumentIndex); + + var absolutePosition = positionInfo.HostDocumentIndex; + if (positionInfo.LanguageKind != RazorLanguageKind.Razor || + absolutePosition < 1) + { + return positionInfo; + } + + // Get the node at previous position to see if we are after markup tag helper attribute, + // and more specifically after the EqualsToken of it + var previousPosition = absolutePosition - 1; + + var syntaxTree = codeDocument.GetSyntaxTree().AssumeNotNull(); + + var owner = syntaxTree.Root is RazorSyntaxNode root + ? root.FindInnermostNode(previousPosition) + : null; + + if (owner is MarkupTagHelperAttributeSyntax { EqualsToken: { IsMissing: false } equalsToken } && + equalsToken.EndPosition == positionInfo.HostDocumentIndex) + { + return positionInfo with { LanguageKind = RazorLanguageKind.Html }; + } + + return positionInfo; + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspExtensions_Location.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspExtensions_Location.cs new file mode 100644 index 00000000000..d79991b8e22 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspExtensions_Location.cs @@ -0,0 +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; + +namespace Roslyn.LanguageServer.Protocol; + +internal static partial class RoslynLspExtensions +{ + public static void Deconstruct(this Location position, out Uri uri, out Range range) + => (uri, range) = (position.Uri, position.Range); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspExtensions_SourceText.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspExtensions_SourceText.cs index fbeb95c1b9f..5c5d391316f 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspExtensions_SourceText.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspExtensions_SourceText.cs @@ -8,9 +8,21 @@ namespace Roslyn.LanguageServer.Protocol; internal static partial class RoslynLspExtensions { + public static int GetPosition(this SourceText text, Position position) + => text.GetPosition(position.ToLinePosition()); + + public static Position GetPosition(this SourceText text, int position) + => text.GetLinePosition(position).ToPosition(); + public static Range GetRange(this SourceText text, TextSpan span) => text.GetLinePositionSpan(span).ToRange(); + public static bool TryGetAbsoluteIndex(this SourceText text, Position position, out int absoluteIndex) + => text.TryGetAbsoluteIndex(position.Line, position.Character, out absoluteIndex); + + public static int GetRequiredAbsoluteIndex(this SourceText text, Position position) + => text.GetRequiredAbsoluteIndex(position.Line, position.Character); + public static TextSpan GetTextSpan(this SourceText text, Range range) => text.GetTextSpan(range.Start.Line, range.Start.Character, range.End.Line, range.End.Character); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspFactory.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspFactory.cs index 841ea8c1ed4..f26c4feb903 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspFactory.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/RoslynLspFactory.cs @@ -1,6 +1,7 @@ // 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 System.Diagnostics; using Microsoft.CodeAnalysis.Text; @@ -159,6 +160,18 @@ public static Range CreateSingleLineRange(LinePosition start, int length) public static Range CreateSingleLineRange((int line, int character) start, int length) => CreateRange(CreatePosition(start), CreatePosition(start.line, start.character + length)); + public static Location CreateLocation(Uri uri, Range range) + => new() { Uri = uri, Range = range }; + + public static Location CreateLocation(Uri uri, LinePositionSpan span) + => new() { Uri = uri, Range = CreateRange(span) }; + + public static DocumentLink CreateDocumentLink(Uri target, Range range) + => new() { Target = target, Range = range }; + + public static DocumentLink CreateDocumentLink(Uri target, LinePositionSpan span) + => new() { Target = target, Range = CreateRange(span) }; + public static TextEdit CreateTextEdit(Range range, string newText) => new() { Range = range, NewText = newText }; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/VsLspExtensions_Location.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/VsLspExtensions_Location.cs new file mode 100644 index 00000000000..e3d77984aae --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/VsLspExtensions_Location.cs @@ -0,0 +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; + +namespace Microsoft.VisualStudio.LanguageServer.Protocol; + +internal static partial class VsLspExtensions +{ + public static void Deconstruct(this Location position, out Uri uri, out Range range) + => (uri, range) = (position.Uri, position.Range); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/VsLspFactory.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/VsLspFactory.cs index 5b29d44cc60..53664af6b1f 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/VsLspFactory.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/VsLspFactory.cs @@ -1,6 +1,7 @@ // 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 System.Diagnostics; using Microsoft.CodeAnalysis.Text; @@ -159,6 +160,18 @@ public static Range CreateSingleLineRange(LinePosition start, int length) public static Range CreateSingleLineRange((int line, int character) start, int length) => CreateRange(CreatePosition(start), CreatePosition(start.line, start.character + length)); + public static Location CreateLocation(string filePath, LinePositionSpan span) + => CreateLocation(CreateFilePathUri(filePath), CreateRange(span)); + + public static Location CreateLocation(Uri uri, LinePositionSpan span) + => CreateLocation(uri, CreateRange(span)); + + public static Location CreateLocation(string filePath, Range range) + => CreateLocation(CreateFilePathUri(filePath), range); + + public static Location CreateLocation(Uri uri, Range range) + => new() { Uri = uri, Range = range }; + public static TextEdit CreateTextEdit(Range range, string newText) => new() { Range = range, NewText = newText }; @@ -185,4 +198,16 @@ public static TextEdit CreateTextEdit(LinePosition position, string newText) public static TextEdit CreateTextEdit((int line, int character) position, string newText) => CreateTextEdit(CreateZeroWidthRange(position), newText); + + public static Uri CreateFilePathUri(string filePath) + { + var builder = new UriBuilder + { + Path = filePath, + Scheme = Uri.UriSchemeFile, + Host = string.Empty, + }; + + return builder.Uri; + } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/AbstractRazorComponentDefinitionService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/AbstractRazorComponentDefinitionService.cs new file mode 100644 index 00000000000..737614147f9 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/AbstractRazorComponentDefinitionService.cs @@ -0,0 +1,90 @@ +// 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; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using LspLocation = Microsoft.VisualStudio.LanguageServer.Protocol.Location; +using LspRange = Microsoft.VisualStudio.LanguageServer.Protocol.Range; + +namespace Microsoft.CodeAnalysis.Razor.GoToDefinition; + +internal abstract class AbstractRazorComponentDefinitionService( + IRazorComponentSearchEngine componentSearchEngine, + IDocumentMappingService documentMappingService, + ILogger logger) : IRazorComponentDefinitionService +{ + private readonly IRazorComponentSearchEngine _componentSearchEngine = componentSearchEngine; + private readonly IDocumentMappingService _documentMappingService = documentMappingService; + private readonly ILogger _logger = logger; + + public async Task GetDefinitionAsync(IDocumentSnapshot documentSnapshot, DocumentPositionInfo positionInfo, bool ignoreAttributes, CancellationToken cancellationToken) + { + // If we're in C# then there is no point checking for a component tag, because there won't be one + if (positionInfo.LanguageKind == RazorLanguageKind.CSharp) + { + return null; + } + + if (!FileKinds.IsComponent(documentSnapshot.FileKind)) + { + _logger.LogInformation($"'{documentSnapshot.FileKind}' is not a component type."); + return null; + } + + var codeDocument = await documentSnapshot.GetGeneratedOutputAsync().ConfigureAwait(false); + + if (!RazorComponentDefinitionHelpers.TryGetBoundTagHelpers(codeDocument, positionInfo.HostDocumentIndex, ignoreAttributes, _logger, out var boundTagHelper, out var boundAttribute)) + { + _logger.LogInformation($"Could not retrieve bound tag helper information."); + return null; + } + + var componentDocument = await _componentSearchEngine.TryLocateComponentAsync(documentSnapshot, boundTagHelper).ConfigureAwait(false); + if (componentDocument is null) + { + _logger.LogInformation($"Could not locate component document."); + return null; + } + + var componentFilePath = componentDocument.FilePath.AssumeNotNull(); + + _logger.LogInformation($"Definition found at file path: {componentFilePath}"); + + var range = await GetNavigateRangeAsync(componentDocument, boundAttribute, cancellationToken).ConfigureAwait(false); + + return VsLspFactory.CreateLocation(componentFilePath, range); + } + + private async Task GetNavigateRangeAsync(IDocumentSnapshot documentSnapshot, BoundAttributeDescriptor? attributeDescriptor, CancellationToken cancellationToken) + { + if (attributeDescriptor is not null) + { + _logger.LogInformation($"Attempting to get definition from an attribute directly."); + + var originCodeDocument = await documentSnapshot.GetGeneratedOutputAsync().ConfigureAwait(false); + + var range = await RazorComponentDefinitionHelpers + .TryGetPropertyRangeAsync(originCodeDocument, attributeDescriptor.GetPropertyName(), _documentMappingService, _logger, cancellationToken) + .ConfigureAwait(false); + + if (range is not null) + { + return range; + } + } + + // When navigating from a start or end tag, we just take the user to the top of the file. + // If we were trying to navigate to a property, and we couldn't find it, we can at least take + // them to the file for the component. If the property was defined in a partial class they can + // at least then press F7 to go there. + return VsLspFactory.DefaultRange; + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/IRazorComponentDefinitionService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/IRazorComponentDefinitionService.cs new file mode 100644 index 00000000000..86ebf62d35f --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/IRazorComponentDefinitionService.cs @@ -0,0 +1,18 @@ +// 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.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using LspLocation = Microsoft.VisualStudio.LanguageServer.Protocol.Location; + +namespace Microsoft.CodeAnalysis.Razor.GoToDefinition; + +/// +/// Go to Definition support for Razor components. +/// +internal interface IRazorComponentDefinitionService +{ + Task GetDefinitionAsync(IDocumentSnapshot documentSnapshot, DocumentPositionInfo positionInfo, bool ignoreAttributes, CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/RazorComponentDefinitionHelpers.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/RazorComponentDefinitionHelpers.cs new file mode 100644 index 00000000000..66177fb6be2 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/GoToDefinition/RazorComponentDefinitionHelpers.cs @@ -0,0 +1,201 @@ +// 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 System.Diagnostics.CodeAnalysis; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Syntax; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using LspRange = Microsoft.VisualStudio.LanguageServer.Protocol.Range; +using RazorSyntaxKind = Microsoft.AspNetCore.Razor.Language.SyntaxKind; +using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; +using RazorSyntaxToken = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxToken; + +namespace Microsoft.CodeAnalysis.Razor.GoToDefinition; + +internal static class RazorComponentDefinitionHelpers +{ + public static bool TryGetBoundTagHelpers( + RazorCodeDocument codeDocument, int absoluteIndex, bool ignoreAttributes, ILogger logger, + [NotNullWhen(true)] out TagHelperDescriptor? boundTagHelper, + [MaybeNullWhen(true)] out BoundAttributeDescriptor? boundAttribute) + { + boundTagHelper = null; + boundAttribute = null; + + var syntaxTree = codeDocument.GetSyntaxTree(); + + var innermostNode = syntaxTree.Root.FindInnermostNode(absoluteIndex); + if (innermostNode is null) + { + logger.LogInformation($"Could not locate innermost node at index, {absoluteIndex}."); + return false; + } + + var tagHelperNode = innermostNode.FirstAncestorOrSelf(IsTagHelperNode); + if (tagHelperNode is null) + { + logger.LogInformation($"Could not locate ancestor of type MarkupTagHelperStartTag or MarkupTagHelperEndTag."); + return false; + } + + if (!TryGetTagName(tagHelperNode, out var tagName)) + { + logger.LogInformation($"Could not retrieve name of start or end tag."); + return false; + } + + var nameSpan = tagName.Span; + string? propertyName = null; + + if (!ignoreAttributes && tagHelperNode is MarkupTagHelperStartTagSyntax startTag) + { + // Include attributes where the end index also matches, since GetSyntaxNodeAsync will consider that the start tag but we behave + // as if the user wants to go to the attribute definition. + // ie: + var selectedAttribute = startTag.Attributes.FirstOrDefault(a => a.Span.Contains(absoluteIndex) || a.Span.End == absoluteIndex); + + // If we're on an attribute then just validate against the attribute name + switch (selectedAttribute) + { + case MarkupTagHelperAttributeSyntax attribute: + // Normal attribute, ie + nameSpan = attribute.Name.Span; + propertyName = attribute.TagHelperAttributeInfo.Name; + break; + + case MarkupMinimizedTagHelperAttributeSyntax minimizedAttribute: + // Minimized attribute, ie + nameSpan = minimizedAttribute.Name.Span; + propertyName = minimizedAttribute.TagHelperAttributeInfo.Name; + break; + } + } + + if (!nameSpan.IntersectsWith(absoluteIndex)) + { + logger.LogInformation($"Tag name or attributes' span does not intersect with index, {absoluteIndex}."); + return false; + } + + if (tagHelperNode.Parent is not MarkupTagHelperElementSyntax tagHelperElement) + { + logger.LogInformation($"Parent of start or end tag is not a MarkupTagHelperElement."); + return false; + } + + if (tagHelperElement.TagHelperInfo?.BindingResult is not TagHelperBinding binding) + { + logger.LogInformation($"MarkupTagHelperElement does not contain TagHelperInfo."); + return false; + } + + boundTagHelper = binding.Descriptors.FirstOrDefault(static d => !d.IsAttributeDescriptor()); + if (boundTagHelper is null) + { + logger.LogInformation($"Could not locate bound TagHelperDescriptor."); + return false; + } + + boundAttribute = propertyName is not null + ? boundTagHelper.BoundAttributes.FirstOrDefault(a => a.Name?.Equals(propertyName, StringComparison.Ordinal) == true) + : null; + + return true; + + static bool IsTagHelperNode(RazorSyntaxNode node) + { + return node.Kind is RazorSyntaxKind.MarkupTagHelperStartTag or RazorSyntaxKind.MarkupTagHelperEndTag; + } + + static bool TryGetTagName(RazorSyntaxNode node, [NotNullWhen(true)] out RazorSyntaxToken? tagName) + { + tagName = node switch + { + MarkupTagHelperStartTagSyntax tagHelperStartTag => tagHelperStartTag.Name, + MarkupTagHelperEndTagSyntax tagHelperEndTag => tagHelperEndTag.Name, + _ => null + }; + + return tagName is not null; + } + } + + public static async Task TryGetPropertyRangeAsync( + RazorCodeDocument codeDocument, + string propertyName, + IDocumentMappingService documentMappingService, + ILogger logger, + CancellationToken cancellationToken) + { + // Parse the C# file and find the property that matches the name. + // We don't worry about parameter attributes here for two main reasons: + // 1. We don't have symbolic information, so the best we could do would be checking for any + // attribute named Parameter, regardless of which namespace. It also means we would have + // to do more checks for all of the various ways that the attribute could be specified + // (eg fully qualified, aliased, etc.) + // 2. Since C# doesn't allow multiple properties with the same name, and we're doing a case + // sensitive search, we know the property we find is the one the user is trying to encode in a + // tag helper attribute. If they don't have the [Parameter] attribute then the Razor compiler + // will error, but allowing them to Go To Def on that property regardless, actually helps + // them fix the error. + var csharpText = codeDocument.GetCSharpSourceText(); + var syntaxTree = CSharpSyntaxTree.ParseText(csharpText, cancellationToken: cancellationToken); + var root = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + // Since we know how the compiler generates the C# source we can be a little specific here, and avoid + // long tree walks. If the compiler ever changes how they generate their code, the tests for this will break + // so we'll know about it. + if (TryGetClassDeclaration(root, out var classDeclaration)) + { + var property = classDeclaration + .Members + .OfType() + .Where(p => p.Identifier.ValueText.Equals(propertyName, StringComparison.Ordinal)) + .FirstOrDefault(); + + if (property is null) + { + // The property probably exists in a partial class + logger.LogInformation($"Could not find property in the generated source. Comes from partial?"); + return null; + } + + var range = csharpText.GetRange(property.Identifier.Span); + if (documentMappingService.TryMapToHostDocumentRange(codeDocument.GetCSharpDocument(), range, out var originalRange)) + { + return originalRange; + } + + logger.LogInformation($"Property found but couldn't map its location."); + } + + logger.LogInformation($"Generated C# was not in expected shape (CompilationUnit [-> Namespace] -> Class)"); + + return null; + + static bool TryGetClassDeclaration(SyntaxNode root, [NotNullWhen(true)] out ClassDeclarationSyntax? classDeclaration) + { + classDeclaration = root switch + { + CompilationUnitSyntax unit => unit switch + { + { Members: [NamespaceDeclarationSyntax { Members: [ClassDeclarationSyntax c, ..] }, ..] } => c, + { Members: [ClassDeclarationSyntax c, ..] } => c, + _ => null, + }, + _ => null, + }; + + return classDeclaration is not null; + } + } +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorSyntaxFacts.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSyntaxFacts.cs similarity index 93% rename from src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorSyntaxFacts.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSyntaxFacts.cs index 1e8c122d9f1..36a77a23129 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorSyntaxFacts.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/RazorSyntaxFacts.cs @@ -4,8 +4,9 @@ using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.CodeAnalysis.Text; +using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; -namespace Microsoft.AspNetCore.Razor.LanguageServer; +namespace Microsoft.CodeAnalysis.Razor; internal static class RazorSyntaxFacts { @@ -79,7 +80,7 @@ public static bool TryGetFullAttributeNameSpan(RazorCodeDocument codeDocument, i return attributeNameSpan != default; } - private static TextSpan GetFullAttributeNameSpan(SyntaxNode? node) + private static TextSpan GetFullAttributeNameSpan(RazorSyntaxNode? node) { return node switch { @@ -112,7 +113,7 @@ static TextSpan CalculateFullSpan(MarkupTextLiteralSyntax attributeName, MarkupT } } - public static CSharpCodeBlockSyntax? TryGetCSharpCodeFromCodeBlock(SyntaxNode node) + public static CSharpCodeBlockSyntax? TryGetCSharpCodeFromCodeBlock(RazorSyntaxNode node) { if (node is CSharpCodeBlockSyntax block && block.Children.FirstOrDefault() is RazorDirectiveSyntax directive && @@ -125,10 +126,9 @@ directive.Body is RazorDirectiveBodySyntax directiveBody && return null; } - public static bool IsAnyStartTag(SyntaxNode n) + public static bool IsAnyStartTag(RazorSyntaxNode n) => n.Kind is SyntaxKind.MarkupStartTag or SyntaxKind.MarkupTagHelperStartTag; - - public static bool IsAnyEndTag(SyntaxNode n) + public static bool IsAnyEndTag(RazorSyntaxNode n) => n.Kind is SyntaxKind.MarkupEndTag or SyntaxKind.MarkupTagHelperEndTag; } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteGoToDefinitionService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteGoToDefinitionService.cs new file mode 100644 index 00000000000..b8e91db72c5 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteGoToDefinitionService.cs @@ -0,0 +1,19 @@ +// 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.CodeAnalysis.ExternalAccess.Razor; +using RoslynLocation = Roslyn.LanguageServer.Protocol.Location; +using RoslynPosition = Roslyn.LanguageServer.Protocol.Position; + +namespace Microsoft.CodeAnalysis.Razor.Remote; + +internal interface IRemoteGoToDefinitionService : IRemoteJsonService +{ + ValueTask> GetDefinitionAsync( + JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, + JsonSerializableDocumentId razorDocumentId, + RoslynPosition position, + CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs index 06bf0950825..e1310e1658e 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs @@ -26,6 +26,7 @@ internal static class RazorServices // Internal for testing internal static readonly IEnumerable<(Type, Type?)> JsonServices = [ + (typeof(IRemoteGoToDefinitionService), null), (typeof(IRemoteSignatureHelpService), null), (typeof(IRemoteInlayHintService), null), (typeof(IRemoteRenameService), null), diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/GoToDefinition/RazorComponentDefinitionService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/GoToDefinition/RazorComponentDefinitionService.cs new file mode 100644 index 00000000000..790ad2c5f2f --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/GoToDefinition/RazorComponentDefinitionService.cs @@ -0,0 +1,20 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Composition; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.GoToDefinition; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Workspaces; + +namespace Microsoft.CodeAnalysis.Remote.Razor.GoToDefinition; + +[Export(typeof(IRazorComponentDefinitionService)), Shared] +[method: ImportingConstructor] +internal sealed class RazorComponentDefinitionService( + IRazorComponentSearchEngine componentSearchEngine, + IDocumentMappingService documentMappingService, + ILoggerFactory loggerFactory) + : AbstractRazorComponentDefinitionService(componentSearchEngine, documentMappingService, loggerFactory.GetOrCreateLogger()) +{ +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/GoToDefinition/RemoteGoToDefinitionService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/GoToDefinition/RemoteGoToDefinitionService.cs new file mode 100644 index 00000000000..02a7aba2217 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/GoToDefinition/RemoteGoToDefinitionService.cs @@ -0,0 +1,134 @@ +// 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.Language; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.GoToDefinition; +using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.CodeAnalysis.Remote.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Roslyn.LanguageServer.Protocol; +using static Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse; +using ExternalHandlers = Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; +using RoslynLocation = Roslyn.LanguageServer.Protocol.Location; +using RoslynPosition = Roslyn.LanguageServer.Protocol.Position; +using VsPosition = Microsoft.VisualStudio.LanguageServer.Protocol.Position; + +namespace Microsoft.CodeAnalysis.Remote.Razor; + +internal sealed class RemoteGoToDefinitionService(in ServiceArgs args) : RazorDocumentServiceBase(in args), IRemoteGoToDefinitionService +{ + internal sealed class Factory : FactoryBase + { + protected override IRemoteGoToDefinitionService CreateService(in ServiceArgs args) + => new RemoteGoToDefinitionService(in args); + } + + private readonly IRazorComponentDefinitionService _componentDefinitionService = args.ExportProvider.GetExportedValue(); + private readonly IFilePathService _filePathService = args.ExportProvider.GetExportedValue(); + + protected override IDocumentPositionInfoStrategy DocumentPositionInfoStrategy => PreferAttributeNameDocumentPositionInfoStrategy.Instance; + + public ValueTask> GetDefinitionAsync( + JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, + JsonSerializableDocumentId documentId, + RoslynPosition position, + CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + documentId, + context => GetDefinitionAsync(context, position, cancellationToken), + cancellationToken); + + private async ValueTask> GetDefinitionAsync( + RemoteDocumentContext context, + RoslynPosition position, + CancellationToken cancellationToken) + { + var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + + if (!codeDocument.Source.Text.TryGetAbsoluteIndex(position, out var hostDocumentIndex)) + { + return NoFurtherHandling; + } + + var positionInfo = GetPositionInfo(codeDocument, hostDocumentIndex); + + // First, see if this is a Razor component. + var componentLocation = await _componentDefinitionService.GetDefinitionAsync(context.Snapshot, positionInfo, ignoreAttributes: false, cancellationToken).ConfigureAwait(false); + if (componentLocation is not null) + { + // Convert from VS LSP Location to Roslyn. This can be removed when Razor moves fully onto Roslyn's LSP types. + return Results([RoslynLspFactory.CreateLocation(componentLocation.Uri, componentLocation.Range.ToLinePositionSpan())]); + } + + if (positionInfo.LanguageKind == RazorLanguageKind.Html) + { + // Sometimes Html can actually be mapped to C#, like for example component attributes, which map to + // C# properties, even though they appear entirely in a Html context. Since remapping is pretty cheap + // it's easier to just try mapping, and see what happens, rather than checking for specific syntax nodes. + if (DocumentMappingService.TryMapToGeneratedDocumentPosition(codeDocument.GetCSharpDocument(), positionInfo.HostDocumentIndex, out VsPosition? csharpPosition, out _)) + { + // We're just gonna pretend this mapped perfectly normally onto C#. Moving this logic to the actual position info + // calculating code is possible, but could have untold effects, so opt-in is better (for now?) + positionInfo = positionInfo with { LanguageKind = RazorLanguageKind.CSharp, Position = csharpPosition }; + } + } + + // If it isn't a Razor component, and it isn't C#, let the server know to delegate to HTML. + if (positionInfo.LanguageKind != RazorLanguageKind.CSharp) + { + return CallHtml; + } + + if (!DocumentMappingService.TryMapToGeneratedDocumentPosition(codeDocument.GetCSharpDocument(), positionInfo.HostDocumentIndex, out var mappedPosition, out _)) + { + // If we can't map to the generated C# file, we're done. + return NoFurtherHandling; + } + + // Finally, call into C#. + var generatedDocument = await context.GetGeneratedDocumentAsync(_filePathService, cancellationToken).ConfigureAwait(false); + + var locations = await ExternalHandlers.GoToDefinition + .GetDefinitionsAsync( + RemoteWorkspaceAccessor.GetWorkspace(), + generatedDocument, + typeOnly: false, + mappedPosition, + cancellationToken) + .ConfigureAwait(false); + + if (locations is null and not []) + { + // C# didn't return anything, so we're done. + return NoFurtherHandling; + } + + // Map the C# locations back to the Razor file. + using var mappedLocations = new PooledArrayBuilder(locations.Length); + + foreach (var location in locations) + { + var (uri, range) = location; + + var (mappedDocumentUri, mappedRange) = await DocumentMappingService + .MapToHostDocumentUriAndRangeAsync((RemoteDocumentSnapshot)context.Snapshot, uri, range.ToLinePositionSpan(), cancellationToken) + .ConfigureAwait(false); + + var mappedLocation = RoslynLspFactory.CreateLocation(mappedDocumentUri, mappedRange); + + mappedLocations.Add(mappedLocation); + } + + return Results(mappedLocations.ToArray()); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/InlayHints/RemoteInlayHintService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/InlayHints/RemoteInlayHintService.cs index ae06c498c58..2f80dd60b34 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/InlayHints/RemoteInlayHintService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/InlayHints/RemoteInlayHintService.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis.ExternalAccess.Razor; using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Protocol.InlayHints; using Microsoft.CodeAnalysis.Razor.Remote; using Microsoft.CodeAnalysis.Razor.Workspaces; @@ -27,7 +26,6 @@ protected override IRemoteInlayHintService CreateService(in ServiceArgs args) => new RemoteInlayHintService(in args); } - private readonly IDocumentMappingService _documentMappingService = args.ExportProvider.GetExportedValue(); private readonly IFilePathService _filePathService = args.ExportProvider.GetExportedValue(); public ValueTask GetInlayHintsAsync(JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, JsonSerializableDocumentId razorDocumentId, InlayHintParams inlayHintParams, bool displayAllOverride, CancellationToken cancellationToken) @@ -47,7 +45,7 @@ protected override IRemoteInlayHintService CreateService(in ServiceArgs args) // 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, span, out var projectedLinePositionSpan) && + if (!DocumentMappingService.TryMapToGeneratedDocumentRange(csharpDocument, span, out var projectedLinePositionSpan) && !codeDocument.TryGetMinimalCSharpRange(span, out projectedLinePositionSpan)) { // There's no C# in the range. @@ -73,7 +71,7 @@ protected override IRemoteInlayHintService CreateService(in ServiceArgs args) foreach (var hint in hints) { if (csharpSourceText.TryGetAbsoluteIndex(hint.Position.ToLinePosition(), out var absoluteIndex) && - _documentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteIndex, out var hostDocumentPosition, out var hostDocumentIndex)) + DocumentMappingService.TryMapToHostDocumentPosition(csharpDocument, absoluteIndex, out var hostDocumentPosition, out var hostDocumentIndex)) { // We know this C# maps to Razor, but does it map to Razor that we like? var node = syntaxTree.Root.FindInnermostNode(hostDocumentIndex); @@ -85,7 +83,7 @@ protected override IRemoteInlayHintService CreateService(in ServiceArgs args) if (hint.TextEdits is not null) { var changes = hint.TextEdits.Select(csharpSourceText.GetTextChange); - var mappedChanges = _documentMappingService.GetHostDocumentEdits(csharpDocument, changes); + var mappedChanges = DocumentMappingService.GetHostDocumentEdits(csharpDocument, changes); hint.TextEdits = mappedChanges.Select(razorSourceText.GetTextEdit).ToArray(); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorDocumentServiceBase.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorDocumentServiceBase.cs index 85b747d60a4..9327d9931e7 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorDocumentServiceBase.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/RazorDocumentServiceBase.cs @@ -4,14 +4,53 @@ using System; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Roslyn.LanguageServer.Protocol; +using RoslynPosition = Roslyn.LanguageServer.Protocol.Position; +using VsPosition = Microsoft.VisualStudio.LanguageServer.Protocol.Position; namespace Microsoft.CodeAnalysis.Remote.Razor; internal abstract class RazorDocumentServiceBase(in ServiceArgs args) : RazorBrokeredServiceBase(in args) { protected DocumentSnapshotFactory DocumentSnapshotFactory { get; } = args.ExportProvider.GetExportedValue(); + protected IDocumentMappingService DocumentMappingService { get; } = args.ExportProvider.GetExportedValue(); + + protected virtual IDocumentPositionInfoStrategy DocumentPositionInfoStrategy { get; } = DefaultDocumentPositionInfoStrategy.Instance; + + protected DocumentPositionInfo GetPositionInfo(RazorCodeDocument codeDocument, int hostDocumentIndex) + { + return DocumentPositionInfoStrategy.GetPositionInfo(DocumentMappingService, codeDocument, hostDocumentIndex); + } + + protected bool TryGetDocumentPositionInfo(RazorCodeDocument codeDocument, RoslynPosition position, out DocumentPositionInfo positionInfo) + { + if (!codeDocument.Source.Text.TryGetAbsoluteIndex(position, out var hostDocumentIndex)) + { + positionInfo = default; + return false; + } + + positionInfo = GetPositionInfo(codeDocument, hostDocumentIndex); + return true; + } + + protected bool TryGetDocumentPositionInfo(RazorCodeDocument codeDocument, VsPosition position, out DocumentPositionInfo positionInfo) + { + if (!codeDocument.Source.Text.TryGetAbsoluteIndex(position, out var hostDocumentIndex)) + { + positionInfo = default; + return false; + } + + positionInfo = GetPositionInfo(codeDocument, hostDocumentIndex); + return true; + } protected ValueTask RunServiceAsync( RazorPinnedSolutionInfoWrapper solutionInfo, diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs index 7f095247258..be31a947509 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Rename/RemoteRenameService.cs @@ -11,8 +11,8 @@ using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; using Microsoft.VisualStudio.LanguageServer.Protocol; -using ExternalHandlers = Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; using static Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse; +using ExternalHandlers = Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost.Handlers; namespace Microsoft.CodeAnalysis.Remote.Razor; @@ -26,7 +26,6 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args) private readonly IRenameService _renameService = args.ExportProvider.GetExportedValue(); private readonly IFilePathService _filePathService = args.ExportProvider.GetExportedValue(); - private readonly IDocumentMappingService _documentMappingService = args.ExportProvider.GetExportedValue(); private readonly IEditMappingService _editMappingService = args.ExportProvider.GetExportedValue(); public ValueTask> GetRenameEditAsync( @@ -48,10 +47,13 @@ protected override IRemoteRenameService CreateService(in ServiceArgs args) CancellationToken cancellationToken) { var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); - var generatedDocument = await context.GetGeneratedDocumentAsync(_filePathService, cancellationToken).ConfigureAwait(false); - var hostDocumentIndex = codeDocument.Source.Text.GetRequiredAbsoluteIndex(position); - var positionInfo = _documentMappingService.GetPositionInfo(codeDocument, hostDocumentIndex); + if (!TryGetDocumentPositionInfo(codeDocument, position, out var positionInfo)) + { + return NoFurtherHandling; + } + + var generatedDocument = await context.GetGeneratedDocumentAsync(_filePathService, cancellationToken).ConfigureAwait(false); var razorEdit = await _renameService.TryGetRazorRenameEditsAsync(context, positionInfo, newName, cancellationToken).ConfigureAwait(false); if (razorEdit is not null) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SignatureHelp/RemoteSignatureHelpService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SignatureHelp/RemoteSignatureHelpService.cs index fb4fa23635a..0aa63984efc 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SignatureHelp/RemoteSignatureHelpService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/SignatureHelp/RemoteSignatureHelpService.cs @@ -5,7 +5,6 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Remote; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; @@ -26,7 +25,6 @@ protected override IRemoteSignatureHelpService CreateService(in ServiceArgs args } private readonly IFilePathService _filePathService = args.ExportProvider.GetExportedValue(); - private readonly IDocumentMappingService _documentMappingService = args.ExportProvider.GetExportedValue(); public ValueTask GetSignatureHelpAsync(JsonSerializableRazorPinnedSolutionInfoWrapper solutionInfo, JsonSerializableDocumentId documentId, Position position, CancellationToken cancellationToken) => RunServiceAsync( @@ -43,7 +41,7 @@ protected override IRemoteSignatureHelpService CreateService(in ServiceArgs args var generatedDocument = await context.GetGeneratedDocumentAsync(_filePathService, cancellationToken).ConfigureAwait(false); - if (_documentMappingService.TryMapToGeneratedDocumentPosition(codeDocument.GetCSharpDocument(), absoluteIndex, out var mappedPosition, out _)) + if (DocumentMappingService.TryMapToGeneratedDocumentPosition(codeDocument.GetCSharpDocument(), absoluteIndex, out var mappedPosition, out _)) { return await ExternalHandlers.SignatureHelp.GetSignatureHelpAsync(generatedDocument, mappedPosition, supportsVisualStudioExtensions: true, cancellationToken).ConfigureAwait(false); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs index 393765a7a0a..06dd2c59b0f 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/UriPresentation/RemoteUriPresentationService.cs @@ -5,7 +5,6 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.DocumentPresentation; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; @@ -25,8 +24,6 @@ protected override IRemoteUriPresentationService CreateService(in ServiceArgs ar => new RemoteUriPresentationService(in args); } - private readonly IDocumentMappingService _documentMappingService = args.ExportProvider.GetExportedValue(); - public ValueTask GetPresentationAsync( RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, @@ -54,7 +51,7 @@ private async ValueTask GetPresentationAsync( var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); - var languageKind = _documentMappingService.GetLanguageKind(codeDocument, index, rightAssociative: true); + var languageKind = DocumentMappingService.GetLanguageKind(codeDocument, index, rightAssociative: true); if (languageKind is not RazorLanguageKind.Html) { // Roslyn doesn't currently support Uri presentation, and whilst it might seem counter intuitive, diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostGoToDefinitionEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostGoToDefinitionEndpoint.cs new file mode 100644 index 00000000000..fbab17f6a38 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostGoToDefinitionEndpoint.cs @@ -0,0 +1,150 @@ +// 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 System.Composition; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using static Roslyn.LanguageServer.Protocol.RoslynLspExtensions; +using RoslynDocumentLink = Roslyn.LanguageServer.Protocol.DocumentLink; +using RoslynLocation = Roslyn.LanguageServer.Protocol.Location; +using RoslynLspFactory = Roslyn.LanguageServer.Protocol.RoslynLspFactory; +using VsLspLocation = Microsoft.VisualStudio.LanguageServer.Protocol.Location; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(Methods.TextDocumentDefinitionName)] +[Export(typeof(IDynamicRegistrationProvider))] +[ExportCohostStatelessLspService(typeof(CohostGoToDefinitionEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal sealed class CohostGoToDefinitionEndpoint( + IRemoteServiceInvoker remoteServiceInvoker, + IHtmlDocumentSynchronizer htmlDocumentSynchronizer, + LSPRequestInvoker requestInvoker) + : AbstractRazorCohostDocumentRequestHandler?>, IDynamicRegistrationProvider +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + private readonly IHtmlDocumentSynchronizer _htmlDocumentSynchronizer = htmlDocumentSynchronizer; + private readonly LSPRequestInvoker _requestInvoker = requestInvoker; + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => true; + + public Registration? GetRegistration(VSInternalClientCapabilities clientCapabilities, DocumentFilter[] filter, RazorCohostRequestContext requestContext) + { + if (clientCapabilities.TextDocument?.Definition?.DynamicRegistration == true) + { + return new Registration + { + Method = Methods.TextDocumentDefinitionName, + RegisterOptions = new DefinitionOptions() + }; + } + + return null; + } + + protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(TextDocumentPositionParams request) + => request.TextDocument.ToRazorTextDocumentIdentifier(); + + protected override Task?> HandleRequestAsync(TextDocumentPositionParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync( + request, + context.TextDocument.AssumeNotNull(), + cancellationToken); + + private async Task?> HandleRequestAsync(TextDocumentPositionParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + var position = RoslynLspFactory.CreatePosition(request.Position.ToLinePosition()); + + var response = await _remoteServiceInvoker + .TryInvokeAsync>( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => + service.GetDefinitionAsync(solutionInfo, razorDocument.Id, position, cancellationToken), + cancellationToken) + .ConfigureAwait(false); + + if (response.Result is RoslynLocation[] locations) + { + return locations; + } + + if (response.StopHandling) + { + return null; + } + + return await GetHtmlDefinitionsAsync(request, razorDocument, cancellationToken).ConfigureAwait(false); + } + + private async Task?> GetHtmlDefinitionsAsync(TextDocumentPositionParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + var htmlDocument = await _htmlDocumentSynchronizer.TryGetSynchronizedHtmlDocumentAsync(razorDocument, cancellationToken).ConfigureAwait(false); + if (htmlDocument is null) + { + return null; + } + + request.TextDocument.Uri = htmlDocument.Uri; + + var result = await _requestInvoker + .ReinvokeRequestOnServerAsync?>( + htmlDocument.Buffer, + Methods.TextDocumentDefinitionName, + RazorLSPConstants.HtmlLanguageServerName, + request, + cancellationToken) + .ConfigureAwait(false); + + if (result is not { Response: { } response }) + { + return null; + } + + if (response.TryGetFirst(out var singleLocation)) + { + return RoslynLspFactory.CreateLocation(singleLocation.Uri, singleLocation.Range.ToLinePositionSpan()); + } + else if (response.TryGetSecond(out var multipleLocations)) + { + return Array.ConvertAll(multipleLocations, static l => RoslynLspFactory.CreateLocation(l.Uri, l.Range.ToLinePositionSpan())); + } + else if (response.TryGetThird(out var documentLinks)) + { + using var builder = new PooledArrayBuilder(capacity: documentLinks.Length); + + foreach (var documentLink in documentLinks) + { + if (documentLink.Target is Uri target) + { + builder.Add(RoslynLspFactory.CreateDocumentLink(target, documentLink.Range.ToLinePositionSpan())); + } + } + + return builder.ToArray(); + } + + return null; + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostGoToDefinitionEndpoint instance) + { + public Task?> HandleRequestAsync( + TextDocumentPositionParams request, TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(request, razorDocument, cancellationToken); + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/AutoInsert/PreferHtmlInAttributeValuesDocumentPositionInfoStrategyTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/AutoInsert/PreferHtmlInAttributeValuesDocumentPositionInfoStrategyTest.cs index 5b72597e19c..c06d4ba3492 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/AutoInsert/PreferHtmlInAttributeValuesDocumentPositionInfoStrategyTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/AutoInsert/PreferHtmlInAttributeValuesDocumentPositionInfoStrategyTest.cs @@ -3,7 +3,7 @@ using System; using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.AutoInsert; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Testing; using Microsoft.CodeAnalysis.Text; @@ -46,15 +46,14 @@ internal async Task TryGetPositionInfoAsync_AtVariousPosition_ReturnsCorrectLang var position = codeDocument.Source.Text.GetPosition(cursorPosition); var uri = new Uri(razorFilePath); _ = await CreateLanguageServerAsync(codeDocument, razorFilePath); - var documentContext = CreateDocumentContext(uri, codeDocument); // Act - var result = await PreferHtmlInAttributeValuesDocumentPositionInfoStrategy.Instance.TryGetPositionInfoAsync( - DocumentMappingService, documentContext, position, DisposalToken); + var result = PreferHtmlInAttributeValuesDocumentPositionInfoStrategy.Instance.GetPositionInfo(DocumentMappingService, codeDocument, cursorPosition); // Assert - Assert.NotNull(result); + Assert.NotEqual(default, result); Assert.Equal(expectedLanguage, result.LanguageKind); + if (expectedLanguage != RazorLanguageKind.CSharp) { Assert.Equal(cursorPosition, result.HostDocumentIndex); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Definition/DefinitionEndpointDelegationTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Definition/DefinitionEndpointDelegationTest.cs index 2711a8dc359..c5c53d1ff0b 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Definition/DefinitionEndpointDelegationTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Definition/DefinitionEndpointDelegationTest.cs @@ -15,8 +15,8 @@ using Xunit; using Xunit.Abstractions; using DefinitionResult = Microsoft.VisualStudio.LanguageServer.Protocol.SumType< - Microsoft.VisualStudio.LanguageServer.Protocol.VSInternalLocation, - Microsoft.VisualStudio.LanguageServer.Protocol.VSInternalLocation[], + Microsoft.VisualStudio.LanguageServer.Protocol.Location, + Microsoft.VisualStudio.LanguageServer.Protocol.Location[], Microsoft.VisualStudio.LanguageServer.Protocol.DocumentLink[]>; namespace Microsoft.AspNetCore.Razor.LanguageServer.Definition; @@ -27,17 +27,17 @@ public class DefinitionEndpointDelegationTest(ITestOutputHelper testOutput) : Si public async Task Handle_SingleServer_CSharp_Method() { var input = """ -
- @{ - var x = Ge$$tX(); - } - @functions +
+ @{ + var x = Ge$$tX(); + } + @functions + { + void [|GetX|]() { - void [|GetX|]() - { - } } - """; + } + """; await VerifyCSharpGoToDefinitionAsync(input); } @@ -46,19 +46,19 @@ public async Task Handle_SingleServer_CSharp_Method() public async Task Handle_SingleServer_CSharp_Local() { var input = """ -
- @{ - var x = GetX(); - } - @functions +
+ @{ + var x = GetX(); + } + @functions + { + private string [|_name|]; + string GetX() { - private string [|_name|]; - string GetX() - { - return _na$$me; - } + return _na$$me; } - """; + } + """; await VerifyCSharpGoToDefinitionAsync(input); } @@ -67,12 +67,12 @@ string GetX() public async Task Handle_SingleServer_CSharp_MetadataReference() { var input = """ -
- @functions - { - private stri$$ng _name; - } - """; +
+ @functions + { + private stri$$ng _name; + } + """; // Arrange TestFileMarkupParser.GetPosition(input, out var output, out var cursorPosition); @@ -104,15 +104,15 @@ public async Task Handle_SingleServer_CSharp_MetadataReference() public async Task Handle_SingleServer_Attribute_SameFile(string method) { var input = $$""" -