diff --git a/eng/targets/Services.props b/eng/targets/Services.props index b2476eab94..03343f4a39 100644 --- a/eng/targets/Services.props +++ b/eng/targets/Services.props @@ -29,5 +29,6 @@ + diff --git a/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorCSharpFormattingBenchmark.cs b/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorCSharpFormattingBenchmark.cs index 12056563e0..88d6e3b633 100644 --- a/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorCSharpFormattingBenchmark.cs +++ b/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorCSharpFormattingBenchmark.cs @@ -112,7 +112,7 @@ public class RazorCSharpFormattingBenchmark : RazorLanguageServerBenchmarkBase { var documentContext = new DocumentContext(DocumentUri, DocumentSnapshot, projectContext: null); - var edits = await RazorFormattingService.GetDocumentFormattingEditsAsync(documentContext, htmlEdits: [], range: null, RazorFormattingOptions.Default, CancellationToken.None); + var edits = await RazorFormattingService.GetDocumentFormattingEditsAsync(documentContext, htmlEdits: [], range: null, new RazorFormattingOptions(), CancellationToken.None); #if DEBUG // For debugging purposes only. diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionResolver.cs index 5e0636c681..7457c02597 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionResolver.cs @@ -68,7 +68,7 @@ internal sealed class DefaultCSharpCodeActionResolver( var formattedEdit = await _razorFormattingService.GetCSharpCodeActionEditAsync( documentContext, csharpTextEdits, - RazorFormattingOptions.Default, + new RazorFormattingOptions(), cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs index 5b5a191120..b717027d9e 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs @@ -56,15 +56,19 @@ internal static class IServiceCollectionExtensions services.AddSingleton(clientConnection); } - public static void AddFormattingServices(this IServiceCollection services) + public static void AddFormattingServices(this IServiceCollection services, LanguageServerFeatureOptions featureOptions) { // Formatting - services.AddSingleton(); services.AddSingleton(); - services.AddHandlerWithCapabilities(); - services.AddHandlerWithCapabilities(); - services.AddHandlerWithCapabilities(); + if (!featureOptions.UseRazorCohostServer) + { + services.AddSingleton(); + + services.AddHandlerWithCapabilities(); + services.AddHandlerWithCapabilities(); + services.AddHandlerWithCapabilities(); + } } public static void AddCompletionServices(this IServiceCollection services) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentOnTypeFormattingEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentOnTypeFormattingEndpoint.cs index e72d79f83c..2c2a853b61 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentOnTypeFormattingEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentOnTypeFormattingEndpoint.cs @@ -15,6 +15,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -25,32 +26,20 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; internal class DocumentOnTypeFormattingEndpoint( IRazorFormattingService razorFormattingService, IHtmlFormatter htmlFormatter, - IDocumentMappingService documentMappingService, RazorLSPOptionsMonitor optionsMonitor, ILoggerFactory loggerFactory) : IRazorRequestHandler, ICapabilitiesProvider { private readonly IRazorFormattingService _razorFormattingService = razorFormattingService; - private readonly IDocumentMappingService _documentMappingService = documentMappingService; private readonly RazorLSPOptionsMonitor _optionsMonitor = optionsMonitor; private readonly IHtmlFormatter _htmlFormatter = htmlFormatter; private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - private static readonly ImmutableArray s_allTriggerCharacters = ["}", ";", "\n", "{"]; - - private static readonly FrozenSet s_csharpTriggerCharacterSet = FrozenSet.ToFrozenSet(["}", ";"], StringComparer.Ordinal); - private static readonly FrozenSet s_htmlTriggerCharacterSet = FrozenSet.ToFrozenSet(["\n", "{", "}", ";"], StringComparer.Ordinal); - private static readonly FrozenSet s_allTriggerCharacterSet = s_allTriggerCharacters.ToFrozenSet(StringComparer.Ordinal); - public bool MutatesSolutionState => false; public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, VSInternalClientCapabilities clientCapabilities) { - serverCapabilities.DocumentOnTypeFormattingProvider = new DocumentOnTypeFormattingOptions - { - FirstTriggerCharacter = s_allTriggerCharacters[0], - MoreTriggerCharacter = s_allTriggerCharacters.AsSpan()[1..].ToArray(), - }; + serverCapabilities.DocumentOnTypeFormattingProvider = new DocumentOnTypeFormattingOptions().EnableOnTypeFormattingTriggerCharacters(); } public TextDocumentIdentifier GetTextDocumentIdentifier(DocumentOnTypeFormattingParams request) @@ -74,7 +63,7 @@ internal class DocumentOnTypeFormattingEndpoint( return null; } - if (!s_allTriggerCharacterSet.Contains(request.Character)) + if (!RazorFormattingService.AllTriggerCharacterSet.Contains(request.Character)) { _logger.LogWarning($"Unexpected trigger character '{request.Character}'."); return null; @@ -102,24 +91,13 @@ internal class DocumentOnTypeFormattingEndpoint( return null; } - var triggerCharacterKind = _documentMappingService.GetLanguageKind(codeDocument, hostDocumentIndex, rightAssociative: false); - if (triggerCharacterKind is not (RazorLanguageKind.CSharp or RazorLanguageKind.Html)) + if (_razorFormattingService.TryGetOnTypeFormattingTriggerKind(codeDocument, hostDocumentIndex, request.Character, out var triggerCharacterKind)) { - _logger.LogInformation($"Unsupported trigger character language {triggerCharacterKind:G}."); - return null; - } - - if (!IsApplicableTriggerCharacter(request.Character, triggerCharacterKind)) - { - // We were triggered but the trigger character doesn't make sense for the current cursor position. Bail. - _logger.LogInformation($"Unsupported trigger character location."); return null; } cancellationToken.ThrowIfCancellationRequested(); - Debug.Assert(request.Character.Length > 0); - var options = RazorFormattingOptions.From(request.Options, _optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine); TextEdit[] formattedEdits; @@ -147,26 +125,4 @@ internal class DocumentOnTypeFormattingEndpoint( _logger.LogInformation($"Returning {formattedEdits.Length} final formatted results."); return formattedEdits; } - - private static bool IsApplicableTriggerCharacter(string triggerCharacter, RazorLanguageKind languageKind) - { - if (languageKind == RazorLanguageKind.CSharp) - { - return s_csharpTriggerCharacterSet.Contains(triggerCharacter); - } - else if (languageKind == RazorLanguageKind.Html) - { - return s_htmlTriggerCharacterSet.Contains(triggerCharacter); - } - - // Unknown trigger character. - return false; - } - - internal static class TestAccessor - { - public static ImmutableArray GetAllTriggerCharacters() => s_allTriggerCharacters; - public static FrozenSet GetCSharpTriggerCharacterSet() => s_csharpTriggerCharacterSet; - public static FrozenSet GetHtmlTriggerCharacterSet() => s_htmlTriggerCharacterSet; - } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspFormattingCodeDocumentProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspFormattingCodeDocumentProvider.cs index eaf9bfa091..e47da7f437 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspFormattingCodeDocumentProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspFormattingCodeDocumentProvider.cs @@ -12,7 +12,7 @@ internal sealed class LspFormattingCodeDocumentProvider : IFormattingCodeDocumen { public Task GetCodeDocumentAsync(IDocumentSnapshot snapshot) { - var useDesignTimeGeneratedOutput = snapshot.Project.Configuration.LanguageServerFlags?.ForceRuntimeCodeGeneration ?? false; - return snapshot.GetGeneratedOutputAsync(useDesignTimeGeneratedOutput); + // Formatting always uses design time + return snapshot.GetGeneratedOutputAsync(forceDesignTimeGeneratedOutput: true); } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/LspInitializationHelpers.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/LspInitializationHelpers.cs index ac141c8168..f6016f918c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/LspInitializationHelpers.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Hosting/LspInitializationHelpers.cs @@ -3,6 +3,7 @@ using System.Collections.Generic; using System.Linq; +using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.SemanticTokens; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -84,4 +85,12 @@ internal static class LspInitializationHelpers return options; } + + public static DocumentOnTypeFormattingOptions EnableOnTypeFormattingTriggerCharacters(this DocumentOnTypeFormattingOptions options) + { + options.FirstTriggerCharacter = RazorFormattingService.FirstTriggerCharacter; + options.MoreTriggerCharacter = RazorFormattingService.MoreTriggerCharacters.ToArray(); + + return options; + } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs index d3162a0e5b..763edb83c3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorLanguageServer.cs @@ -135,7 +135,7 @@ internal partial class RazorLanguageServer : SystemTextJsonLanguageServer MoreTriggerCharacters = [";", "\n", "{"]; + public static readonly FrozenSet AllTriggerCharacterSet = FrozenSet.ToFrozenSet([FirstTriggerCharacter, .. MoreTriggerCharacters], StringComparer.Ordinal); + + private static readonly FrozenSet s_csharpTriggerCharacterSet = FrozenSet.ToFrozenSet(["}", ";"], StringComparer.Ordinal); + private static readonly FrozenSet s_htmlTriggerCharacterSet = FrozenSet.ToFrozenSet(["\n", "{", "}", ";"], StringComparer.Ordinal); + private readonly IFormattingCodeDocumentProvider _codeDocumentProvider; + private readonly IDocumentMappingService _documentMappingService; private readonly IAdhocWorkspaceFactory _workspaceFactory; private readonly ImmutableArray _documentFormattingPasses; @@ -35,6 +46,7 @@ internal class RazorFormattingService : IRazorFormattingService ILoggerFactory loggerFactory) { _codeDocumentProvider = codeDocumentProvider; + _documentMappingService = documentMappingService; _workspaceFactory = workspaceFactory; _htmlOnTypeFormattingPass = new HtmlOnTypeFormattingPass(loggerFactory); @@ -186,6 +198,18 @@ internal class RazorFormattingService : IRazorFormattingService return razorEdits.SingleOrDefault(); } + public bool TryGetOnTypeFormattingTriggerKind(RazorCodeDocument codeDocument, int hostDocumentIndex, string triggerCharacter, out RazorLanguageKind triggerCharacterKind) + { + triggerCharacterKind = _documentMappingService.GetLanguageKind(codeDocument, hostDocumentIndex, rightAssociative: false); + + return triggerCharacterKind switch + { + RazorLanguageKind.CSharp => s_csharpTriggerCharacterSet.Contains(triggerCharacter), + RazorLanguageKind.Html => s_htmlTriggerCharacterSet.Contains(triggerCharacter), + _ => false, + }; + } + private async Task ApplyFormattedEditsAsync( DocumentContext documentContext, TextEdit[] generatedDocumentEdits, @@ -203,7 +227,7 @@ internal class RazorFormattingService : IRazorFormattingService var documentSnapshot = documentContext.Snapshot; var uri = documentContext.Uri; - var codeDocument = await documentSnapshot.GetGeneratedOutputAsync().ConfigureAwait(false); + var codeDocument = await _codeDocumentProvider.GetCodeDocumentAsync(documentSnapshot).ConfigureAwait(false); using var context = FormattingContext.CreateForOnTypeFormatting( uri, documentSnapshot, @@ -286,7 +310,7 @@ internal class RazorFormattingService : IRazorFormattingService /// If LF line endings are more prevalent, it removes any CR characters from the text edits /// to ensure consistency with the LF style. /// - private TextEdit[] NormalizeLineEndings(SourceText originalText, TextEdit[] edits) + private static TextEdit[] NormalizeLineEndings(SourceText originalText, TextEdit[] edits) { if (originalText.HasLFLineEndings()) { @@ -298,4 +322,10 @@ internal class RazorFormattingService : IRazorFormattingService return edits; } + + internal static class TestAccessor + { + public static FrozenSet GetCSharpTriggerCharacterSet() => s_csharpTriggerCharacterSet; + public static FrozenSet GetHtmlTriggerCharacterSet() => s_htmlTriggerCharacterSet; + } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteFormattingService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteFormattingService.cs new file mode 100644 index 0000000000..3ec3ae4cb9 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/IRemoteFormattingService.cs @@ -0,0 +1,52 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.CodeAnalysis.Text; + +namespace Microsoft.CodeAnalysis.Razor.Remote; + +internal interface IRemoteFormattingService +{ + ValueTask> GetDocumentFormattingEditsAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + ImmutableArray htmlChanges, + RazorFormattingOptions options, + CancellationToken cancellationToken); + + ValueTask> GetRangeFormattingEditsAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + ImmutableArray htmlChanges, + LinePositionSpan linePositionSpan, + RazorFormattingOptions options, + CancellationToken cancellationToken); + + ValueTask> GetOnTypeFormattingEditsAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + ImmutableArray htmlChanges, + LinePosition linePosition, + string triggerCharacter, + RazorFormattingOptions options, + CancellationToken cancellationToken); + + ValueTask GetOnTypeFormattingTriggerKindAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + LinePosition linePosition, + string triggerCharacter, + CancellationToken cancellationToken); + + internal enum TriggerKind + { + Invalid, + ValidHtml, + ValidCSharp, + } +} 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 558b737d4c..1974d72c49 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Remote/RazorServices.cs @@ -22,6 +22,7 @@ internal static class RazorServices (typeof(IRemoteFoldingRangeService), null), (typeof(IRemoteDocumentHighlightService), null), (typeof(IRemoteAutoInsertService), null), + (typeof(IRemoteFormattingService), null), ]; // Internal for testing diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingCodeDocumentProvider.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingCodeDocumentProvider.cs index 92a0ccd1ae..64c90c21cb 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingCodeDocumentProvider.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingCodeDocumentProvider.cs @@ -14,6 +14,7 @@ internal sealed class RemoteFormattingCodeDocumentProvider : IFormattingCodeDocu { public Task GetCodeDocumentAsync(IDocumentSnapshot snapshot) { - return snapshot.GetGeneratedOutputAsync(); + // Formatting always uses design time + return snapshot.GetGeneratedOutputAsync(forceDesignTimeGeneratedOutput: true); } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingService.cs new file mode 100644 index 0000000000..e3e334604c --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingService.cs @@ -0,0 +1,178 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Diagnostics; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.CodeAnalysis.Razor.Protocol; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Remote.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Response = Microsoft.CodeAnalysis.Razor.Remote.IRemoteFormattingService.TriggerKind; + +namespace Microsoft.CodeAnalysis.Remote.Razor; + +internal sealed class RemoteFormattingService(in ServiceArgs args) : RazorDocumentServiceBase(in args), IRemoteFormattingService +{ + internal sealed class Factory : FactoryBase + { + protected override IRemoteFormattingService CreateService(in ServiceArgs args) + => new RemoteFormattingService(in args); + } + + private readonly IRazorFormattingService _formattingService = args.ExportProvider.GetExportedValue(); + + public ValueTask> GetDocumentFormattingEditsAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + ImmutableArray htmlChanges, + RazorFormattingOptions options, + CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + documentId, + context => GetDocumentFormattingEditsAsync(context, htmlChanges, options, cancellationToken), + cancellationToken); + + private async ValueTask> GetDocumentFormattingEditsAsync( + RemoteDocumentContext context, + ImmutableArray htmlChanges, + RazorFormattingOptions options, + CancellationToken cancellationToken) + { + var sourceText = await context.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); + var htmlEdits = htmlChanges.Select(sourceText.GetTextEdit).ToArray(); + + var edits = await _formattingService.GetDocumentFormattingEditsAsync(context, htmlEdits, range: null, options, cancellationToken).ConfigureAwait(false); + + if (edits is null) + { + return []; + } + + return edits.SelectAsArray(sourceText.GetTextChange); + } + + public ValueTask> GetRangeFormattingEditsAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + ImmutableArray htmlChanges, + LinePositionSpan linePositionSpan, + RazorFormattingOptions options, + CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + documentId, + context => GetRangeFormattingEditsAsync(context, htmlChanges, linePositionSpan, options, cancellationToken), + cancellationToken); + + private async ValueTask> GetRangeFormattingEditsAsync( + RemoteDocumentContext context, + ImmutableArray htmlChanges, + LinePositionSpan linePositionSpan, + RazorFormattingOptions options, + CancellationToken cancellationToken) + { + var sourceText = await context.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); + var htmlEdits = htmlChanges.Select(sourceText.GetTextEdit).ToArray(); + + var edits = await _formattingService.GetDocumentFormattingEditsAsync(context, htmlEdits, range: linePositionSpan.ToRange(), options, cancellationToken).ConfigureAwait(false); + + if (edits is null) + { + return []; + } + + return edits.SelectAsArray(sourceText.GetTextChange); + } + + public ValueTask> GetOnTypeFormattingEditsAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + ImmutableArray htmlChanges, + LinePosition linePosition, + string character, + RazorFormattingOptions options, + CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + documentId, + context => GetOnTypeFormattingEditsAsync(context, htmlChanges, linePosition, character, options, cancellationToken), + cancellationToken); + + private async ValueTask> GetOnTypeFormattingEditsAsync(RemoteDocumentContext context, ImmutableArray htmlChanges, LinePosition linePosition, string triggerCharacter, RazorFormattingOptions options, CancellationToken cancellationToken) + { + var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + var sourceText = await context.GetSourceTextAsync(cancellationToken).ConfigureAwait(false); + if (!sourceText.TryGetAbsoluteIndex(linePosition, out var hostDocumentIndex)) + { + return []; + } + + if (!_formattingService.TryGetOnTypeFormattingTriggerKind(codeDocument, hostDocumentIndex, triggerCharacter, out var triggerCharacterKind)) + { + return []; + } + + TextEdit[] result; + if (triggerCharacterKind is RazorLanguageKind.Html) + { + var htmlEdits = htmlChanges.Select(sourceText.GetTextEdit).ToArray(); + result = await _formattingService.GetHtmlOnTypeFormattingEditsAsync(context, htmlEdits, options, hostDocumentIndex, triggerCharacter[0], cancellationToken).ConfigureAwait(false); + } + else if (triggerCharacterKind is RazorLanguageKind.CSharp) + { + result = await _formattingService.GetCSharpOnTypeFormattingEditsAsync(context, options, hostDocumentIndex, triggerCharacter[0], cancellationToken).ConfigureAwait(false); + } + else + { + return Assumed.Unreachable>(); + } + + return result.SelectAsArray(sourceText.GetTextChange); + } + + public ValueTask GetOnTypeFormattingTriggerKindAsync( + RazorPinnedSolutionInfoWrapper solutionInfo, + DocumentId documentId, + LinePosition linePosition, + string triggerCharacter, + CancellationToken cancellationToken) + => RunServiceAsync( + solutionInfo, + documentId, + context => IsValidOnTypeFormattingTriggerAsync(context, linePosition, triggerCharacter, cancellationToken), + cancellationToken); + + private async ValueTask IsValidOnTypeFormattingTriggerAsync(RemoteDocumentContext context, LinePosition linePosition, string triggerCharacter, CancellationToken cancellationToken) + { + var codeDocument = await context.GetCodeDocumentAsync(cancellationToken).ConfigureAwait(false); + var sourceText = codeDocument.Source.Text; + if (!sourceText.TryGetAbsoluteIndex(linePosition, out var hostDocumentIndex)) + { + return Response.Invalid; + } + + if (!_formattingService.TryGetOnTypeFormattingTriggerKind(codeDocument, hostDocumentIndex, triggerCharacter, out var triggerCharacterKind)) + { + return Response.Invalid; + } + + if (triggerCharacterKind is RazorLanguageKind.Html) + { + return Response.ValidHtml; + } + + // TryGetOnTypeFormattingTriggerKind only returns true for C# or Html + Debug.Assert(triggerCharacterKind is RazorLanguageKind.CSharp); + + return Response.ValidCSharp; + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingService.cs index 7d9344fe3d..6974dabd01 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingService.cs @@ -11,7 +11,7 @@ namespace Microsoft.CodeAnalysis.Remote.Razor.Formatting; [Export(typeof(IRazorFormattingService)), Shared] [method: ImportingConstructor] -internal class RemoteRazorFormattingService(IFormattingCodeDocumentProvider codeDocumentProvider, IDocumentMappingService documentMappingService, IAdhocWorkspaceFactory adhocWorkspaceFactory, ILoggerFactory loggerFactory) +internal sealed class RemoteRazorFormattingService(IFormattingCodeDocumentProvider codeDocumentProvider, IDocumentMappingService documentMappingService, IAdhocWorkspaceFactory adhocWorkspaceFactory, ILoggerFactory loggerFactory) : RazorFormattingService(codeDocumentProvider, documentMappingService, adhocWorkspaceFactory, loggerFactory) { } diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentFormattingEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentFormattingEndpoint.cs new file mode 100644 index 0000000000..9fa42babe9 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostDocumentFormattingEndpoint.cs @@ -0,0 +1,140 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.Settings; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(Methods.TextDocumentFormattingName)] +[Export(typeof(IDynamicRegistrationProvider))] +[ExportCohostStatelessLspService(typeof(CohostDocumentFormattingEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal sealed class CohostDocumentFormattingEndpoint( + IRemoteServiceInvoker remoteServiceInvoker, + IHtmlDocumentSynchronizer htmlDocumentSynchronizer, + LSPRequestInvoker requestInvoker, + IClientSettingsManager clientSettingsManager, + ILoggerFactory loggerFactory) + : AbstractRazorCohostDocumentRequestHandler, IDynamicRegistrationProvider +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + private readonly IHtmlDocumentSynchronizer _htmlDocumentSynchronizer = htmlDocumentSynchronizer; + private readonly LSPRequestInvoker _requestInvoker = requestInvoker; + private readonly IClientSettingsManager _clientSettingsManager = clientSettingsManager; + private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => true; + + public Registration? GetRegistration(VSInternalClientCapabilities clientCapabilities, DocumentFilter[] filter, RazorCohostRequestContext requestContext) + { + if (clientCapabilities.TextDocument?.Formatting?.DynamicRegistration is true) + { + return new Registration() + { + Method = Methods.TextDocumentFormattingName, + RegisterOptions = new DocumentFormattingRegistrationOptions() + { + DocumentSelector = filter + } + }; + } + + return null; + } + + protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(DocumentFormattingParams request) + => request.TextDocument.ToRazorTextDocumentIdentifier(); + + protected override Task HandleRequestAsync(DocumentFormattingParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync(request, context.TextDocument.AssumeNotNull(), cancellationToken); + + private async Task HandleRequestAsync(DocumentFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + _logger.LogDebug($"Getting Html formatting changes for {razorDocument.FilePath}"); + var htmlResult = await TryGetHtmlFormattingEditsAsync(request, razorDocument, cancellationToken).ConfigureAwait(false); + + if (htmlResult is not { } htmlEdits) + { + // We prefer to return null, so the client will try again + _logger.LogDebug($"Didn't get any edits back from Html"); + return null; + } + + var sourceText = await razorDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); + var htmlChanges = htmlEdits.SelectAsArray(sourceText.GetTextChange); + + var options = RazorFormattingOptions.From(request.Options, _clientSettingsManager.GetClientSettings().AdvancedSettings.CodeBlockBraceOnNextLine); + + _logger.LogDebug($"Calling OOP with the {htmlChanges.Length} html edits, so it can fill in the rest"); + var remoteResult = await _remoteServiceInvoker.TryInvokeAsync>( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => service.GetDocumentFormattingEditsAsync(solutionInfo, razorDocument.Id, htmlChanges, options, cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (remoteResult.Length > 0) + { + _logger.LogDebug($"Got a total of {remoteResult.Length} ranges back from OOP"); + + return remoteResult.Select(sourceText.GetTextEdit).ToArray(); + } + + return null; + } + + private async Task TryGetHtmlFormattingEditsAsync(DocumentFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + var htmlDocument = await _htmlDocumentSynchronizer.TryGetSynchronizedHtmlDocumentAsync(razorDocument, cancellationToken).ConfigureAwait(false); + if (htmlDocument is null) + { + return null; + } + + request.TextDocument = request.TextDocument.WithUri(htmlDocument.Uri); + + _logger.LogDebug($"Requesting document formatting edits for {htmlDocument.Uri}"); + + var result = await _requestInvoker.ReinvokeRequestOnServerAsync( + htmlDocument.Buffer, + Methods.TextDocumentFormattingName, + RazorLSPConstants.HtmlLanguageServerName, + request, + cancellationToken).ConfigureAwait(false); + + if (result?.Response is null) + { + _logger.LogDebug($"Didn't get any ranges back from Html. Returning null so we can abandon the whole thing"); + return null; + } + + return result.Response; + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostDocumentFormattingEndpoint instance) + { + public Task HandleRequestAsync(DocumentFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(request, razorDocument, cancellationToken); + } +} + diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostOnTypeFormattingEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostOnTypeFormattingEndpoint.cs new file mode 100644 index 0000000000..a08a12a2a6 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostOnTypeFormattingEndpoint.cs @@ -0,0 +1,175 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.Settings; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(Methods.TextDocumentOnTypeFormattingName)] +[Export(typeof(IDynamicRegistrationProvider))] +[ExportCohostStatelessLspService(typeof(CohostOnTypeFormattingEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal sealed class CohostOnTypeFormattingEndpoint( + IRemoteServiceInvoker remoteServiceInvoker, + IHtmlDocumentSynchronizer htmlDocumentSynchronizer, + LSPRequestInvoker requestInvoker, + IClientSettingsManager clientSettingsManager, + ILoggerFactory loggerFactory) + : AbstractRazorCohostDocumentRequestHandler, IDynamicRegistrationProvider +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + private readonly IHtmlDocumentSynchronizer _htmlDocumentSynchronizer = htmlDocumentSynchronizer; + private readonly LSPRequestInvoker _requestInvoker = requestInvoker; + private readonly IClientSettingsManager _clientSettingsManager = clientSettingsManager; + private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => true; + + public Registration? GetRegistration(VSInternalClientCapabilities clientCapabilities, DocumentFilter[] filter, RazorCohostRequestContext requestContext) + { + if (clientCapabilities.TextDocument?.Formatting?.DynamicRegistration is true) + { + return new Registration() + { + Method = Methods.TextDocumentOnTypeFormattingName, + RegisterOptions = new DocumentOnTypeFormattingRegistrationOptions() + { + DocumentSelector = filter, + }.EnableOnTypeFormattingTriggerCharacters() + }; + } + + return null; + } + + protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(DocumentOnTypeFormattingParams request) + => request.TextDocument.ToRazorTextDocumentIdentifier(); + + protected override Task HandleRequestAsync(DocumentOnTypeFormattingParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync(request, context.TextDocument.AssumeNotNull(), cancellationToken); + + private async Task HandleRequestAsync(DocumentOnTypeFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + var clientSettings = _clientSettingsManager.GetClientSettings(); + if (!clientSettings.AdvancedSettings.FormatOnType) + { + _logger.LogInformation($"Formatting on type disabled."); + return null; + } + + if (!RazorFormattingService.AllTriggerCharacterSet.Contains(request.Character)) + { + _logger.LogWarning($"Unexpected trigger character '{request.Character}'."); + return null; + } + + // We have to go to OOP to find out if we want Html formatting for this request. This is a little unfortunate + // but just asking Html for formatting, just in case, would be bad for a couple of reasons. Firstly, the Html + // trigger characters are a superset of the C# triggers, so we can't use that as a sign. Secondly, whilst we + // might be making one Html request, it could be then calling CSS or TypeScript servers, so our single request + // to OOP could potentially save a few requests downstream. Lastly, our request to OOP is MessagePack which is + // generally faster than Json anyway. + var triggerKind = await _remoteServiceInvoker.TryInvokeAsync( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => service.GetOnTypeFormattingTriggerKindAsync(solutionInfo, razorDocument.Id, request.Position.ToLinePosition(), request.Character, cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (triggerKind == IRemoteFormattingService.TriggerKind.Invalid) + { + return null; + } + + var sourceText = await razorDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); + + ImmutableArray htmlChanges = []; + if (triggerKind == IRemoteFormattingService.TriggerKind.ValidHtml) + { + _logger.LogDebug($"Getting Html formatting changes for {razorDocument.FilePath}"); + var htmlResult = await GetHtmlFormattingEditsAsync(request, razorDocument, cancellationToken).ConfigureAwait(false); + + if (htmlResult is not { } htmlEdits) + { + // We prefer to return null, so the client will try again + _logger.LogDebug($"Didn't get any edits back from Html"); + return null; + } + + htmlChanges = htmlEdits.SelectAsArray(sourceText.GetTextChange); + } + + var options = RazorFormattingOptions.From(request.Options, clientSettings.AdvancedSettings.CodeBlockBraceOnNextLine); + + _logger.LogDebug($"Calling OOP with the {htmlChanges.Length} html edits, so it can fill in the rest"); + var remoteResult = await _remoteServiceInvoker.TryInvokeAsync>( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => service.GetOnTypeFormattingEditsAsync(solutionInfo, razorDocument.Id, htmlChanges, request.Position.ToLinePosition(), request.Character, options, cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (remoteResult.Length > 0) + { + _logger.LogDebug($"Got a total of {remoteResult.Length} ranges back from OOP"); + + return remoteResult.Select(sourceText.GetTextEdit).ToArray(); + } + + return null; + } + + private async Task GetHtmlFormattingEditsAsync(DocumentOnTypeFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + var htmlDocument = await _htmlDocumentSynchronizer.TryGetSynchronizedHtmlDocumentAsync(razorDocument, cancellationToken).ConfigureAwait(false); + if (htmlDocument is null) + { + return null; + } + + request.TextDocument = request.TextDocument.WithUri(htmlDocument.Uri); + + _logger.LogDebug($"Requesting document formatting edits for {htmlDocument.Uri}"); + + var result = await _requestInvoker.ReinvokeRequestOnServerAsync( + htmlDocument.Buffer, + Methods.TextDocumentOnTypeFormattingName, + RazorLSPConstants.HtmlLanguageServerName, + request, + cancellationToken).ConfigureAwait(false); + + if (result?.Response is null) + { + _logger.LogDebug($"Didn't get any ranges back from Html. Returning null so we can abandon the whole thing"); + return null; + } + + return result.Response; + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostOnTypeFormattingEndpoint instance) + { + public Task HandleRequestAsync(DocumentOnTypeFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(request, razorDocument, cancellationToken); + } +} + diff --git a/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostRangeFormattingEndpoint.cs b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostRangeFormattingEndpoint.cs new file mode 100644 index 0000000000..cc27035b56 --- /dev/null +++ b/src/Razor/src/Microsoft.VisualStudio.LanguageServices.Razor/LanguageClient/Cohost/CohostRangeFormattingEndpoint.cs @@ -0,0 +1,147 @@ +// 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.Generic; +using System.Collections.Immutable; +using System.Composition; +using System.Linq; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor.Cohost; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Razor.Remote; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.Settings; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +#pragma warning disable RS0030 // Do not use banned APIs +[Shared] +[CohostEndpoint(Methods.TextDocumentRangeFormattingName)] +[Export(typeof(IDynamicRegistrationProvider))] +[ExportCohostStatelessLspService(typeof(CohostRangeFormattingEndpoint))] +[method: ImportingConstructor] +#pragma warning restore RS0030 // Do not use banned APIs +internal sealed class CohostRangeFormattingEndpoint( + IRemoteServiceInvoker remoteServiceInvoker, + IHtmlDocumentSynchronizer htmlDocumentSynchronizer, + LSPRequestInvoker requestInvoker, + IClientSettingsManager clientSettingsManager, + ILoggerFactory loggerFactory) + : AbstractRazorCohostDocumentRequestHandler, IDynamicRegistrationProvider +{ + private readonly IRemoteServiceInvoker _remoteServiceInvoker = remoteServiceInvoker; + private readonly IHtmlDocumentSynchronizer _htmlDocumentSynchronizer = htmlDocumentSynchronizer; + private readonly LSPRequestInvoker _requestInvoker = requestInvoker; + private readonly IClientSettingsManager _clientSettingsManager = clientSettingsManager; + private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + + protected override bool MutatesSolutionState => false; + + protected override bool RequiresLSPSolution => true; + + public Registration? GetRegistration(VSInternalClientCapabilities clientCapabilities, DocumentFilter[] filter, RazorCohostRequestContext requestContext) + { + if (clientCapabilities.TextDocument?.Formatting?.DynamicRegistration is true) + { + return new Registration() + { + Method = Methods.TextDocumentRangeFormattingName, + RegisterOptions = new DocumentRangeFormattingRegistrationOptions() + { + DocumentSelector = filter + } + }; + } + + return null; + } + + protected override RazorTextDocumentIdentifier? GetRazorTextDocumentIdentifier(DocumentRangeFormattingParams request) + => request.TextDocument.ToRazorTextDocumentIdentifier(); + + protected override Task HandleRequestAsync(DocumentRangeFormattingParams request, RazorCohostRequestContext context, CancellationToken cancellationToken) + => HandleRequestAsync(request, context.TextDocument.AssumeNotNull(), cancellationToken); + + private async Task HandleRequestAsync(DocumentRangeFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + _logger.LogDebug($"Getting Html formatting changes for {razorDocument.FilePath}"); + var htmlResult = await GetHtmlFormattingEditsAsync(request, razorDocument, cancellationToken).ConfigureAwait(false); + + if (htmlResult is not { } htmlEdits) + { + // We prefer to return null, so the client will try again + _logger.LogDebug($"Didn't get any edits back from Html"); + return null; + } + + var sourceText = await razorDocument.GetTextAsync(cancellationToken).ConfigureAwait(false); + var htmlChanges = htmlEdits.SelectAsArray(sourceText.GetTextChange); + + var options = RazorFormattingOptions.From(request.Options, _clientSettingsManager.GetClientSettings().AdvancedSettings.CodeBlockBraceOnNextLine); + + _logger.LogDebug($"Calling OOP with the {htmlChanges.Length} html edits, so it can fill in the rest"); + var remoteResult = await _remoteServiceInvoker.TryInvokeAsync>( + razorDocument.Project.Solution, + (service, solutionInfo, cancellationToken) => service.GetRangeFormattingEditsAsync(solutionInfo, razorDocument.Id, htmlChanges, request.Range.ToLinePositionSpan(), options, cancellationToken), + cancellationToken).ConfigureAwait(false); + + if (remoteResult.Length > 0) + { + _logger.LogDebug($"Got a total of {remoteResult.Length} ranges back from OOP"); + + return remoteResult.Select(sourceText.GetTextEdit).ToArray(); + } + + return null; + } + + private async Task GetHtmlFormattingEditsAsync(DocumentRangeFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + { + var htmlDocument = await _htmlDocumentSynchronizer.TryGetSynchronizedHtmlDocumentAsync(razorDocument, cancellationToken).ConfigureAwait(false); + if (htmlDocument is null) + { + return null; + } + + // We don't actually request range formatting results from Html, because our formatting engine can't deal with + // relative formatting results. Instead we request full document formatting, and filter the edits inside the + // formatting service to only the ones we care about. + var formattingRequest = new DocumentFormattingParams + { + TextDocument = request.TextDocument.WithUri(htmlDocument.Uri), + Options = request.Options + }; + + _logger.LogDebug($"Requesting document formatting edits for {htmlDocument.Uri}"); + + var result = await _requestInvoker.ReinvokeRequestOnServerAsync( + htmlDocument.Buffer, + Methods.TextDocumentFormattingName, + RazorLSPConstants.HtmlLanguageServerName, + formattingRequest, + cancellationToken).ConfigureAwait(false); + + if (result?.Response is null) + { + _logger.LogDebug($"Didn't get any ranges back from Html. Returning null so we can abandon the whole thing"); + return null; + } + + return result.Response; + } + + internal TestAccessor GetTestAccessor() => new(this); + + internal readonly struct TestAccessor(CohostRangeFormattingEndpoint instance) + { + public Task HandleRequestAsync(DocumentRangeFormattingParams request, TextDocument razorDocument, CancellationToken cancellationToken) + => instance.HandleRequestAsync(request, razorDocument, cancellationToken); + } +} + diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentOnTypeFormattingEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentOnTypeFormattingEndpointTest.cs index 776cb9a295..7d313bfffa 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentOnTypeFormattingEndpointTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentOnTypeFormattingEndpointTest.cs @@ -2,16 +2,11 @@ // 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.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.VisualStudio.LanguageServer.Protocol; -using Moq; using Xunit; using Xunit.Abstractions; @@ -19,49 +14,17 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; public class DocumentOnTypeFormattingEndpointTest(ITestOutputHelper testOutput) : FormattingLanguageServerTestBase(testOutput) { - [Fact] - public void AllTriggerCharacters_IncludesCSharpTriggerCharacters() - { - var allChars = DocumentOnTypeFormattingEndpoint.TestAccessor.GetAllTriggerCharacters(); - - foreach (var character in DocumentOnTypeFormattingEndpoint.TestAccessor.GetCSharpTriggerCharacterSet()) - { - Assert.Contains(character, allChars); - } - } - - [Fact] - public void AllTriggerCharacters_IncludesHtmlTriggerCharacters() - { - var allChars = DocumentOnTypeFormattingEndpoint.TestAccessor.GetAllTriggerCharacters(); - - foreach (var character in DocumentOnTypeFormattingEndpoint.TestAccessor.GetHtmlTriggerCharacterSet()) - { - Assert.Contains(character, allChars); - } - } - - [Fact] - public void AllTriggerCharacters_ContainsUniqueCharacters() - { - var allChars = DocumentOnTypeFormattingEndpoint.TestAccessor.GetAllTriggerCharacters(); - var distinctChars = allChars.Distinct().ToArray(); - - Assert.Equal(distinctChars, allChars); - } - [Fact] public async Task Handle_OnTypeFormatting_FormattingDisabled_ReturnsNull() { // Arrange var uri = new Uri("file://path/test.razor"); var formattingService = new DummyRazorFormattingService(); - var documentMappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: false); var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams { TextDocument = new TextDocumentIdentifier { Uri = uri, } }; var requestContext = CreateRazorRequestContext(documentContext: null); @@ -85,12 +48,11 @@ public class DocumentOnTypeFormattingEndpointTest(ITestOutputHelper testOutput) var documentContext = CreateDocumentContext(new Uri("file://path/testDifferentFile.razor"), codeDocument); var formattingService = new DummyRazorFormattingService(); - var documentMappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -120,12 +82,11 @@ public class DocumentOnTypeFormattingEndpointTest(ITestOutputHelper testOutput) var documentContext = CreateDocumentContext(uri, codeDocument); var formattingService = new DummyRazorFormattingService(); - var documentMappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -154,14 +115,12 @@ public class DocumentOnTypeFormattingEndpointTest(ITestOutputHelper testOutput) var uri = new Uri("file://path/test.razor"); var documentContext = CreateDocumentContext(uri, codeDocument); - var formattingService = new DummyRazorFormattingService(); + var formattingService = new DummyRazorFormattingService(RazorLanguageKind.Html); - var documentMappingService = new Mock(MockBehavior.Strict); - documentMappingService.Setup(s => s.GetLanguageKind(codeDocument, 17, false)).Returns(RazorLanguageKind.Html); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, htmlFormatter, documentMappingService.Object, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -190,14 +149,12 @@ public class DocumentOnTypeFormattingEndpointTest(ITestOutputHelper testOutput) var uri = new Uri("file://path/test.razor"); var documentContext = CreateDocumentContext(uri, codeDocument); - var formattingService = new DummyRazorFormattingService(); + var formattingService = new DummyRazorFormattingService(RazorLanguageKind.Razor); - var documentMappingService = new Mock(MockBehavior.Strict); - documentMappingService.Setup(s => s.GetLanguageKind(codeDocument, 17, false)).Returns(RazorLanguageKind.Razor); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, htmlFormatter, documentMappingService.Object, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -227,12 +184,11 @@ public class DocumentOnTypeFormattingEndpointTest(ITestOutputHelper testOutput) var documentContextFactory = CreateDocumentContextFactory(uri, codeDocument); var formattingService = new DummyRazorFormattingService(); - var documentMappingService = new LspDocumentMappingService(FilePathService, documentContextFactory, LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerClient.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerClient.cs index 66dc964784..f647e7d31d 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerClient.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerClient.cs @@ -3,25 +3,17 @@ using System; using System.Collections.Generic; -using System.Linq; -using System.Text; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; -using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.AspNetCore.Razor.Test.Common.Mef; using Microsoft.AspNetCore.Razor.Utilities; -using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.Formatting; -using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; -using Microsoft.VisualStudio.Text; -using Microsoft.VisualStudio.Utilities; -using Microsoft.WebTools.Languages.Shared.ContentTypes; using Newtonsoft.Json.Linq; namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; @@ -40,52 +32,28 @@ internal class FormattingLanguageServerClient(ILoggerFactory loggerFactory) : IC _documents.Add("/" + path, codeDocument); } - private Task FormatAsync(DocumentOnTypeFormattingParams @params) + private async Task FormatAsync(DocumentOnTypeFormattingParams @params) { var generatedHtml = GetGeneratedHtml(@params.TextDocument.Uri); - var generatedHtmlSource = SourceText.From(generatedHtml, Encoding.UTF8); - var absoluteIndex = generatedHtmlSource.GetRequiredAbsoluteIndex(@params.Position); - var request = $$""" - { - "Options": - { - "UseSpaces": {{(@params.Options.InsertSpaces ? "true" : "false")}}, - "TabSize": {{@params.Options.TabSize}}, - "IndentSize": {{@params.Options.TabSize}} - }, - "Uri": "{{@params.TextDocument.Uri}}", - "GeneratedChanges": [], - "OperationType": "FormatOnType", - "SpanToFormat": - { - "Start": {{absoluteIndex}}, - "End": {{absoluteIndex}} - } - } - """; + var edits = await HtmlFormatting.GetOnTypeFormattingEditsAsync(_loggerFactory, @params.TextDocument.Uri, generatedHtml, @params.Position, @params.Options.InsertSpaces, @params.Options.TabSize); - return CallWebToolsApplyFormattedEditsHandlerAsync(request, @params.TextDocument.Uri, generatedHtml); + return new() + { + Edits = edits + }; } - private Task FormatAsync(DocumentFormattingParams @params) + private async Task FormatAsync(DocumentFormattingParams @params) { var generatedHtml = GetGeneratedHtml(@params.TextDocument.Uri); - var request = $$""" - { - "Options": - { - "UseSpaces": {{(@params.Options.InsertSpaces ? "true" : "false")}}, - "TabSize": {{@params.Options.TabSize}}, - "IndentSize": {{@params.Options.TabSize}} - }, - "Uri": "{{@params.TextDocument.Uri}}", - "GeneratedChanges": [], - } - """; + var edits = await HtmlFormatting.GetDocumentFormattingEditsAsync(_loggerFactory, @params.TextDocument.Uri, generatedHtml, @params.Options.InsertSpaces, @params.Options.TabSize); - return CallWebToolsApplyFormattedEditsHandlerAsync(request, @params.TextDocument.Uri, generatedHtml); + return new() + { + Edits = edits + }; } private string GetGeneratedHtml(Uri uri) @@ -95,51 +63,6 @@ internal class FormattingLanguageServerClient(ILoggerFactory loggerFactory) : IC return generatedHtml.Replace("\r", "").Replace("\n", "\r\n"); } - private async Task CallWebToolsApplyFormattedEditsHandlerAsync(string serializedValue, Uri documentUri, string generatedHtml) - { - var exportProvider = TestComposition.Editor.ExportProviderFactory.CreateExportProvider(); - var contentTypeService = exportProvider.GetExportedValue(); - - if (!contentTypeService.ContentTypes.Any(t => t.TypeName == HtmlContentTypeDefinition.HtmlContentType)) - { - contentTypeService.AddContentType(HtmlContentTypeDefinition.HtmlContentType, [StandardContentTypeNames.Text]); - } - - var textBufferFactoryService = (ITextBufferFactoryService3)exportProvider.GetExportedValue(); - var bufferManager = WebTools.BufferManager.New(contentTypeService, textBufferFactoryService, []); - var logger = _loggerFactory.GetOrCreateLogger("ApplyFormattedEditsHandler"); - var applyFormatEditsHandler = WebTools.ApplyFormatEditsHandler.New(textBufferFactoryService, bufferManager, logger); - - // Make sure the buffer manager knows about the source document - var textSnapshot = bufferManager.CreateBuffer( - documentUri: documentUri, - contentTypeName: HtmlContentTypeDefinition.HtmlContentType, - initialContent: generatedHtml, - snapshotVersionFromLSP: 0); - - var requestContext = WebTools.RequestContext.New(textSnapshot); - - var request = WebTools.ApplyFormatEditsParam.DeserializeFrom(serializedValue); - var response = await applyFormatEditsHandler.HandleRequestAsync(request, requestContext, CancellationToken.None); - - var sourceText = SourceText.From(generatedHtml); - - using var edits = new PooledArrayBuilder(); - - foreach (var textChange in response.TextChanges) - { - var span = new TextSpan(textChange.Position, textChange.Length); - var edit = VsLspFactory.CreateTextEdit(sourceText.GetRange(span), textChange.NewText); - - edits.Add(edit); - } - - return new() - { - Edits = edits.ToArray() - }; - } - public async Task SendRequestAsync(string method, TParams @params, CancellationToken cancellationToken) { if (@params is DocumentFormattingParams formattingParams && diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerTestBase.cs index bf6ce5a569..85dfee530a 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerTestBase.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingLanguageServerTestBase.cs @@ -10,6 +10,7 @@ using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.AspNetCore.Razor.Threading; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.VisualStudio.LanguageServer.Protocol; using Xunit.Abstractions; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; @@ -31,7 +32,7 @@ public abstract class FormattingLanguageServerTestBase(ITestOutputHelper testOut return codeDocument; } - internal class DummyRazorFormattingService : IRazorFormattingService + internal class DummyRazorFormattingService(RazorLanguageKind? languageKind = null) : IRazorFormattingService { public bool Called { get; private set; } @@ -65,5 +66,11 @@ public abstract class FormattingLanguageServerTestBase(ITestOutputHelper testOut { throw new NotImplementedException(); } + + public bool TryGetOnTypeFormattingTriggerKind(RazorCodeDocument codeDocument, int hostDocumentIndex, string triggerCharacter, out RazorLanguageKind triggerCharacterKind) + { + triggerCharacterKind = languageKind.GetValueOrDefault(); + return languageKind is not null; + } } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/RazorFormattingServiceTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/RazorFormattingServiceTest.cs index 67a2f3e182..6c56614d77 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/RazorFormattingServiceTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/RazorFormattingServiceTest.cs @@ -40,4 +40,22 @@ public class Foo{} Assert.Equal(multiEditChange.ToString(), singleEditChange.ToString()); } + + [Fact] + public void AllTriggerCharacters_IncludesCSharpTriggerCharacters() + { + foreach (var character in RazorFormattingService.TestAccessor.GetCSharpTriggerCharacterSet()) + { + Assert.Contains(character, RazorFormattingService.AllTriggerCharacterSet); + } + } + + [Fact] + public void AllTriggerCharacters_IncludesHtmlTriggerCharacters() + { + foreach (var character in RazorFormattingService.TestAccessor.GetHtmlTriggerCharacterSet()) + { + Assert.Contains(character, RazorFormattingService.AllTriggerCharacterSet); + } + } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Microsoft.AspNetCore.Razor.LanguageServer.Test.csproj b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Microsoft.AspNetCore.Razor.LanguageServer.Test.csproj index b50276628e..685173189f 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Microsoft.AspNetCore.Razor.LanguageServer.Test.csproj +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Microsoft.AspNetCore.Razor.LanguageServer.Test.csproj @@ -13,19 +13,6 @@ - - - - - - - - - - - - - diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Formatting_NetFx/HtmlFormatting.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Formatting_NetFx/HtmlFormatting.cs new file mode 100644 index 0000000000..1879dfadf1 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Formatting_NetFx/HtmlFormatting.cs @@ -0,0 +1,111 @@ +// 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.Text; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.AspNetCore.Razor.Test.Common.Mef; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Text; +using Microsoft.VisualStudio.Utilities; +using Microsoft.WebTools.Languages.Shared.ContentTypes; + +namespace Microsoft.CodeAnalysis.Razor.Formatting; + +internal static class HtmlFormatting +{ + public static Task GetDocumentFormattingEditsAsync(ILoggerFactory loggerFactory, Uri uri, string generatedHtml, bool insertSpaces, int tabSize) + { + var request = $$""" + { + "Options": + { + "UseSpaces": {{(insertSpaces ? "true" : "false")}}, + "TabSize": {{tabSize}}, + "IndentSize": {{tabSize}} + }, + "Uri": "{{uri}}", + "GeneratedChanges": [], + } + """; + + return CallWebToolsApplyFormattedEditsHandlerAsync(loggerFactory, request, uri, generatedHtml); + } + + public static Task GetOnTypeFormattingEditsAsync(ILoggerFactory loggerFactory, Uri uri, string generatedHtml, Position position, bool insertSpaces, int tabSize) + { + var generatedHtmlSource = SourceText.From(generatedHtml, Encoding.UTF8); + var absoluteIndex = generatedHtmlSource.GetRequiredAbsoluteIndex(position); + + var request = $$""" + { + "Options": + { + "UseSpaces": {{(insertSpaces ? "true" : "false")}}, + "TabSize": {{tabSize}}, + "IndentSize": {{tabSize}} + }, + "Uri": "{{uri}}", + "GeneratedChanges": [], + "OperationType": "FormatOnType", + "SpanToFormat": + { + "Start": {{absoluteIndex}}, + "End": {{absoluteIndex}} + } + } + """; + + return CallWebToolsApplyFormattedEditsHandlerAsync(loggerFactory, request, uri, generatedHtml); + } + + private static async Task CallWebToolsApplyFormattedEditsHandlerAsync(ILoggerFactory loggerFactory, string serializedValue, Uri documentUri, string generatedHtml) + { + var exportProvider = TestComposition.Editor.ExportProviderFactory.CreateExportProvider(); + var contentTypeService = exportProvider.GetExportedValue(); + + lock (contentTypeService) + { + if (!contentTypeService.ContentTypes.Any(t => t.TypeName == HtmlContentTypeDefinition.HtmlContentType)) + { + contentTypeService.AddContentType(HtmlContentTypeDefinition.HtmlContentType, [StandardContentTypeNames.Text]); + } + } + + var textBufferFactoryService = (ITextBufferFactoryService3)exportProvider.GetExportedValue(); + var bufferManager = WebTools.BufferManager.New(contentTypeService, textBufferFactoryService, []); + var logger = loggerFactory.GetOrCreateLogger("ApplyFormattedEditsHandler"); + var applyFormatEditsHandler = WebTools.ApplyFormatEditsHandler.New(textBufferFactoryService, bufferManager, logger); + + // Make sure the buffer manager knows about the source document + var textSnapshot = bufferManager.CreateBuffer( + documentUri: documentUri, + contentTypeName: HtmlContentTypeDefinition.HtmlContentType, + initialContent: generatedHtml, + snapshotVersionFromLSP: 0); + + var requestContext = WebTools.RequestContext.New(textSnapshot); + + var request = WebTools.ApplyFormatEditsParam.DeserializeFrom(serializedValue); + var response = await applyFormatEditsHandler.HandleRequestAsync(request, requestContext, CancellationToken.None); + + var sourceText = SourceText.From(generatedHtml); + + using var edits = new PooledArrayBuilder(); + + foreach (var textChange in response.TextChanges) + { + var span = new TextSpan(textChange.Position, textChange.Length); + var edit = VsLspFactory.CreateTextEdit(sourceText.GetRange(span), textChange.NewText); + + edits.Add(edit); + } + + return edits.ToArray(); + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/WebTools.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Formatting_NetFx/WebTools.cs similarity index 99% rename from src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/WebTools.cs rename to src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Formatting_NetFx/WebTools.cs index ec0430afe1..1077a3b09e 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/WebTools.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Formatting_NetFx/WebTools.cs @@ -7,6 +7,7 @@ using System.Collections.Immutable; using System.Reflection; using System.Threading; using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.VisualStudio.Settings.Internal; @@ -16,7 +17,7 @@ using Microsoft.WebTools.Languages.Shared.Editor.Composition; using Microsoft.WebTools.Languages.Shared.Editor.Text; using Newtonsoft.Json; -namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; +namespace Microsoft.CodeAnalysis.Razor.Formatting; /// /// Provides reflection-based access to the Web Tools LSP infrastructure needed for tests. diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Microsoft.AspNetCore.Razor.Test.Common.Tooling.csproj b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Microsoft.AspNetCore.Razor.Test.Common.Tooling.csproj index 6caeb6b298..89019499fa 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Microsoft.AspNetCore.Razor.Test.Common.Tooling.csproj +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.Test.Common.Tooling/Microsoft.AspNetCore.Razor.Test.Common.Tooling.csproj @@ -35,6 +35,19 @@ + + + + + + + + + + + + + diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentFormattingEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentFormattingEndpointTest.cs new file mode 100644 index 0000000000..3c8f9b268b --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostDocumentFormattingEndpointTest.cs @@ -0,0 +1,144 @@ +// 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.Tasks; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Test.Common.Mef; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.Settings; +using Microsoft.VisualStudio.Threading; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +[UseExportProvider] +public class CohostDocumentFormattingEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) +{ + // All of the formatting tests in the language server exercise the formatting engine and cover various edge cases + // and provide regression prevention. The tests here are not exhaustive, but they validate the the cohost endpoints + // call into the formatting engine at least, and handles C#, Html and Razor formatting changes correctly. + + [Fact] + public Task Formatting() + => VerifyDocumentFormattingAsync( + input: """ + @preservewhitespace true + +
+ + @{ +

+ @{ + var t = 1; + if (true) + { + + } + } +

+
+ @{ +
+
+ This is heavily nested +
+
+ } +
+ } + + @code { + private void M(string thisIsMyString) + { + var x = 5; + + var y = "Hello"; + + M("Hello"); + } + } + + """, + expected: """ + @preservewhitespace true + +
+ + @{ +

+ @{ + var t = 1; + if (true) + { + + } + } +

+
+ @{ +
+
+ This is heavily nested +
+
+ } +
+ } + + @code { + private void M(string thisIsMyString) + { + var x = 5; + + var y = "Hello"; + + M("Hello"); + } + } + + """); + + private async Task VerifyDocumentFormattingAsync(string input, string expected) + { + var document = CreateProjectAndRazorDocument(input); + var inputText = await document.GetTextAsync(DisposalToken); + + var htmlDocumentPublisher = new HtmlDocumentPublisher(RemoteServiceInvoker, StrictMock.Of(), StrictMock.Of(), LoggerFactory); + var generatedHtml = await htmlDocumentPublisher.GetHtmlSourceFromOOPAsync(document, DisposalToken); + Assert.NotNull(generatedHtml); + + var uri = new Uri(document.CreateUri(), $"{document.FilePath}{FeatureOptions.HtmlVirtualDocumentSuffix}"); + var htmlEdits = await HtmlFormatting.GetDocumentFormattingEditsAsync(LoggerFactory, uri, generatedHtml, insertSpaces: true, tabSize: 4); + + var requestInvoker = new TestLSPRequestInvoker([(Methods.TextDocumentFormattingName, htmlEdits)]); + + var clientSettingsManager = new ClientSettingsManager(changeTriggers: []); + + var endpoint = new CohostDocumentFormattingEndpoint(RemoteServiceInvoker, TestHtmlDocumentSynchronizer.Instance, requestInvoker, clientSettingsManager, LoggerFactory); + + var request = new DocumentFormattingParams() + { + TextDocument = new TextDocumentIdentifier() { Uri = document.CreateUri() }, + Options = new FormattingOptions() + { + TabSize = 4, + InsertSpaces = true + } + }; + + var edits = await endpoint.GetTestAccessor().HandleRequestAsync(request, document, DisposalToken); + + var changes = edits.Select(inputText.GetTextChange); + var finalText = inputText.WithChanges(changes); + + AssertEx.EqualOrDiff(expected, finalText.ToString()); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostOnTypeFormattingEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostOnTypeFormattingEndpointTest.cs new file mode 100644 index 0000000000..b39ca778ab --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostOnTypeFormattingEndpointTest.cs @@ -0,0 +1,158 @@ +// 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.Tasks; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Test.Common.Mef; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.Settings; +using Microsoft.VisualStudio.TestPlatform.ObjectModel; +using Microsoft.VisualStudio.Threading; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +[UseExportProvider] +public class CohostOnTypeFormattingEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) +{ + [Fact] + public async Task InvalidTrigger() + { + await VerifyOnTypeFormattingAsync( + input: """ + @{ + if(true){}$$ + } + """, + expected: """ + @{ + if(true){} + } + """, + triggerCharacter: 'h'); + } + + [Fact] + public async Task CSharp_InvalidTrigger() + { + await VerifyOnTypeFormattingAsync( + input: """ + @{ + if(true){}$$ + } + """, + expected: """ + @{ + if(true){} + } + """, + triggerCharacter: '\n'); + } + + [Fact] + public async Task CSharp() + { + await VerifyOnTypeFormattingAsync( + input: """ + @{ + if(true){}$$ + } + """, + expected: """ + @{ + if (true) { } + } + """, + triggerCharacter: '}'); + } + + [Fact] + public async Task FormatsSimpleHtmlTag_OnType() + { + await VerifyOnTypeFormattingAsync( + input: """ + + + Hello + + + + """, + expected: """ + + + Hello + + + + """, + triggerCharacter: ';', + html: true); + } + + private async Task VerifyOnTypeFormattingAsync(TestCode input, string expected, char triggerCharacter, bool html = false) + { + var document = CreateProjectAndRazorDocument(input.Text); + var inputText = await document.GetTextAsync(DisposalToken); + var position = inputText.GetPosition(input.Position); + + LSPRequestInvoker requestInvoker; + if (html) + { + var htmlDocumentPublisher = new HtmlDocumentPublisher(RemoteServiceInvoker, StrictMock.Of(), StrictMock.Of(), LoggerFactory); + var generatedHtml = await htmlDocumentPublisher.GetHtmlSourceFromOOPAsync(document, DisposalToken); + Assert.NotNull(generatedHtml); + + var uri = new Uri(document.CreateUri(), $"{document.FilePath}{FeatureOptions.HtmlVirtualDocumentSuffix}"); + var htmlEdits = await HtmlFormatting.GetOnTypeFormattingEditsAsync(LoggerFactory, uri, generatedHtml, position, insertSpaces: true, tabSize: 4); + + requestInvoker = new TestLSPRequestInvoker([(Methods.TextDocumentOnTypeFormattingName, htmlEdits)]); + } + else + { + // We use a mock here so that it will throw if called + requestInvoker = StrictMock.Of(); + } + + var clientSettingsManager = new ClientSettingsManager(changeTriggers: []); + + var endpoint = new CohostOnTypeFormattingEndpoint(RemoteServiceInvoker, TestHtmlDocumentSynchronizer.Instance, requestInvoker, clientSettingsManager, LoggerFactory); + + var request = new DocumentOnTypeFormattingParams() + { + TextDocument = new TextDocumentIdentifier() { Uri = document.CreateUri() }, + Options = new FormattingOptions() + { + TabSize = 4, + InsertSpaces = true + }, + Character = triggerCharacter.ToString(), + Position = position + }; + + var edits = await endpoint.GetTestAccessor().HandleRequestAsync(request, document, DisposalToken); + + if (edits is null) + { + Assert.Equal(expected, input.Text); + return; + } + + var changes = edits.Select(inputText.GetTextChange); + var finalText = inputText.WithChanges(changes); + + AssertEx.EqualOrDiff(expected, finalText.ToString()); + } +} diff --git a/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRangeFormattingEndpointTest.cs b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRangeFormattingEndpointTest.cs new file mode 100644 index 0000000000..32d03e2800 --- /dev/null +++ b/src/Razor/test/Microsoft.VisualStudio.LanguageServices.Razor.Test/Cohost/CohostRangeFormattingEndpointTest.cs @@ -0,0 +1,139 @@ +// 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.Tasks; +using Microsoft.AspNetCore.Razor.Test.Common; +using Microsoft.AspNetCore.Razor.Test.Common.Mef; +using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.VisualStudio.LanguageServer.ContainedLanguage; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using Microsoft.VisualStudio.Razor.Settings; +using Microsoft.VisualStudio.Threading; +using Roslyn.Test.Utilities; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.VisualStudio.Razor.LanguageClient.Cohost; + +[UseExportProvider] +public class CohostRangeFormattingEndpointTest(ITestOutputHelper testOutputHelper) : CohostEndpointTestBase(testOutputHelper) +{ + [Fact] + public Task RangeFormatting() + => VerifyRangeFormattingAsync( + input: """ + @preservewhitespace true + +
+ + @{ +

+ @{ + var t = 1; + if (true) + { + + } + } +

+ [|
+ @{ +
+
+ This is heavily nested +
+
+ } +
|] + } + + @code { + private void M(string thisIsMyString) + { + var x = 5; + + var y = "Hello"; + + M("Hello"); + } + } + """, + expected: """ + @preservewhitespace true + +
+ + @{ +

+ @{ + var t = 1; + if (true) + { + + } + } +

+
+ @{ +
+
+ This is heavily nested +
+
+ } +
+ } + + @code { + private void M(string thisIsMyString) + { + var x = 5; + + var y = "Hello"; + + M("Hello"); + } + } + """); + + private async Task VerifyRangeFormattingAsync(TestCode input, string expected) + { + var document = CreateProjectAndRazorDocument(input.Text); + var inputText = await document.GetTextAsync(DisposalToken); + + var htmlDocumentPublisher = new HtmlDocumentPublisher(RemoteServiceInvoker, StrictMock.Of(), StrictMock.Of(), LoggerFactory); + var generatedHtml = await htmlDocumentPublisher.GetHtmlSourceFromOOPAsync(document, DisposalToken); + Assert.NotNull(generatedHtml); + + var uri = new Uri(document.CreateUri(), $"{document.FilePath}{FeatureOptions.HtmlVirtualDocumentSuffix}"); + var htmlEdits = await HtmlFormatting.GetDocumentFormattingEditsAsync(LoggerFactory, uri, generatedHtml, insertSpaces: true, tabSize: 4); + + var requestInvoker = new TestLSPRequestInvoker([(Methods.TextDocumentFormattingName, htmlEdits)]); + + var clientSettingsManager = new ClientSettingsManager(changeTriggers: []); + + var endpoint = new CohostRangeFormattingEndpoint(RemoteServiceInvoker, TestHtmlDocumentSynchronizer.Instance, requestInvoker, clientSettingsManager, LoggerFactory); + + var request = new DocumentRangeFormattingParams() + { + TextDocument = new TextDocumentIdentifier() { Uri = document.CreateUri() }, + Options = new FormattingOptions() + { + TabSize = 4, + InsertSpaces = true + }, + Range = inputText.GetRange(input.Span) + }; + + var edits = await endpoint.GetTestAccessor().HandleRequestAsync(request, document, DisposalToken); + + var changes = edits.Select(inputText.GetTextChange); + var finalText = inputText.WithChanges(changes); + + AssertEx.EqualOrDiff(expected, finalText.ToString()); + } +}