diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Mapping/RazorMapToDocumentRangesEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Mapping/RazorMapToDocumentRangesEndpoint.cs index ba962944fd2..184fd3bb26a 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Mapping/RazorMapToDocumentRangesEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Mapping/RazorMapToDocumentRangesEndpoint.cs @@ -34,9 +34,9 @@ public Uri GetTextDocumentIdentifier(RazorMapToDocumentRangesParams request) public async Task 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; @@ -45,22 +45,26 @@ 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; @@ -68,12 +72,14 @@ public Uri GetTextDocumentIdentifier(RazorMapToDocumentRangesParams request) } ranges[i] = originalRange; + spans[i] = originalRange.ToRazorTextSpan(generatedDocument.Text); } return new RazorMapToDocumentRangesResponse() { Ranges = ranges, HostDocumentVersion = documentContext.Snapshot.Version, + Spans = spans, }; } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LspExtensions_Range.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LspExtensions_Range.cs index 0241f5566d6..451b19e499b 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LspExtensions_Range.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/LspExtensions_Range.cs @@ -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); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/DocumentMapping/RazorMapToDocumentRangesResponse.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/DocumentMapping/RazorMapToDocumentRangesResponse.cs index f82ea528651..3b36446795d 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/DocumentMapping/RazorMapToDocumentRangesResponse.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Protocol/DocumentMapping/RazorMapToDocumentRangesResponse.cs @@ -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; } } diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/DynamicFileInfoProvider.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/DynamicFileInfoProvider.cs index 769af8e35b7..e645336d0a7 100644 --- a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/DynamicFileInfoProvider.cs +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/DynamicFileInfoProvider.cs @@ -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) diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/EmptyServiceProvider.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/EmptyServiceProvider.cs index ec89e1b29fa..bc8273356c1 100644 --- a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/EmptyServiceProvider.cs +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/EmptyServiceProvider.cs @@ -5,7 +5,7 @@ namespace Microsoft.VisualStudioCode.RazorExtension.Services; -internal class EmptyServiceProvider : IRazorDocumentServiceProvider +internal sealed class EmptyServiceProvider : IRazorDocumentServiceProvider { public static readonly EmptyServiceProvider Instance = new(); diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/LspDocumentServiceProvider.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/LspDocumentServiceProvider.cs new file mode 100644 index 00000000000..f65e359a7f0 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/LspDocumentServiceProvider.cs @@ -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() 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); + } +} diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/MappingService.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/MappingService.cs new file mode 100644 index 00000000000..155344779db --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/MappingService.cs @@ -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> MapSpansAsync(Document document, IEnumerable 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( + 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(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> 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( + 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]; + } +} diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapSpansParams.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapSpansParams.cs new file mode 100644 index 00000000000..6871325c00a --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapSpansParams.cs @@ -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; } +} diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapSpansResponse.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapSpansResponse.cs new file mode 100644 index 00000000000..ced1003ea28 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapSpansResponse.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.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; } +} diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapTextChangesParams.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapTextChangesParams.cs new file mode 100644 index 00000000000..91f6e373ed5 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapTextChangesParams.cs @@ -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; } +} diff --git a/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapTextChangesResponse.cs b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapTextChangesResponse.cs new file mode 100644 index 00000000000..d2d86e7a339 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudioCode.RazorExtension/Services/RazorMapTextChangesResponse.cs @@ -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; } +} + diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Mapping/RazorMapToDocumentRangesEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Mapping/RazorMapToDocumentRangesEndpointTest.cs index 016c4d57fbc..b9eb520f6cc 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Mapping/RazorMapToDocumentRangesEndpointTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Mapping/RazorMapToDocumentRangesEndpointTest.cs @@ -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] @@ -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] @@ -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] @@ -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] @@ -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] @@ -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 sourceMappings) diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/DocumentMapping/LSPDocumentMappingProviderTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/DocumentMapping/LSPDocumentMappingProviderTest.cs index bd942f66daa..3f0b8aaa75b 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/DocumentMapping/LSPDocumentMappingProviderTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/DocumentMapping/LSPDocumentMappingProviderTest.cs @@ -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(MockBehavior.Strict); requestInvoker diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/RazorLSPMappingServiceTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/RazorLSPMappingServiceTest.cs index 95ce87f8f18..9cbf19fb7d6 100644 --- a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/RazorLSPMappingServiceTest.cs +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/LanguageClient/RazorLSPMappingServiceTest.cs @@ -55,7 +55,8 @@ public async Task MapSpans_WithinRange_ReturnsMapping() var mappingResult = new RazorMapToDocumentRangesResponse() { - Ranges = [mappedRange] + Ranges = [mappedRange], + Spans = [textSpan.ToRazorTextSpan()] }; var requestInvoker = new TestLSPRequestInvoker(new List<(string, object)>() { @@ -130,7 +131,7 @@ public void MapSpans_GetMappedSpanResults_MappingErrorReturnsDefaultMappedSpan() { // Arrange var sourceTextRazor = SourceText.From(""); - var response = new RazorMapToDocumentRangesResponse { Ranges = [LspFactory.UndefinedRange] }; + var response = new RazorMapToDocumentRangesResponse { Ranges = [LspFactory.UndefinedRange], Spans = Array.Empty() }; // Act var results = RazorLSPMappingService.GetMappedSpanResults(_mockDocumentUri.LocalPath, sourceTextRazor, response); diff --git a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Microsoft.VisualStudioCode.RazorExtension.Test.csproj b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Microsoft.VisualStudioCode.RazorExtension.Test.csproj index cae8bff4c87..3be924da639 100644 --- a/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Microsoft.VisualStudioCode.RazorExtension.Test.csproj +++ b/src/Razor/test/Microsoft.VisualStudioCode.RazorExtension.Test/Microsoft.VisualStudioCode.RazorExtension.Test.csproj @@ -8,6 +8,10 @@ + + + +