Do some fancy remapping to allow code actions everywhere

This commit is contained in:
David Wengier 2021-11-18 22:35:40 +11:00
Родитель d8313c95e9
Коммит 8f3cdc448c
9 изменённых файлов: 297 добавлений и 166 удалений

Просмотреть файл

@ -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<RazorCodeAction> 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;
}
}
}

Просмотреть файл

@ -51,11 +51,6 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions
return EmptyResult;
}
if (InFunctionsBlockThatCantHaveCodeActions(context))
{
return EmptyResult;
}
var results = new List<RazorCodeAction>();
foreach (var codeAction in codeActions)

Просмотреть файл

@ -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<TextEdit>();
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);

Просмотреть файл

@ -107,16 +107,9 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting
private TextEdit[] MapEditsToHostDocument(RazorCodeDocument codeDocument, TextEdit[] csharpEdits)
{
var actualEdits = new List<TextEdit>();
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<TextEdit[]> FormatOnClientAsync(

Просмотреть файл

@ -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<TextEdit>();
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<TextEdit>();
}
return edits.ToArray();
var edits = DocumentMappingService.GetProjectedDocumentEdits(codeDocument, projectedTextEdits);
return edits;
}
}
}

Просмотреть файл

@ -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);

Просмотреть файл

@ -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]

Просмотреть файл

@ -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*/
}
}
");
}
}
}

Просмотреть файл

@ -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<TextEdit>();
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<TextEdit[]> GetFormattedCSharpEditsAsync(
RazorCodeDocument codeDocument,
char typedChar,