Skip to content
Original file line number Diff line number Diff line change
Expand Up @@ -34,9 +34,9 @@ public Uri GetTextDocumentIdentifier(RazorMapToDocumentRangesParams request)

public async Task<RazorMapToDocumentRangesResponse?> HandleRequestAsync(RazorMapToDocumentRangesParams request, RazorRequestContext requestContext, CancellationToken cancellationToken)
{
if (request is null)
if (request.Kind == RazorLanguageKind.Razor)
{
throw new ArgumentNullException(nameof(request));
return null;
}

var documentContext = requestContext.DocumentContext;
Expand All @@ -45,35 +45,41 @@ public Uri GetTextDocumentIdentifier(RazorMapToDocumentRangesParams request)
return null;
}

if (request.Kind != RazorLanguageKind.CSharp)
var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
IRazorGeneratedDocument? generatedDocument = request.Kind switch
{
// All other non-C# requests map directly to where they are in the document.
return new RazorMapToDocumentRangesResponse()
{
Ranges = request.ProjectedRanges,
HostDocumentVersion = documentContext.Snapshot.Version,
};
RazorLanguageKind.CSharp => codeDocument.GetCSharpDocument(),
RazorLanguageKind.Html => codeDocument.GetHtmlDocument(),
_ => throw new NotSupportedException($"Unsupported language kind '{request.Kind}'."),
};

if (generatedDocument is null)
{
return null;
}

var codeDocument = await documentContext.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false);
var ranges = new LspRange[request.ProjectedRanges.Length];
var spans = new RazorTextSpan[request.ProjectedRanges.Length];

for (var i = 0; i < request.ProjectedRanges.Length; i++)
{
var projectedRange = request.ProjectedRanges[i];
if (!_documentMappingService.TryMapToHostDocumentRange(codeDocument.GetCSharpDocument(), projectedRange, request.MappingBehavior, out var originalRange))
if (!_documentMappingService.TryMapToHostDocumentRange(generatedDocument, projectedRange, request.MappingBehavior, out var originalRange))
{
// All language queries on unsupported documents return Html. This is equivalent to what pre-VSCode Razor was capable of.
ranges[i] = LspFactory.UndefinedRange;
continue;
}

ranges[i] = originalRange;
spans[i] = originalRange.ToRazorTextSpan(generatedDocument.Text);
}

return new RazorMapToDocumentRangesResponse()
{
Ranges = ranges,
HostDocumentVersion = documentContext.Snapshot.Version,
Spans = spans,
};
}
}
Original file line number Diff line number Diff line change
@@ -1,12 +1,23 @@
// 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.Protocol;
using Microsoft.CodeAnalysis.Text;

namespace Roslyn.LanguageServer.Protocol;

internal static partial class LspExtensions
{
public static RazorTextSpan ToRazorTextSpan(this LspRange range, SourceText sourceText)
{
var textSpan = sourceText.GetTextSpan(range);
return new()
{
Start = textSpan.Start,
Length = textSpan.Length,
};
}

public static void Deconstruct(this LspRange range, out Position start, out Position end)
=> (start, end) = (range.Start, range.End);

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,9 @@ internal class RazorMapToDocumentRangesResponse
[JsonPropertyName("ranges")]
public required LspRange[] Ranges { get; init; }

[JsonPropertyName("spans")]
public required RazorTextSpan[] Spans { get; set; }

[JsonPropertyName("hostDocumentVersion")]
public int? HostDocumentVersion { get; init; }
}
Original file line number Diff line number Diff line change
Expand Up @@ -51,7 +51,7 @@ internal sealed partial class LspDynamicFileProvider(IRazorClientLanguageServerM
RazorUri.GetDocumentFilePathFromUri(response.CSharpDocument.Uri),
SourceCodeKind.Regular,
textLoader,
documentServiceProvider: EmptyServiceProvider.Instance);
documentServiceProvider: new LspDocumentServiceProvider(_clientLanguageServerManager));
}

public override Task RemoveDynamicFileInfoAsync(Workspace workspace, ProjectId projectId, string? projectFilePath, string filePath, CancellationToken cancellationToken)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@

namespace Microsoft.VisualStudioCode.RazorExtension.Services;

internal class EmptyServiceProvider : IRazorDocumentServiceProvider
internal sealed class EmptyServiceProvider : IRazorDocumentServiceProvider
{
public static readonly EmptyServiceProvider Instance = new();

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,34 @@
// 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.CodeAnalysis.ExternalAccess.Razor;

namespace Microsoft.VisualStudioCode.RazorExtension.Services;

internal sealed class LspDocumentServiceProvider(IRazorClientLanguageServerManager razorClientLanguageServerManager) : IRazorDocumentServiceProvider
{
public bool CanApplyChange => true;

public bool SupportDiagnostics => false;

private IRazorMappingService? _mappingService;

public TService? GetService<TService>() where TService : class
{
var serviceType = typeof(TService);

if (serviceType == typeof(IRazorMappingService))
{
var mappingService = _mappingService ?? InterlockedOperations.Initialize(ref _mappingService, CreateMappingService());
return (TService?)mappingService;
}

return this as TService;
}

private IRazorMappingService CreateMappingService()
{
return new MappingService(razorClientLanguageServerManager);
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,113 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Collections.Immutable;
using System.Diagnostics;
using Microsoft.AspNetCore.Razor.PooledObjects;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor;
using Microsoft.CodeAnalysis.Razor.Protocol;
using Microsoft.CodeAnalysis.Text;

namespace Microsoft.VisualStudioCode.RazorExtension.Services;

internal sealed class MappingService(IRazorClientLanguageServerManager razorClientLanguageServerManager) : IRazorMappingService
{
private const string RazorMapSpansEndpoint = "razor/mapSpans";
private const string RazorMapTextChangesEndpoint = "razor/mapTextChanges";

private readonly IRazorClientLanguageServerManager _razorClientLanguageServerManager = razorClientLanguageServerManager;

public async Task<ImmutableArray<RazorMappedSpanResult>> MapSpansAsync(Document document, IEnumerable<TextSpan> spans, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(document.FilePath) ||
(spans.TryGetNonEnumeratedCount(out var count) && count == 0))
{
return [];
}

var sourceText = await document.GetTextAsync(cancellationToken).ConfigureAwait(false);

var mapParams = new RazorMapSpansParams()
{
CSharpDocument = new()
{
Uri = new(document.FilePath)
},
Ranges = [.. spans.Select(sourceText.GetRange)]
};

var response = await _razorClientLanguageServerManager.SendRequestAsync<RazorMapSpansParams, RazorMapSpansResponse?>(
RazorMapSpansEndpoint,
mapParams,
cancellationToken).ConfigureAwait(false);

if (response is not { Spans.Length: > 0, Ranges.Length: > 0 })
{
return [];
}

Debug.Assert(response.Spans.Length == spans.Count(), "The number of mapped spans should match the number of input spans.");
Debug.Assert(response.Ranges.Length == spans.Count(), "The number of mapped ranges should match the number of input spans.");

using var builder = new PooledArrayBuilder<RazorMappedSpanResult>(response.Spans.Length);
var filePath = response.RazorDocument.Uri.GetDocumentFilePath();

for (var i = 0; i < response.Spans.Length; i++)
{
var span = response.Spans[i];
var range = response.Ranges[i];

if (range.IsUndefined())
{
continue;
}

builder.Add(new RazorMappedSpanResult(filePath, range.ToLinePositionSpan(), span.ToTextSpan()));
}

return builder.DrainToImmutable();
}

public async Task<ImmutableArray<RazorMappedEditResult>> MapTextChangesAsync(Document oldDocument, Document newDocument, CancellationToken cancellationToken)
{
if (string.IsNullOrWhiteSpace(newDocument.FilePath))
{
return [];
}

var changes = await newDocument.GetTextChangesAsync(oldDocument, cancellationToken).ConfigureAwait(false);
var textChanges = changes.Select(c => c.ToRazorTextChange()).ToArray();

if (textChanges.Length == 0)
{
return [];
}

var mapParams = new RazorMapTextChangesParams()
{
CSharpDocument = new()
{
Uri = new(newDocument.FilePath)
},
TextChanges = textChanges
};

var response = await _razorClientLanguageServerManager.SendRequestAsync<RazorMapTextChangesParams, RazorMapTextChangesResponse?>(
RazorMapTextChangesEndpoint,
mapParams,
cancellationToken).ConfigureAwait(false);

if (response is not { MappedTextChanges.Length: > 0 })
{
return [];
}

Debug.Assert(response.MappedTextChanges.Length == changes.Count(), "The number of mapped text changes should match the number of input text changes.");
var filePath = response.RazorDocument.Uri.GetDocumentFilePath();
var convertedChanges = Array.ConvertAll(response.MappedTextChanges, mappedChange => mappedChange.ToTextChange());
var result = new RazorMappedEditResult(filePath, convertedChanges);
return [result];
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,15 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Text.Json.Serialization;

namespace Microsoft.VisualStudioCode.RazorExtension.Services;

internal sealed class RazorMapSpansParams
{
[JsonPropertyName("csharpDocument")]
public required TextDocumentIdentifier CSharpDocument { get; internal set; }

[JsonPropertyName("ranges")]
public required LspRange[] Ranges { get; internal set; }
}
Original file line number Diff line number Diff line change
@@ -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.Text.Json.Serialization;
using Microsoft.CodeAnalysis.Razor.Protocol;

namespace Microsoft.VisualStudioCode.RazorExtension.Services;

internal sealed class RazorMapSpansResponse
{
[JsonPropertyName("ranges")]
public required LspRange[] Ranges { get; set; }

[JsonPropertyName("spans")]
public required RazorTextSpan[] Spans { get; set; }

[JsonPropertyName("razorDocument")]
public required TextDocumentIdentifier RazorDocument { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Text.Json.Serialization;
using Microsoft.CodeAnalysis.Razor.Protocol;

namespace Microsoft.VisualStudioCode.RazorExtension.Services;

internal sealed class RazorMapTextChangesParams
{
[JsonPropertyName("csharpDocument")]
public required TextDocumentIdentifier CSharpDocument { get; set; }

[JsonPropertyName("textChanges")]
public required RazorTextChange[] TextChanges { get; set; }
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,17 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.

using System.Text.Json.Serialization;
using Microsoft.CodeAnalysis.Razor.Protocol;

namespace Microsoft.VisualStudioCode.RazorExtension.Services;

internal sealed class RazorMapTextChangesResponse
{
[JsonPropertyName("razorDocument")]
public required TextDocumentIdentifier RazorDocument { get; set; }

[JsonPropertyName("mappedTextChanges")]
public required RazorTextChange[] MappedTextChanges { get; set; }
}

Original file line number Diff line number Diff line change
Expand Up @@ -54,7 +54,7 @@ public async Task Handle_MapToDocumentRanges_CSharp()

// Assert
Assert.NotNull(response);
Assert.Equal(expectedRange, response!.Ranges[0]);
Assert.Equal(expectedRange, response.Ranges[0]);
}

[Fact]
Expand Down Expand Up @@ -82,7 +82,7 @@ public async Task Handle_MapToDocumentRanges_CSharp_Unmapped()

// Assert
Assert.NotNull(response);
Assert.Equal(LspFactory.UndefinedRange, response!.Ranges[0]);
Assert.Equal(LspFactory.UndefinedRange, response.Ranges[0]);
}

[Fact]
Expand Down Expand Up @@ -110,7 +110,7 @@ public async Task Handle_MapToDocumentRanges_CSharp_LeadingOverlapsUnmapped()

// Assert
Assert.NotNull(response);
Assert.Equal(LspFactory.UndefinedRange, response!.Ranges[0]);
Assert.Equal(LspFactory.UndefinedRange, response.Ranges[0]);
}

[Fact]
Expand Down Expand Up @@ -138,7 +138,7 @@ public async Task Handle_MapToDocumentRanges_CSharp_TrailingOverlapsUnmapped()

// Assert
Assert.NotNull(response);
Assert.Equal(LspFactory.UndefinedRange, response!.Ranges[0]);
Assert.Equal(LspFactory.UndefinedRange, response.Ranges[0]);
}

[Fact]
Expand All @@ -163,7 +163,7 @@ public async Task Handle_MapToDocumentRanges_Html()

// Assert
Assert.NotNull(response);
Assert.Equal(request.ProjectedRanges[0], response!.Ranges[0]);
Assert.Equal(request.ProjectedRanges[0], response.Ranges[0]);
}

[Fact]
Expand All @@ -187,8 +187,7 @@ public async Task Handle_MapToDocumentRanges_Razor()
var response = await languageEndpoint.HandleRequestAsync(request, requestContext, DisposalToken);

// Assert
Assert.NotNull(response);
Assert.Equal(request.ProjectedRanges[0], response!.Ranges[0]);
Assert.Null(response);
}

private static RazorCodeDocument CreateCodeDocumentWithCSharpProjection(string razorSource, string projectedCSharpSource, ImmutableArray<SourceMapping> sourceMappings)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -47,7 +47,8 @@ public async Task RazorMapToDocumentRangeAsync_InvokesLanguageServer()
var response = new RazorMapToDocumentRangesResponse()
{
Ranges = [LspFactory.CreateRange(1, 1, 3, 3)],
HostDocumentVersion = 1
HostDocumentVersion = 1,
Spans = [new() { Start = 1, Length = 2 }],
};
var requestInvoker = new Mock<LSPRequestInvoker>(MockBehavior.Strict);
requestInvoker
Expand Down
Loading