Merge pull request #6785 from davidwengier/FullAddUsingSupport

This commit is contained in:
David Wengier 2022-09-07 12:57:45 +10:00 коммит произвёл GitHub
Родитель 655e204a5e 70f95252c1
Коммит a8a8f4ad36
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
6 изменённых файлов: 273 добавлений и 108 удалений

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

@ -2,15 +2,72 @@
// Licensed under the MIT license. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
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.LanguageServer.Common;
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<TextEdit[]> 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);
var newUsings = await FindUsingDirectiveStringsAsync(changedCSharpText, cancellationToken);
var edits = new List<TextEdit>();
foreach (var usingStatement in newUsings.Except(oldUsings))
{
// This identifier will be eventually thrown away.
var identifier = new OptionalVersionedTextDocumentIdentifier { Uri = new Uri(codeDocument.Source.FilePath, UriKind.Relative) };
var workspaceEdit = AddUsingsCodeActionResolver.CreateAddUsingWorkspaceEdit(usingStatement, codeDocument, codeDocumentIdentifier: identifier);
edits.AddRange(workspaceEdit.DocumentChanges!.Value.First.First().Edits);
}
return edits.ToArray();
}
private static async Task<IEnumerable<string>> FindUsingDirectiveStringsAsync(SourceText originalCSharpText, CancellationToken cancellationToken)
{
var syntaxTree = CSharpSyntaxTree.ParseText(originalCSharpText, cancellationToken: cancellationToken);
var syntaxRoot = await syntaxTree.GetRootAsync(cancellationToken);
// 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<UsingDirectiveSyntax>()
// Select everything after the initial "using " part of the statement. This is slightly lazy, for sure, but has
// the advantage of us not caring about chagnes to C# syntax, we just grab whatever Roslyn wanted to put in, so
// we should still work in C# v26
.Select(u => u.ToString().Substring("using ".Length));
return usings;
}
internal static readonly Regex AddUsingVSCodeAction = new Regex("^@?using ([^;]+);?$", RegexOptions.Compiled, TimeSpan.FromSeconds(1));
// Internal for testing

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

@ -2,11 +2,15 @@
// Licensed under the MIT license. See License.txt in the project root for license information.
using System;
using System.Diagnostics;
using System.Linq;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.Common.Extensions;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
using Microsoft.CodeAnalysis.Razor.Workspaces.Extensions;
using Microsoft.VisualStudio.LanguageServer.Protocol;
namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions
@ -46,9 +50,20 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions
cancellationToken.ThrowIfCancellationRequested();
if (!AddUsingsCodeActionProviderHelper.TryExtractNamespace(codeAction.Title, out var @namespace))
var resolvedCodeAction = await ResolveCodeActionWithServerAsync(csharpParams.RazorFileUri, codeAction, cancellationToken).ConfigureAwait(false);
// TODO: Move this higher, so it happens on any code action.
// For that though, we need a deeper understanding of applying workspace edits to documents, rather than
// just picking out the first one because we assume thats where it will be.
// Tracked by https://github.com/dotnet/razor-tooling/issues/6159
if (resolvedCodeAction?.Edit?.TryGetDocumentChanges(out var documentChanges) != true)
{
// Invalid text edit, missing namespace
return codeAction;
}
if (documentChanges!.Length != 1)
{
Debug.Fail("We don't yet support multi-document code actions! If you're seeing this, something about Roslyn changed and we should react.");
return codeAction;
}
@ -64,14 +79,28 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions
return codeAction;
}
var codeDocumentIdentifier = new OptionalVersionedTextDocumentIdentifier()
{
Uri = csharpParams.RazorFileUri,
Version = documentContext.Version
};
var csharpText = codeDocument.GetCSharpSourceText();
var edits = documentChanges[0].Edits;
var changes = edits.Select(e => e.AsTextChange(csharpText));
var changedText = csharpText.WithChanges(changes);
var edit = AddUsingsCodeActionResolver.CreateAddUsingWorkspaceEdit(@namespace, codeDocument, codeDocumentIdentifier);
codeAction.Edit = edit;
edits = await AddUsingsCodeActionProviderHelper.GetUsingStatementEditsAsync(codeDocument, csharpText, changedText, cancellationToken).ConfigureAwait(false);
codeAction.Edit = new WorkspaceEdit
{
DocumentChanges = new TextDocumentEdit[]
{
new TextDocumentEdit
{
TextDocument = new OptionalVersionedTextDocumentIdentifier()
{
Uri = csharpParams.RazorFileUri,
Version = documentContext.Version
},
Edits = edits
}
}
};
return codeAction;
}

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

@ -429,7 +429,7 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer
public async override Task<WorkspaceEdit> RemapWorkspaceEditAsync(WorkspaceEdit workspaceEdit, CancellationToken cancellationToken)
{
if (TryGetDocumentChanges(workspaceEdit, out var documentChanges))
if (workspaceEdit.TryGetDocumentChanges(out var documentChanges))
{
// The LSP spec says, we should prefer `DocumentChanges` property over `Changes` if available.
var remappedEdits = await RemapVersionedDocumentEditsAsync(documentChanges, cancellationToken).ConfigureAwait(false);
@ -794,36 +794,6 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer
}
}
private static bool TryGetDocumentChanges(WorkspaceEdit workspaceEdit, [NotNullWhen(true)] out TextDocumentEdit[]? documentChanges)
{
if (workspaceEdit.DocumentChanges?.Value is TextDocumentEdit[] documentEdits)
{
documentChanges = documentEdits;
return true;
}
if (workspaceEdit.DocumentChanges?.Value is SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[] sumTypeArray)
{
var documentEditList = new List<TextDocumentEdit>();
foreach (var sumType in sumTypeArray)
{
if (sumType.Value is TextDocumentEdit textDocumentEdit)
{
documentEditList.Add(textDocumentEdit);
}
}
if (documentEditList.Count > 0)
{
documentChanges = documentEditList.ToArray();
return true;
}
}
documentChanges = null;
return false;
}
private async Task<TextDocumentEdit[]> RemapVersionedDocumentEditsAsync(TextDocumentEdit[] documentEdits, CancellationToken cancellationToken)
{
var remappedDocumentEdits = new List<TextDocumentEdit>();

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

@ -0,0 +1,42 @@
// 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.Diagnostics.CodeAnalysis;
using Microsoft.VisualStudio.LanguageServer.Protocol;
namespace Microsoft.AspNetCore.Razor.LanguageServer.Extensions
{
internal static class WorkspaceEditExtensions
{
public static bool TryGetDocumentChanges(this WorkspaceEdit workspaceEdit, [NotNullWhen(true)] out TextDocumentEdit[]? documentChanges)
{
if (workspaceEdit.DocumentChanges?.Value is TextDocumentEdit[] documentEdits)
{
documentChanges = documentEdits;
return true;
}
if (workspaceEdit.DocumentChanges?.Value is SumType<TextDocumentEdit, CreateFile, RenameFile, DeleteFile>[] sumTypeArray)
{
var documentEditList = new List<TextDocumentEdit>();
foreach (var sumType in sumTypeArray)
{
if (sumType.Value is TextDocumentEdit textDocumentEdit)
{
documentEditList.Add(textDocumentEdit);
}
}
if (documentEditList.Count > 0)
{
documentChanges = documentEditList.ToArray();
return true;
}
}
documentChanges = null;
return false;
}
}
}

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

@ -16,8 +16,6 @@ using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
using Microsoft.AspNetCore.Razor.LanguageServer.Protocol;
using Microsoft.CodeAnalysis;
using Microsoft.CodeAnalysis.CSharp;
using Microsoft.CodeAnalysis.CSharp.Syntax;
using Microsoft.CodeAnalysis.ExternalAccess.Razor;
using Microsoft.CodeAnalysis.Razor.Workspaces.Extensions;
using Microsoft.CodeAnalysis.Text;
@ -212,51 +210,14 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.Formatting
// 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))
{
finalEdits = await AddUsingStatementEditsAsync(codeDocument, finalEdits, csharpText, originalTextWithChanges, cancellationToken);
var usingStatementEdits = await AddUsingsCodeActionProviderHelper.GetUsingStatementEditsAsync(codeDocument, csharpText, originalTextWithChanges, cancellationToken);
finalEdits = usingStatementEdits.Concat(finalEdits).ToArray();
}
}
return new FormattingResult(finalEdits);
}
private async Task<TextEdit[]> AddUsingStatementEditsAsync(RazorCodeDocument codeDocument, TextEdit[] finalEdits, SourceText csharpText, SourceText originalTextWithChanges, 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.
// First grab the old usings. We just convert them all to strings, because we only care about how the statements are represented in code.
var oldSyntaxTree = CSharpSyntaxTree.ParseText(csharpText, cancellationToken: cancellationToken);
var oldRoot = await oldSyntaxTree.GetRootAsync(cancellationToken);
var oldUsings = oldRoot.DescendantNodes(n => n is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax).OfType<UsingDirectiveSyntax>().Select(u => u.ToString().Substring(6));
// Grab the new usings
var newSyntaxTree = CSharpSyntaxTree.ParseText(originalTextWithChanges, cancellationToken: cancellationToken);
var newRoot = await newSyntaxTree.GetRootAsync(cancellationToken);
var newUsings = newRoot.DescendantNodes(n => n is BaseNamespaceDeclarationSyntax or CompilationUnitSyntax).OfType<UsingDirectiveSyntax>().Select(u => u.ToString().Substring(6));
var edits = new List<TextEdit>();
foreach (var usingStatement in newUsings.Except(oldUsings))
{
// This identifier will be eventually thrown away.
var identifier = new OptionalVersionedTextDocumentIdentifier { Uri = new Uri(codeDocument.Source.FilePath, UriKind.Relative) };
var workspaceEdit = AddUsingsCodeActionResolver.CreateAddUsingWorkspaceEdit(usingStatement, codeDocument, codeDocumentIdentifier: identifier);
edits.AddRange(workspaceEdit.DocumentChanges!.Value.First.First().Edits);
}
edits.AddRange(finalEdits);
return edits.ToArray();
}
// Returns the minimal TextSpan that encompasses all the differences between the old and the new text.
private static SourceText ApplyChangesAndTrackChange(SourceText oldText, IEnumerable<TextChange> changes, out TextSpan spanBeforeChange, out TextSpan spanAfterChange)
{

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

@ -4,41 +4,25 @@
#nullable disable
using System;
using System.Threading;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Mvc.Razor.Extensions;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
using Microsoft.AspNetCore.Razor.Test.Common;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Moq;
using Newtonsoft.Json.Linq;
using OmniSharp.Extensions.JsonRpc;
using Xunit;
using Range = Microsoft.VisualStudio.LanguageServer.Protocol.Range;
namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions
{
public class AddUsingsCSharpCodeActionResolverTest : LanguageServerTestBase
{
private static readonly CodeAction s_defaultResolvedCodeAction = new CodeAction()
{
Title = "@using System.Net",
Data = null,
Edit = new WorkspaceEdit()
{
DocumentChanges = new TextDocumentEdit[] {
new TextDocumentEdit()
{
Edits = new TextEdit[] {
new TextEdit()
{
NewText = "using System.Net;"
}
}
}
}
}
};
private static readonly CodeAction s_defaultUnresolvedCodeAction = new CodeAction()
{
Title = "@using System.Net"
@ -48,25 +32,139 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions
public async Task ResolveAsync_ReturnsResolvedCodeAction()
{
// Arrange
CreateCodeActionResolver(out var codeActionParams, out var csharpCodeActionResolver);
var resolvedCodeAction = new CodeAction()
{
Title = "@using System.Net",
Data = null,
Edit = new WorkspaceEdit()
{
DocumentChanges = new TextDocumentEdit[] {
new TextDocumentEdit()
{
Edits = new TextEdit[] {
new TextEdit()
{
Range = new Range
{
Start = new Position(0, 0),
End = new Position(0, 0)
},
NewText = "using System.Net;"
}
}
}
}
}
};
CreateCodeActionResolver(out var codeActionParams, out var csharpCodeActionResolver, resolvedCodeAction);
// Act
var returnedCodeAction = await csharpCodeActionResolver.ResolveAsync(codeActionParams, s_defaultUnresolvedCodeAction, default);
// Assert
Assert.Equal(s_defaultResolvedCodeAction.Title, returnedCodeAction.Title);
Assert.Equal(s_defaultResolvedCodeAction.Data, returnedCodeAction.Data);
Assert.Equal(resolvedCodeAction.Title, returnedCodeAction.Title);
Assert.Equal(resolvedCodeAction.Data, returnedCodeAction.Data);
Assert.Equal(1, returnedCodeAction.Edit.DocumentChanges.Value.Count());
var returnedEdits = returnedCodeAction.Edit.DocumentChanges.Value.First();
Assert.True(returnedEdits.TryGetFirst(out var textDocumentEdit));
var returnedTextDocumentEdit = Assert.Single(textDocumentEdit.Edits);
Assert.Equal($"@using System.Net{Environment.NewLine}", returnedTextDocumentEdit.NewText);
Assert.Equal($"@using System.Net;{Environment.NewLine}", returnedTextDocumentEdit.NewText);
}
[Fact]
public async Task ResolveAsync_FragmentedEdit_ReturnsResolvedCodeAction()
{
// Arrange
var resolvedCodeAction = new CodeAction()
{
Title = "@using System.Net",
Data = null,
Edit = new WorkspaceEdit()
{
DocumentChanges = new TextDocumentEdit[] {
new TextDocumentEdit()
{
Edits = new TextEdit[] {
new TextEdit()
{
Range = new Range
{
// This puts it just after another "using" keyword
Start = new Position(8, 9),
End = new Position(8, 9)
},
NewText = " System.Net;\r\n using"
}
}
}
}
}
};
CreateCodeActionResolver(out var codeActionParams, out var csharpCodeActionResolver, resolvedCodeAction);
// Act
var returnedCodeAction = await csharpCodeActionResolver.ResolveAsync(codeActionParams, s_defaultUnresolvedCodeAction, default);
// Assert
Assert.Equal(resolvedCodeAction.Title, returnedCodeAction.Title);
Assert.Equal(resolvedCodeAction.Data, returnedCodeAction.Data);
Assert.Equal(1, returnedCodeAction.Edit.DocumentChanges.Value.Count());
var returnedEdits = returnedCodeAction.Edit.DocumentChanges.Value.First();
Assert.True(returnedEdits.TryGetFirst(out var textDocumentEdit));
var returnedTextDocumentEdit = Assert.Single(textDocumentEdit.Edits);
Assert.Equal($"@using System.Net;{Environment.NewLine}", returnedTextDocumentEdit.NewText);
}
[Fact]
public async Task ResolveAsync_GlobalUsing_ReturnsResolvedCodeAction()
{
// Arrange
var resolvedCodeAction = new CodeAction()
{
Title = "@using System.Net",
Data = null,
Edit = new WorkspaceEdit()
{
DocumentChanges = new TextDocumentEdit[] {
new TextDocumentEdit()
{
Edits = new TextEdit[] {
new TextEdit()
{
Range = new Range
{
Start = new Position(0, 0),
End = new Position(0, 0)
},
NewText = "using global::System.Net;"
}
}
}
}
}
};
CreateCodeActionResolver(out var codeActionParams, out var csharpCodeActionResolver, resolvedCodeAction);
// Act
var returnedCodeAction = await csharpCodeActionResolver.ResolveAsync(codeActionParams, s_defaultUnresolvedCodeAction, default);
// Assert
Assert.Equal(resolvedCodeAction.Title, returnedCodeAction.Title);
Assert.Equal(resolvedCodeAction.Data, returnedCodeAction.Data);
Assert.Equal(1, returnedCodeAction.Edit.DocumentChanges.Value.Count());
var returnedEdits = returnedCodeAction.Edit.DocumentChanges.Value.First();
Assert.True(returnedEdits.TryGetFirst(out var textDocumentEdit));
var returnedTextDocumentEdit = Assert.Single(textDocumentEdit.Edits);
Assert.Equal($"@using global::System.Net;{Environment.NewLine}", returnedTextDocumentEdit.NewText);
}
private void CreateCodeActionResolver(
out CSharpCodeActionParams codeActionParams,
out AddUsingsCSharpCodeActionResolver addUsingResolver)
out AddUsingsCSharpCodeActionResolver addUsingResolver,
CodeAction resolvedCodeAction)
{
var documentUri = new Uri("c:/Test.razor");
var contents = string.Empty;
@ -78,16 +176,24 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions
RazorFileUri = documentUri
};
var languageServer = CreateLanguageServer();
var languageServer = CreateLanguageServer(resolvedCodeAction);
addUsingResolver = new AddUsingsCSharpCodeActionResolver(
CreateDocumentContextFactory(documentUri, codeDocument),
languageServer);
}
private static ClientNotifierServiceBase CreateLanguageServer()
private static ClientNotifierServiceBase CreateLanguageServer(CodeAction resolvedCodeAction)
{
var responseRouterReturns = new Mock<IResponseRouterReturns>(MockBehavior.Strict);
responseRouterReturns
.Setup(l => l.Returning<CodeAction>(It.IsAny<CancellationToken>()))
.Returns(Task.FromResult(resolvedCodeAction));
var languageServer = new Mock<ClientNotifierServiceBase>(MockBehavior.Strict);
languageServer
.Setup(l => l.SendRequestAsync(RazorLanguageServerCustomMessageTargets.RazorResolveCodeActionsEndpoint, It.IsAny<RazorResolveCodeActionParams>()))
.Returns(Task.FromResult(responseRouterReturns.Object));
return languageServer.Object;
}