diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/CSharpCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/CSharpCodeActionProvider.cs index 53fe24152f..9d1588fe4b 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/CSharpCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/CSharpCodeActionProvider.cs @@ -3,16 +3,9 @@ using System; using System.Collections.Generic; -using System.Diagnostics; -using System.Linq; using System.Threading; using System.Threading.Tasks; -using Microsoft.AspNetCore.Razor.Language; -using Microsoft.AspNetCore.Razor.Language.Extensions; -using Microsoft.AspNetCore.Razor.Language.Legacy; -using Microsoft.AspNetCore.Razor.Language.Syntax; using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models; -using Microsoft.AspNetCore.Razor.LanguageServer.Extensions; namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions { @@ -25,63 +18,5 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions RazorCodeActionContext context, IEnumerable codeActions, CancellationToken cancellationToken); - - protected static bool InFunctionsBlockThatCantHaveCodeActions(RazorCodeActionContext context) - { - var change = new SourceChange(context.Location.AbsoluteIndex, length: 0, newText: string.Empty); - var syntaxTree = context.CodeDocument.GetSyntaxTree(); - if (syntaxTree?.Root is null) - { - return false; - } - - var owner = syntaxTree.Root.LocateOwner(change); - if (owner is null) - { - Debug.Fail("Owner should never be null."); - return false; - } - - var node = owner.Ancestors().FirstOrDefault(n => n.Kind == SyntaxKind.RazorDirective); - if (node is not RazorDirectiveSyntax directiveNode) - { - return false; - } - - if (directiveNode.DirectiveDescriptor != FunctionsDirective.Directive) - { - return false; - } - - // At this point we know its a functions block, but because of how the source mappings work, - // if the opening brace for the functions block is not on the same line as the functions node itself - // then we can offer code actions. - // - // This is because when we have a block like this: - // - // @functions { - // class Goo { } - // } - // - // The source mapping starts at char 13 on the "@functions" line (after the open brace). Unfortunately - // and code that is needed on that line, say an attribute that the code action wants to insert, will - // start at char 8 because of the indentation of the generated code. This means it starts outside of the - // mapping, so is thrown away, which results in data loss. - // - // When the open brace is on the next line, the source mapping starts at char 2, so the insertion at char 8 - // is fine. - if (directiveNode.Body is RazorDirectiveBodySyntax directiveBody && - directiveBody.CSharpCode.Children.TryGetOpenBraceNode(out var openBrace)) - { - context.SourceText.GetLineAndOffset(directiveNode.SpanStart, out var directiveLine, out _); - context.SourceText.GetLineAndOffset(openBrace.SpanStart, out var braceLine, out _); - if (braceLine > directiveLine) - { - return false; - } - } - - return true; - } } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs index cd7cf8e65b..9311e5a1f3 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/CodeActions/CSharp/DefaultCSharpCodeActionProvider.cs @@ -51,11 +51,6 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions return EmptyResult; } - if (InFunctionsBlockThatCantHaveCodeActions(context)) - { - return EmptyResult; - } - var results = new List(); foreach (var codeAction in codeActions) diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultRazorDocumentMappingService.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultRazorDocumentMappingService.cs index f5ba097ccd..752dd8ba2f 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultRazorDocumentMappingService.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/DefaultRazorDocumentMappingService.cs @@ -18,77 +18,156 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer { internal class DefaultRazorDocumentMappingService : RazorDocumentMappingService { - public override bool TryMapFromProjectedDocumentEdit(RazorCodeDocument codeDocument, TextEdit edit, out TextEdit newEdit) + public override TextEdit[] GetProjectedDocumentEdits(RazorCodeDocument codeDocument, TextEdit[] edits) { - newEdit = default; - + var projectedEdits = new List(); var csharpSourceText = codeDocument.GetCSharpSourceText(); - var range = edit.Range; - if (!IsRangeWithinDocument(range, csharpSourceText)) + var lastNewLineAddedToLine = 0; + + foreach (var edit in edits) { - return false; - } - - var startIndex = range.Start.GetAbsoluteIndex(csharpSourceText); - var endIndex = range.End.GetAbsoluteIndex(csharpSourceText); - var mappedStart = TryMapFromProjectedDocumentPosition(codeDocument, startIndex, out var hostDocumentStart, out _); - var mappedEnd = TryMapFromProjectedDocumentPosition(codeDocument, endIndex, out var hostDocumentEnd, out _); - - // Ideal case, both start and end can be mapped so just return the edit - if (mappedStart && mappedEnd) - { - newEdit = new TextEdit() + var range = edit.Range; + if (!IsRangeWithinDocument(range, csharpSourceText)) { - NewText = edit.NewText, - Range = new Range(hostDocumentStart, hostDocumentEnd) - }; - return true; - } + continue; + } - // For the first line of a code block the C# formatter will often return an edit that starts - // before our mapping, but ends within. In those cases, when the edit spans multiple lines - // we just take the last line and try to use that. - // - // eg in the C# document you might see: - // - // protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) - // { - // #nullable restore - // #line 1 "/path/to/Document.component" - // - // var x = DateTime.Now; - // - // To indent the 'var x' line the formatter will return an edit that starts the line before, - // with a NewText of '\n '. The start of that edit is outside our mapping, but we - // still want to know how to format the 'var x' line, so we have to break up the edit. - if (!mappedStart && mappedEnd && range.SpansMultipleLines()) - { - // Construct a theoretical edit that is just for the last line of the edit that the C# formatter - // gave us, and see if we can map that. - // The +1 here skips the newline character that is found, but also protects from Substring throwing - // if there are no newlines (which should be impossible anyway) - var lastNewLine = edit.NewText.LastIndexOfAny(new char[] { '\n', '\r' }) + 1; + var startIndex = range.Start.GetAbsoluteIndex(csharpSourceText); + var endIndex = range.End.GetAbsoluteIndex(csharpSourceText); + var mappedStart = TryMapFromProjectedDocumentPosition(codeDocument, startIndex, out var hostDocumentStart, out _); + var mappedEnd = TryMapFromProjectedDocumentPosition(codeDocument, endIndex, out var hostDocumentEnd, out _); - // Strictly speaking we could be dropping more lines than we need to, because our mapping point could be anywhere within the edit - // but we know that the C# formatter will only be returning blank lines up until the first bit of content that needs to be indented - // so we can ignore all but the last line. This assert ensures that is true, just in case something changes in Roslyn - Debug.Assert(edit.NewText.Substring(0, lastNewLine - 1).All(c => c == '\r' || c == '\n'), "We are throwing away part of an edit that has more than just empty lines!"); - - var proposedEdit = new TextEdit() + // Ideal case, both start and end can be mapped so just return the edit + if (mappedStart && mappedEnd) { - NewText = edit.NewText.Substring(lastNewLine), - Range = new Range(range.End.Line, 0, range.End.Line, range.End.Character) - }; + // If the previous edit was on the same line, and added a newline, then we need to add a space + // between this edit and the previous one, because the normalization will have swallowed it. See + // below for a more info. + var newText = (lastNewLineAddedToLine == range.Start.Line ? " " : "") + edit.NewText; + projectedEdits.Add(new TextEdit() + { + NewText = newText, + Range = new Range(hostDocumentStart, hostDocumentEnd) + }); + continue; + } - // We don't need to worry about the recursion here because we're deliberately constructing a range - // that is on a single line, but only recursing if the range spans multiple. - if (TryMapFromProjectedDocumentEdit(codeDocument, proposedEdit, out newEdit)) + // For the first line of a code block the C# formatter will often return an edit that starts + // before our mapping, but ends within. In those cases, when the edit spans multiple lines + // we just take the last line and try to use that. + // + // eg in the C# document you might see: + // + // protected override void BuildRenderTree(Microsoft.AspNetCore.Components.Rendering.RenderTreeBuilder __builder) + // { + // #nullable restore + // #line 1 "/path/to/Document.component" + // + // var x = DateTime.Now; + // + // To indent the 'var x' line the formatter will return an edit that starts the line before, + // with a NewText of '\n '. The start of that edit is outside our mapping, but we + // still want to know how to format the 'var x' line, so we have to break up the edit. + if (!mappedStart && mappedEnd && range.SpansMultipleLines()) { - return true; + // Construct a theoretical edit that is just for the last line of the edit that the C# formatter + // gave us, and see if we can map that. + // The +1 here skips the newline character that is found, but also protects from Substring throwing + // if there are no newlines (which should be impossible anyway) + var lastNewLine = edit.NewText.LastIndexOfAny(new char[] { '\n', '\r' }) + 1; + + // Strictly speaking we could be dropping more lines than we need to, because our mapping point could be anywhere within the edit + // but we know that the C# formatter will only be returning blank lines up until the first bit of content that needs to be indented + // so we can ignore all but the last line. This assert ensures that is true, just in case something changes in Roslyn + Debug.Assert(lastNewLine == 0 || edit.NewText.Substring(0, lastNewLine - 1).All(c => c == '\r' || c == '\n'), "We are throwing away part of an edit that has more than just empty lines!"); + + var proposedRange = new Range(range.End.Line, 0, range.End.Line, range.End.Character); + startIndex = proposedRange.Start.GetAbsoluteIndex(csharpSourceText); + endIndex = proposedRange.End.GetAbsoluteIndex(csharpSourceText); + mappedStart = TryMapFromProjectedDocumentPosition(codeDocument, startIndex, out hostDocumentStart, out _); + mappedEnd = TryMapFromProjectedDocumentPosition(codeDocument, endIndex, out hostDocumentEnd, out _); + + if (mappedStart && mappedEnd) + { + projectedEdits.Add(new TextEdit() + { + NewText = edit.NewText.Substring(lastNewLine), + Range = new Range(hostDocumentStart, hostDocumentEnd) + }); + continue; + } + } + + // If we couldn't map either the start or the end then we still might want to do something tricky. + // When we have a block like this: + // + // @functions { + // class Goo + // { + // } + // } + // + // The source mapping starts at char 13 on the "@functions" line (after the open brace). Unfortunately + // and code that is needed on that line, say an attribute that the code action wants to insert, will + // start at char 8 because of the desired indentation of that new code. This means it starts outside of the + // mapping, so is thrown away, which results in data loss. + // + // To fix this we check and if the mapping would have been successful at the end of the line (char 13 above) + // then we insert a newline, and enough indentation to get us back out to where the new code wanted to start (char 8) + // and then we're good - we've left the @functions bit alone which razor needs, but we're still able to insert + // new code above where the C# code is in the generated document. + // + // One last hurdle is that sometimes these edits come in as separate edits. So for example replacing "class Goo" above + // with "public class Goo" would come in as one edit for "public", one for "class" and one for "Goo", all on the same line. + // When we map the edit for "public" we will push everything down a line, so we don't want to do it for other edits + // on that line. + if (!mappedStart && !mappedEnd && !range.SpansMultipleLines()) + { + // If the new text doesn't have any content we don't care - throwing away invisible whitespace is fine + if (string.IsNullOrWhiteSpace(edit.NewText)) + { + continue; + } + + var line = csharpSourceText.Lines[range.Start.Line]; + + // If the line isn't blank, then this isn't a functions directive + if (line.GetFirstNonWhitespaceOffset() is not null) + { + continue; + } + + // Only do anything if the end of the line in question is a valid mapping point (ie, a transition) + var endOfLine = line.Span.End; + if (TryMapFromProjectedDocumentPosition(codeDocument, endOfLine, out var hostDocumentIndex, out _)) + { + if (range.Start.Line == lastNewLineAddedToLine) + { + // If we already added a newline to this line, then we don't want to add another one, but + // we do need to add a space between this edit and the previous one, because the normalization + // will have swallowed it. + projectedEdits.Add(new TextEdit() + { + NewText = " " + edit.NewText, + Range = new Range(hostDocumentIndex, hostDocumentIndex) + }); + } + else + { + // Otherwise, add a newline and the real content, and remember where we added it + lastNewLineAddedToLine = range.Start.Line; + projectedEdits.Add(new TextEdit() + { + NewText = Environment.NewLine + new string(' ', range.Start.Character) + edit.NewText, + Range = new Range(hostDocumentIndex, hostDocumentIndex) + }); + } + continue; + } } } - return false; + return projectedEdits.ToArray(); } public override bool TryMapFromProjectedDocumentRange(RazorCodeDocument codeDocument, Range projectedRange, out Range originalRange) => TryMapFromProjectedDocumentRange(codeDocument, projectedRange, MappingBehavior.Strict, out originalRange); diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/CSharpFormatter.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/CSharpFormatter.cs index 8377c65440..461206a7dc 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/CSharpFormatter.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/CSharpFormatter.cs @@ -107,16 +107,9 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting private TextEdit[] MapEditsToHostDocument(RazorCodeDocument codeDocument, TextEdit[] csharpEdits) { - var actualEdits = new List(); - foreach (var edit in csharpEdits) - { - if (_documentMappingService.TryMapFromProjectedDocumentEdit(codeDocument, edit, out var newEdit)) - { - actualEdits.Add(newEdit); - } - } + var actualEdits = _documentMappingService.GetProjectedDocumentEdits(codeDocument, csharpEdits); - return actualEdits.ToArray(); + return actualEdits; } private async Task FormatOnClientAsync( diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/FormattingPassBase.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/FormattingPassBase.cs index 83eeab2ea3..8b12438ca5 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/FormattingPassBase.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/Formatting/FormattingPassBase.cs @@ -4,7 +4,6 @@ #nullable enable using System; -using System.Collections.Generic; using System.Threading; using System.Threading.Tasks; using Microsoft.AspNetCore.Razor.Language; @@ -67,27 +66,14 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting return projectedTextEdits; } - var edits = new List(); - for (var i = 0; i < projectedTextEdits.Length; i++) + if (codeDocument.IsUnsupported()) { - var projectedRange = projectedTextEdits[i].Range; - if (codeDocument.IsUnsupported() || - !DocumentMappingService.TryMapFromProjectedDocumentRange(codeDocument, projectedRange, out var originalRange)) - { - // Can't map range. Discard this edit. - continue; - } - - var edit = new TextEdit() - { - Range = originalRange, - NewText = projectedTextEdits[i].NewText - }; - - edits.Add(edit); + return Array.Empty(); } - return edits.ToArray(); + var edits = DocumentMappingService.GetProjectedDocumentEdits(codeDocument, projectedTextEdits); + + return edits; } } } diff --git a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorDocumentMappingService.cs b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorDocumentMappingService.cs index 51c96228bd..0222e7cbd7 100644 --- a/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorDocumentMappingService.cs +++ b/src/Razor/src/Microsoft.AspNetCore.Razor.LanguageServer/RazorDocumentMappingService.cs @@ -10,7 +10,7 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer { internal abstract class RazorDocumentMappingService { - public abstract bool TryMapFromProjectedDocumentEdit(RazorCodeDocument codeDocument, TextEdit edit, out TextEdit newEdit); + public abstract TextEdit[] GetProjectedDocumentEdits(RazorCodeDocument codeDocument, TextEdit[] edits); public abstract bool TryMapFromProjectedDocumentRange(RazorCodeDocument codeDocument, Range projectedRange, out Range originalRange); diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs index 61937518db..9cb8686de8 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/CodeActions/CSharp/DefaultCSharpCodeActionProviderTest.cs @@ -85,7 +85,7 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions } [Fact] - public async Task ProvideAsync_FunctionsBlock_SingleLine_ValidCodeActions_ReturnsEmpty() + public async Task ProvideAsync_FunctionsBlock_SingleLine_ValidCodeActions_ReturnsProvidedCodeAction() { // Arrange var documentPath = "c:/Test.razor"; @@ -107,11 +107,14 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions var providedCodeActions = await provider.ProvideAsync(context, _supportedCodeActions, default); // Assert - Assert.Empty(providedCodeActions); + Assert.Equal(_supportedCodeActions.Length, providedCodeActions.Count); + var providedNames = providedCodeActions.Select(action => action.Name); + var expectedNames = _supportedCodeActions.Select(action => action.Name); + Assert.Equal(expectedNames, providedNames); } [Fact] - public async Task ProvideAsync_FunctionsBlock_OpenBraceSameLine_ValidCodeActions_ReturnsEmpty() + public async Task ProvideAsync_FunctionsBlock_OpenBraceSameLine_ValidCodeActions_ReturnsProvidedCodeAction() { // Arrange var documentPath = "c:/Test.razor"; @@ -135,7 +138,10 @@ Path; var providedCodeActions = await provider.ProvideAsync(context, _supportedCodeActions, default); // Assert - Assert.Empty(providedCodeActions); + Assert.Equal(_supportedCodeActions.Length, providedCodeActions.Count); + var providedNames = providedCodeActions.Select(action => action.Name); + var expectedNames = _supportedCodeActions.Select(action => action.Name); + Assert.Equal(expectedNames, providedNames); } [Fact] diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting/CodeActionFormattingTest.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting/CodeActionFormattingTest.cs new file mode 100644 index 0000000000..06b7a8d2ef --- /dev/null +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting/CodeActionFormattingTest.cs @@ -0,0 +1,111 @@ +// Copyright (c) .NET Foundation. All rights reserved. +// Licensed under the MIT license. See License.txt in the project root for license information. + +using System.Threading.Tasks; +using Xunit; +using Xunit.Abstractions; + +namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting +{ + public class CodeActionFormattingTest : FormattingTestBase + { + public CodeActionFormattingTest(ITestOutputHelper output) + : base(output) + { + } + + [Fact] + public async Task AddDebuggerDisplay() + { + await RunCodeActionFormattingTestAsync( +input: @" +@functions { + class Goo$$ + { + + } +} +", +codeActionEdits: new [] +{ + Edit(7, 17, 7, 17, "Diagnostics;\r\n using System."), + Edit(67, 0, 67, 8, ""), + Edit(69, 34, 70, 7, "\r\n\r\n [DebuggerDisplay($\"{{{nameof(GetDebuggerDisplay)}(),nq}}\")]"), + Edit(71, 0, 71, 4, " "), + Edit(72, 5, 72, 5, "\r\n private string GetDebuggerDisplay()\r\n {"), + Edit(73, 0, 73, 0, " return ToString();\r\n }\r\n"), + Edit(73, 8, 74, 4, "") +}, +expected: @" +@functions { + [DebuggerDisplay($""{{{nameof(GetDebuggerDisplay)}(),nq}}"")] + class Goo + { + private string GetDebuggerDisplay() + { + return ToString(); + } + } +} +"); + } + + [Fact] + public async Task GenerateConstructor() + { + await RunCodeActionFormattingTestAsync( +input: @" +@functions { + class Goo$$ + { + + } +} +", +codeActionEdits: new[] +{ + Edit(67, 0, 67, 8, ""), + Edit(69, 34, 69, 34, "\r\n\r\n class Goo"), + Edit(70, 0, 70, 12, " {"), + Edit(71, 0, 71, 9, " public"), + Edit(71, 13, 71, 13, "()"), + Edit(72, 0, 72, 4, " "), + Edit(73, 0, 73, 4, " }"), + Edit(74, 0, 74, 4, " "), +}, +expected: @" +@functions { + class Goo + { + public Goo() + { + } + } +} +"); + } + + [Fact] + public async Task OverrideCompletion() + { + await RunCodeActionFormattingTestAsync( +input: @" +@functions { + override $$ +} +", +codeActionEdits: new[] +{ + Edit(65, 0, 72, 0, " {\r\n }\r\n#pragma warning restore 1998\r\n#nullable restore\r\n#line 2 \"e:/Scratch/BlazorApp13/BlazorApp13/Client/Pages/Test.razor\"\r\n\r\n protected override void OnAfterRender(bool firstRender)\r\n {\r\n base.OnAfterRender(firstRender);/*$0*/\r\n }\r\n"), +}, +expected: @" +@functions { + protected override void OnAfterRender(bool firstRender) + { + base.OnAfterRender(firstRender);/*$0*/ + } +} +"); + } + } +} diff --git a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting/FormattingTestBase.cs b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting/FormattingTestBase.cs index 6e5a6be52d..16e41452ce 100644 --- a/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting/FormattingTestBase.cs +++ b/src/Razor/test/Microsoft.AspNetCore.Razor.LanguageServer.Test/Formatting/FormattingTestBase.cs @@ -126,12 +126,31 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting new XUnitVerifier().EqualOrDiff(expected, actual); } + protected async Task RunCodeActionFormattingTestAsync( + string input, + TextEdit[] codeActionEdits, + string expected, + int tabSize = 4, + bool insertSpaces = true, + string fileKind = null) + { + var (razorSourceText, edits) = await GetOnTypeFormattingEditsAsync(input, ' ', tabSize, insertSpaces, fileKind, codeActionEdits, bypassValidationPasses: true); + + // Assert + var edited = ApplyEdits(razorSourceText, edits); + var actual = edited.ToString(); + + new XUnitVerifier().EqualOrDiff(expected, actual); + } + protected async Task<(SourceText, TextEdit[])> GetOnTypeFormattingEditsAsync( string input, char triggerCharacter, int tabSize = 4, bool insertSpaces = true, - string fileKind = null) + string fileKind = null, + TextEdit[] codeActionEdits = null, + bool bypassValidationPasses = false) { // Arrange fileKind ??= FileKinds.Component; @@ -151,8 +170,8 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting throw new InvalidOperationException("Could not map from Razor document to generated document"); } - var projectedEdits = Array.Empty(); - if (languageKind == RazorLanguageKind.CSharp) + var projectedEdits = codeActionEdits; + if (projectedEdits is null && languageKind == RazorLanguageKind.CSharp) { projectedEdits = await GetFormattedCSharpEditsAsync( codeDocument, triggerCharacter, projectedIndex, insertSpaces, tabSize).ConfigureAwait(false); @@ -171,11 +190,18 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting // Act var edits = await formattingService.ApplyFormattedEditsAsync( - uri, documentSnapshot, languageKind, projectedEdits, options, CancellationToken.None); + uri, documentSnapshot, languageKind, projectedEdits, options, CancellationToken.None, bypassValidationPasses: bypassValidationPasses); return (razorSourceText, edits); } + protected static TextEdit Edit(int startLine, int startChar, int endLine, int endChar, string newText) + => new TextEdit() + { + Range = new OmniSharp.Extensions.LanguageServer.Protocol.Models.Range(startLine, startChar, endLine, endChar), + NewText = newText + }; + private static async Task GetFormattedCSharpEditsAsync( RazorCodeDocument codeDocument, char typedChar,