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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions eng/targets/Services.props
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
For example, if your service interface is IRemoteFrobulatorService, then the Include should be "Microsoft.VisualStudio.Razor.Frobulator".
-->
<ItemGroup>
<ServiceHubService Include="Microsoft.VisualStudio.Razor.LinkedEditingRange" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteLinkedEditingRangeServiceFactory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.SemanticTokens" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteSemanticTokensServiceFactory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.HtmlDocument" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteHtmlDocumentServiceFactory" />
<ServiceHubService Include="Microsoft.VisualStudio.Razor.TagHelperProvider" ClassName="Microsoft.CodeAnalysis.Remote.Razor.RemoteTagHelperProviderServiceFactory"/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts;
using Microsoft.CodeAnalysis.Razor.LinkedEditingRange;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Razor.Workspaces;
Expand All @@ -18,12 +19,6 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.LinkedEditingRange;
[RazorLanguageServerEndpoint(Methods.TextDocumentLinkedEditingRangeName)]
internal class LinkedEditingRangeEndpoint : IRazorRequestHandler<LinkedEditingRangeParams, LinkedEditingRanges?>, ICapabilitiesProvider
{
// The regex below excludes characters that can never be valid in a TagHelper name.
// This is loosely based off logic from the Razor compiler:
// https://github.com/dotnet/aspnetcore/blob/9da42b9fab4c61fe46627ac0c6877905ec845d5a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Legacy/HtmlTokenizer.cs
// Internal for testing only.
internal static readonly string WordPattern = @"!?[^ <>!\/\?\[\]=""\\@" + Environment.NewLine + "]+";

private readonly ILogger _logger;

public LinkedEditingRangeEndpoint(ILoggerFactory loggerFactory)
Expand Down Expand Up @@ -67,81 +62,19 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(LinkedEditingRangeParams
return null;
}

var syntaxTree = await documentContext.GetSyntaxTreeAsync(cancellationToken).ConfigureAwait(false);

var location = await GetSourceLocationAsync(request, documentContext, cancellationToken).ConfigureAwait(false);
if (location is not SourceLocation validLocation)
{
return null;
}

// We only care if the user is within a TagHelper or HTML tag with a valid start and end tag.
if (TryGetNearestMarkupNameTokens(syntaxTree, validLocation, out var startTagNameToken, out var endTagNameToken) &&
(startTagNameToken.Span.Contains(validLocation.AbsoluteIndex) || endTagNameToken.Span.Contains(validLocation.AbsoluteIndex) ||
startTagNameToken.Span.End == validLocation.AbsoluteIndex || endTagNameToken.Span.End == validLocation.AbsoluteIndex))
if (LinkedEditingRangeHelper.GetLinkedSpans(request.Position.ToLinePosition(), codeDocument, _logger) is { } linkedSpans && linkedSpans.Length == 2)
{
var startSpan = startTagNameToken.GetLinePositionSpan(codeDocument.Source);
var endSpan = endTagNameToken.GetLinePositionSpan(codeDocument.Source);
var ranges = new Range[2] { startSpan.ToRange(), endSpan.ToRange() };
var ranges = new Range[2] { linkedSpans[0].ToRange(), linkedSpans[1].ToRange() };

return new LinkedEditingRanges
{
Ranges = ranges,
WordPattern = WordPattern
WordPattern = LinkedEditingRangeHelper.WordPattern
};
}

_logger.LogInformation($"LinkedEditingRange request was null at {location} for {request.TextDocument.Uri}");
return null;
_logger.LogInformation($"LinkedEditingRange request was null at line {request.Position.Line}, column {request.Position.Character} for {request.TextDocument.Uri}");

async Task<SourceLocation?> GetSourceLocationAsync(
LinkedEditingRangeParams request,
DocumentContext documentContext,
CancellationToken cancellationToken)
{
var sourceText = await documentContext.GetSourceTextAsync(cancellationToken).ConfigureAwait(false);
if (request.Position.TryGetSourceLocation(sourceText, _logger, out var location))
{
return location;
}
else
{
return null;
}
}

static bool TryGetNearestMarkupNameTokens(
RazorSyntaxTree syntaxTree,
SourceLocation location,
[NotNullWhen(true)] out SyntaxToken? startTagNameToken,
[NotNullWhen(true)] out SyntaxToken? endTagNameToken)
{
var owner = syntaxTree.Root.FindInnermostNode(location.AbsoluteIndex);
var element = owner?.FirstAncestorOrSelf<MarkupSyntaxNode>(
a => a.Kind is SyntaxKind.MarkupTagHelperElement || a.Kind is SyntaxKind.MarkupElement);

if (element is null)
{
startTagNameToken = null;
endTagNameToken = null;
return false;
}

switch (element)
{
// Tag helper
case MarkupTagHelperElementSyntax markupTagHelperElement:
startTagNameToken = markupTagHelperElement.StartTag?.Name;
endTagNameToken = markupTagHelperElement.EndTag?.Name;
return startTagNameToken is not null && endTagNameToken is not null;
// HTML
case MarkupElementSyntax markupElement:
startTagNameToken = markupElement.StartTag?.Name;
endTagNameToken = markupElement.EndTag?.Name;
return startTagNameToken is not null && endTagNameToken is not null;
default:
throw new InvalidOperationException("Element is expected to be a MarkupTagHelperElement or MarkupElement.");
}
}
return null;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -178,7 +178,10 @@ static void AddHandlers(IServiceCollection services, LanguageServerFeatureOption

services.AddHandlerWithCapabilities<RenameEndpoint>();
services.AddHandlerWithCapabilities<DefinitionEndpoint>();
services.AddHandlerWithCapabilities<LinkedEditingRangeEndpoint>();
if (!featureOptions.UseRazorCohostServer)
{
services.AddHandlerWithCapabilities<LinkedEditingRangeEndpoint>();
}
services.AddHandler<WrapWithTagEndpoint>();
services.AddHandler<RazorBreakpointSpanEndpoint>();
services.AddHandler<RazorProximityExpressionsEndpoint>();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
// 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 Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Syntax;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Workspaces;
using Microsoft.CodeAnalysis.Text;

using RazorSyntaxToken = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxToken;

namespace Microsoft.CodeAnalysis.Razor.LinkedEditingRange;

internal static class LinkedEditingRangeHelper
Copy link
Contributor Author

Choose a reason for hiding this comment

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

Decided to make this a static helper - one of the factored out methods was already static and I believe only one call needed a logger. Other than logger it doesn't really keep any state (and even logger seems like it's nice to have from whichever endpoing is using the helper code). Plus it's all temporary until we switch to cohosting full time. The three-pronged service seemed like an overkill here.

Copy link
Member

Choose a reason for hiding this comment

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

Plus it's all temporary

We'll see 😁

{
// The regex below excludes characters that can never be valid in a TagHelper name.
// This is loosely based off logic from the Razor compiler:
// https://github.com/dotnet/aspnetcore/blob/9da42b9fab4c61fe46627ac0c6877905ec845d5a/src/Razor/Microsoft.AspNetCore.Razor.Language/src/Legacy/HtmlTokenizer.cs
public static readonly string WordPattern = @"!?[^ <>!\/\?\[\]=""\\@" + Environment.NewLine + "]+";

public static LinePositionSpan[]? GetLinkedSpans(LinePosition linePosition, RazorCodeDocument codeDocument, ILogger logger)
{
if (GetSourceLocation(linePosition, codeDocument, logger) is not { } validLocation)
{
return null;
}

var syntaxTree = codeDocument.GetSyntaxTree();

// We only care if the user is within a TagHelper or HTML tag with a valid start and end tag.
if (TryGetNearestMarkupNameTokens(syntaxTree, validLocation, out var startTagNameToken, out var endTagNameToken) &&
(startTagNameToken.Span.Contains(validLocation.AbsoluteIndex) || endTagNameToken.Span.Contains(validLocation.AbsoluteIndex) ||
startTagNameToken.Span.End == validLocation.AbsoluteIndex || endTagNameToken.Span.End == validLocation.AbsoluteIndex))
{
var startSpan = startTagNameToken.GetLinePositionSpan(codeDocument.Source);
var endSpan = endTagNameToken.GetLinePositionSpan(codeDocument.Source);

return [startSpan, endSpan];
}

return null;
}

private static SourceLocation? GetSourceLocation(
LinePosition linePosition,
RazorCodeDocument codeDocument,
ILogger logger)
{
var sourceText = codeDocument.GetSourceText();

if (linePosition.ToPosition().TryGetSourceLocation(sourceText, logger, out var location))
{
return location;
}
else
{
return null;
}
}

private static bool TryGetNearestMarkupNameTokens(
RazorSyntaxTree syntaxTree,
SourceLocation location,
[NotNullWhen(true)] out RazorSyntaxToken? startTagNameToken,
[NotNullWhen(true)] out RazorSyntaxToken? endTagNameToken)
{
var owner = syntaxTree.Root.FindInnermostNode(location.AbsoluteIndex);
var element = owner?.FirstAncestorOrSelf<MarkupSyntaxNode>(
a => a.Kind is SyntaxKind.MarkupTagHelperElement || a.Kind is SyntaxKind.MarkupElement);

if (element is null)
{
startTagNameToken = null;
endTagNameToken = null;
return false;
}

switch (element)
{
// Tag helper
case MarkupTagHelperElementSyntax markupTagHelperElement:
startTagNameToken = markupTagHelperElement.StartTag?.Name;
endTagNameToken = markupTagHelperElement.EndTag?.Name;
return startTagNameToken is not null && endTagNameToken is not null;
// HTML
case MarkupElementSyntax markupElement:
startTagNameToken = markupElement.StartTag?.Name;
endTagNameToken = markupElement.EndTag?.Name;
return startTagNameToken is not null && endTagNameToken is not null;
default:
throw new InvalidOperationException("Element is expected to be a MarkupTagHelperElement or MarkupElement.");
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Threading;
using System.Threading.Tasks;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.CodeAnalysis.Razor.Remote;

interface IRemoteLinkedEditingRangeService
{
ValueTask<LinePositionSpan[]?> GetRangesAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, LinePosition linePosition, CancellationToken cancellationToken);
}
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal static class RazorServices
additionalResolvers: TopLevelResolvers.All,
interfaces:
[
(typeof(IRemoteLinkedEditingRangeService), null),
(typeof(IRemoteTagHelperProviderService), null),
(typeof(IRemoteClientInitializationService), null),
(typeof(IRemoteSemanticTokensService), null),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,10 +15,8 @@ namespace Microsoft.CodeAnalysis.Remote.Razor;
internal sealed class RemoteHtmlDocumentService(
IServiceBroker serviceBroker,
DocumentSnapshotFactory documentSnapshotFactory)
: RazorServiceBase(serviceBroker), IRemoteHtmlDocumentService
: RazorDocumentServiceBase(serviceBroker, documentSnapshotFactory), IRemoteHtmlDocumentService
{
private readonly DocumentSnapshotFactory _documentSnapshotFactory = documentSnapshotFactory;

public ValueTask<string?> GetHtmlDocumentTextAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, CancellationToken cancellationToken)
=> RazorBrokeredServiceImplementation.RunServiceAsync(
solutionInfo,
Expand All @@ -28,15 +26,11 @@ internal sealed class RemoteHtmlDocumentService(

private async ValueTask<string?> GetHtmlDocumentTextAsync(Solution solution, DocumentId razorDocumentId, CancellationToken _)
{
var razorDocument = solution.GetAdditionalDocument(razorDocumentId);
if (razorDocument is null)
if (await GetRazorCodeDocumentAsync(solution, razorDocumentId) is not { } codeDocument)
{
return null;
}

var documentSnapshot = _documentSnapshotFactory.GetOrCreate(razorDocument);
var codeDocument = await documentSnapshot.GetGeneratedOutputAsync();

return codeDocument.GetHtmlSourceText().ToString();
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
// 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 Microsoft.CodeAnalysis.ExternalAccess.Razor.Api;
using Microsoft.CodeAnalysis.Razor.LinkedEditingRange;
using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem;
using Microsoft.CodeAnalysis.Text;
using Microsoft.ServiceHub.Framework;

namespace Microsoft.CodeAnalysis.Remote.Razor;

internal sealed class RemoteLinkedEditingRangeService(
IServiceBroker serviceBroker,
DocumentSnapshotFactory documentSnapshotFactory,
ILoggerFactory loggerFactory)
: RazorDocumentServiceBase(serviceBroker, documentSnapshotFactory), IRemoteLinkedEditingRangeService
{
private readonly ILogger _logger = loggerFactory.GetOrCreateLogger<RemoteLinkedEditingRangeService>();

public ValueTask<LinePositionSpan[]?> GetRangesAsync(RazorPinnedSolutionInfoWrapper solutionInfo, DocumentId razorDocumentId, LinePosition linePosition, CancellationToken cancellationToken)
=> RazorBrokeredServiceImplementation.RunServiceAsync(
solutionInfo,
ServiceBrokerClient,
solution => GetRangesAsync(solution, razorDocumentId, linePosition, cancellationToken),
cancellationToken);

public async ValueTask<LinePositionSpan[]?> GetRangesAsync(Solution solution, DocumentId razorDocumentId, LinePosition linePosition, CancellationToken cancellationToken)
{
if (cancellationToken.IsCancellationRequested)
{
return null;
}

if (await GetRazorCodeDocumentAsync(solution, razorDocumentId).ConfigureAwait(false) is not { } codeDocument)
{
return null;
}

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

using Microsoft.CodeAnalysis.Razor.Logging;
using Microsoft.CodeAnalysis.Razor.Remote;
using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem;
using Microsoft.ServiceHub.Framework;
using Microsoft.VisualStudio.Composition;

namespace Microsoft.CodeAnalysis.Remote.Razor;

internal sealed class RemoteLinkedEditingRangeServiceFactory : RazorServiceFactoryBase<IRemoteLinkedEditingRangeService>
{
// WARNING: We must always have a parameterless constructor in order to be properly handled by ServiceHub.
public RemoteLinkedEditingRangeServiceFactory()
: base(RazorServices.Descriptors)
{
}

protected override IRemoteLinkedEditingRangeService CreateService(IServiceBroker serviceBroker, ExportProvider exportProvider)
{
var documentSnapshotFactory = exportProvider.GetExportedValue<DocumentSnapshotFactory>();
var loggerFactory = exportProvider.GetExportedValue<ILoggerFactory>();

return new RemoteLinkedEditingRangeService(serviceBroker, documentSnapshotFactory, loggerFactory);
}
}
Loading