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 868eba8dfa0..12056563e09 100644 --- a/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorCSharpFormattingBenchmark.cs +++ b/src/Razor/benchmarks/Microsoft.AspNetCore.Razor.Microbenchmarks/LanguageServer/RazorCSharpFormattingBenchmark.cs @@ -110,15 +110,9 @@ void Method() { } [Benchmark(Description = "Formatting")] public async Task RazorCSharpFormattingAsync() { - var options = new FormattingOptions() - { - TabSize = 4, - InsertSpaces = true - }; - var documentContext = new DocumentContext(DocumentUri, DocumentSnapshot, projectContext: null); - var edits = await RazorFormattingService.FormatAsync(documentContext, range: null, options, CancellationToken.None); + var edits = await RazorFormattingService.GetDocumentFormattingEditsAsync(documentContext, htmlEdits: [], range: null, RazorFormattingOptions.Default, CancellationToken.None); #if DEBUG // For debugging purposes only. diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AutoInsert/OnAutoInsertEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AutoInsert/OnAutoInsertEndpoint.cs index 93dd2e890e8..b2d85de56b2 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AutoInsert/OnAutoInsertEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/AutoInsert/OnAutoInsertEndpoint.cs @@ -4,7 +4,7 @@ using System; using System.Collections.Frozen; using System.Collections.Generic; -using System.Collections.Immutable; +using System.Diagnostics; using System.Linq; using System.Threading; using System.Threading.Tasks; @@ -185,19 +185,21 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V } // For C# we run the edit through our formatting engine - var edits = new[] { delegatedResponse.TextEdit }; + Debug.Assert(positionInfo.LanguageKind == RazorLanguageKind.CSharp); - var mappedEdits = delegatedResponse.TextEditFormat == InsertTextFormat.Snippet - ? await _razorFormattingService.FormatSnippetAsync(documentContext, positionInfo.LanguageKind, edits, originalRequest.Options, cancellationToken).ConfigureAwait(false) - : await _razorFormattingService.FormatOnTypeAsync(documentContext, positionInfo.LanguageKind, edits, originalRequest.Options, hostDocumentIndex: 0, triggerCharacter: '\0', cancellationToken).ConfigureAwait(false); - if (mappedEdits is not [{ } edit]) + var options = RazorFormattingOptions.From(originalRequest.Options, _optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine); + + var mappedEdit = delegatedResponse.TextEditFormat == InsertTextFormat.Snippet + ? await _razorFormattingService.GetCSharpSnippetFormattingEditAsync(documentContext, [delegatedResponse.TextEdit], options, cancellationToken).ConfigureAwait(false) + : await _razorFormattingService.GetSingleCSharpEditAsync(documentContext, delegatedResponse.TextEdit, options, cancellationToken).ConfigureAwait(false); + if (mappedEdit is null) { return null; } return new VSInternalDocumentOnAutoInsertResponseItem() { - TextEdit = edit, + TextEdit = mappedEdit, TextEditFormat = delegatedResponse.TextEditFormat, }; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/AddUsingsCodeActionProviderHelper.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/AddUsingsCodeActionProviderHelper.cs deleted file mode 100644 index a216287aea1..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/AddUsingsCodeActionProviderHelper.cs +++ /dev/null @@ -1,190 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Collections.Generic; -using System.Diagnostics; -using System.Diagnostics.CodeAnalysis; -using System.Linq; -using System.Text.RegularExpressions; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; -using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.CodeAnalysis.CSharp; -using Microsoft.CodeAnalysis.CSharp.Syntax; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; - -internal static class AddUsingsCodeActionProviderHelper -{ - public static async Task GetUsingStatementEditsAsync(RazorCodeDocument codeDocument, SourceText originalCSharpText, SourceText changedCSharpText, CancellationToken cancellationToken) - { - // Now that we're done with everything, lets see if there are any using statements to fix up - // We do this by comparing the original generated C# code, and the changed C# code, and look for a difference - // in using statements. We can't use edits for this for two main reasons: - // - // 1. Using statements in the generated code might come from _Imports.razor, or from this file, and C# will shove them anywhere - // 2. The edit might not be clean. eg given: - // using System; - // using System.Text; - // Adding "using System.Linq;" could result in an insert of "Linq;\r\nusing System." on line 2 - // - // So because of the above, we look for a difference in C# using directive nodes directly from the C# syntax tree, and apply them manually - // to the Razor document. - - var oldUsings = await FindUsingDirectiveStringsAsync(originalCSharpText, cancellationToken).ConfigureAwait(false); - var newUsings = await FindUsingDirectiveStringsAsync(changedCSharpText, cancellationToken).ConfigureAwait(false); - - using var edits = new PooledArrayBuilder(); - foreach (var usingStatement in newUsings.Except(oldUsings)) - { - // This identifier will be eventually thrown away. - Debug.Assert(codeDocument.Source.FilePath != null); - var identifier = new OptionalVersionedTextDocumentIdentifier { Uri = new Uri(codeDocument.Source.FilePath, UriKind.Relative) }; - var workspaceEdit = AddUsingsCodeActionResolver.CreateAddUsingWorkspaceEdit(usingStatement, additionalEdit: null, codeDocument, codeDocumentIdentifier: identifier); - edits.AddRange(workspaceEdit.DocumentChanges!.Value.First.First().Edits); - } - - return edits.ToArray(); - } - - private static async Task> FindUsingDirectiveStringsAsync(SourceText originalCSharpText, CancellationToken cancellationToken) - { - var syntaxTree = CSharpSyntaxTree.ParseText(originalCSharpText, cancellationToken: cancellationToken); - var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); - - // We descend any compilation unit (ie, the file) or and namespaces because the compiler puts all usings inside - // the namespace node. - var usings = syntaxRoot.DescendantNodes(n => n is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax) - // Filter to using directives - .OfType() - // Select everything after the initial "using " part of the statement, and excluding the ending semi-colon. The - // semi-colon is valid in Razor, but users find it surprising. This is slightly lazy, for sure, but has - // the advantage of us not caring about changes to C# syntax, we just grab whatever Roslyn wanted to put in, so - // we should still work in C# v26 - .Select(u => u.ToString()["using ".Length..^1]); - - return usings; - } - - internal static readonly Regex AddUsingVSCodeAction = new Regex("@?using ([^;]+);?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); - - // Internal for testing - internal static string GetNamespaceFromFQN(string fullyQualifiedName) - { - if (!TrySplitNamespaceAndType(fullyQualifiedName.AsSpan(), out var namespaceName, out _)) - { - return string.Empty; - } - - return namespaceName.ToString(); - } - - internal static bool TryCreateAddUsingResolutionParams(string fullyQualifiedName, Uri uri, TextDocumentEdit? additionalEdit, [NotNullWhen(true)] out string? @namespace, [NotNullWhen(true)] out RazorCodeActionResolutionParams? resolutionParams) - { - @namespace = GetNamespaceFromFQN(fullyQualifiedName); - if (string.IsNullOrEmpty(@namespace)) - { - @namespace = null; - resolutionParams = null; - return false; - } - - var actionParams = new AddUsingsCodeActionParams - { - Uri = uri, - Namespace = @namespace, - AdditionalEdit = additionalEdit - }; - - resolutionParams = new RazorCodeActionResolutionParams - { - Action = LanguageServerConstants.CodeActions.AddUsing, - Language = LanguageServerConstants.CodeActions.Languages.Razor, - Data = actionParams, - }; - - return true; - } - - /// - /// Extracts the namespace from a C# add using statement provided by Visual Studio - /// - /// Add using statement of the form `using System.X;` - /// Extract namespace `System.X` - /// The prefix to show, before the namespace, if any - /// - internal static bool TryExtractNamespace(string csharpAddUsing, out string @namespace, out string prefix) - { - // We must remove any leading/trailing new lines from the add using edit - csharpAddUsing = csharpAddUsing.Trim(); - var regexMatchedTextEdit = AddUsingVSCodeAction.Match(csharpAddUsing); - if (!regexMatchedTextEdit.Success || - - // Two Regex matching groups are expected - // 1. `using namespace;` - // 2. `namespace` - regexMatchedTextEdit.Groups.Count != 2) - { - // Text edit in an unexpected format - @namespace = string.Empty; - prefix = string.Empty; - return false; - } - - @namespace = regexMatchedTextEdit.Groups[1].Value; - prefix = csharpAddUsing[..regexMatchedTextEdit.Index]; - return true; - } - - internal static bool TrySplitNamespaceAndType(ReadOnlySpan fullTypeName, out ReadOnlySpan @namespace, out ReadOnlySpan typeName) - { - @namespace = default; - typeName = default; - - if (fullTypeName.IsEmpty) - { - return false; - } - - var nestingLevel = 0; - var splitLocation = -1; - for (var i = fullTypeName.Length - 1; i >= 0; i--) - { - var c = fullTypeName[i]; - if (c == Type.Delimiter && nestingLevel == 0) - { - splitLocation = i; - break; - } - else if (c == '>') - { - nestingLevel++; - } - else if (c == '<') - { - nestingLevel--; - } - } - - if (splitLocation == -1) - { - typeName = fullTypeName; - return true; - } - - @namespace = fullTypeName[..splitLocation]; - - var typeNameStartLocation = splitLocation + 1; - if (typeNameStartLocation < fullTypeName.Length) - { - typeName = fullTypeName[typeNameStartLocation..]; - } - - return true; - } -} 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 d6d329ee899..5e0636c6818 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 @@ -22,21 +22,6 @@ internal sealed class DefaultCSharpCodeActionResolver( IClientConnection clientConnection, IRazorFormattingService razorFormattingService) : CSharpCodeActionResolver(clientConnection) { - // Usually when we need to format code, we utilize the formatting options provided - // by the platform. However, we aren't provided such options in the case of code actions - // so we use a default (and commonly used) configuration. - private static readonly FormattingOptions s_defaultFormattingOptions = new FormattingOptions() - { - TabSize = 4, - InsertSpaces = true, - OtherOptions = new Dictionary - { - { "trimTrailingWhitespace", true }, - { "insertFinalNewline", true }, - { "trimFinalNewlines", true }, - }, - }; - private readonly IDocumentContextFactory _documentContextFactory = documentContextFactory; private readonly IRazorFormattingService _razorFormattingService = razorFormattingService; @@ -80,11 +65,10 @@ public async override Task ResolveAsync( // Remaps the text edits from the generated C# to the razor file, // as well as applying appropriate formatting. - var formattedEdits = await _razorFormattingService.FormatCodeActionAsync( + var formattedEdit = await _razorFormattingService.GetCSharpCodeActionEditAsync( documentContext, - RazorLanguageKind.CSharp, csharpTextEdits, - s_defaultFormattingOptions, + RazorFormattingOptions.Default, cancellationToken).ConfigureAwait(false); cancellationToken.ThrowIfCancellationRequested(); @@ -99,7 +83,7 @@ public async override Task ResolveAsync( new TextDocumentEdit() { TextDocument = codeDocumentIdentifier, - Edits = formattedEdits, + Edits = formattedEdit is null ? [] : [formattedEdit], } } }; diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs index 0cbd6d6f4a6..679eb58c843 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/TypeAccessibilityCodeActionProvider.cs @@ -16,6 +16,7 @@ using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.AspNetCore.Razor.Threading; using Microsoft.CodeAnalysis.ExternalAccess.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -138,7 +139,7 @@ private static ImmutableArray ProcessCodeActionsVSCod var fqnCodeAction = CreateFQNCodeAction(context, diagnostic, codeAction, fqn); typeAccessibilityCodeActions.Add(fqnCodeAction); - if (AddUsingsCodeActionProviderHelper.TryCreateAddUsingResolutionParams(fqn, context.Request.TextDocument.Uri, additionalEdit: null, out var @namespace, out var resolutionParams)) + if (AddUsingsCodeActionResolver.TryCreateAddUsingResolutionParams(fqn, context.Request.TextDocument.Uri, additionalEdit: null, out var @namespace, out var resolutionParams)) { var addUsingCodeAction = RazorCodeActionFactory.CreateAddComponentUsing(@namespace, newTagName: null, resolutionParams); typeAccessibilityCodeActions.Add(addUsingCodeAction); @@ -191,7 +192,7 @@ private static ImmutableArray ProcessCodeActionsVS( // For add using suggestions, the code action title is of the form: // `using System.Net;` else if (codeAction.Name is not null && codeAction.Name.Equals(RazorPredefinedCodeFixProviderNames.AddImport, StringComparison.Ordinal) && - AddUsingsCodeActionProviderHelper.TryExtractNamespace(codeAction.Title, out var @namespace, out var prefix)) + AddUsingsHelper.TryExtractNamespace(codeAction.Title, out var @namespace, out var prefix)) { codeAction.Title = $"{prefix}@using {@namespace}"; typeAccessibilityCodeActions.Add(codeAction.WrapResolvableCodeAction(context, LanguageServerConstants.CodeActions.Default)); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Html/DefaultHtmlCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Html/DefaultHtmlCodeActionProvider.cs index 1ce1cba0a8e..b5d22fabb6c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Html/DefaultHtmlCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Html/DefaultHtmlCodeActionProvider.cs @@ -54,7 +54,7 @@ public static async Task RemapAndFixHtmlCodeActionEditAsync(IEditMappingService foreach (var edit in documentEdits) { - edit.Edits = HtmlFormatter.FixHtmlTestEdits(htmlSourceText, edit.Edits); + edit.Edits = HtmlFormatter.FixHtmlTextEdits(htmlSourceText, edit.Edits); } codeAction.Edit = new WorkspaceEdit diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/AddUsingsCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/AddUsingsCodeActionResolver.cs index 5923d2ed861..f0516a53585 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/AddUsingsCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/AddUsingsCodeActionResolver.cs @@ -4,6 +4,7 @@ using System; using System.Collections.Generic; using System.Diagnostics; +using System.Diagnostics.CodeAnalysis; using System.Linq; using System.Text.Json; using System.Threading; @@ -16,6 +17,7 @@ using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -50,169 +52,91 @@ internal sealed class AddUsingsCodeActionResolver(IDocumentContextFactory docume } var codeDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier() { Uri = actionParams.Uri }; - return CreateAddUsingWorkspaceEdit(actionParams.Namespace, actionParams.AdditionalEdit, codeDocument, codeDocumentIdentifier); + return AddUsingsHelper.CreateAddUsingWorkspaceEdit(actionParams.Namespace, actionParams.AdditionalEdit, codeDocument, codeDocumentIdentifier); } - internal static WorkspaceEdit CreateAddUsingWorkspaceEdit(string @namespace, TextDocumentEdit? additionalEdit, RazorCodeDocument codeDocument, OptionalVersionedTextDocumentIdentifier codeDocumentIdentifier) + internal static bool TryCreateAddUsingResolutionParams(string fullyQualifiedName, Uri uri, TextDocumentEdit? additionalEdit, [NotNullWhen(true)] out string? @namespace, [NotNullWhen(true)] out RazorCodeActionResolutionParams? resolutionParams) { - /* The heuristic is as follows: - * - * - If no @using, @namespace, or @page directives are present, insert the statements at the top of the - * file in alphabetical order. - * - If a @namespace or @page are present, the statements are inserted after the last line-wise in - * alphabetical order. - * - If @using directives are present and alphabetized with System directives at the top, the statements - * will be placed in the correct locations according to that ordering. - * - Otherwise it's kind of undefined; it's only geared to insert based on alphabetization. - * - * This is generally sufficient for our current situation (inserting a single @using statement to include a - * component), however it has holes if we eventually use it for other purposes. If we want to deal with - * that now I can come up with a more sophisticated heuristic (something along the lines of checking if - * there's already an ordering, etc.). - */ - using var documentChanges = new PooledArrayBuilder(); - - // Need to add the additional edit first, as the actual usings go at the top of the file, and would - // change the ranges needed in the additional edit if they went in first - if (additionalEdit is not null) + @namespace = GetNamespaceFromFQN(fullyQualifiedName); + if (string.IsNullOrEmpty(@namespace)) { - documentChanges.Add(additionalEdit); + @namespace = null; + resolutionParams = null; + return false; } - using var usingDirectives = new PooledArrayBuilder(); - CollectUsingDirectives(codeDocument, ref usingDirectives.AsRef()); - if (usingDirectives.Count > 0) + var actionParams = new AddUsingsCodeActionParams { - // Interpolate based on existing @using statements - var edits = GenerateSingleUsingEditsInterpolated(codeDocument, codeDocumentIdentifier, @namespace, in usingDirectives); - documentChanges.Add(edits); - } - else - { - // Just throw them at the top - var edits = GenerateSingleUsingEditsAtTop(codeDocument, codeDocumentIdentifier, @namespace); - documentChanges.Add(edits); - } + Uri = uri, + Namespace = @namespace, + AdditionalEdit = additionalEdit + }; - return new WorkspaceEdit() + resolutionParams = new RazorCodeActionResolutionParams { - DocumentChanges = documentChanges.ToArray(), + Action = LanguageServerConstants.CodeActions.AddUsing, + Language = LanguageServerConstants.CodeActions.Languages.Razor, + Data = actionParams, }; + + return true; } - private static TextDocumentEdit GenerateSingleUsingEditsInterpolated( - RazorCodeDocument codeDocument, - OptionalVersionedTextDocumentIdentifier codeDocumentIdentifier, - string newUsingNamespace, - ref readonly PooledArrayBuilder existingUsingDirectives) + // Internal for testing + internal static string GetNamespaceFromFQN(string fullyQualifiedName) { - Debug.Assert(existingUsingDirectives.Count > 0); - - using var edits = new PooledArrayBuilder(); - var newText = $"@using {newUsingNamespace}{Environment.NewLine}"; - - foreach (var usingDirective in existingUsingDirectives) + if (!TrySplitNamespaceAndType(fullyQualifiedName.AsSpan(), out var namespaceName, out _)) { - // Skip System directives; if they're at the top we don't want to insert before them - var usingDirectiveNamespace = usingDirective.Statement.ParsedNamespace; - if (usingDirectiveNamespace.StartsWith("System", StringComparison.Ordinal)) - { - continue; - } - - if (string.CompareOrdinal(newUsingNamespace, usingDirectiveNamespace) < 0) - { - var usingDirectiveLineIndex = codeDocument.Source.Text.GetLinePosition(usingDirective.Node.Span.Start).Line; - var edit = VsLspFactory.CreateTextEdit(line: usingDirectiveLineIndex, character: 0, newText); - edits.Add(edit); - break; - } - } - - // If we haven't actually found a place to insert the using directive, do so at the end - if (edits.Count == 0) - { - var endIndex = existingUsingDirectives[^1].Node.Span.End; - var lineIndex = GetLineIndexOrEnd(codeDocument, endIndex - 1) + 1; - var edit = VsLspFactory.CreateTextEdit(line: lineIndex, character: 0, newText); - edits.Add(edit); + return string.Empty; } - return new TextDocumentEdit() - { - TextDocument = codeDocumentIdentifier, - Edits = edits.ToArray() - }; + return namespaceName.ToString(); } - private static TextDocumentEdit GenerateSingleUsingEditsAtTop( - RazorCodeDocument codeDocument, - OptionalVersionedTextDocumentIdentifier codeDocumentIdentifier, - string newUsingNamespace) + private static bool TrySplitNamespaceAndType(ReadOnlySpan fullTypeName, out ReadOnlySpan @namespace, out ReadOnlySpan typeName) { - var insertPosition = (0, 0); - - // If we don't have usings, insert after the last namespace or page directive, which ever comes later - var syntaxTreeRoot = codeDocument.GetSyntaxTree().Root; - var lastNamespaceOrPageDirective = syntaxTreeRoot - .DescendantNodes() - .LastOrDefault(IsNamespaceOrPageDirective); + @namespace = default; + typeName = default; - if (lastNamespaceOrPageDirective != null) + if (fullTypeName.IsEmpty) { - var lineIndex = GetLineIndexOrEnd(codeDocument, lastNamespaceOrPageDirective.Span.End - 1) + 1; - insertPosition = (lineIndex, 0); + return false; } - // Insert all usings at the given point - return new TextDocumentEdit + var nestingLevel = 0; + var splitLocation = -1; + for (var i = fullTypeName.Length - 1; i >= 0; i--) { - TextDocument = codeDocumentIdentifier, - Edits = [VsLspFactory.CreateTextEdit(insertPosition, newText: $"@using {newUsingNamespace}{Environment.NewLine}")] - }; - } - - private static int GetLineIndexOrEnd(RazorCodeDocument codeDocument, int endIndex) - { - if (endIndex < codeDocument.Source.Text.Length) - { - return codeDocument.Source.Text.GetLinePosition(endIndex).Line; - } - else - { - return codeDocument.Source.Text.Lines.Count; + var c = fullTypeName[i]; + if (c == Type.Delimiter && nestingLevel == 0) + { + splitLocation = i; + break; + } + else if (c == '>') + { + nestingLevel++; + } + else if (c == '<') + { + nestingLevel--; + } } - } - private static void CollectUsingDirectives(RazorCodeDocument codeDocument, ref PooledArrayBuilder directives) - { - var syntaxTreeRoot = codeDocument.GetSyntaxTree().Root; - foreach (var node in syntaxTreeRoot.DescendantNodes()) + if (splitLocation == -1) { - if (node is RazorDirectiveSyntax directiveNode) - { - foreach (var child in directiveNode.DescendantNodes()) - { - if (child.GetChunkGenerator() is AddImportChunkGenerator { IsStatic: false } usingStatement) - { - directives.Add(new RazorUsingDirective(directiveNode, usingStatement)); - } - } - } + typeName = fullTypeName; + return true; } - } - private static bool IsNamespaceOrPageDirective(SyntaxNode node) - { - if (node is RazorDirectiveSyntax directiveNode) + @namespace = fullTypeName[..splitLocation]; + + var typeNameStartLocation = splitLocation + 1; + if (typeNameStartLocation < fullTypeName.Length) { - return directiveNode.DirectiveDescriptor == ComponentPageDirective.Directive || - directiveNode.DirectiveDescriptor == NamespaceDirective.Directive || - directiveNode.DirectiveDescriptor == PageDirective.Directive; + typeName = fullTypeName[typeNameStartLocation..]; } - return false; + return true; } - - private readonly record struct RazorUsingDirective(RazorDirectiveSyntax Node, AddImportChunkGenerator Statement); } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs index 2e8ec37c2f9..8a551f300b3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/ComponentAccessibilityCodeActionProvider.cs @@ -176,7 +176,7 @@ private static async Task AddComponentAccessFromTagAsync(RazorCodeActionContext // name to give the tag. if (!tagHelperPair.CaseInsensitiveMatch || newTagName is not null) { - if (AddUsingsCodeActionProviderHelper.TryCreateAddUsingResolutionParams(fullyQualifiedName, context.Request.TextDocument.Uri, additionalEdit, out var @namespace, out var resolutionParams)) + if (AddUsingsCodeActionResolver.TryCreateAddUsingResolutionParams(fullyQualifiedName, context.Request.TextDocument.Uri, additionalEdit, out var @namespace, out var resolutionParams)) { var addUsingCodeAction = RazorCodeActionFactory.CreateAddComponentUsing(@namespace, newTagName, resolutionParams); container.Add(addUsingCodeAction); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionResolver.cs index c3c48beb5bf..b6e6a8043b3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/Razor/GenerateMethodCodeActionResolver.cs @@ -204,20 +204,20 @@ private async Task GenerateMethodInCodeBlockAsync( if (result is not null) { - var formattingOptions = new FormattingOptions() + var formattingOptions = new RazorFormattingOptions() { TabSize = _razorLSPOptionsMonitor.CurrentValue.TabSize, InsertSpaces = _razorLSPOptionsMonitor.CurrentValue.InsertSpaces, + CodeBlockBraceOnNextLine = _razorLSPOptionsMonitor.CurrentValue.CodeBlockBraceOnNextLine }; - var formattedEdits = await _razorFormattingService.FormatCodeActionAsync( + var formattedEdit = await _razorFormattingService.GetCSharpCodeActionEditAsync( documentContext, - RazorLanguageKind.CSharp, result, formattingOptions, cancellationToken).ConfigureAwait(false); - edits = formattedEdits; + edits = formattedEdit is null ? [] : [formattedEdit]; } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionItemResolver.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionItemResolver.cs index 7d51829a27c..4945894c35d 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionItemResolver.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Completion/Delegation/DelegatedCompletionItemResolver.cs @@ -6,7 +6,6 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.ProjectSystem; @@ -15,21 +14,16 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Completion.Delegation; -internal class DelegatedCompletionItemResolver : CompletionItemResolver +internal class DelegatedCompletionItemResolver( + IDocumentContextFactory documentContextFactory, + IRazorFormattingService formattingService, + RazorLSPOptionsMonitor optionsMonitor, + IClientConnection clientConnection) : CompletionItemResolver { - private readonly IDocumentContextFactory _documentContextFactory; - private readonly IRazorFormattingService _formattingService; - private readonly IClientConnection _clientConnection; - - public DelegatedCompletionItemResolver( - IDocumentContextFactory documentContextFactory, - IRazorFormattingService formattingService, - IClientConnection clientConnection) - { - _documentContextFactory = documentContextFactory; - _formattingService = formattingService; - _clientConnection = clientConnection; - } + private readonly IDocumentContextFactory _documentContextFactory = documentContextFactory; + private readonly IRazorFormattingService _formattingService = formattingService; + private readonly RazorLSPOptionsMonitor _optionsMonitor = optionsMonitor; + private readonly IClientConnection _clientConnection = clientConnection; public override async Task ResolveAsync( VSInternalCompletionItem item, @@ -118,18 +112,19 @@ private async Task PostProcessCompletionItemAsync( return resolvedCompletionItem; } + var options = RazorFormattingOptions.From(formattingOptions, _optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine); + if (resolvedCompletionItem.TextEdit is not null) { if (resolvedCompletionItem.TextEdit.Value.TryGetFirst(out var textEdit)) { - var formattedTextEdit = await _formattingService.FormatSnippetAsync( + var formattedTextEdit = await _formattingService.GetCSharpSnippetFormattingEditAsync( documentContext, - RazorLanguageKind.CSharp, - new[] { textEdit }, - formattingOptions, + [textEdit], + options, cancellationToken).ConfigureAwait(false); - resolvedCompletionItem.TextEdit = formattedTextEdit.FirstOrDefault(); + resolvedCompletionItem.TextEdit = formattedTextEdit; } else { @@ -141,14 +136,13 @@ private async Task PostProcessCompletionItemAsync( if (resolvedCompletionItem.AdditionalTextEdits is not null) { - var formattedTextEdits = await _formattingService.FormatSnippetAsync( + var formattedTextEdit = await _formattingService.GetCSharpSnippetFormattingEditAsync( documentContext, - RazorLanguageKind.CSharp, resolvedCompletionItem.AdditionalTextEdits, - formattingOptions, + options, cancellationToken).ConfigureAwait(false); - resolvedCompletionItem.AdditionalTextEdits = formattedTextEdits; + resolvedCompletionItem.AdditionalTextEdits = formattedTextEdit is null ? null : [formattedTextEdit]; } return resolvedCompletionItem; 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 2f7fd59c30f..5b5a1911208 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Extensions/IServiceCollectionExtensions.cs @@ -59,16 +59,9 @@ public static void AddLifeCycleServices(this IServiceCollection services, RazorL public static void AddFormattingServices(this IServiceCollection services) { // Formatting + services.AddSingleton(); services.AddSingleton(); - // Formatting Passes - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddSingleton(); - services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); services.AddHandlerWithCapabilities(); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentFormattingEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentFormattingEndpoint.cs index d25fff093bc..fd61a194553 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentFormattingEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentFormattingEndpoint.cs @@ -1,29 +1,25 @@ // 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.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; +using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; [RazorLanguageServerEndpoint(Methods.TextDocumentFormattingName)] -internal class DocumentFormattingEndpoint : IRazorRequestHandler, ICapabilitiesProvider +internal class DocumentFormattingEndpoint( + IRazorFormattingService razorFormattingService, + IHtmlFormatter htmlFormatter, + RazorLSPOptionsMonitor optionsMonitor) : IRazorRequestHandler, ICapabilitiesProvider { - private readonly IRazorFormattingService _razorFormattingService; - private readonly RazorLSPOptionsMonitor _optionsMonitor; - - public DocumentFormattingEndpoint( - IRazorFormattingService razorFormattingService, - RazorLSPOptionsMonitor optionsMonitor) - { - _razorFormattingService = razorFormattingService ?? throw new ArgumentNullException(nameof(razorFormattingService)); - _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); - } + private readonly IRazorFormattingService _razorFormattingService = razorFormattingService; + private readonly RazorLSPOptionsMonitor _optionsMonitor = optionsMonitor; + private readonly IHtmlFormatter _htmlFormatter = htmlFormatter; public bool MutatesSolutionState => false; @@ -56,7 +52,10 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(DocumentFormattingParams return null; } - var edits = await _razorFormattingService.FormatAsync(documentContext, range: null, request.Options, cancellationToken).ConfigureAwait(false); + var options = RazorFormattingOptions.From(request.Options, _optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine); + + var htmlEdits = await _htmlFormatter.GetDocumentFormattingEditsAsync(documentContext.Snapshot, documentContext.Uri, request.Options, cancellationToken).ConfigureAwait(false); + var edits = await _razorFormattingService.GetDocumentFormattingEditsAsync(documentContext, htmlEdits, range: null, options, cancellationToken).ConfigureAwait(false); return edits; } } 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 c165d829a7a..e72d79f83ce 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentOnTypeFormattingEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentOnTypeFormattingEndpoint.cs @@ -2,13 +2,16 @@ // Licensed under the MIT license. See License.txt in the project root for license information. using System; +using System.Collections.Frozen; 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.Language; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; +using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.Logging; @@ -21,19 +24,23 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; [RazorLanguageServerEndpoint(Methods.TextDocumentOnTypeFormattingName)] internal class DocumentOnTypeFormattingEndpoint( IRazorFormattingService razorFormattingService, + IHtmlFormatter htmlFormatter, IDocumentMappingService documentMappingService, RazorLSPOptionsMonitor optionsMonitor, ILoggerFactory loggerFactory) : IRazorRequestHandler, ICapabilitiesProvider { - private readonly IRazorFormattingService _razorFormattingService = razorFormattingService ?? throw new ArgumentNullException(nameof(razorFormattingService)); - private readonly IDocumentMappingService _documentMappingService = documentMappingService ?? throw new ArgumentNullException(nameof(documentMappingService)); - private readonly RazorLSPOptionsMonitor _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); + 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 IReadOnlyList s_csharpTriggerCharacters = new[] { "}", ";" }; - private static readonly IReadOnlyList s_htmlTriggerCharacters = new[] { "\n", "{", "}", ";" }; - private static readonly IReadOnlyList s_allTriggerCharacters = s_csharpTriggerCharacters.Concat(s_htmlTriggerCharacters).ToArray(); + 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; @@ -42,7 +49,7 @@ public void ApplyCapabilities(VSInternalServerCapabilities serverCapabilities, V serverCapabilities.DocumentOnTypeFormattingProvider = new DocumentOnTypeFormattingOptions { FirstTriggerCharacter = s_allTriggerCharacters[0], - MoreTriggerCharacter = s_allTriggerCharacters.Skip(1).ToArray(), + MoreTriggerCharacter = s_allTriggerCharacters.AsSpan()[1..].ToArray(), }; } @@ -67,14 +74,13 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(DocumentOnTypeFormatting return null; } - if (!s_allTriggerCharacters.Contains(request.Character, StringComparer.Ordinal)) + if (!s_allTriggerCharacterSet.Contains(request.Character)) { _logger.LogWarning($"Unexpected trigger character '{request.Character}'."); return null; } var documentContext = requestContext.DocumentContext; - if (documentContext is null) { _logger.LogWarning($"Failed to find document {request.TextDocument.Uri}."); @@ -114,7 +120,24 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(DocumentOnTypeFormatting Debug.Assert(request.Character.Length > 0); - var formattedEdits = await _razorFormattingService.FormatOnTypeAsync(documentContext, triggerCharacterKind, Array.Empty(), request.Options, hostDocumentIndex, request.Character[0], cancellationToken).ConfigureAwait(false); + var options = RazorFormattingOptions.From(request.Options, _optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine); + + TextEdit[] formattedEdits; + if (triggerCharacterKind == RazorLanguageKind.CSharp) + { + formattedEdits = await _razorFormattingService.GetCSharpOnTypeFormattingEditsAsync(documentContext, options, hostDocumentIndex, request.Character[0], cancellationToken).ConfigureAwait(false); + } + else if (triggerCharacterKind == RazorLanguageKind.Html) + { + var htmlEdits = await _htmlFormatter.GetOnTypeFormattingEditsAsync(documentContext.Snapshot, documentContext.Uri, request.Position, request.Character, request.Options, cancellationToken).ConfigureAwait(false); + formattedEdits = await _razorFormattingService.GetHtmlOnTypeFormattingEditsAsync(documentContext, htmlEdits, options, hostDocumentIndex, request.Character[0], cancellationToken).ConfigureAwait(false); + } + else + { + Assumed.Unreachable(); + return null; + } + if (formattedEdits.Length == 0) { _logger.LogInformation($"No formatting changes were necessary"); @@ -129,14 +152,21 @@ private static bool IsApplicableTriggerCharacter(string triggerCharacter, RazorL { if (languageKind == RazorLanguageKind.CSharp) { - return s_csharpTriggerCharacters.Contains(triggerCharacter); + return s_csharpTriggerCharacterSet.Contains(triggerCharacter); } else if (languageKind == RazorLanguageKind.Html) { - return s_htmlTriggerCharacters.Contains(triggerCharacter); + 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/DocumentRangeFormattingEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentRangeFormattingEndpoint.cs index 4fa0207619b..0cd138bf0c7 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentRangeFormattingEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/DocumentRangeFormattingEndpoint.cs @@ -1,29 +1,25 @@ // 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.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; +using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; [RazorLanguageServerEndpoint(Methods.TextDocumentRangeFormattingName)] -internal class DocumentRangeFormattingEndpoint : IRazorRequestHandler, ICapabilitiesProvider +internal class DocumentRangeFormattingEndpoint( + IRazorFormattingService razorFormattingService, + IHtmlFormatter htmlFormatter, + RazorLSPOptionsMonitor optionsMonitor) : IRazorRequestHandler, ICapabilitiesProvider { - private readonly IRazorFormattingService _razorFormattingService; - private readonly RazorLSPOptionsMonitor _optionsMonitor; - - public DocumentRangeFormattingEndpoint( - IRazorFormattingService razorFormattingService, - RazorLSPOptionsMonitor optionsMonitor) - { - _razorFormattingService = razorFormattingService ?? throw new ArgumentNullException(nameof(razorFormattingService)); - _optionsMonitor = optionsMonitor ?? throw new ArgumentNullException(nameof(optionsMonitor)); - } + private readonly IRazorFormattingService _razorFormattingService = razorFormattingService; + private readonly RazorLSPOptionsMonitor _optionsMonitor = optionsMonitor; + private readonly IHtmlFormatter _htmlFormatter = htmlFormatter; public bool MutatesSolutionState => false; @@ -56,7 +52,10 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(DocumentRangeFormattingP return null; } - var edits = await _razorFormattingService.FormatAsync(documentContext, request.Range, request.Options, cancellationToken).ConfigureAwait(false); + var options = RazorFormattingOptions.From(request.Options, _optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine); + + var htmlEdits = await _htmlFormatter.GetDocumentFormattingEditsAsync(documentContext.Snapshot, documentContext.Uri, request.Options, cancellationToken).ConfigureAwait(false); + var edits = await _razorFormattingService.GetDocumentFormattingEditsAsync(documentContext, htmlEdits, request.Range, options, cancellationToken).ConfigureAwait(false); return edits; } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/HtmlFormatter.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/HtmlFormatter.cs index 75409a911ab..d376f9824ec 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/HtmlFormatter.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/HtmlFormatter.cs @@ -6,8 +6,7 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; -using Microsoft.AspNetCore.Razor.TextDifferencing; -using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Protocol.Formatting; using Microsoft.CodeAnalysis.Text; @@ -15,34 +14,25 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; -internal class HtmlFormatter +internal sealed class HtmlFormatter( + IClientConnection clientConnection) : IHtmlFormatter { - private readonly IClientConnection _clientConnection; + private readonly IClientConnection _clientConnection = clientConnection; - public HtmlFormatter(IClientConnection clientConnection) - { - _clientConnection = clientConnection; - } - - public async Task FormatAsync( - FormattingContext context, + public async Task GetDocumentFormattingEditsAsync( + IDocumentSnapshot documentSnapshot, + Uri uri, + FormattingOptions options, CancellationToken cancellationToken) { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - var documentVersion = context.OriginalSnapshot.Version; - var @params = new RazorDocumentFormattingParams() { TextDocument = new TextDocumentIdentifier { - Uri = context.Uri, + Uri = uri, }, - HostDocumentVersion = documentVersion, - Options = context.Options + HostDocumentVersion = documentSnapshot.Version, + Options = options }; var result = await _clientConnection.SendRequestAsync( @@ -50,22 +40,24 @@ public async Task FormatAsync( @params, cancellationToken).ConfigureAwait(false); - return result?.Edits ?? Array.Empty(); + return result?.Edits ?? []; } - public async Task FormatOnTypeAsync( - FormattingContext context, - CancellationToken cancellationToken) + public async Task GetOnTypeFormattingEditsAsync( + IDocumentSnapshot documentSnapshot, + Uri uri, + Position position, + string triggerCharacter, + FormattingOptions options, + CancellationToken cancellationToken) { - var documentVersion = context.OriginalSnapshot.Version; - var @params = new RazorDocumentOnTypeFormattingParams() { - Position = context.SourceText.GetPosition(context.HostDocumentIndex), - Character = context.TriggerCharacter.ToString(), - TextDocument = new TextDocumentIdentifier { Uri = context.Uri }, - Options = context.Options, - HostDocumentVersion = documentVersion, + Position = position, + Character = triggerCharacter.ToString(), + TextDocument = new TextDocumentIdentifier { Uri = uri }, + Options = options, + HostDocumentVersion = documentSnapshot.Version, }; var result = await _clientConnection.SendRequestAsync( @@ -73,7 +65,7 @@ public async Task FormatOnTypeAsync( @params, cancellationToken).ConfigureAwait(false); - return result?.Edits ?? Array.Empty(); + return result?.Edits ?? []; } /// @@ -82,20 +74,12 @@ public async Task FormatOnTypeAsync( /// minimal text edits /// // Internal for testing - public static TextEdit[] FixHtmlTestEdits(SourceText htmlSourceText, TextEdit[] edits) + public static TextEdit[] FixHtmlTextEdits(SourceText htmlSourceText, TextEdit[] edits) { // Avoid computing a minimal diff if we don't need to - if (!edits.Any(e => e.NewText.Contains("~"))) + if (!edits.Any(static e => e.NewText.Contains("~"))) return edits; - // First we apply the edits that the Html language server wanted, to the Html document - var textChanges = edits.Select(htmlSourceText.GetTextChange); - var changedText = htmlSourceText.WithChanges(textChanges); - - // Now we use our minimal text differ algorithm to get the bare minimum of edits - var minimalChanges = SourceTextDiffer.GetMinimalTextChanges(htmlSourceText, changedText, DiffKind.Char); - var minimalEdits = minimalChanges.Select(htmlSourceText.GetTextEdit).ToArray(); - - return minimalEdits; + return htmlSourceText.MinimizeTextEdits(edits); } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/IHtmlFormatter.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/IHtmlFormatter.cs new file mode 100644 index 00000000000..cf3f9bf2633 --- /dev/null +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/IHtmlFormatter.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; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; + +internal interface IHtmlFormatter +{ + Task GetDocumentFormattingEditsAsync(IDocumentSnapshot documentSnapshot, Uri uri, FormattingOptions options, CancellationToken cancellationToken); + Task GetOnTypeFormattingEditsAsync(IDocumentSnapshot documentSnapshot, Uri uri, Position position, string triggerCharacter, FormattingOptions options, CancellationToken cancellationToken); +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspCSharpOnTypeFormattingPass.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspCSharpOnTypeFormattingPass.cs deleted file mode 100644 index c163bc336d7..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspCSharpOnTypeFormattingPass.cs +++ /dev/null @@ -1,39 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Collections.Generic; -using System.Linq; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; -using Microsoft.CodeAnalysis; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.Formatting; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; - -internal sealed class LspCSharpOnTypeFormattingPass( - IDocumentMappingService documentMappingService, - ILoggerFactory loggerFactory) - : CSharpOnTypeFormattingPassBase(documentMappingService, loggerFactory) -{ - - protected override async Task AddUsingStatementEditsIfNecessaryAsync(CodeAnalysis.Razor.Formatting.FormattingContext context, RazorCodeDocument codeDocument, SourceText csharpText, TextEdit[] textEdits, SourceText originalTextWithChanges, TextEdit[] finalEdits, CancellationToken cancellationToken) - { - if (context.AutomaticallyAddUsings) - { - // Because we need to parse the C# code twice for this operation, lets do a quick check to see if its even necessary - if (textEdits.Any(e => e.NewText.IndexOf("using") != -1)) - { - var usingStatementEdits = await AddUsingsCodeActionProviderHelper.GetUsingStatementEditsAsync(codeDocument, csharpText, originalTextWithChanges, cancellationToken).ConfigureAwait(false); - finalEdits = [.. usingStatementEdits, .. finalEdits]; - } - } - - return finalEdits; - } -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspRazorFormattingPass.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspRazorFormattingPass.cs deleted file mode 100644 index 49731bf21a1..00000000000 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/LspRazorFormattingPass.cs +++ /dev/null @@ -1,15 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.Formatting; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; - -internal sealed class LspRazorFormattingPass( - IDocumentMappingService documentMappingService, - RazorLSPOptionsMonitor optionsMonitor) - : RazorFormattingPassBase(documentMappingService) -{ - protected override bool CodeBlockBraceOnNextLine => optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine; -} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/InlineCompletion/InlineCompletionEndPoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/InlineCompletion/InlineCompletionEndPoint.cs index 76d61ba0eb8..652d1100b0c 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/InlineCompletion/InlineCompletionEndPoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/InlineCompletion/InlineCompletionEndPoint.cs @@ -13,6 +13,7 @@ using Microsoft.AspNetCore.Razor.LanguageServer.EndpointContracts; using Microsoft.AspNetCore.Razor.LanguageServer.Formatting; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.Logging; @@ -30,6 +31,7 @@ internal sealed class InlineCompletionEndpoint( IClientConnection clientConnection, IFormattingCodeDocumentProvider formattingCodeDocumentProvider, IAdhocWorkspaceFactory adhocWorkspaceFactory, + RazorLSPOptionsMonitor optionsMonitor, ILoggerFactory loggerFactory) : IRazorRequestHandler, ICapabilitiesProvider { @@ -42,6 +44,7 @@ internal sealed class InlineCompletionEndpoint( private readonly IClientConnection _clientConnection = clientConnection ?? throw new ArgumentNullException(nameof(clientConnection)); private readonly IFormattingCodeDocumentProvider _formattingCodeDocumentProvider = formattingCodeDocumentProvider; private readonly IAdhocWorkspaceFactory _adhocWorkspaceFactory = adhocWorkspaceFactory ?? throw new ArgumentNullException(nameof(adhocWorkspaceFactory)); + private readonly RazorLSPOptionsMonitor _optionsMonitor = optionsMonitor; private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); public bool MutatesSolutionState => false; @@ -113,7 +116,7 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(VSInternalInlineCompleti return null; } - var items = new List(); + using var items = new PooledArrayBuilder(list.Items.Length); foreach (var item in list.Items) { var containsSnippet = item.TextFormat == InsertTextFormat.Snippet; @@ -125,11 +128,12 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(VSInternalInlineCompleti continue; } + var options = RazorFormattingOptions.From(request.Options, _optionsMonitor.CurrentValue.CodeBlockBraceOnNextLine); using var formattingContext = FormattingContext.Create( request.TextDocument.Uri, documentContext.Snapshot, codeDocument, - request.Options, + options, _formattingCodeDocumentProvider, _adhocWorkspaceFactory); if (!TryGetSnippetWithAdjustedIndentation(formattingContext, item.Text, hostDocumentIndex, out var newSnippetText)) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WrapWithTag/WrapWithTagEndpoint.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WrapWithTag/WrapWithTagEndpoint.cs index aa168ece625..6011c943ff3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WrapWithTag/WrapWithTagEndpoint.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/WrapWithTag/WrapWithTagEndpoint.cs @@ -116,7 +116,7 @@ public TextDocumentIdentifier GetTextDocumentIdentifier(WrapWithTagParams reques if (htmlResponse.TextEdits is not null) { var htmlSourceText = await documentContext.GetHtmlSourceTextAsync(cancellationToken).ConfigureAwait(false); - htmlResponse.TextEdits = HtmlFormatter.FixHtmlTestEdits(htmlSourceText, htmlResponse.TextEdits); + htmlResponse.TextEdits = HtmlFormatter.FixHtmlTextEdits(htmlSourceText, htmlResponse.TextEdits); } return htmlResponse; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/SourceTextExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/SourceTextExtensions.cs index 6d72290f117..77b8b9b1b8e 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/SourceTextExtensions.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Extensions/SourceTextExtensions.cs @@ -3,9 +3,12 @@ using System; using System.Buffers; +using System.Linq; using Microsoft.AspNetCore.Razor; using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.TextDifferencing; using Microsoft.CodeAnalysis.Razor.Workspaces; +using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Text; @@ -269,4 +272,28 @@ public static bool TryGetSourceLocation(this SourceText text, int line, int char location = default; return false; } + + /// + /// Applies the set of edits specified, and returns the minimal set needed to make the same changes + /// + public static TextEdit[] MinimizeTextEdits(this SourceText text, TextEdit[] edits) + => MinimizeTextEdits(text, edits, out _); + + /// + /// Applies the set of edits specified, and returns the minimal set needed to make the same changes + /// + public static TextEdit[] MinimizeTextEdits(this SourceText text, TextEdit[] edits, out SourceText originalTextWithChanges) + { + var changes = edits.Select(text.GetTextChange); + originalTextWithChanges = text.WithChanges(changes); + + if (text.ContentEquals(originalTextWithChanges)) + { + return []; + } + + var cleanChanges = SourceTextDiffer.GetMinimalTextChanges(text, originalTextWithChanges, DiffKind.Char); + var cleanEdits = cleanChanges.Select(text.GetTextEdit).ToArray(); + return cleanEdits; + } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/AddUsingsHelper.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/AddUsingsHelper.cs new file mode 100644 index 00000000000..b7bc5f33fc3 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/AddUsingsHelper.cs @@ -0,0 +1,272 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System; +using System.Collections.Generic; +using System.Diagnostics; +using System.Linq; +using System.Text.RegularExpressions; +using System.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Mvc.Razor.Extensions; +using Microsoft.AspNetCore.Razor.Language; +using Microsoft.AspNetCore.Razor.Language.Components; +using Microsoft.AspNetCore.Razor.Language.Extensions; +using Microsoft.AspNetCore.Razor.Language.Legacy; +using Microsoft.AspNetCore.Razor.Language.Syntax; +using Microsoft.AspNetCore.Razor.PooledObjects; +using Microsoft.CodeAnalysis.CSharp; +using Microsoft.CodeAnalysis.CSharp.Syntax; +using Microsoft.CodeAnalysis.Text; +using Microsoft.VisualStudio.LanguageServer.Protocol; +using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; + +namespace Microsoft.CodeAnalysis.Razor.Formatting; + +internal static class AddUsingsHelper +{ + private static readonly Regex s_addUsingVSCodeAction = new Regex("@?using ([^;]+);?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1)); + + private readonly record struct RazorUsingDirective(RazorDirectiveSyntax Node, AddImportChunkGenerator Statement); + + public static async Task GetUsingStatementEditsAsync(RazorCodeDocument codeDocument, SourceText originalCSharpText, SourceText changedCSharpText, CancellationToken cancellationToken) + { + // Now that we're done with everything, lets see if there are any using statements to fix up + // We do this by comparing the original generated C# code, and the changed C# code, and look for a difference + // in using statements. We can't use edits for this for two main reasons: + // + // 1. Using statements in the generated code might come from _Imports.razor, or from this file, and C# will shove them anywhere + // 2. The edit might not be clean. eg given: + // using System; + // using System.Text; + // Adding "using System.Linq;" could result in an insert of "Linq;\r\nusing System." on line 2 + // + // So because of the above, we look for a difference in C# using directive nodes directly from the C# syntax tree, and apply them manually + // to the Razor document. + + var oldUsings = await FindUsingDirectiveStringsAsync(originalCSharpText, cancellationToken).ConfigureAwait(false); + var newUsings = await FindUsingDirectiveStringsAsync(changedCSharpText, cancellationToken).ConfigureAwait(false); + + using var edits = new PooledArrayBuilder(); + foreach (var usingStatement in newUsings.Except(oldUsings)) + { + // This identifier will be eventually thrown away. + Debug.Assert(codeDocument.Source.FilePath != null); + var identifier = new OptionalVersionedTextDocumentIdentifier { Uri = new Uri(codeDocument.Source.FilePath, UriKind.Relative) }; + var workspaceEdit = CreateAddUsingWorkspaceEdit(usingStatement, additionalEdit: null, codeDocument, codeDocumentIdentifier: identifier); + edits.AddRange(workspaceEdit.DocumentChanges!.Value.First.First().Edits); + } + + return edits.ToArray(); + } + + /// + /// Extracts the namespace from a C# add using statement provided by Visual Studio + /// + /// Add using statement of the form `using System.X;` + /// Extract namespace `System.X` + /// The prefix to show, before the namespace, if any + /// + public static bool TryExtractNamespace(string csharpAddUsing, out string @namespace, out string prefix) + { + // We must remove any leading/trailing new lines from the add using edit + csharpAddUsing = csharpAddUsing.Trim(); + var regexMatchedTextEdit = s_addUsingVSCodeAction.Match(csharpAddUsing); + if (!regexMatchedTextEdit.Success || + + // Two Regex matching groups are expected + // 1. `using namespace;` + // 2. `namespace` + regexMatchedTextEdit.Groups.Count != 2) + { + // Text edit in an unexpected format + @namespace = string.Empty; + prefix = string.Empty; + return false; + } + + @namespace = regexMatchedTextEdit.Groups[1].Value; + prefix = csharpAddUsing[..regexMatchedTextEdit.Index]; + return true; + } + + public static WorkspaceEdit CreateAddUsingWorkspaceEdit(string @namespace, TextDocumentEdit? additionalEdit, RazorCodeDocument codeDocument, OptionalVersionedTextDocumentIdentifier codeDocumentIdentifier) + { + /* The heuristic is as follows: + * + * - If no @using, @namespace, or @page directives are present, insert the statements at the top of the + * file in alphabetical order. + * - If a @namespace or @page are present, the statements are inserted after the last line-wise in + * alphabetical order. + * - If @using directives are present and alphabetized with System directives at the top, the statements + * will be placed in the correct locations according to that ordering. + * - Otherwise it's kind of undefined; it's only geared to insert based on alphabetization. + * + * This is generally sufficient for our current situation (inserting a single @using statement to include a + * component), however it has holes if we eventually use it for other purposes. If we want to deal with + * that now I can come up with a more sophisticated heuristic (something along the lines of checking if + * there's already an ordering, etc.). + */ + using var documentChanges = new PooledArrayBuilder(); + + // Need to add the additional edit first, as the actual usings go at the top of the file, and would + // change the ranges needed in the additional edit if they went in first + if (additionalEdit is not null) + { + documentChanges.Add(additionalEdit); + } + + using var usingDirectives = new PooledArrayBuilder(); + CollectUsingDirectives(codeDocument, ref usingDirectives.AsRef()); + if (usingDirectives.Count > 0) + { + // Interpolate based on existing @using statements + var edits = GenerateSingleUsingEditsInterpolated(codeDocument, codeDocumentIdentifier, @namespace, in usingDirectives); + documentChanges.Add(edits); + } + else + { + // Just throw them at the top + var edits = GenerateSingleUsingEditsAtTop(codeDocument, codeDocumentIdentifier, @namespace); + documentChanges.Add(edits); + } + + return new WorkspaceEdit() + { + DocumentChanges = documentChanges.ToArray(), + }; + } + + private static async Task> FindUsingDirectiveStringsAsync(SourceText originalCSharpText, CancellationToken cancellationToken) + { + var syntaxTree = CSharpSyntaxTree.ParseText(originalCSharpText, cancellationToken: cancellationToken); + var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken).ConfigureAwait(false); + + // We descend any compilation unit (ie, the file) or and namespaces because the compiler puts all usings inside + // the namespace node. + var usings = syntaxRoot.DescendantNodes(n => n is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax) + // Filter to using directives + .OfType() + // Select everything after the initial "using " part of the statement, and excluding the ending semi-colon. The + // semi-colon is valid in Razor, but users find it surprising. This is slightly lazy, for sure, but has + // the advantage of us not caring about changes to C# syntax, we just grab whatever Roslyn wanted to put in, so + // we should still work in C# v26 + .Select(u => u.ToString()["using ".Length..^1]); + + return usings; + } + + private static TextDocumentEdit GenerateSingleUsingEditsInterpolated( + RazorCodeDocument codeDocument, + OptionalVersionedTextDocumentIdentifier codeDocumentIdentifier, + string newUsingNamespace, + ref readonly PooledArrayBuilder existingUsingDirectives) + { + Debug.Assert(existingUsingDirectives.Count > 0); + + using var edits = new PooledArrayBuilder(); + var newText = $"@using {newUsingNamespace}{Environment.NewLine}"; + + foreach (var usingDirective in existingUsingDirectives) + { + // Skip System directives; if they're at the top we don't want to insert before them + var usingDirectiveNamespace = usingDirective.Statement.ParsedNamespace; + if (usingDirectiveNamespace.StartsWith("System", StringComparison.Ordinal)) + { + continue; + } + + if (string.CompareOrdinal(newUsingNamespace, usingDirectiveNamespace) < 0) + { + var usingDirectiveLineIndex = codeDocument.Source.Text.GetLinePosition(usingDirective.Node.Span.Start).Line; + var edit = VsLspFactory.CreateTextEdit(line: usingDirectiveLineIndex, character: 0, newText); + edits.Add(edit); + break; + } + } + + // If we haven't actually found a place to insert the using directive, do so at the end + if (edits.Count == 0) + { + var endIndex = existingUsingDirectives[^1].Node.Span.End; + var lineIndex = GetLineIndexOrEnd(codeDocument, endIndex - 1) + 1; + var edit = VsLspFactory.CreateTextEdit(line: lineIndex, character: 0, newText); + edits.Add(edit); + } + + return new TextDocumentEdit() + { + TextDocument = codeDocumentIdentifier, + Edits = edits.ToArray() + }; + } + + private static TextDocumentEdit GenerateSingleUsingEditsAtTop( + RazorCodeDocument codeDocument, + OptionalVersionedTextDocumentIdentifier codeDocumentIdentifier, + string newUsingNamespace) + { + var insertPosition = (0, 0); + + // If we don't have usings, insert after the last namespace or page directive, which ever comes later + var syntaxTreeRoot = codeDocument.GetSyntaxTree().Root; + var lastNamespaceOrPageDirective = syntaxTreeRoot + .DescendantNodes() + .LastOrDefault(IsNamespaceOrPageDirective); + + if (lastNamespaceOrPageDirective != null) + { + var lineIndex = GetLineIndexOrEnd(codeDocument, lastNamespaceOrPageDirective.Span.End - 1) + 1; + insertPosition = (lineIndex, 0); + } + + // Insert all usings at the given point + return new TextDocumentEdit + { + TextDocument = codeDocumentIdentifier, + Edits = [VsLspFactory.CreateTextEdit(insertPosition, newText: $"@using {newUsingNamespace}{Environment.NewLine}")] + }; + } + + private static int GetLineIndexOrEnd(RazorCodeDocument codeDocument, int endIndex) + { + if (endIndex < codeDocument.Source.Text.Length) + { + return codeDocument.Source.Text.GetLinePosition(endIndex).Line; + } + else + { + return codeDocument.Source.Text.Lines.Count; + } + } + + private static void CollectUsingDirectives(RazorCodeDocument codeDocument, ref PooledArrayBuilder directives) + { + var syntaxTreeRoot = codeDocument.GetSyntaxTree().Root; + foreach (var node in syntaxTreeRoot.DescendantNodes()) + { + if (node is RazorDirectiveSyntax directiveNode) + { + foreach (var child in directiveNode.DescendantNodes()) + { + if (child.GetChunkGenerator() is AddImportChunkGenerator { IsStatic: false } usingStatement) + { + directives.Add(new RazorUsingDirective(directiveNode, usingStatement)); + } + } + } + } + } + + private static bool IsNamespaceOrPageDirective(RazorSyntaxNode node) + { + if (node is RazorDirectiveSyntax directiveNode) + { + return directiveNode.DirectiveDescriptor == ComponentPageDirective.Directive || + directiveNode.DirectiveDescriptor == NamespaceDirective.Directive || + directiveNode.DirectiveDescriptor == PageDirective.Directive; + } + + return false; + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormatter.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormatter.cs index fbef2ed9e2a..e0bd5a87211 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormatter.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormatter.cs @@ -1,7 +1,6 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. -using System; using System.Collections.Generic; using System.Linq; using System.Threading; @@ -27,16 +26,6 @@ internal sealed class CSharpFormatter(IDocumentMappingService documentMappingSer public async Task FormatAsync(FormattingContext context, Range rangeToFormat, CancellationToken cancellationToken) { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (rangeToFormat is null) - { - throw new ArgumentNullException(nameof(rangeToFormat)); - } - if (!_documentMappingService.TryMapToGeneratedDocumentRange(context.CodeDocument.GetCSharpDocument(), rangeToFormat, out var projectedRange)) { return []; @@ -52,16 +41,6 @@ public static async Task> GetCSharpIndentationAsyn IReadOnlyCollection projectedDocumentLocations, CancellationToken cancellationToken) { - if (context is null) - { - throw new ArgumentNullException(nameof(context)); - } - - if (projectedDocumentLocations is null) - { - throw new ArgumentNullException(nameof(projectedDocumentLocations)); - } - // Sorting ensures we count the marker offsets correctly. // We also want to ensure there are no duplicates to avoid duplicate markers. var filteredLocations = projectedDocumentLocations.Distinct().OrderBy(l => l).ToList(); @@ -84,7 +63,7 @@ private static async Task GetFormattingEditsAsync(FormattingContext var root = await context.CSharpWorkspaceDocument.GetSyntaxRootAsync(cancellationToken).ConfigureAwait(false); Assumes.NotNull(root); - var changes = RazorCSharpFormattingInteractionService.GetFormattedTextChanges(context.CSharpWorkspace.Services, root, spanToFormat, context.Options.GetIndentationOptions(), cancellationToken); + var changes = RazorCSharpFormattingInteractionService.GetFormattedTextChanges(context.CSharpWorkspace.Services, root, spanToFormat, context.Options.ToIndentationOptions(), cancellationToken); var edits = changes.Select(csharpSourceText.GetTextEdit).ToArray(); return edits; @@ -106,7 +85,7 @@ private static async Task> GetCSharpIndentationCoreAsync(Fo // At this point, we have added all the necessary markers and attached annotations. // Let's invoke the C# formatter and hope for the best. - var formattedRoot = RazorCSharpFormattingInteractionService.Format(context.CSharpWorkspace.Services, root, context.Options.GetIndentationOptions(), cancellationToken); + var formattedRoot = RazorCSharpFormattingInteractionService.Format(context.CSharpWorkspace.Services, root, context.Options.ToIndentationOptions(), cancellationToken); var formattedText = formattedRoot.GetText(); var desiredIndentationMap = new Dictionary(); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingContext.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingContext.cs index bd0fd0db7b7..e0a16ef90d4 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingContext.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingContext.cs @@ -35,8 +35,7 @@ private FormattingContext( Uri uri, IDocumentSnapshot originalSnapshot, RazorCodeDocument codeDocument, - FormattingOptions options, - bool isFormatOnType, + RazorFormattingOptions options, bool automaticallyAddUsings, int hostDocumentIndex, char triggerCharacter) @@ -47,7 +46,6 @@ private FormattingContext( OriginalSnapshot = originalSnapshot; CodeDocument = codeDocument; Options = options; - IsFormatOnType = isFormatOnType; AutomaticallyAddUsings = automaticallyAddUsings; HostDocumentIndex = hostDocumentIndex; TriggerCharacter = triggerCharacter; @@ -58,8 +56,7 @@ private FormattingContext( public Uri Uri { get; } public IDocumentSnapshot OriginalSnapshot { get; } public RazorCodeDocument CodeDocument { get; } - public FormattingOptions Options { get; } - public bool IsFormatOnType { get; } + public RazorFormattingOptions Options { get; } public bool AutomaticallyAddUsings { get; } public int HostDocumentIndex { get; } public char TriggerCharacter { get; } @@ -97,7 +94,7 @@ public IReadOnlyDictionary GetIndentations() { if (_indentations is null) { - var sourceText = this.SourceText; + var sourceText = SourceText; var indentations = new Dictionary(); var previousIndentationLevel = 0; @@ -111,7 +108,7 @@ public IReadOnlyDictionary GetIndentations() // The existingIndentation above is measured in characters, and is used to create text edits // The below is measured in columns, so takes into account tab size. This is useful for creating // new indentation strings - var existingIndentationSize = line.GetIndentationSize(this.Options.TabSize); + var existingIndentationSize = line.GetIndentationSize(Options.TabSize); var emptyOrWhitespaceLine = false; if (nonWsPos is null) @@ -190,11 +187,6 @@ private IReadOnlyList GetFormattingSpans() private static IReadOnlyList GetFormattingSpans(RazorSyntaxTree syntaxTree, bool inGlobalNamespace) { - if (syntaxTree is null) - { - throw new ArgumentNullException(nameof(syntaxTree)); - } - var visitor = new FormattingVisitor(inGlobalNamespace: inGlobalNamespace); visitor.Visit(syntaxTree.Root); @@ -238,11 +230,10 @@ public bool TryGetIndentationLevel(int position, out int indentationLevel) public bool TryGetFormattingSpan(int absoluteIndex, [NotNullWhen(true)] out FormattingSpan? result) { result = null; - var formattingspans = GetFormattingSpans(); - for (var i = 0; i < formattingspans.Count; i++) + var formattingSpans = GetFormattingSpans(); + foreach (var formattingSpan in formattingSpans.AsEnumerable()) { - var formattingspan = formattingspans[i]; - var span = formattingspan.Span; + var span = formattingSpan.Span; if (span.Start <= absoluteIndex && span.End >= absoluteIndex) { @@ -253,7 +244,7 @@ public bool TryGetFormattingSpan(int absoluteIndex, [NotNullWhen(true)] out Form continue; } - result = formattingspan; + result = formattingSpan; return true; } } @@ -272,11 +263,6 @@ public void Dispose() public async Task WithTextAsync(SourceText changedText) { - if (changedText is null) - { - throw new ArgumentNullException(nameof(changedText)); - } - var changedSnapshot = OriginalSnapshot.WithText(changedText); var codeDocument = await _codeDocumentProvider.GetCodeDocumentAsync(changedSnapshot).ConfigureAwait(false); @@ -290,7 +276,6 @@ public async Task WithTextAsync(SourceText changedText) OriginalSnapshot, codeDocument, Options, - IsFormatOnType, AutomaticallyAddUsings, HostDocumentIndex, TriggerCharacter); @@ -320,21 +305,20 @@ public static FormattingContext CreateForOnTypeFormatting( Uri uri, IDocumentSnapshot originalSnapshot, RazorCodeDocument codeDocument, - FormattingOptions options, + RazorFormattingOptions options, IFormattingCodeDocumentProvider codeDocumentProvider, IAdhocWorkspaceFactory workspaceFactory, bool automaticallyAddUsings, int hostDocumentIndex, char triggerCharacter) { - return CreateCore( + return new FormattingContext( + codeDocumentProvider, + workspaceFactory, uri, originalSnapshot, codeDocument, options, - codeDocumentProvider, - workspaceFactory, - isFormatOnType: true, automaticallyAddUsings, hostDocumentIndex, triggerCharacter); @@ -344,76 +328,20 @@ public static FormattingContext Create( Uri uri, IDocumentSnapshot originalSnapshot, RazorCodeDocument codeDocument, - FormattingOptions options, + RazorFormattingOptions options, IFormattingCodeDocumentProvider codeDocumentProvider, IAdhocWorkspaceFactory workspaceFactory) { - return CreateCore( - uri, - originalSnapshot, - codeDocument, - options, - codeDocumentProvider, - workspaceFactory, - isFormatOnType: false, - automaticallyAddUsings: false, - hostDocumentIndex: 0, - triggerCharacter: '\0'); - } - - private static FormattingContext CreateCore( - Uri uri, - IDocumentSnapshot originalSnapshot, - RazorCodeDocument codeDocument, - FormattingOptions options, - IFormattingCodeDocumentProvider codeDocumentProvider, - IAdhocWorkspaceFactory workspaceFactory, - bool isFormatOnType, - bool automaticallyAddUsings, - int hostDocumentIndex, - char triggerCharacter) - { - if (uri is null) - { - throw new ArgumentNullException(nameof(uri)); - } - - if (originalSnapshot is null) - { - throw new ArgumentNullException(nameof(originalSnapshot)); - } - - if (codeDocument is null) - { - throw new ArgumentNullException(nameof(codeDocument)); - } - - if (options is null) - { - throw new ArgumentNullException(nameof(options)); - } - - if (workspaceFactory is null) - { - throw new ArgumentNullException(nameof(workspaceFactory)); - } - - // hostDocumentIndex, triggerCharacter and automaticallyAddUsings are only supported in on type formatting - Debug.Assert(isFormatOnType || (hostDocumentIndex == 0 && triggerCharacter == '\0' && automaticallyAddUsings == false)); - - var result = new FormattingContext( + return new FormattingContext( codeDocumentProvider, workspaceFactory, uri, originalSnapshot, codeDocument, options, - isFormatOnType, - automaticallyAddUsings, - hostDocumentIndex, - triggerCharacter - ); - - return result; + automaticallyAddUsings: false, + hostDocumentIndex: 0, + triggerCharacter: '\0' + ); } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingOptionsExtensions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingOptionsExtensions.cs deleted file mode 100644 index 63fcae2af9d..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingOptionsExtensions.cs +++ /dev/null @@ -1,16 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using Microsoft.CodeAnalysis.ExternalAccess.Razor; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.CodeAnalysis.Razor.Formatting; - -internal static class FormattingOptionsExtensions -{ - public static RazorIndentationOptions GetIndentationOptions(this FormattingOptions options) - => new( - UseTabs: !options.InsertSpaces, - TabSize: options.TabSize, - IndentationSize: options.TabSize); -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingPassBase.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingPassBase.cs deleted file mode 100644 index 535649ec766..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingPassBase.cs +++ /dev/null @@ -1,53 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.Protocol; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.CodeAnalysis.Razor.Formatting; - -internal abstract class FormattingPassBase(IDocumentMappingService documentMappingService) : IFormattingPass -{ - protected static readonly int DefaultOrder = 1000; - - public abstract bool IsValidationPass { get; } - - public virtual int Order => DefaultOrder; - - protected IDocumentMappingService DocumentMappingService { get; } = documentMappingService; - - public abstract Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken); - - protected TextEdit[] RemapTextEdits(RazorCodeDocument codeDocument, TextEdit[] projectedTextEdits, RazorLanguageKind projectedKind) - { - if (codeDocument is null) - { - throw new ArgumentNullException(nameof(codeDocument)); - } - - if (projectedTextEdits is null) - { - throw new ArgumentNullException(nameof(projectedTextEdits)); - } - - if (projectedKind != RazorLanguageKind.CSharp) - { - // Non C# projections map directly to Razor. No need to remap. - return projectedTextEdits; - } - - if (codeDocument.IsUnsupported()) - { - return []; - } - - var edits = DocumentMappingService.GetHostDocumentEdits(codeDocument.GetCSharpDocument(), projectedTextEdits); - - return edits; - } -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingResult.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingResult.cs deleted file mode 100644 index e8db79e5204..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingResult.cs +++ /dev/null @@ -1,26 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using Microsoft.CodeAnalysis.Razor.Protocol; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.CodeAnalysis.Razor.Formatting; - -internal readonly struct FormattingResult -{ - public FormattingResult(TextEdit[] edits, RazorLanguageKind kind = RazorLanguageKind.Razor) - { - if (edits is null) - { - throw new ArgumentNullException(nameof(edits)); - } - - Edits = edits; - Kind = kind; - } - - public TextEdit[] Edits { get; } - - public RazorLanguageKind Kind { get; } -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IFormattingPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IFormattingPass.cs index cebfed8ad87..18e774ed89e 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IFormattingPass.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IFormattingPass.cs @@ -3,14 +3,11 @@ using System.Threading; using System.Threading.Tasks; +using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Formatting; internal interface IFormattingPass { - int Order { get; } - - bool IsValidationPass { get; } - - Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken); + Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IRazorFormattingService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IRazorFormattingService.cs index 989b139638e..1ad189e90fd 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IRazorFormattingService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/IRazorFormattingService.cs @@ -4,39 +4,49 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Formatting; internal interface IRazorFormattingService { - Task FormatAsync( + Task GetDocumentFormattingEditsAsync( DocumentContext documentContext, + TextEdit[] htmlEdits, Range? range, - FormattingOptions options, + RazorFormattingOptions options, CancellationToken cancellationToken); - Task FormatOnTypeAsync( + Task GetHtmlOnTypeFormattingEditsAsync( DocumentContext documentContext, - RazorLanguageKind kind, - TextEdit[] formattedEdits, - FormattingOptions options, + TextEdit[] htmlEdits, + RazorFormattingOptions options, int hostDocumentIndex, char triggerCharacter, CancellationToken cancellationToken); - Task FormatCodeActionAsync( + Task GetCSharpOnTypeFormattingEditsAsync( + DocumentContext documentContext, + RazorFormattingOptions options, + int hostDocumentIndex, + char triggerCharacter, + CancellationToken cancellationToken); + + Task GetSingleCSharpEditAsync( + DocumentContext documentContext, + TextEdit csharpEdit, + RazorFormattingOptions options, + CancellationToken cancellationToken); + + Task GetCSharpCodeActionEditAsync( DocumentContext documentContext, - RazorLanguageKind kind, - TextEdit[] formattedEdits, - FormattingOptions options, + TextEdit[] csharpEdits, + RazorFormattingOptions options, CancellationToken cancellationToken); - Task FormatSnippetAsync( + Task GetCSharpSnippetFormattingEditAsync( DocumentContext documentContext, - RazorLanguageKind kind, - TextEdit[] edits, - FormattingOptions options, + TextEdit[] csharpEdits, + RazorFormattingOptions options, CancellationToken cancellationToken); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormattingPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpFormattingPass.cs similarity index 79% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormattingPass.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpFormattingPass.cs index 722b986ffee..d1d5505c83b 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormattingPass.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpFormattingPass.cs @@ -11,37 +11,31 @@ using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Formatting; -internal class CSharpFormattingPass( +/// +/// Gets edits in Razor files, and returns edits to Razor files, with nicely formatted Html +/// +internal sealed class CSharpFormattingPass( IDocumentMappingService documentMappingService, ILoggerFactory loggerFactory) - : CSharpFormattingPassBase(documentMappingService) + : CSharpFormattingPassBase(documentMappingService, isFormatOnType: false) { + private readonly CSharpFormatter _csharpFormatter = new CSharpFormatter(documentMappingService); private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - // Run after the HTML and Razor formatter pass. - public override int Order => DefaultOrder - 3; - - public async override Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken) + public async override Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken) { - if (context.IsFormatOnType || result.Kind != RazorLanguageKind.Razor) - { - // We don't want to handle OnTypeFormatting here. - return result; - } - // Apply previous edits if any. var originalText = context.SourceText; var changedText = originalText; var changedContext = context; - if (result.Edits.Length > 0) + if (edits.Length > 0) { - var changes = result.Edits.Select(originalText.GetTextChange).ToArray(); + var changes = edits.Select(originalText.GetTextChange); changedText = changedText.WithChanges(changes); changedContext = await context.WithTextAsync(changedText).ConfigureAwait(false); } @@ -75,7 +69,7 @@ public async override Task ExecuteAsync(FormattingContext cont var finalChanges = changedText.GetTextChanges(originalText); var finalEdits = finalChanges.Select(originalText.GetTextEdit).ToArray(); - return new FormattingResult(finalEdits); + return finalEdits; } private async Task> FormatCSharpAsync(FormattingContext context, CancellationToken cancellationToken) @@ -94,7 +88,7 @@ private async Task> FormatCSharpAsync(FormattingContext // These should already be remapped. var range = sourceText.GetRange(span); - var edits = await CSharpFormatter.FormatAsync(context, range, cancellationToken).ConfigureAwait(false); + var edits = await _csharpFormatter.FormatAsync(context, range, cancellationToken).ConfigureAwait(false); csharpEdits.AddRange(edits.Where(e => range.Contains(e.Range))); } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormattingPassBase.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpFormattingPassBase.cs similarity index 96% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormattingPassBase.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpFormattingPassBase.cs index 27b38a0e31a..4387ce3d7e6 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpFormattingPassBase.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpFormattingPassBase.cs @@ -15,22 +15,17 @@ using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; +using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; namespace Microsoft.CodeAnalysis.Razor.Formatting; -using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; - -internal abstract class CSharpFormattingPassBase : FormattingPassBase +internal abstract class CSharpFormattingPassBase(IDocumentMappingService documentMappingService, bool isFormatOnType) : IFormattingPass { - protected CSharpFormattingPassBase(IDocumentMappingService documentMappingService) - : base(documentMappingService) - { - CSharpFormatter = new CSharpFormatter(documentMappingService); - } + private readonly bool _isFormatOnType = isFormatOnType; - protected CSharpFormatter CSharpFormatter { get; } + protected IDocumentMappingService DocumentMappingService { get; } = documentMappingService; - public override bool IsValidationPass => false; + public abstract Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken); protected async Task> AdjustIndentationAsync(FormattingContext context, CancellationToken cancellationToken, Range? range = null) { @@ -255,7 +250,7 @@ protected async Task> AdjustIndentationAsync(FormattingContext if (indentations[i].StartsInHtmlContext) { // This is a non-C# line. - if (context.IsFormatOnType) + if (_isFormatOnType) { // HTML formatter doesn't run in the case of format on type. // Let's stick with our syntax understanding of HTML to figure out the desired indentation. @@ -296,13 +291,13 @@ protected async Task> AdjustIndentationAsync(FormattingContext protected static bool ShouldFormat(FormattingContext context, TextSpan mappingSpan, bool allowImplicitStatements) => ShouldFormat(context, mappingSpan, allowImplicitStatements, out _); - protected static bool ShouldFormat(FormattingContext context, TextSpan mappingSpan, bool allowImplicitStatements, out SyntaxNode? foundOwner) + protected static bool ShouldFormat(FormattingContext context, TextSpan mappingSpan, bool allowImplicitStatements, out RazorSyntaxNode? foundOwner) => ShouldFormat(context, mappingSpan, new ShouldFormatOptions(allowImplicitStatements, isLineRequest: false), out foundOwner); private static bool ShouldFormatLine(FormattingContext context, TextSpan mappingSpan, bool allowImplicitStatements) => ShouldFormat(context, mappingSpan, new ShouldFormatOptions(allowImplicitStatements, isLineRequest: true), out _); - private static bool ShouldFormat(FormattingContext context, TextSpan mappingSpan, ShouldFormatOptions options, out SyntaxNode? foundOwner) + private static bool ShouldFormat(FormattingContext context, TextSpan mappingSpan, ShouldFormatOptions options, out RazorSyntaxNode? foundOwner) { // We should be called with the range of various C# SourceMappings. @@ -442,10 +437,10 @@ bool IsInBoundComponentAttributeName() return owner is MarkupTextLiteralSyntax { - Parent: MarkupTagHelperAttributeSyntax { TagHelperAttributeInfo: { Bound: true } } or - MarkupTagHelperDirectiveAttributeSyntax { TagHelperAttributeInfo: { Bound: true } } or - MarkupMinimizedTagHelperAttributeSyntax { TagHelperAttributeInfo: { Bound: true } } or - MarkupMinimizedTagHelperDirectiveAttributeSyntax { TagHelperAttributeInfo: { Bound: true } } + Parent: MarkupTagHelperAttributeSyntax { TagHelperAttributeInfo.Bound: true } or + MarkupTagHelperDirectiveAttributeSyntax { TagHelperAttributeInfo.Bound: true } or + MarkupMinimizedTagHelperAttributeSyntax { TagHelperAttributeInfo.Bound: true } or + MarkupMinimizedTagHelperDirectiveAttributeSyntax { TagHelperAttributeInfo.Bound: true } } && !options.IsLineRequest; } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpOnTypeFormattingPassBase.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpOnTypeFormattingPass.cs similarity index 88% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpOnTypeFormattingPassBase.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpOnTypeFormattingPass.cs index 8e17fba8298..f8025b7f8d3 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/CSharpOnTypeFormattingPassBase.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/CSharpOnTypeFormattingPass.cs @@ -12,46 +12,38 @@ using Microsoft.AspNetCore.Razor.Language.Legacy; using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.PooledObjects; -using Microsoft.AspNetCore.Razor.TextDifferencing; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.ExternalAccess.Razor; using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range; namespace Microsoft.CodeAnalysis.Razor.Formatting; -using SyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; - -internal abstract class CSharpOnTypeFormattingPassBase( +/// +/// Gets edits in C# files, and returns edits to Razor files, with nicely formatted Html +/// +internal sealed class CSharpOnTypeFormattingPass( IDocumentMappingService documentMappingService, ILoggerFactory loggerFactory) - : CSharpFormattingPassBase(documentMappingService) + : CSharpFormattingPassBase(documentMappingService, isFormatOnType: true) { - private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - public async override Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken) + public async override Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken) { - if (!context.IsFormatOnType || result.Kind != RazorLanguageKind.CSharp) - { - // We don't want to handle regular formatting or non-C# on type formatting here. - return result; - } - // Normalize and re-map the C# edits. var codeDocument = context.CodeDocument; var csharpText = codeDocument.GetCSharpSourceText(); - var textEdits = result.Edits; - if (textEdits.Length == 0) + if (edits.Length == 0) { if (!DocumentMappingService.TryMapToGeneratedDocumentPosition(codeDocument.GetCSharpDocument(), context.HostDocumentIndex, out _, out var projectedIndex)) { _logger.LogWarning($"Failed to map to projected position for document {context.Uri}."); - return result; + return edits; } // Ask C# for formatting changes. @@ -62,7 +54,7 @@ public async override Task ExecuteAsync(FormattingContext cont context.CSharpWorkspaceDocument, typedChar: context.TriggerCharacter, projectedIndex, - context.Options.GetIndentationOptions(), + context.Options.ToIndentationOptions(), autoFormattingOptions, indentStyle: CodeAnalysis.Formatting.FormattingOptions.IndentStyle.Smart, cancellationToken).ConfigureAwait(false); @@ -70,18 +62,18 @@ public async override Task ExecuteAsync(FormattingContext cont if (formattingChanges.IsEmpty) { _logger.LogInformation($"Received no results."); - return result; + return edits; } - textEdits = formattingChanges.Select(csharpText.GetTextEdit).ToArray(); - _logger.LogInformation($"Received {textEdits.Length} results from C#."); + edits = formattingChanges.Select(csharpText.GetTextEdit).ToArray(); + _logger.LogInformation($"Received {edits.Length} results from C#."); } // Sometimes the C# document is out of sync with our document, so Roslyn can return edits to us that will throw when we try // to normalize them. Instead of having this flow up and log a NFW, we just capture it here. Since this only happens when typing // very quickly, it is a safe assumption that we'll get another chance to do on type formatting, since we know the user is typing. // The proper fix for this is https://github.com/dotnet/razor-tooling/issues/6650 at which point this can be removed - foreach (var edit in textEdits) + foreach (var edit in edits) { var startLine = edit.Range.Start.Line; var endLine = edit.Range.End.Line; @@ -89,12 +81,12 @@ public async override Task ExecuteAsync(FormattingContext cont if (startLine >= count || endLine >= count) { _logger.LogWarning($"Got a bad edit that couldn't be applied. Edit is {startLine}-{endLine} but there are only {count} lines in C#."); - return result; + return edits; } } - var normalizedEdits = NormalizeTextEdits(csharpText, textEdits, out var originalTextWithChanges); - var mappedEdits = RemapTextEdits(codeDocument, normalizedEdits, result.Kind); + var normalizedEdits = csharpText.MinimizeTextEdits(edits, out var originalTextWithChanges); + var mappedEdits = RemapTextEdits(codeDocument, normalizedEdits); var filteredEdits = FilterCSharpTextEdits(context, mappedEdits); if (filteredEdits.Length == 0) { @@ -102,10 +94,9 @@ public async override Task ExecuteAsync(FormattingContext cont // because they are non mappable, but might be the only thing changed (eg from the Add Using code action) // // If there aren't any edits that are likely to contain using statement changes, this call will no-op. + filteredEdits = await AddUsingStatementEditsIfNecessaryAsync(context, codeDocument, csharpText, edits, originalTextWithChanges, filteredEdits, cancellationToken).ConfigureAwait(false); - filteredEdits = await AddUsingStatementEditsIfNecessaryAsync(context, codeDocument, csharpText, textEdits, originalTextWithChanges, filteredEdits, cancellationToken).ConfigureAwait(false); - - return new FormattingResult(filteredEdits); + return filteredEdits; } // Find the lines that were affected by these edits. @@ -203,12 +194,37 @@ public async override Task ExecuteAsync(FormattingContext cont var finalChanges = cleanedText.GetTextChanges(originalText); var finalEdits = finalChanges.Select(originalText.GetTextEdit).ToArray(); - finalEdits = await AddUsingStatementEditsIfNecessaryAsync(context, codeDocument, csharpText, textEdits, originalTextWithChanges, finalEdits, cancellationToken).ConfigureAwait(false); + finalEdits = await AddUsingStatementEditsIfNecessaryAsync(context, codeDocument, csharpText, edits, originalTextWithChanges, finalEdits, cancellationToken).ConfigureAwait(false); + + return finalEdits; + } + + private TextEdit[] RemapTextEdits(RazorCodeDocument codeDocument, TextEdit[] projectedTextEdits) + { + if (codeDocument.IsUnsupported()) + { + return []; + } + + var edits = DocumentMappingService.GetHostDocumentEdits(codeDocument.GetCSharpDocument(), projectedTextEdits); - return new FormattingResult(finalEdits); + return edits; } - protected abstract Task AddUsingStatementEditsIfNecessaryAsync(FormattingContext context, RazorCodeDocument codeDocument, SourceText csharpText, TextEdit[] textEdits, SourceText originalTextWithChanges, TextEdit[] finalEdits, CancellationToken cancellationToken); + private static async Task AddUsingStatementEditsIfNecessaryAsync(FormattingContext context, RazorCodeDocument codeDocument, SourceText csharpText, TextEdit[] textEdits, SourceText originalTextWithChanges, TextEdit[] finalEdits, CancellationToken cancellationToken) + { + if (context.AutomaticallyAddUsings) + { + // Because we need to parse the C# code twice for this operation, lets do a quick check to see if its even necessary + if (textEdits.Any(static e => e.NewText.IndexOf("using") != -1)) + { + var usingStatementEdits = await AddUsingsHelper.GetUsingStatementEditsAsync(codeDocument, csharpText, originalTextWithChanges, cancellationToken).ConfigureAwait(false); + finalEdits = [.. usingStatementEdits, .. finalEdits]; + } + } + + return finalEdits; + } // Returns the minimal TextSpan that encompasses all the differences between the old and the new text. private static SourceText ApplyChangesAndTrackChange(SourceText oldText, IEnumerable changes, out TextSpan spanBeforeChange, out TextSpan spanAfterChange) @@ -323,7 +339,7 @@ private static void CleanupSourceMappingStart(FormattingContext context, Range s if (owner is CSharpStatementLiteralSyntax && owner.TryGetPreviousSibling(out var prevNode) && - prevNode.FirstAncestorOrSelf(a => a is CSharpTemplateBlockSyntax) is { } template && + prevNode.FirstAncestorOrSelf(static a => a is CSharpTemplateBlockSyntax) is { } template && owner.SpanStart == template.Span.End && IsOnSingleLine(template, text)) { @@ -477,7 +493,7 @@ private static void CleanupSourceMappingEnd(FormattingContext context, Range sou if (owner is CSharpStatementLiteralSyntax && owner.NextSpan() is { } nextNode && - nextNode.FirstAncestorOrSelf(a => a is CSharpTemplateBlockSyntax) is { } template && + nextNode.FirstAncestorOrSelf(static a => a is CSharpTemplateBlockSyntax) is { } template && template.SpanStart == owner.Span.End && IsOnSingleLine(template, text)) { @@ -523,19 +539,10 @@ private static void CleanupSourceMappingEnd(FormattingContext context, Range sou changes.Add(change); } - private static bool IsOnSingleLine(SyntaxNode node, SourceText text) + private static bool IsOnSingleLine(RazorSyntaxNode node, SourceText text) { var linePositionSpan = text.GetLinePositionSpan(node.Span); return linePositionSpan.Start.Line == linePositionSpan.End.Line; } - - private static TextEdit[] NormalizeTextEdits(SourceText originalText, TextEdit[] edits, out SourceText originalTextWithChanges) - { - var changes = edits.Select(originalText.GetTextChange); - originalTextWithChanges = originalText.WithChanges(changes); - var cleanChanges = SourceTextDiffer.GetMinimalTextChanges(originalText, originalTextWithChanges, DiffKind.Char); - var cleanEdits = cleanChanges.Select(originalText.GetTextEdit).ToArray(); - return cleanEdits; - } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingContentValidationPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/FormattingContentValidationPass.cs similarity index 67% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingContentValidationPass.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/FormattingContentValidationPass.cs index 0b80da73d28..938dc9dd91f 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingContentValidationPass.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/FormattingContentValidationPass.cs @@ -5,39 +5,22 @@ using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Formatting; -internal class FormattingContentValidationPass( - IDocumentMappingService documentMappingService, - ILoggerFactory loggerFactory) - : FormattingPassBase(documentMappingService) +internal sealed class FormattingContentValidationPass(ILoggerFactory loggerFactory) : IFormattingPass { private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - // We want this to run at the very end. - public override int Order => DefaultOrder + 1000; - - public override bool IsValidationPass => true; - // Internal for testing. internal bool DebugAssertsEnabled { get; set; } = true; - public override Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken) + public Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken) { - if (result.Kind != RazorLanguageKind.Razor) - { - // We don't care about changes to projected documents here. - return Task.FromResult(result); - } - var text = context.SourceText; - var edits = result.Edits; var changes = edits.Select(text.GetTextChange); var changedText = text.WithChanges(changes); @@ -65,9 +48,9 @@ public override Task ExecuteAsync(FormattingContext context, F Debug.Fail("A formatting result was rejected because it was going to change non-whitespace content in the document."); } - return Task.FromResult(new FormattingResult([])); + return Task.FromResult([]); } - return Task.FromResult(result); + return Task.FromResult(edits); } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingDiagnosticValidationPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/FormattingDiagnosticValidationPass.cs similarity index 77% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingDiagnosticValidationPass.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/FormattingDiagnosticValidationPass.cs index 8515f5ff13b..c223878de25 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/FormattingDiagnosticValidationPass.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/FormattingDiagnosticValidationPass.cs @@ -8,40 +8,23 @@ using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.VisualStudio.LanguageServer.Protocol; namespace Microsoft.CodeAnalysis.Razor.Formatting; -internal class FormattingDiagnosticValidationPass( - IDocumentMappingService documentMappingService, - ILoggerFactory loggerFactory) - : FormattingPassBase(documentMappingService) +internal sealed class FormattingDiagnosticValidationPass(ILoggerFactory loggerFactory) : IFormattingPass { private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); - // We want this to run at the very end. - public override int Order => DefaultOrder + 1000; - - public override bool IsValidationPass => true; - // Internal for testing. internal bool DebugAssertsEnabled { get; set; } = true; - public async override Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken) + public async Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken) { - if (result.Kind != RazorLanguageKind.Razor) - { - // We don't care about changes to projected documents here. - return result; - } - var originalDiagnostics = context.CodeDocument.GetSyntaxTree().Diagnostics; var text = context.SourceText; - var edits = result.Edits; var changes = edits.Select(text.GetTextChange); var changedText = text.WithChanges(changes); var changedContext = await context.WithTextAsync(changedText).ConfigureAwait(false); @@ -72,10 +55,10 @@ public async override Task ExecuteAsync(FormattingContext cont Debug.Fail("A formatting result was rejected because the formatted text produced different diagnostics compared to the original text."); } - return new FormattingResult([]); + return []; } - return result; + return edits; } private class LocationIgnoringDiagnosticComparer : IEqualityComparer diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlFormattingPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlFormattingPass.cs new file mode 100644 index 00000000000..4cb84022a63 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlFormattingPass.cs @@ -0,0 +1,13 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using Microsoft.CodeAnalysis.Razor.Logging; + +namespace Microsoft.CodeAnalysis.Razor.Formatting; + +/// +/// Gets edits in Razor files, and returns edits to Razor files, with nicely formatted Html +/// +internal sealed class HtmlFormattingPass(ILoggerFactory loggerFactory) : HtmlFormattingPassBase(loggerFactory.GetOrCreateLogger()) +{ +} diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/HtmlFormattingPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlFormattingPassBase.cs similarity index 80% rename from src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/HtmlFormattingPass.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlFormattingPassBase.cs index 6289aba23cb..a6b073acc22 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/HtmlFormattingPass.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlFormattingPassBase.cs @@ -7,69 +7,34 @@ using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.Language.Syntax; -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.Protocol; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; -namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; +namespace Microsoft.CodeAnalysis.Razor.Formatting; -internal sealed class HtmlFormattingPass( - IDocumentMappingService documentMappingService, - IClientConnection clientConnection, - ILoggerFactory loggerFactory) - : FormattingPassBase(documentMappingService) +internal abstract class HtmlFormattingPassBase(ILogger logger) : IFormattingPass { - private readonly HtmlFormatter _htmlFormatter = new HtmlFormatter(clientConnection); - private readonly ILogger _logger = loggerFactory.GetOrCreateLogger(); + private readonly ILogger _logger = logger; - // We want this to run first because it uses the client HTML formatter. - public override int Order => DefaultOrder - 5; - - public override bool IsValidationPass => false; - - public async override Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken) + public virtual async Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken) { var originalText = context.SourceText; - TextEdit[] htmlEdits; - - if (context.IsFormatOnType && result.Kind == RazorLanguageKind.Html) - { - htmlEdits = await _htmlFormatter.FormatOnTypeAsync(context, cancellationToken).ConfigureAwait(false); - } - else if (!context.IsFormatOnType) - { - htmlEdits = await _htmlFormatter.FormatAsync(context, cancellationToken).ConfigureAwait(false); - } - else - { - // We don't want to handle on type formatting requests for other languages - return result; - } - var changedText = originalText; var changedContext = context; _logger.LogTestOnly($"Before HTML formatter:\r\n{changedText}"); - if (htmlEdits.Length > 0) + if (edits.Length > 0) { - var changes = htmlEdits.Select(originalText.GetTextChange); + var changes = edits.Select(originalText.GetTextChange); changedText = originalText.WithChanges(changes); // Create a new formatting context for the changed razor document. changedContext = await context.WithTextAsync(changedText).ConfigureAwait(false); _logger.LogTestOnly($"After normalizedEdits:\r\n{changedText}"); } - else if (context.IsFormatOnType) - { - // There are no HTML edits for us to apply. No op. - return new FormattingResult(htmlEdits); - } var indentationChanges = AdjustRazorIndentation(changedContext); if (indentationChanges.Count > 0) @@ -82,7 +47,7 @@ public async override Task ExecuteAsync(FormattingContext cont var finalChanges = changedText.GetTextChanges(originalText); var finalEdits = finalChanges.Select(originalText.GetTextEdit).ToArray(); - return new FormattingResult(finalEdits); + return finalEdits; } private static List AdjustRazorIndentation(FormattingContext context) diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlOnTypeFormattingPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlOnTypeFormattingPass.cs new file mode 100644 index 00000000000..e7cbf216806 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/HtmlOnTypeFormattingPass.cs @@ -0,0 +1,26 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading; +using System.Threading.Tasks; +using Microsoft.CodeAnalysis.Razor.Logging; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.Razor.Formatting; + +/// +/// Gets edits in Html files, and returns edits to Razor files, with nicely formatted Html +/// +internal sealed class HtmlOnTypeFormattingPass(ILoggerFactory loggerFactory) : HtmlFormattingPassBase(loggerFactory.GetOrCreateLogger()) +{ + public override Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken) + { + if (edits.Length == 0) + { + // There are no HTML edits for us to apply. No op. + return Task.FromResult([]); + } + + return base.ExecuteAsync(context, edits, cancellationToken); + } +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingPassBase.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/RazorFormattingPass.cs similarity index 81% rename from src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingPassBase.cs rename to src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/RazorFormattingPass.cs index 883538508d2..6eb7e036b44 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingPassBase.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/Passes/RazorFormattingPass.cs @@ -2,6 +2,7 @@ // 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.CodeAnalysis; using System.Linq; using System.Threading; @@ -11,41 +12,26 @@ using Microsoft.AspNetCore.Razor.Language.Components; using Microsoft.AspNetCore.Razor.Language.Extensions; using Microsoft.AspNetCore.Razor.Language.Syntax; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.AspNetCore.Razor.PooledObjects; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; - using RazorRazorSyntaxNodeList = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxList; +using RazorSyntaxNode = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxNode; using RazorSyntaxNodeList = Microsoft.AspNetCore.Razor.Language.Syntax.SyntaxList; namespace Microsoft.CodeAnalysis.Razor.Formatting; -using SyntaxNode = AspNetCore.Razor.Language.Syntax.SyntaxNode; - -internal abstract class RazorFormattingPassBase( - IDocumentMappingService documentMappingService) - : FormattingPassBase(documentMappingService) +internal sealed class RazorFormattingPass : IFormattingPass { - // Run after the C# formatter pass. - public override int Order => DefaultOrder - 4; - - public override bool IsValidationPass => false; - - public async override Task ExecuteAsync(FormattingContext context, FormattingResult result, CancellationToken cancellationToken) + public async Task ExecuteAsync(FormattingContext context, TextEdit[] edits, CancellationToken cancellationToken) { - if (context.IsFormatOnType) - { - // We don't want to handle OnTypeFormatting here. - return result; - } - // Apply previous edits if any. var originalText = context.SourceText; var changedText = originalText; var changedContext = context; - if (result.Edits.Length > 0) + if (edits.Length > 0) { - var changes = result.Edits.Select(originalText.GetTextChange).ToArray(); + var changes = edits.Select(originalText.GetTextChange); changedText = changedText.WithChanges(changes); changedContext = await context.WithTextAsync(changedText).ConfigureAwait(false); @@ -54,48 +40,46 @@ public async override Task ExecuteAsync(FormattingContext cont // Format the razor bits of the file var syntaxTree = changedContext.CodeDocument.GetSyntaxTree(); - var edits = FormatRazor(changedContext, syntaxTree); + var razorEdits = FormatRazor(changedContext, syntaxTree); // Compute the final combined set of edits - var formattingChanges = edits.Select(changedText.GetTextChange); + var formattingChanges = razorEdits.Select(changedText.GetTextChange); changedText = changedText.WithChanges(formattingChanges); var finalChanges = changedText.GetTextChanges(originalText); var finalEdits = finalChanges.Select(originalText.GetTextEdit).ToArray(); - return new FormattingResult(finalEdits); + return finalEdits; } - protected abstract bool CodeBlockBraceOnNextLine { get; } - - private IEnumerable FormatRazor(FormattingContext context, RazorSyntaxTree syntaxTree) + private static ImmutableArray FormatRazor(FormattingContext context, RazorSyntaxTree syntaxTree) { - var edits = new List(); + using var edits = new PooledArrayBuilder(); var source = syntaxTree.Source; foreach (var node in syntaxTree.Root.DescendantNodes()) { // Disclaimer: CSharpCodeBlockSyntax is used a _lot_ in razor so these methods are probably // being overly careful to only try to format syntax forms they care about. - TryFormatCSharpBlockStructure(context, edits, source, node); // TODO - TryFormatSingleLineDirective(edits, source, node); - TryFormatBlocks(context, edits, source, node); + TryFormatCSharpBlockStructure(context, ref edits.AsRef(), source, node); + TryFormatSingleLineDirective(ref edits.AsRef(), source, node); + TryFormatBlocks(context, ref edits.AsRef(), source, node); } - return edits; + return edits.ToImmutable(); } - private static void TryFormatBlocks(FormattingContext context, List edits, RazorSourceDocument source, SyntaxNode node) + private static void TryFormatBlocks(FormattingContext context, ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // We only want to run one of these - _ = TryFormatFunctionsBlock(context, edits, source, node) || - TryFormatCSharpExplicitTransition(context, edits, source, node) || - TryFormatHtmlInCSharp(context, edits, source, node) || - TryFormatComplexCSharpBlock(context, edits, source, node) || - TryFormatSectionBlock(context, edits, source, node); + _ = TryFormatFunctionsBlock(context, ref edits, source, node) || + TryFormatCSharpExplicitTransition(context, ref edits, source, node) || + TryFormatHtmlInCSharp(context, ref edits, source, node) || + TryFormatComplexCSharpBlock(context, ref edits, source, node) || + TryFormatSectionBlock(context, ref edits, source, node); } - private static bool TryFormatSectionBlock(FormattingContext context, List edits, RazorSourceDocument source, SyntaxNode node) + private static bool TryFormatSectionBlock(FormattingContext context, ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // @section Goo { // } @@ -114,8 +98,8 @@ directiveCode.Children is [RazorDirectiveSyntax directive] && if (TryGetWhitespace(children, out var whitespaceBeforeSectionName, out var whitespaceAfterSectionName)) { // For whitespace we normalize it differently depending on if its multi-line or not - FormatWhitespaceBetweenDirectiveAndBrace(whitespaceBeforeSectionName, directive, edits, source, context, forceNewLine: false); - FormatWhitespaceBetweenDirectiveAndBrace(whitespaceAfterSectionName, directive, edits, source, context, forceNewLine: false); + FormatWhitespaceBetweenDirectiveAndBrace(whitespaceBeforeSectionName, directive, ref edits, source, context, forceNewLine: false); + FormatWhitespaceBetweenDirectiveAndBrace(whitespaceAfterSectionName, directive, ref edits, source, context, forceNewLine: false); return true; } @@ -152,7 +136,7 @@ children[2] is UnclassifiedTextLiteralSyntax after && } } - private static bool TryFormatFunctionsBlock(FormattingContext context, IList edits, RazorSourceDocument source, SyntaxNode node) + private static bool TryFormatFunctionsBlock(FormattingContext context, ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // @functions // { @@ -184,13 +168,13 @@ private static bool TryFormatFunctionsBlock(FormattingContext context, IList edits, RazorSourceDocument source, SyntaxNode node) + private static bool TryFormatCSharpExplicitTransition(FormattingContext context, ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // We're looking for a code block like this: // @@ -205,13 +189,13 @@ private static bool TryFormatCSharpExplicitTransition(FormattingContext context, var codeNode = csharpStatementBody.CSharpCode; var closeBraceNode = csharpStatementBody.CloseBrace; - return FormatBlock(context, source, directiveNode: null, openBraceNode, codeNode, closeBraceNode, edits); + return FormatBlock(context, source, directiveNode: null, openBraceNode, codeNode, closeBraceNode, ref edits); } return false; } - private static bool TryFormatComplexCSharpBlock(FormattingContext context, IList edits, RazorSourceDocument source, SyntaxNode node) + private static bool TryFormatComplexCSharpBlock(FormattingContext context, ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // complex situations like // @{ @@ -227,13 +211,13 @@ csharpRazorBlock.Parent is CSharpCodeBlockSyntax innerCodeBlock && var openBraceNode = outerCodeBlock.Children.PreviousSiblingOrSelf(innerCodeBlock); var closeBraceNode = outerCodeBlock.Children.NextSiblingOrSelf(innerCodeBlock); - return FormatBlock(context, source, directiveNode: null, openBraceNode, codeNode, closeBraceNode, edits); + return FormatBlock(context, source, directiveNode: null, openBraceNode, codeNode, closeBraceNode, ref edits); } return false; } - private static bool TryFormatHtmlInCSharp(FormattingContext context, IList edits, RazorSourceDocument source, SyntaxNode node) + private static bool TryFormatHtmlInCSharp(FormattingContext context, ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // void Method() // { @@ -245,13 +229,13 @@ private static bool TryFormatHtmlInCSharp(FormattingContext context, IList edits, RazorSourceDocument source, SyntaxNode node) + private static void TryFormatCSharpBlockStructure(FormattingContext context, ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // We're looking for a code block like this: // @@ -271,7 +255,7 @@ private void TryFormatCSharpBlockStructure(FormattingContext context, List edits, RazorSourceDocument source, SyntaxNode node) + private static void TryFormatSingleLineDirective(ref PooledArrayBuilder edits, RazorSourceDocument source, RazorSyntaxNode node) { // Looking for single line directives like // @@ -327,12 +311,12 @@ private static void TryFormatSingleLineDirective(List edits, RazorSour { if (child.ContainsOnlyWhitespace(includingNewLines: false)) { - ShrinkToSingleSpace(child, edits, source); + ShrinkToSingleSpace(child, ref edits, source); } } } - static bool IsSingleLineDirective(SyntaxNode node, out RazorSyntaxNodeList children) + static bool IsSingleLineDirective(RazorSyntaxNode node, out RazorSyntaxNodeList children) { if (node is CSharpCodeBlockSyntax content && node.Parent?.Parent is RazorDirectiveSyntax directive && @@ -347,11 +331,11 @@ static bool IsSingleLineDirective(SyntaxNode node, out RazorSyntaxNodeList child } } - private static void FormatWhitespaceBetweenDirectiveAndBrace(SyntaxNode node, RazorDirectiveSyntax directive, List edits, RazorSourceDocument source, FormattingContext context, bool forceNewLine) + private static void FormatWhitespaceBetweenDirectiveAndBrace(RazorSyntaxNode node, RazorDirectiveSyntax directive, ref PooledArrayBuilder edits, RazorSourceDocument source, FormattingContext context, bool forceNewLine) { if (node.ContainsOnlyWhitespace(includingNewLines: false) && !forceNewLine) { - ShrinkToSingleSpace(node, edits, source); + ShrinkToSingleSpace(node, ref edits, source); } else { @@ -366,7 +350,7 @@ private static void FormatWhitespaceBetweenDirectiveAndBrace(SyntaxNode node, Ra } } - private static void ShrinkToSingleSpace(SyntaxNode node, List edits, RazorSourceDocument source) + private static void ShrinkToSingleSpace(RazorSyntaxNode node, ref PooledArrayBuilder edits, RazorSourceDocument source) { // If there is anything other than one single space then we replace with one space between directive and brace. // @@ -375,7 +359,7 @@ private static void ShrinkToSingleSpace(SyntaxNode node, List edits, R edits.Add(edit); } - private static bool FormatBlock(FormattingContext context, RazorSourceDocument source, SyntaxNode? directiveNode, SyntaxNode openBraceNode, SyntaxNode codeNode, SyntaxNode closeBraceNode, IList edits) + private static bool FormatBlock(FormattingContext context, RazorSourceDocument source, RazorSyntaxNode? directiveNode, RazorSyntaxNode openBraceNode, RazorSyntaxNode codeNode, RazorSyntaxNode closeBraceNode, ref PooledArrayBuilder edits) { var didFormat = false; @@ -385,7 +369,7 @@ private static bool FormatBlock(FormattingContext context, RazorSourceDocument s if (openBraceRange is not null && codeRange is not null && openBraceRange.End.Line == codeRange.Start.Line && - !RangeHasBeenModified(edits, codeRange)) + !RangeHasBeenModified(ref edits, codeRange)) { var additionalIndentationLevel = GetAdditionalIndentationLevel(context, openBraceRange, openBraceNode, codeNode); var newText = context.NewLineString; @@ -402,7 +386,7 @@ codeRange is not null && var closeBraceRange = closeBraceNode.GetRangeWithoutWhitespace(source); if (codeRange is not null && closeBraceRange is not null && - !RangeHasBeenModified(edits, codeRange)) + !RangeHasBeenModified(ref edits, codeRange)) { if (directiveNode is not null && directiveNode.GetRange(source).Start.Character < closeBraceRange.Start.Character) @@ -427,7 +411,7 @@ closeBraceRange is not null && return didFormat; - static bool RangeHasBeenModified(IList edits, Range range) + static bool RangeHasBeenModified(ref readonly PooledArrayBuilder edits, Range range) { // Because we don't always know what kind of Razor object we're operating on we have to do this to avoid duplicate edits. // The other way to accomplish this would be to apply the edits after every node and function, but that's not in scope for my current work. @@ -436,7 +420,7 @@ static bool RangeHasBeenModified(IList edits, Range range) return hasBeenModified; } - static int GetAdditionalIndentationLevel(FormattingContext context, Range range, SyntaxNode openBraceNode, SyntaxNode codeNode) + static int GetAdditionalIndentationLevel(FormattingContext context, Range range, RazorSyntaxNode openBraceNode, RazorSyntaxNode codeNode) { if (!context.TryGetIndentationLevel(codeNode.Position, out var desiredIndentationLevel)) { @@ -451,7 +435,7 @@ static int GetAdditionalIndentationLevel(FormattingContext context, Range range, return desiredIndentationOffset - currentIndentationOffset; - static int GetLeadingWhitespaceLength(SyntaxNode node, FormattingContext context) + static int GetLeadingWhitespaceLength(RazorSyntaxNode node, FormattingContext context) { var tokens = node.GetTokens(); var whitespaceLength = 0; @@ -483,7 +467,7 @@ static int GetLeadingWhitespaceLength(SyntaxNode node, FormattingContext context return whitespaceLength; } - static int GetTrailingWhitespaceLength(SyntaxNode node, FormattingContext context) + static int GetTrailingWhitespaceLength(RazorSyntaxNode node, FormattingContext context) { var tokens = node.GetTokens(); var whitespaceLength = 0; diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingOptions.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingOptions.cs new file mode 100644 index 00000000000..a0397ec80f5 --- /dev/null +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingOptions.cs @@ -0,0 +1,36 @@ +// 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.ExternalAccess.Razor; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.CodeAnalysis.Razor.Formatting; + +internal readonly record struct RazorFormattingOptions +{ + public static readonly RazorFormattingOptions Default = new(); + + public bool InsertSpaces { get; init; } = true; + public int TabSize { get; init; } = 4; + public bool CodeBlockBraceOnNextLine { get; init; } = false; + + public RazorFormattingOptions() + { + } + + public static RazorFormattingOptions From(FormattingOptions options, bool codeBlockBraceOnNextLine) + { + return new RazorFormattingOptions() + { + InsertSpaces = options.InsertSpaces, + TabSize = options.TabSize, + CodeBlockBraceOnNextLine = codeBlockBraceOnNextLine + }; + } + + public RazorIndentationOptions ToIndentationOptions() + => new( + UseTabs: !InsertSpaces, + TabSize: TabSize, + IndentationSize: TabSize); +} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingService.cs index 15ef27643c9..d9f012ff881 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Razor.Workspaces/Formatting/RazorFormattingService.cs @@ -1,16 +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; using System.Collections.Generic; +using System.Collections.Immutable; using System.Linq; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.TextDifferencing; using Microsoft.CodeAnalysis; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; +using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Razor.Workspaces; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; @@ -20,29 +20,44 @@ namespace Microsoft.CodeAnalysis.Razor.Formatting; internal class RazorFormattingService : IRazorFormattingService { - private readonly List _formattingPasses; private readonly IFormattingCodeDocumentProvider _codeDocumentProvider; private readonly IAdhocWorkspaceFactory _workspaceFactory; + private readonly ImmutableArray _documentFormattingPasses; + private readonly ImmutableArray _validationPasses; + private readonly CSharpOnTypeFormattingPass _csharpOnTypeFormattingPass; + private readonly HtmlOnTypeFormattingPass _htmlOnTypeFormattingPass; + public RazorFormattingService( - IEnumerable formattingPasses, IFormattingCodeDocumentProvider codeDocumentProvider, - IAdhocWorkspaceFactory workspaceFactory) + IDocumentMappingService documentMappingService, + IAdhocWorkspaceFactory workspaceFactory, + ILoggerFactory loggerFactory) { - if (formattingPasses is null) - { - throw new ArgumentNullException(nameof(formattingPasses)); - } - - _formattingPasses = formattingPasses.OrderBy(f => f.Order).ToList(); - _codeDocumentProvider = codeDocumentProvider ?? throw new ArgumentNullException(nameof(codeDocumentProvider)); - _workspaceFactory = workspaceFactory ?? throw new ArgumentNullException(nameof(workspaceFactory)); + _codeDocumentProvider = codeDocumentProvider; + _workspaceFactory = workspaceFactory; + + _htmlOnTypeFormattingPass = new HtmlOnTypeFormattingPass(loggerFactory); + _csharpOnTypeFormattingPass = new CSharpOnTypeFormattingPass(documentMappingService, loggerFactory); + _validationPasses = + [ + new FormattingDiagnosticValidationPass(loggerFactory), + new FormattingContentValidationPass(loggerFactory) + ]; + _documentFormattingPasses = + [ + new HtmlFormattingPass(loggerFactory), + new RazorFormattingPass(), + new CSharpFormattingPass(documentMappingService, loggerFactory), + .. _validationPasses + ]; } - public async Task FormatAsync( + public async Task GetDocumentFormattingEditsAsync( DocumentContext documentContext, + TextEdit[] htmlEdits, Range? range, - FormattingOptions options, + RazorFormattingOptions options, CancellationToken cancellationToken) { var codeDocument = await _codeDocumentProvider.GetCodeDocumentAsync(documentContext.Snapshot).ConfigureAwait(false); @@ -82,85 +97,108 @@ public async Task FormatAsync( _workspaceFactory); var originalText = context.SourceText; - var result = new FormattingResult([]); - foreach (var pass in _formattingPasses) + var result = htmlEdits; + foreach (var pass in _documentFormattingPasses) { cancellationToken.ThrowIfCancellationRequested(); result = await pass.ExecuteAsync(context, result, cancellationToken).ConfigureAwait(false); } var filteredEdits = range is null - ? result.Edits - : result.Edits.Where(e => range.LineOverlapsWith(e.Range)); + ? result + : result.Where(e => range.LineOverlapsWith(e.Range)).ToArray(); - return GetMinimalEdits(originalText, filteredEdits); + return originalText.MinimizeTextEdits(filteredEdits); } - private static TextEdit[] GetMinimalEdits(SourceText originalText, IEnumerable filteredEdits) - { - // Make sure the edits actually change something, or its not worth responding - var textChanges = filteredEdits.Select(originalText.GetTextChange); - var changedText = originalText.WithChanges(textChanges); - if (changedText.ContentEquals(originalText)) - { - return Array.Empty(); - } + public Task GetCSharpOnTypeFormattingEditsAsync(DocumentContext documentContext, RazorFormattingOptions options, int hostDocumentIndex, char triggerCharacter, CancellationToken cancellationToken) + => ApplyFormattedEditsAsync( + documentContext, + generatedDocumentEdits: [], + options, + hostDocumentIndex, + triggerCharacter, + [_csharpOnTypeFormattingPass, .. _validationPasses], + collapseEdits: false, + automaticallyAddUsings: false, + cancellationToken: cancellationToken); - // Only send back the minimum edits - var minimalChanges = SourceTextDiffer.GetMinimalTextChanges(originalText, changedText, DiffKind.Char); - var finalEdits = minimalChanges.Select(originalText.GetTextEdit).ToArray(); + public Task GetHtmlOnTypeFormattingEditsAsync(DocumentContext documentContext, TextEdit[] htmlEdits, RazorFormattingOptions options, int hostDocumentIndex, char triggerCharacter, CancellationToken cancellationToken) + => ApplyFormattedEditsAsync( + documentContext, + htmlEdits, + options, + hostDocumentIndex, + triggerCharacter, + [_htmlOnTypeFormattingPass, .. _validationPasses], + collapseEdits: false, + automaticallyAddUsings: false, + cancellationToken: cancellationToken); - return finalEdits; + public async Task GetSingleCSharpEditAsync(DocumentContext documentContext, TextEdit csharpEdit, RazorFormattingOptions options, CancellationToken cancellationToken) + { + var razorEdits = await ApplyFormattedEditsAsync( + documentContext, + [csharpEdit], + options, + hostDocumentIndex: 0, + triggerCharacter: '\0', + [_csharpOnTypeFormattingPass, .. _validationPasses], + collapseEdits: false, + automaticallyAddUsings: false, + cancellationToken: cancellationToken).ConfigureAwait(false); + return razorEdits.SingleOrDefault(); } - public Task FormatOnTypeAsync(DocumentContext documentContext, RazorLanguageKind kind, TextEdit[] formattedEdits, FormattingOptions options, int hostDocumentIndex, char triggerCharacter, CancellationToken cancellationToken) - => ApplyFormattedEditsAsync(documentContext, kind, formattedEdits, options, hostDocumentIndex, triggerCharacter, bypassValidationPasses: false, collapseEdits: false, automaticallyAddUsings: false, cancellationToken: cancellationToken); - - public Task FormatCodeActionAsync(DocumentContext documentContext, RazorLanguageKind kind, TextEdit[] formattedEdits, FormattingOptions options, CancellationToken cancellationToken) - => ApplyFormattedEditsAsync(documentContext, kind, formattedEdits, options, hostDocumentIndex: 0, triggerCharacter: '\0', bypassValidationPasses: true, collapseEdits: false, automaticallyAddUsings: true, cancellationToken: cancellationToken); + public async Task GetCSharpCodeActionEditAsync(DocumentContext documentContext, TextEdit[] csharpEdits, RazorFormattingOptions options, CancellationToken cancellationToken) + { + var razorEdits = await ApplyFormattedEditsAsync( + documentContext, + csharpEdits, + options, + hostDocumentIndex: 0, + triggerCharacter: '\0', + [_csharpOnTypeFormattingPass], + collapseEdits: true, + automaticallyAddUsings: true, + cancellationToken: cancellationToken).ConfigureAwait(false); + return razorEdits.SingleOrDefault(); + } - public async Task FormatSnippetAsync(DocumentContext documentContext, RazorLanguageKind kind, TextEdit[] edits, FormattingOptions options, CancellationToken cancellationToken) + public async Task GetCSharpSnippetFormattingEditAsync(DocumentContext documentContext, TextEdit[] csharpEdits, RazorFormattingOptions options, CancellationToken cancellationToken) { - if (kind == RazorLanguageKind.CSharp) - { - WrapCSharpSnippets(edits); - } + WrapCSharpSnippets(csharpEdits); - var formattedEdits = await ApplyFormattedEditsAsync( + var razorEdits = await ApplyFormattedEditsAsync( documentContext, - kind, - edits, + csharpEdits, options, hostDocumentIndex: 0, triggerCharacter: '\0', - bypassValidationPasses: true, + [_csharpOnTypeFormattingPass], collapseEdits: true, automaticallyAddUsings: false, cancellationToken: cancellationToken).ConfigureAwait(false); - if (kind == RazorLanguageKind.CSharp) - { - UnwrapCSharpSnippets(formattedEdits); - } + UnwrapCSharpSnippets(razorEdits); - return formattedEdits; + return razorEdits.SingleOrDefault(); } private async Task ApplyFormattedEditsAsync( DocumentContext documentContext, - RazorLanguageKind kind, - TextEdit[] formattedEdits, - FormattingOptions options, + TextEdit[] generatedDocumentEdits, + RazorFormattingOptions options, int hostDocumentIndex, char triggerCharacter, - bool bypassValidationPasses, + ImmutableArray formattingPasses, bool collapseEdits, bool automaticallyAddUsings, CancellationToken cancellationToken) { // If we only received a single edit, let's always return a single edit back. // Otherwise, merge only if explicitly asked. - collapseEdits |= formattedEdits.Length == 1; + collapseEdits |= generatedDocumentEdits.Length == 1; var documentSnapshot = documentContext.Snapshot; var uri = documentContext.Uri; @@ -175,35 +213,30 @@ private async Task ApplyFormattedEditsAsync( automaticallyAddUsings: automaticallyAddUsings, hostDocumentIndex, triggerCharacter); - var result = new FormattingResult(formattedEdits, kind); + var result = generatedDocumentEdits; - foreach (var pass in _formattingPasses) + foreach (var pass in formattingPasses) { - if (pass.IsValidationPass && bypassValidationPasses) - { - continue; - } - cancellationToken.ThrowIfCancellationRequested(); result = await pass.ExecuteAsync(context, result, cancellationToken).ConfigureAwait(false); } var originalText = context.SourceText; - var edits = GetMinimalEdits(originalText, result.Edits); + var razorEdits = originalText.MinimizeTextEdits(result); if (collapseEdits) { - var collapsedEdit = MergeEdits(edits, originalText); + var collapsedEdit = MergeEdits(razorEdits, originalText); if (collapsedEdit.NewText.Length == 0 && collapsedEdit.Range.IsZeroWidth()) { - return Array.Empty(); + return []; } - return new[] { collapsedEdit }; + return [collapsedEdit]; } - return edits; + return razorEdits; } // Internal for testing @@ -214,14 +247,7 @@ internal static TextEdit MergeEdits(TextEdit[] edits, SourceText sourceText) return edits[0]; } - var textChanges = new List(); - foreach (var edit in edits) - { - var change = new TextChange(sourceText.GetTextSpan(edit.Range), edit.NewText); - textChanges.Add(change); - } - - var changedText = sourceText.WithChanges(textChanges); + var changedText = sourceText.WithChanges(edits.Select(sourceText.GetTextChange)); var affectedRange = changedText.GetEncompassingTextChangeRange(sourceText); var spanBeforeChange = affectedRange.Span; var spanAfterChange = new TextSpan(spanBeforeChange.Start, affectedRange.NewLength); @@ -232,30 +258,25 @@ internal static TextEdit MergeEdits(TextEdit[] edits, SourceText sourceText) return sourceText.GetTextEdit(encompassingChange); } - private static void WrapCSharpSnippets(TextEdit[] snippetEdits) + private static void WrapCSharpSnippets(TextEdit[] csharpEdits) { // Currently this method only supports wrapping `$0`, any additional markers aren't formatted properly. - for (var i = 0; i < snippetEdits.Length; i++) + foreach (var edit in csharpEdits) { - var snippetEdit = snippetEdits[i]; - // Formatting doesn't work with syntax errors caused by the cursor marker ($0). // So, let's avoid the error by wrapping the cursor marker in a comment. - var wrappedText = snippetEdit.NewText.Replace("$0", "/*$0*/"); - snippetEdit.NewText = wrappedText; + edit.NewText = edit.NewText.Replace("$0", "/*$0*/"); } } - private static void UnwrapCSharpSnippets(TextEdit[] snippetEdits) + private static void UnwrapCSharpSnippets(TextEdit[] razorEdits) { - for (var i = 0; i < snippetEdits.Length; i++) + foreach (var edit in razorEdits) { - var snippetEdit = snippetEdits[i]; - - // Unwrap the cursor marker. - var unwrappedText = snippetEdit.NewText.Replace("/*$0*/", "$0"); - snippetEdit.NewText = unwrappedText; + // Formatting doesn't work with syntax errors caused by the cursor marker ($0). + // So, let's avoid the error by wrapping the cursor marker in a comment. + edit.NewText = edit.NewText.Replace("/*$0*/", "$0"); } } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/AutoInsert/RemoteAutoInsertService.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/AutoInsert/RemoteAutoInsertService.cs index 4c009c6b7ad..9793c86b68e 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/AutoInsert/RemoteAutoInsertService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/AutoInsert/RemoteAutoInsertService.cs @@ -19,7 +19,6 @@ using Response = Microsoft.CodeAnalysis.Razor.Remote.RemoteResponse; using RoslynFormattingOptions = Roslyn.LanguageServer.Protocol.FormattingOptions; using RoslynInsertTextFormat = Roslyn.LanguageServer.Protocol.InsertTextFormat; -using VsLspFormattingOptions = Microsoft.VisualStudio.LanguageServer.Protocol.FormattingOptions; namespace Microsoft.CodeAnalysis.Remote.Razor; @@ -171,7 +170,7 @@ private async ValueTask TryResolveInsertionInCSharpAsync( return Response.NoFurtherHandling; } - var razorFormattingOptions = new VsLspFormattingOptions() + var razorFormattingOptions = new RazorFormattingOptions() { InsertSpaces = !indentWithTabs, TabSize = indentSize @@ -180,33 +179,29 @@ private async ValueTask TryResolveInsertionInCSharpAsync( var vsLspTextEdit = VsLspFactory.CreateTextEdit( autoInsertResponseItem.TextEdit.Range.ToLinePositionSpan(), autoInsertResponseItem.TextEdit.NewText); - var mappedEdits = autoInsertResponseItem.TextEditFormat == RoslynInsertTextFormat.Snippet - ? await _razorFormattingService.FormatSnippetAsync( + var mappedEdit = autoInsertResponseItem.TextEditFormat == RoslynInsertTextFormat.Snippet + ? await _razorFormattingService.GetCSharpSnippetFormattingEditAsync( remoteDocumentContext, - RazorLanguageKind.CSharp, [vsLspTextEdit], razorFormattingOptions, cancellationToken) .ConfigureAwait(false) - : await _razorFormattingService.FormatOnTypeAsync( + : await _razorFormattingService.GetSingleCSharpEditAsync( remoteDocumentContext, - RazorLanguageKind.CSharp, - [vsLspTextEdit], + vsLspTextEdit, razorFormattingOptions, - hostDocumentIndex: 0, - triggerCharacter: '\0', cancellationToken) .ConfigureAwait(false); - if (mappedEdits is not [{ } edit]) + if (mappedEdit is null) { return Response.NoFurtherHandling; } return Response.Results( new RemoteAutoInsertTextEdit( - edit.Range.ToLinePositionSpan(), - edit.NewText, + mappedEdit.Range.ToLinePositionSpan(), + mappedEdit.NewText, autoInsertResponseItem.TextEditFormat)); } } diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteCSharpOnTypeFormattingPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteCSharpOnTypeFormattingPass.cs deleted file mode 100644 index 638aa40b213..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteCSharpOnTypeFormattingPass.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Composition; -using System.Threading; -using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.Formatting; -using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Text; -using Microsoft.VisualStudio.LanguageServer.Protocol; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; - -[Export(typeof(IFormattingPass)), Shared] -[method: ImportingConstructor] -internal sealed class RemoteCSharpOnTypeFormattingPass( - IDocumentMappingService documentMappingService, - ILoggerFactory loggerFactory) - : CSharpOnTypeFormattingPassBase(documentMappingService, loggerFactory) -{ - protected override Task AddUsingStatementEditsIfNecessaryAsync(CodeAnalysis.Razor.Formatting.FormattingContext context, RazorCodeDocument codeDocument, SourceText csharpText, TextEdit[] textEdits, SourceText originalTextWithChanges, TextEdit[] finalEdits, CancellationToken cancellationToken) - { - // Implement this when code actions are migrated to cohosting, - // probably will be able to move it back into base class and make that non-abstract. - - return Task.FromResult(finalEdits); - } -} diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingPasses.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingPasses.cs deleted file mode 100644 index 2ca3bda8a99..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteFormattingPasses.cs +++ /dev/null @@ -1,30 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Composition; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.Formatting; -using Microsoft.CodeAnalysis.Razor.Logging; - -namespace Microsoft.CodeAnalysis.Remote.Razor.Formatting; - -[Export(typeof(IFormattingPass)), Shared] -[method: ImportingConstructor] -internal sealed class RemoteCSharpFormattingPass( - IDocumentMappingService documentMappingService, - ILoggerFactory loggerFactory) - : CSharpFormattingPass(documentMappingService, loggerFactory); - -[Export(typeof(IFormattingPass)), Shared] -[method: ImportingConstructor] -internal sealed class RemoteFormattingContentValidationPass( - IDocumentMappingService documentMappingService, - ILoggerFactory loggerFactory) - : FormattingContentValidationPass(documentMappingService, loggerFactory); - -[Export(typeof(IFormattingPass)), Shared] -[method: ImportingConstructor] -internal sealed class RemoteFormattingDiagnosticValidationPass( - IDocumentMappingService documentMappingService, - ILoggerFactory loggerFactory) - : FormattingDiagnosticValidationPass(documentMappingService, loggerFactory); diff --git a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingPass.cs b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingPass.cs deleted file mode 100644 index 0347e5591c9..00000000000 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingPass.cs +++ /dev/null @@ -1,18 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System.Composition; -using Microsoft.CodeAnalysis.Razor.DocumentMapping; -using Microsoft.CodeAnalysis.Razor.Formatting; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; - -[Export(typeof(IFormattingPass)), Shared] -[method: ImportingConstructor] -internal sealed class RemoteRazorFormattingPass( - IDocumentMappingService documentMappingService) - : RazorFormattingPassBase(documentMappingService) -{ - // TODO: properly plumb this through - protected override bool CodeBlockBraceOnNextLine => false; -} 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 bff121bd4c0..7d9344fe3d0 100644 --- a/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingService.cs +++ b/src/Razor/src/Microsoft.CodeAnalysis.Remote.Razor/Formatting/RemoteRazorFormattingService.cs @@ -1,22 +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.Collections.Generic; using System.Composition; +using Microsoft.CodeAnalysis.Razor.DocumentMapping; using Microsoft.CodeAnalysis.Razor.Formatting; +using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.Workspaces; namespace Microsoft.CodeAnalysis.Remote.Razor.Formatting; [Export(typeof(IRazorFormattingService)), Shared] [method: ImportingConstructor] -internal class RemoteRazorFormattingService( - [ImportMany] IEnumerable formattingPasses, - IFormattingCodeDocumentProvider codeDocumentProvider, - IAdhocWorkspaceFactory adhocWorkspaceFactory) - : RazorFormattingService( - formattingPasses, - codeDocumentProvider, - adhocWorkspaceFactory) +internal class RemoteRazorFormattingService(IFormattingCodeDocumentProvider codeDocumentProvider, IDocumentMappingService documentMappingService, IAdhocWorkspaceFactory adhocWorkspaceFactory, ILoggerFactory loggerFactory) + : RazorFormattingService(codeDocumentProvider, documentMappingService, adhocWorkspaceFactory, loggerFactory) { } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/AddUsingsCodeActionProviderFactoryTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/AddUsingsCodeActionProviderFactoryTest.cs deleted file mode 100644 index 5ce053f830a..00000000000 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/AddUsingsCodeActionProviderFactoryTest.cs +++ /dev/null @@ -1,114 +0,0 @@ -// Copyright (c) .NET Foundation. All rights reserved. -// Licensed under the MIT license. See License.txt in the project root for license information. - -using System; -using Microsoft.AspNetCore.Razor.Test.Common; -using Xunit; -using Xunit.Abstractions; - -namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions; - -public class AddUsingsCodeActionProviderFactoryTest(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) -{ - [Fact] - public void GetNamespaceFromFQN_Invalid_ReturnsEmpty() - { - // Arrange - var fqn = "Abc"; - - // Act - var namespaceName = AddUsingsCodeActionProviderHelper.GetNamespaceFromFQN(fqn); - - // Assert - Assert.Empty(namespaceName); - } - - [Fact] - public void GetNamespaceFromFQN_Valid_ReturnsNamespace() - { - // Arrange - var fqn = "Abc.Xyz"; - - // Act - var namespaceName = AddUsingsCodeActionProviderHelper.GetNamespaceFromFQN(fqn); - - // Assert - Assert.Equal("Abc", namespaceName); - } - - [Fact] - public void TryCreateAddUsingResolutionParams_CreatesResolutionParams() - { - // Arrange - var fqn = "Abc.Xyz"; - var docUri = new Uri("c:/path"); - - // Act - var result = AddUsingsCodeActionProviderHelper.TryCreateAddUsingResolutionParams(fqn, docUri, additionalEdit: null, out var @namespace, out var resolutionParams); - - // Assert - Assert.True(result); - Assert.Equal("Abc", @namespace); - Assert.NotNull(resolutionParams); - } - - [Fact] - public void TryExtractNamespace_Invalid_ReturnsFalse() - { - // Arrange - var csharpAddUsing = "Abc.Xyz;"; - - // Act - var res = AddUsingsCodeActionProviderHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); - - // Assert - Assert.False(res); - Assert.Empty(@namespace); - Assert.Empty(prefix); - } - - [Fact] - public void TryExtractNamespace_ReturnsTrue() - { - // Arrange - var csharpAddUsing = "using Abc.Xyz;"; - - // Act - var res = AddUsingsCodeActionProviderHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); - - // Assert - Assert.True(res); - Assert.Equal("Abc.Xyz", @namespace); - Assert.Empty(prefix); - } - - [Fact] - public void TryExtractNamespace_WithStatic_ReturnsTrue() - { - // Arrange - var csharpAddUsing = "using static X.Y.Z;"; - - // Act - var res = AddUsingsCodeActionProviderHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); - - // Assert - Assert.True(res); - Assert.Equal("static X.Y.Z", @namespace); - Assert.Empty(prefix); - } - - [Fact] - public void TryExtractNamespace_WithTypeNameCorrection_ReturnsTrue() - { - // Arrange - var csharpAddUsing = "Goo - using X.Y.Z;"; - - // Act - var res = AddUsingsCodeActionProviderHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); - - // Assert - Assert.True(res); - Assert.Equal("X.Y.Z", @namespace); - Assert.Equal("Goo - ", prefix); - } -} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/AddUsingsCodeActionResolverTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/AddUsingsCodeActionResolverTest.cs index 7d22fc8ee82..2808f79a5f3 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/AddUsingsCodeActionResolverTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/AddUsingsCodeActionResolverTest.cs @@ -20,6 +20,48 @@ public class AddUsingsCodeActionResolverTest(ITestOutputHelper testOutput) : Lan { private readonly IDocumentContextFactory _emptyDocumentContextFactory = new TestDocumentContextFactory(); + [Fact] + public void GetNamespaceFromFQN_Invalid_ReturnsEmpty() + { + // Arrange + var fqn = "Abc"; + + // Act + var namespaceName = AddUsingsCodeActionResolver.GetNamespaceFromFQN(fqn); + + // Assert + Assert.Empty(namespaceName); + } + + [Fact] + public void GetNamespaceFromFQN_Valid_ReturnsNamespace() + { + // Arrange + var fqn = "Abc.Xyz"; + + // Act + var namespaceName = AddUsingsCodeActionResolver.GetNamespaceFromFQN(fqn); + + // Assert + Assert.Equal("Abc", namespaceName); + } + + [Fact] + public void TryCreateAddUsingResolutionParams_CreatesResolutionParams() + { + // Arrange + var fqn = "Abc.Xyz"; + var docUri = new Uri("c:/path"); + + // Act + var result = AddUsingsCodeActionResolver.TryCreateAddUsingResolutionParams(fqn, docUri, additionalEdit: null, out var @namespace, out var resolutionParams); + + // Assert + Assert.True(result); + Assert.Equal("Abc", @namespace); + Assert.NotNull(resolutionParams); + } + [Fact] public async Task Handle_MissingFile() { diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionResolverTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionResolverTest.cs index 8ee8500337e..72219c5e5d4 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionResolverTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionResolverTest.cs @@ -39,7 +39,7 @@ public class DefaultCSharpCodeActionResolverTest(ITestOutputHelper testOutput) : } }; - private static readonly TextEdit[] s_defaultFormattedEdits = [VsLspFactory.CreateTextEdit(position: (0, 0), "Remapped & Formatted Edit")]; + private static readonly TextEdit s_defaultFormattedEdit = VsLspFactory.CreateTextEdit(position: (0, 0), "Remapped & Formatted Edit"); private static readonly CodeAction s_defaultUnresolvedCodeAction = new CodeAction() { @@ -63,7 +63,7 @@ public async Task ResolveAsync_ReturnsResolvedCodeAction() var returnedEdits = returnedCodeAction.Edit.DocumentChanges.Value; Assert.True(returnedEdits.TryGetFirst(out var textDocumentEdits)); var returnedTextDocumentEdit = Assert.Single(textDocumentEdits[0].Edits); - Assert.Equal(s_defaultFormattedEdits.First(), returnedTextDocumentEdit); + Assert.Equal(s_defaultFormattedEdit, returnedTextDocumentEdit); } [Fact] @@ -188,12 +188,11 @@ private static void CreateCodeActionResolver( private static IRazorFormattingService CreateRazorFormattingService(Uri documentUri) { var razorFormattingService = Mock.Of( - rfs => rfs.FormatCodeActionAsync( + rfs => rfs.GetCSharpCodeActionEditAsync( It.Is(c => c.Uri == documentUri), - RazorLanguageKind.CSharp, It.IsAny(), - It.IsAny(), - It.IsAny()) == Task.FromResult(s_defaultFormattedEdits), MockBehavior.Strict); + It.IsAny(), + It.IsAny()) == Task.FromResult(s_defaultFormattedEdit), MockBehavior.Strict); return razorFormattingService; } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/Delegation/DelegatedCompletionItemResolverTest.NetFx.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/Delegation/DelegatedCompletionItemResolverTest.NetFx.cs index 82628ec0922..06fcc14d264 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/Delegation/DelegatedCompletionItemResolverTest.NetFx.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Completion/Delegation/DelegatedCompletionItemResolverTest.NetFx.cs @@ -80,7 +80,8 @@ public async Task ResolveAsync_CanNotFindCompletionItem_Noops() { // Arrange var server = TestDelegatedCompletionItemResolverServer.Create(); - var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), server); + var optionsMonitor = TestRazorLSPOptionsMonitor.Create(); + var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), optionsMonitor, server); var item = new VSInternalCompletionItem(); var notContainingCompletionList = new VSInternalCompletionList(); var originalRequestContext = new object(); @@ -98,7 +99,8 @@ public async Task ResolveAsync_UnknownRequestContext_Noops() { // Arrange var server = TestDelegatedCompletionItemResolverServer.Create(); - var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), server); + var optionsMonitor = TestRazorLSPOptionsMonitor.Create(); + var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), optionsMonitor, server); var item = new VSInternalCompletionItem(); var containingCompletionList = new VSInternalCompletionList() { Items = new[] { item, } }; var originalRequestContext = new object(); @@ -116,7 +118,8 @@ public async Task ResolveAsync_UsesItemsData() { // Arrange var server = TestDelegatedCompletionItemResolverServer.Create(); - var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), server); + var optionsMonitor = TestRazorLSPOptionsMonitor.Create(); + var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), optionsMonitor, server); var expectedData = new object(); var item = new VSInternalCompletionItem() { @@ -138,7 +141,8 @@ public async Task ResolveAsync_InheritsOriginalCompletionListData() { // Arrange var server = TestDelegatedCompletionItemResolverServer.Create(); - var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), server); + var optionsMonitor = TestRazorLSPOptionsMonitor.Create(); + var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), optionsMonitor, server); var item = new VSInternalCompletionItem(); var containingCompletionList = new VSInternalCompletionList() { Items = new[] { item, }, Data = new object() }; var expectedData = new object(); @@ -201,7 +205,8 @@ public async Task ResolveAsync_Html_Resolves() // Arrange var expectedResolvedItem = new VSInternalCompletionItem(); var server = TestDelegatedCompletionItemResolverServer.Create(expectedResolvedItem); - var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), server); + var optionsMonitor = TestRazorLSPOptionsMonitor.Create(); + var resolver = new DelegatedCompletionItemResolver(_documentContextFactory, _formattingService.GetValue(), optionsMonitor, server); var item = new VSInternalCompletionItem(); var containingCompletionList = new VSInternalCompletionList() { Items = new[] { item, } }; var originalRequestContext = new DelegatedCompletionResolutionContext(_htmlCompletionParams, new object()); @@ -224,7 +229,8 @@ private async Task ResolveCompletionItemAsync(string c var server = TestDelegatedCompletionItemResolverServer.Create(csharpServer, DisposalToken); var documentContextFactory = new TestDocumentContextFactory("C:/path/to/file.razor", codeDocument); - var resolver = new DelegatedCompletionItemResolver(documentContextFactory, _formattingService.GetValue(), server); + var optionsMonitor = TestRazorLSPOptionsMonitor.Create(); + var resolver = new DelegatedCompletionItemResolver(documentContextFactory, _formattingService.GetValue(), optionsMonitor, server); var (containingCompletionList, csharpCompletionParams) = await GetCompletionListAndOriginalParamsAsync( cursorPosition, codeDocument, csharpServer); 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 e15d9f1d4e5..776cb9a2957 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 @@ -3,6 +3,7 @@ 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; @@ -18,6 +19,37 @@ 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() { @@ -27,8 +59,9 @@ public async Task Handle_OnTypeFormatting_FormattingDisabled_ReturnsNull() var documentMappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: false); + var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams { TextDocument = new TextDocumentIdentifier { Uri = uri, } }; var requestContext = CreateRazorRequestContext(documentContext: null); @@ -55,8 +88,9 @@ public async Task Handle_OnTypeFormatting_DocumentNotFound_ReturnsNull() var documentMappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); + var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -89,8 +123,9 @@ public async Task Handle_OnTypeFormatting_RemapFailed_ReturnsNull() var documentMappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); + var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -124,8 +159,9 @@ public async Task Handle_OnTypeFormatting_HtmlLanguageKind_ReturnsNull() 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, documentMappingService.Object, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, documentMappingService.Object, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -159,8 +195,9 @@ public async Task Handle_OnTypeFormatting_RazorLanguageKind_ReturnsNull() 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, documentMappingService.Object, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, documentMappingService.Object, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, @@ -193,8 +230,9 @@ public async Task Handle_OnTypeFormatting_UnexpectedTriggerCharacter_ReturnsNull var documentMappingService = new LspDocumentMappingService(FilePathService, documentContextFactory, LoggerFactory); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); + var htmlFormatter = new TestHtmlFormatter(); var endpoint = new DocumentOnTypeFormattingEndpoint( - formattingService, documentMappingService, optionsMonitor, LoggerFactory); + formattingService, htmlFormatter, documentMappingService, optionsMonitor, LoggerFactory); var @params = new DocumentOnTypeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, }, diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentRangeFormattingEndpointTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentRangeFormattingEndpointTest.cs index 964ed2d0ef8..0bb29ed5bba 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentRangeFormattingEndpointTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/DocumentRangeFormattingEndpointTest.cs @@ -22,12 +22,14 @@ public async Task Handle_FormattingEnabled_InvokesFormattingService() var documentContext = CreateDocumentContext(uri, codeDocument); var formattingService = new DummyRazorFormattingService(); + var htmlFormatter = new TestHtmlFormatter(); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); var endpoint = new DocumentRangeFormattingEndpoint( - formattingService, optionsMonitor); + formattingService, htmlFormatter, optionsMonitor); var @params = new DocumentRangeFormattingParams() { - TextDocument = new TextDocumentIdentifier { Uri = uri, } + TextDocument = new TextDocumentIdentifier { Uri = uri, }, + Options = new FormattingOptions() }; var requestContext = CreateRazorRequestContext(documentContext); @@ -45,7 +47,8 @@ public async Task Handle_DocumentNotFound_ReturnsNull() // Arrange var formattingService = new DummyRazorFormattingService(); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); - var endpoint = new DocumentRangeFormattingEndpoint(formattingService, optionsMonitor); + var htmlFormatter = new TestHtmlFormatter(); + var endpoint = new DocumentRangeFormattingEndpoint(formattingService, htmlFormatter, optionsMonitor); var uri = new Uri("file://path/test.razor"); var @params = new DocumentRangeFormattingParams() { @@ -71,7 +74,8 @@ public async Task Handle_UnsupportedCodeDocument_ReturnsNull() var documentContext = CreateDocumentContext(uri, codeDocument); var formattingService = new DummyRazorFormattingService(); var optionsMonitor = GetOptionsMonitor(enableFormatting: true); - var endpoint = new DocumentRangeFormattingEndpoint(formattingService, optionsMonitor); + var htmlFormatter = new TestHtmlFormatter(); + var endpoint = new DocumentRangeFormattingEndpoint(formattingService, htmlFormatter, optionsMonitor); var @params = new DocumentRangeFormattingParams() { TextDocument = new TextDocumentIdentifier { Uri = uri, } @@ -91,7 +95,8 @@ public async Task Handle_FormattingDisabled_ReturnsNull() // Arrange var formattingService = new DummyRazorFormattingService(); var optionsMonitor = GetOptionsMonitor(enableFormatting: false); - var endpoint = new DocumentRangeFormattingEndpoint(formattingService, optionsMonitor); + var htmlFormatter = new TestHtmlFormatter(); + var endpoint = new DocumentRangeFormattingEndpoint(formattingService, htmlFormatter, optionsMonitor); var @params = new DocumentRangeFormattingParams(); var requestContext = CreateRazorRequestContext(documentContext: null); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingContentValidationPassTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingContentValidationPassTest.cs index f35c3dffeeb..c1278bfd9b6 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingContentValidationPassTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingContentValidationPassTest.cs @@ -10,7 +10,6 @@ using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using Moq; @@ -21,46 +20,6 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; public class FormattingContentValidationPassTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) { - [Fact] - public async Task Execute_LanguageKindCSharp_Noops() - { - // Arrange - var source = SourceText.From(@" -@code { - public class Foo { } -} -"); - using var context = CreateFormattingContext(source); - var input = new FormattingResult([], RazorLanguageKind.CSharp); - var pass = GetPass(); - - // Act - var result = await pass.ExecuteAsync(context, input, DisposalToken); - - // Assert - Assert.Equal(input, result); - } - - [Fact] - public async Task Execute_LanguageKindHtml_Noops() - { - // Arrange - var source = SourceText.From(@" -@code { - public class Foo { } -} -"); - using var context = CreateFormattingContext(source); - var input = new FormattingResult([], RazorLanguageKind.Html); - var pass = GetPass(); - - // Act - var result = await pass.ExecuteAsync(context, input, DisposalToken); - - // Assert - Assert.Equal(input, result); - } - [Fact] public async Task Execute_NonDestructiveEdit_Allowed() { @@ -75,14 +34,14 @@ public class Foo { } { VsLspFactory.CreateTextEdit(2, 0, " ") }; - var input = new FormattingResult(edits, RazorLanguageKind.Razor); + var input = edits; var pass = GetPass(); // Act - var result = await pass.ExecuteAsync(context, input, DisposalToken); + var result = await pass.ExecuteAsync(context, edits, DisposalToken); // Assert - Assert.Equal(input, result); + Assert.Same(input, result); } [Fact] @@ -99,21 +58,19 @@ public class Foo { } { VsLspFactory.CreateTextEdit(2, 0, 3, 0, " ") // Nukes a line }; - var input = new FormattingResult(edits, RazorLanguageKind.Razor); + var input = edits; var pass = GetPass(); // Act var result = await pass.ExecuteAsync(context, input, DisposalToken); // Assert - Assert.Empty(result.Edits); + Assert.Empty(result); } private FormattingContentValidationPass GetPass() { - var mappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); - - var pass = new FormattingContentValidationPass(mappingService, LoggerFactory) + var pass = new FormattingContentValidationPass(LoggerFactory) { DebugAssertsEnabled = false }; @@ -126,7 +83,7 @@ private static FormattingContext CreateFormattingContext(SourceText source, int var path = "file:///path/to/document.razor"; var uri = new Uri(path); var (codeDocument, documentSnapshot) = CreateCodeDocumentAndSnapshot(source, uri.AbsolutePath, fileKind: fileKind); - var options = new FormattingOptions() + var options = new RazorFormattingOptions() { TabSize = tabSize, InsertSpaces = insertSpaces, diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingDiagnosticValidationPassTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingDiagnosticValidationPassTest.cs index 7ca4655ac5e..0028c406098 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingDiagnosticValidationPassTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingDiagnosticValidationPassTest.cs @@ -9,7 +9,6 @@ using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.ProjectSystem; -using Microsoft.CodeAnalysis.Razor.Protocol; using Microsoft.CodeAnalysis.Text; using Microsoft.VisualStudio.LanguageServer.Protocol; using Xunit; @@ -19,48 +18,6 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; public class FormattingDiagnosticValidationPassTest(ITestOutputHelper testOutput) : LanguageServerTestBase(testOutput) { - [Fact] - public async Task ExecuteAsync_LanguageKindCSharp_Noops() - { - // Arrange - var source = SourceText.From(@" -@code { - public class Foo { } -} -"); - using var context = CreateFormattingContext(source); - var badEdit = VsLspFactory.CreateTextEdit(position: (0, 0), "@ "); - var input = new FormattingResult([badEdit], RazorLanguageKind.CSharp); - var pass = GetPass(); - - // Act - var result = await pass.ExecuteAsync(context, input, DisposalToken); - - // Assert - Assert.Equal(input, result); - } - - [Fact] - public async Task ExecuteAsync_LanguageKindHtml_Noops() - { - // Arrange - var source = SourceText.From(@" -@code { - public class Foo { } -} -"); - using var context = CreateFormattingContext(source); - var badEdit = VsLspFactory.CreateTextEdit(position: (0, 0), "@ "); - var input = new FormattingResult([badEdit], RazorLanguageKind.Html); - var pass = GetPass(); - - // Act - var result = await pass.ExecuteAsync(context, input, DisposalToken); - - // Assert - Assert.Equal(input, result); - } - [Fact] public async Task ExecuteAsync_NonDestructiveEdit_Allowed() { @@ -75,14 +32,14 @@ public class Foo { } { VsLspFactory.CreateTextEdit(2, 0, " ") }; - var input = new FormattingResult(edits, RazorLanguageKind.Razor); + var input = edits; var pass = GetPass(); // Act var result = await pass.ExecuteAsync(context, input, DisposalToken); // Assert - Assert.Equal(input, result); + Assert.Same(input, result); } [Fact] @@ -96,21 +53,18 @@ public class Foo { } "); using var context = CreateFormattingContext(source); var badEdit = VsLspFactory.CreateTextEdit(position: (0, 0), "@ "); // Creates a diagnostic - var input = new FormattingResult([badEdit], RazorLanguageKind.Razor); var pass = GetPass(); // Act - var result = await pass.ExecuteAsync(context, input, DisposalToken); + var result = await pass.ExecuteAsync(context, [badEdit], DisposalToken); // Assert - Assert.Empty(result.Edits); + Assert.Empty(result); } private FormattingDiagnosticValidationPass GetPass() { - var mappingService = new LspDocumentMappingService(FilePathService, new TestDocumentContextFactory(), LoggerFactory); - - var pass = new FormattingDiagnosticValidationPass(mappingService, LoggerFactory) + var pass = new FormattingDiagnosticValidationPass(LoggerFactory) { DebugAssertsEnabled = false }; @@ -123,7 +77,7 @@ private static FormattingContext CreateFormattingContext(SourceText source, int var path = "file:///path/to/document.razor"; var uri = new Uri(path); var (codeDocument, documentSnapshot) = CreateCodeDocumentAndSnapshot(source, uri.AbsolutePath, fileKind: fileKind); - var options = new FormattingOptions() + var options = new RazorFormattingOptions() { TabSize = tabSize, InsertSpaces = insertSpaces, @@ -147,7 +101,7 @@ private static (RazorCodeDocument, IDocumentSnapshot) CreateCodeDocumentAndSnaps var projectEngine = RazorProjectEngine.Create(builder => builder.SetRootNamespace("Test")); var codeDocument = projectEngine.ProcessDesignTime(sourceDocument, fileKind, importSources: default, tagHelpers); - var documentSnapshot = FormattingTestBase.CreateDocumentSnapshot(path, tagHelpers, fileKind, [], [], projectEngine, codeDocument); + var documentSnapshot = FormattingTestBase.CreateDocumentSnapshot(path, tagHelpers, fileKind, importsDocuments: [], imports: [], projectEngine, codeDocument); return (codeDocument, documentSnapshot); } 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 5ccdb96158e..bf6ce5a569e 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 @@ -1,6 +1,7 @@ // Copyright (c) .NET Foundation. All rights reserved. // Licensed under the MIT license. See License.txt in the project root for license information. +using System; using System.Collections.Immutable; using System.Threading; using System.Threading.Tasks; @@ -9,7 +10,6 @@ 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; @@ -35,25 +35,35 @@ internal class DummyRazorFormattingService : IRazorFormattingService { public bool Called { get; private set; } - public Task FormatAsync(DocumentContext documentContext, Range? range, FormattingOptions options, CancellationToken cancellationToken) + public Task GetDocumentFormattingEditsAsync(DocumentContext documentContext, TextEdit[] htmlEdits, Range? range, RazorFormattingOptions options, CancellationToken cancellationToken) { Called = true; return SpecializedTasks.EmptyArray(); } - public Task FormatCodeActionAsync(DocumentContext documentContext, RazorLanguageKind kind, TextEdit[] formattedEdits, FormattingOptions options, CancellationToken cancellationToken) + public Task GetCSharpCodeActionEditAsync(DocumentContext documentContext, TextEdit[] formattedEdits, RazorFormattingOptions options, CancellationToken cancellationToken) { - return Task.FromResult(formattedEdits); + throw new NotImplementedException(); } - public Task FormatOnTypeAsync(DocumentContext documentContext, RazorLanguageKind kind, TextEdit[] formattedEdits, FormattingOptions options, int hostDocumentIndex, char triggerCharacter, CancellationToken cancellationToken) + public Task GetCSharpOnTypeFormattingEditsAsync(DocumentContext documentContext, RazorFormattingOptions options, int hostDocumentIndex, char triggerCharacter, CancellationToken cancellationToken) { - return Task.FromResult(formattedEdits); + throw new NotImplementedException(); } - public Task FormatSnippetAsync(DocumentContext documentContext, RazorLanguageKind kind, TextEdit[] formattedEdits, FormattingOptions options, CancellationToken cancellationToken) + public Task GetCSharpSnippetFormattingEditAsync(DocumentContext documentContext, TextEdit[] edits, RazorFormattingOptions options, CancellationToken cancellationToken) { - return Task.FromResult(formattedEdits); + throw new NotImplementedException(); + } + + public Task GetHtmlOnTypeFormattingEditsAsync(DocumentContext documentContext, TextEdit[] htmlEdits, RazorFormattingOptions options, int hostDocumentIndex, char triggerCharacter, CancellationToken cancellationToken) + { + return Task.FromResult(htmlEdits); + } + + public Task GetSingleCSharpEditAsync(DocumentContext documentContext, TextEdit initialEdit, RazorFormattingOptions options, CancellationToken cancellationToken) + { + throw new NotImplementedException(); } } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingTestBase.cs index dc8c6ddd2be..8835b479edc 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingTestBase.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/FormattingTestBase.cs @@ -18,6 +18,7 @@ using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; using Microsoft.CodeAnalysis; using Microsoft.CodeAnalysis.Razor; +using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.Logging; using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Microsoft.CodeAnalysis.Razor.Protocol; @@ -76,12 +77,19 @@ private async Task RunFormattingTestAsync(string input, string expected, int tab TabSize = tabSize, InsertSpaces = insertSpaces, }; + var razorOptions = RazorFormattingOptions.From(options, codeBlockBraceOnNextLine: razorLSPOptions?.CodeBlockBraceOnNextLine ?? false); var formattingService = await TestRazorFormattingService.CreateWithFullSupportAsync(LoggerFactory, codeDocument, razorLSPOptions); var documentContext = new DocumentContext(uri, documentSnapshot, projectContext: null); + var client = new FormattingLanguageServerClient(LoggerFactory); + client.AddCodeDocument(codeDocument); + + var htmlFormatter = new HtmlFormatter(client); + var htmlEdits = await htmlFormatter.GetDocumentFormattingEditsAsync(documentSnapshot, uri, options, DisposalToken); + // Act - var edits = await formattingService.FormatAsync(documentContext, range, options, DisposalToken); + var edits = await formattingService.GetDocumentFormattingEditsAsync(documentContext, htmlEdits, range, razorOptions, DisposalToken); // Assert var edited = ApplyEdits(source, edits); @@ -127,10 +135,25 @@ private protected async Task RunOnTypeFormattingTestAsync( TabSize = tabSize, InsertSpaces = insertSpaces, }; + var razorOptions = RazorFormattingOptions.From(options, codeBlockBraceOnNextLine: razorLSPOptions?.CodeBlockBraceOnNextLine ?? false); + var documentContext = new DocumentContext(uri, documentSnapshot, projectContext: null); // Act - var edits = await formattingService.FormatOnTypeAsync(documentContext, languageKind, Array.Empty(), options, hostDocumentIndex: positionAfterTrigger, triggerCharacter: triggerCharacter, DisposalToken); + TextEdit[] edits; + if (languageKind == RazorLanguageKind.CSharp) + { + edits = await formattingService.GetCSharpOnTypeFormattingEditsAsync(documentContext, razorOptions, hostDocumentIndex: positionAfterTrigger, triggerCharacter: triggerCharacter, DisposalToken); + } + else + { + var client = new FormattingLanguageServerClient(LoggerFactory); + client.AddCodeDocument(codeDocument); + + var htmlFormatter = new HtmlFormatter(client); + var htmlEdits = await htmlFormatter.GetDocumentFormattingEditsAsync(documentSnapshot, uri, options, DisposalToken); + edits = await formattingService.GetHtmlOnTypeFormattingEditsAsync(documentContext, htmlEdits, razorOptions, hostDocumentIndex: positionAfterTrigger, triggerCharacter: triggerCharacter, DisposalToken); + } // Assert var edited = ApplyEdits(razorSourceText, edits); @@ -190,7 +213,7 @@ protected async Task RunCodeActionFormattingTestAsync( } var formattingService = await TestRazorFormattingService.CreateWithFullSupportAsync(LoggerFactory, codeDocument); - var options = new FormattingOptions() + var options = new RazorFormattingOptions() { TabSize = tabSize, InsertSpaces = insertSpaces, @@ -198,10 +221,10 @@ protected async Task RunCodeActionFormattingTestAsync( var documentContext = new DocumentContext(uri, documentSnapshot, projectContext: null); // Act - var edits = await formattingService.FormatCodeActionAsync(documentContext, languageKind, codeActionEdits, options, DisposalToken); + var edit = await formattingService.GetCSharpCodeActionEditAsync(documentContext, codeActionEdits, options, DisposalToken); // Assert - var edited = ApplyEdits(razorSourceText, edits); + var edited = ApplyEdits(razorSourceText, [edit]); var actual = edited.ToString(); AssertEx.EqualOrDiff(expected, actual); @@ -260,7 +283,7 @@ @using Microsoft.AspNetCore.Components.Web ]); var projectEngine = RazorProjectEngine.Create( - new RazorConfiguration(RazorLanguageVersion.Latest, "TestConfiguration", ImmutableArray.Empty, new LanguageServerFlags(forceRuntimeCodeGeneration)), + new RazorConfiguration(RazorLanguageVersion.Latest, "TestConfiguration", Extensions: [], new LanguageServerFlags(forceRuntimeCodeGeneration)), projectFileSystem, builder => { @@ -269,7 +292,7 @@ @using Microsoft.AspNetCore.Components.Web RazorExtensions.Register(builder); }); - var codeDocument = projectEngine.ProcessDesignTime(sourceDocument, fileKind, ImmutableArray.Create(importsDocument), tagHelpers); + var codeDocument = projectEngine.ProcessDesignTime(sourceDocument, fileKind, [importsDocument], tagHelpers); if (!allowDiagnostics) { diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/TestHtmlFormatter.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/TestHtmlFormatter.cs new file mode 100644 index 00000000000..0774619dfa5 --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/TestHtmlFormatter.cs @@ -0,0 +1,24 @@ +// 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.Threading; +using System.Threading.Tasks; +using Microsoft.AspNetCore.Razor.Threading; +using Microsoft.CodeAnalysis.Razor.ProjectSystem; +using Microsoft.VisualStudio.LanguageServer.Protocol; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; + +internal class TestHtmlFormatter : IHtmlFormatter +{ + public Task GetDocumentFormattingEditsAsync(IDocumentSnapshot documentSnapshot, Uri uri, FormattingOptions options, CancellationToken cancellationToken) + { + return SpecializedTasks.EmptyArray(); + } + + public Task GetOnTypeFormattingEditsAsync(IDocumentSnapshot documentSnapshot, Uri uri, Position position, string triggerCharacter, FormattingOptions options, CancellationToken cancellationToken) + { + return SpecializedTasks.EmptyArray(); + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/TestRazorFormattingService.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/TestRazorFormattingService.cs index 21b381771c8..fc10917be71 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/TestRazorFormattingService.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting_NetFx/TestRazorFormattingService.cs @@ -1,18 +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.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; using Microsoft.AspNetCore.Razor.LanguageServer.Hosting; using Microsoft.AspNetCore.Razor.LanguageServer.Test; -using Microsoft.AspNetCore.Razor.Test.Common; using Microsoft.AspNetCore.Razor.Test.Common.LanguageServer; using Microsoft.AspNetCore.Razor.Test.Common.Workspaces; using Microsoft.CodeAnalysis.Razor.Formatting; using Microsoft.CodeAnalysis.Razor.Logging; -using Microsoft.CodeAnalysis.Razor.ProjectSystem; using Moq; namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting; @@ -29,11 +26,6 @@ public static async Task CreateWithFullSupportAsync( var filePathService = new LSPFilePathService(TestLanguageServerFeatureOptions.Instance); var mappingService = new LspDocumentMappingService(filePathService, new TestDocumentContextFactory(), loggerFactory); - var projectManager = StrictMock.Of(); - - var client = new FormattingLanguageServerClient(loggerFactory); - client.AddCodeDocument(codeDocument); - var configurationSyncService = new Mock(MockBehavior.Strict); configurationSyncService .Setup(c => c.GetLatestOptionsAsync(It.IsAny())) @@ -47,19 +39,8 @@ public static async Task CreateWithFullSupportAsync( await optionsMonitor.UpdateAsync(CancellationToken.None); } - var passes = new List() - { - new HtmlFormattingPass(mappingService, client, loggerFactory), - new CSharpFormattingPass(mappingService, loggerFactory), - new LspCSharpOnTypeFormattingPass(mappingService, loggerFactory), - new LspRazorFormattingPass(mappingService, optionsMonitor), - new FormattingDiagnosticValidationPass(mappingService, loggerFactory), - new FormattingContentValidationPass(mappingService, loggerFactory), - }; + var formattingCodeDocumentProvider = new LspFormattingCodeDocumentProvider(); - return new RazorFormattingService( - passes, - new LspFormattingCodeDocumentProvider(), - TestAdhocWorkspaceFactory.Instance); + return new RazorFormattingService(formattingCodeDocumentProvider, mappingService, TestAdhocWorkspaceFactory.Instance, loggerFactory); } } diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/WrapWithTag/WrapWithTagEndpointTests.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/WrapWithTag/WrapWithTagEndpointTests.cs index 80a563b38d4..b7c5955ea81 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/WrapWithTag/WrapWithTagEndpointTests.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/WrapWithTag/WrapWithTagEndpointTests.cs @@ -282,7 +282,7 @@ public async Task CleanUpTextEdits_NoTilde() }; var htmlSourceText = await context!.GetHtmlSourceTextAsync(DisposalToken); - var edits = HtmlFormatter.FixHtmlTestEdits(htmlSourceText, computedEdits); + var edits = HtmlFormatter.FixHtmlTextEdits(htmlSourceText, computedEdits); Assert.Same(computedEdits, edits); var finalText = inputSourceText.WithChanges(edits.Select(inputSourceText.GetTextChange)); @@ -322,7 +322,7 @@ public async Task CleanUpTextEdits_BadEditWithTilde() }; var htmlSourceText = await context!.GetHtmlSourceTextAsync(DisposalToken); - var edits = HtmlFormatter.FixHtmlTestEdits(htmlSourceText, computedEdits); + var edits = HtmlFormatter.FixHtmlTextEdits(htmlSourceText, computedEdits); Assert.NotSame(computedEdits, edits); var finalText = inputSourceText.WithChanges(edits.Select(inputSourceText.GetTextChange)); @@ -362,7 +362,7 @@ public async Task CleanUpTextEdits_GoodEditWithTilde() }; var htmlSourceText = await context.GetHtmlSourceTextAsync(DisposalToken); - var edits = HtmlFormatter.FixHtmlTestEdits(htmlSourceText, computedEdits); + var edits = HtmlFormatter.FixHtmlTextEdits(htmlSourceText, computedEdits); Assert.NotSame(computedEdits, edits); var finalText = inputSourceText.WithChanges(edits.Select(inputSourceText.GetTextChange)); diff --git a/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Formatting/AddUsingsHelperTest.cs b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Formatting/AddUsingsHelperTest.cs new file mode 100644 index 00000000000..3f2b2066886 --- /dev/null +++ b/src/Razor/test/Microsoft.CodeAnalysis.Razor.Workspaces.Test/Formatting/AddUsingsHelperTest.cs @@ -0,0 +1,71 @@ +// 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.Test.Common; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.CodeAnalysis.Razor.Formatting; + +public class AddUsingsHelperTest(ITestOutputHelper testOutput) : ToolingTestBase(testOutput) +{ + [Fact] + public void TryExtractNamespace_Invalid_ReturnsFalse() + { + // Arrange + var csharpAddUsing = "Abc.Xyz;"; + + // Act + var res = AddUsingsHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); + + // Assert + Assert.False(res); + Assert.Empty(@namespace); + Assert.Empty(prefix); + } + + [Fact] + public void TryExtractNamespace_ReturnsTrue() + { + // Arrange + var csharpAddUsing = "using Abc.Xyz;"; + + // Act + var res = AddUsingsHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); + + // Assert + Assert.True(res); + Assert.Equal("Abc.Xyz", @namespace); + Assert.Empty(prefix); + } + + [Fact] + public void TryExtractNamespace_WithStatic_ReturnsTrue() + { + // Arrange + var csharpAddUsing = "using static X.Y.Z;"; + + // Act + var res = AddUsingsHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); + + // Assert + Assert.True(res); + Assert.Equal("static X.Y.Z", @namespace); + Assert.Empty(prefix); + } + + [Fact] + public void TryExtractNamespace_WithTypeNameCorrection_ReturnsTrue() + { + // Arrange + var csharpAddUsing = "Goo - using X.Y.Z;"; + + // Act + var res = AddUsingsHelper.TryExtractNamespace(csharpAddUsing, out var @namespace, out var prefix); + + // Assert + Assert.True(res); + Assert.Equal("X.Y.Z", @namespace); + Assert.Equal("Goo - ", prefix); + } +}