Call Roslyn to format new code behind documents (#9263)

Fixes https://github.com/dotnet/razor/issues/4330
Fixes https://github.com/dotnet/razor/issues/8766
Includes https://github.com/dotnet/razor/pull/9262 so just review from
8a2b8affa2
onwards

Goes with https://github.com/dotnet/roslyn/pull/69878 and
https://github.com/dotnet/vscode-csharp/pull/6329

I logged https://github.com/dotnet/razor/issues/9264 to follow up with a
better test, though strictly speaking the one I've added id exhaustive
:)

Behaviour of Extract to Code Behind before this change. Note the many
using statements and block scoped namespace.

![ExtractToCodeBehindBefore](https://github.com/dotnet/razor/assets/754264/10ac5595-b3b2-44c2-a1a7-66664d3c8f1b)

Behaviour after this change. Note the file scoped namespace, and file
header.

![ExtractToCodeBehindAfter](https://github.com/dotnet/razor/assets/754264/65160715-bc98-4336-b19d-37f9b14adf94)

The squiggle on `NavigationManager` is due to an `.editorconfig` rule I
have on in that project, requiring `this.` qualification, so is
unrelated.
This commit is contained in:
David Wengier 2023-09-14 17:46:30 +10:00 коммит произвёл GitHub
Родитель 328202545a 53723469de
Коммит da56c4410b
Не найден ключ, соответствующий данной подписи
Идентификатор ключа GPG: 4AEE18F83AFDEB23
6 изменённых файлов: 170 добавлений и 34 удалений

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

@ -9,6 +9,7 @@ using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.Language.Intermediate;
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models;
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
using Microsoft.AspNetCore.Razor.PooledObjects;
@ -29,13 +30,16 @@ internal sealed class ExtractToCodeBehindCodeActionResolver : IRazorCodeActionRe
private readonly DocumentContextFactory _documentContextFactory;
private readonly LanguageServerFeatureOptions _languageServerFeatureOptions;
private readonly ClientNotifierServiceBase _languageServer;
public ExtractToCodeBehindCodeActionResolver(
DocumentContextFactory documentContextFactory,
LanguageServerFeatureOptions languageServerFeatureOptions)
LanguageServerFeatureOptions languageServerFeatureOptions,
ClientNotifierServiceBase languageServer)
{
_documentContextFactory = documentContextFactory ?? throw new ArgumentNullException(nameof(documentContextFactory));
_languageServerFeatureOptions = languageServerFeatureOptions;
_languageServerFeatureOptions = languageServerFeatureOptions ?? throw new ArgumentNullException(nameof(languageServerFeatureOptions));
_languageServer = languageServer ?? throw new ArgumentNullException(nameof(languageServer));
}
public string Action => LanguageServerConstants.CodeActions.ExtractToCodeBehindAction;
@ -93,8 +97,8 @@ internal sealed class ExtractToCodeBehindCodeActionResolver : IRazorCodeActionRe
}
var className = Path.GetFileNameWithoutExtension(path);
var codeBlockContent = text.GetSubTextString(new CodeAnalysis.Text.TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart));
var codeBehindContent = GenerateCodeBehindClass(className, actionParams.Namespace, codeBlockContent, codeDocument);
var codeBlockContent = text.GetSubTextString(new CodeAnalysis.Text.TextSpan(actionParams.ExtractStart, actionParams.ExtractEnd - actionParams.ExtractStart)).Trim();
var codeBehindContent = await GenerateCodeBehindClassAsync(documentContext.Project, codeBehindUri, className, actionParams.Namespace, codeBlockContent, codeDocument, cancellationToken).ConfigureAwait(false);
var start = codeDocument.Source.Lines.GetLocation(actionParams.RemoveStart);
var end = codeDocument.Source.Lines.GetLocation(actionParams.RemoveEnd);
@ -169,17 +173,7 @@ internal sealed class ExtractToCodeBehindCodeActionResolver : IRazorCodeActionRe
return codeBehindPath;
}
/// <summary>
/// Generate a complete C# compilation unit containing a partial class
/// with the given name, body contents, and the namespace and all
/// usings from the existing code document.
/// </summary>
/// <param name="className">Name of the resultant partial class.</param>
/// <param name="namespaceName">Name of the namespace to put the resultant class in.</param>
/// <param name="contents">Class body contents.</param>
/// <param name="razorCodeDocument">Existing code document we're extracting from.</param>
/// <returns></returns>
private string GenerateCodeBehindClass(string className, string namespaceName, string contents, RazorCodeDocument razorCodeDocument)
private async Task<string> GenerateCodeBehindClassAsync(CodeAnalysis.Razor.ProjectSystem.IProjectSnapshot project, Uri codeBehindUri, string className, string namespaceName, string contents, RazorCodeDocument razorCodeDocument, CancellationToken cancellationToken)
{
using var _ = StringBuilderPool.GetPooledObject(out var builder);
@ -210,12 +204,32 @@ internal sealed class ExtractToCodeBehindCodeActionResolver : IRazorCodeActionRe
builder.AppendLine(contents);
builder.Append('}');
// Sadly we can't use a "real" workspace here, because we don't have access. If we use our workspace, it wouldn't have the right settings
// for C# formatting, only Razor formatting, and we have no access to Roslyn's real workspace, since it could be in another process.
// TODO: Rather than format here, call Roslyn via LSP to format, and remove and sort usings: https://github.com/dotnet/razor/issues/8766
var node = CSharpSyntaxTree.ParseText(builder.ToString()).GetRoot();
node = Formatter.Format(node, s_workspace);
var newFileContent = builder.ToString();
return node.ToFullString();
var parameters = new FormatNewFileParams()
{
Project = new TextDocumentIdentifier
{
Uri = new Uri(project.FilePath, UriKind.Absolute)
},
Document = new TextDocumentIdentifier
{
Uri = codeBehindUri
},
Contents = newFileContent
};
var fixedContent = await _languageServer.SendRequestAsync<FormatNewFileParams, string?>(CustomMessageNames.RazorFormatNewFileEndpointName, parameters, cancellationToken).ConfigureAwait(false);
if (fixedContent is null)
{
// Sadly we can't use a "real" workspace here, because we don't have access. If we use our workspace, it wouldn't have the right settings
// for C# formatting, only Razor formatting, and we have no access to Roslyn's real workspace, since it could be in another process.
var node = await CSharpSyntaxTree.ParseText(newFileContent).GetRootAsync(cancellationToken).ConfigureAwait(false);
node = Formatter.Format(node, s_workspace);
return node.ToFullString();
}
return fixedContent;
}
}

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

@ -0,0 +1,20 @@
// Copyright (c) .NET Foundation. All rights reserved.
// Licensed under the MIT license. See License.txt in the project root for license information.
using System.Runtime.Serialization;
using Microsoft.VisualStudio.LanguageServer.Protocol;
namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Razor;
[DataContract]
internal record FormatNewFileParams
{
[DataMember(Name = "document")]
public required TextDocumentIdentifier Document { get; set; }
[DataMember(Name = "project")]
public required TextDocumentIdentifier Project { get; set; }
[DataMember(Name = "contents")]
public required string Contents { get; set; }
}

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

@ -34,6 +34,8 @@ internal static class CustomMessageNames
public const string RazorFoldingRangeEndpoint = "razor/foldingRange";
public const string RazorHtmlFormattingEndpoint = "razor/htmlFormatting";
public const string RazorHtmlOnTypeFormattingEndpoint = "razor/htmlOnTypeFormatting";
public const string RazorSimplifyMethodEndpointName = "razor/simplifyMethod";
public const string RazorFormatNewFileEndpointName = "razor/formatNewFile";
// VS Windows only at the moment, but could/should be migrated
public const string RazorDocumentSymbolEndpoint = "razor/documentSymbol";
@ -52,8 +54,6 @@ internal static class CustomMessageNames
public const string RazorReferencesEndpointName = "razor/references";
public const string RazorSimplifyMethodEndpointName = "razor/simplifyMethod";
// Called to get C# diagnostics from Roslyn when publishing diagnostics for VS Code
public const string RazorCSharpPullDiagnosticsEndpointName = "razor/csharpPullDiagnostics";
}

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

@ -0,0 +1,38 @@
// 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.AspNetCore.Razor.LanguageServer.CodeActions.Razor;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.VisualStudio.LanguageServer.Protocol;
using Newtonsoft.Json.Linq;
using StreamJsonRpc;
namespace Microsoft.VisualStudio.LanguageServerClient.Razor;
internal partial class RazorCustomMessageTarget
{
[JsonRpcMethod(CustomMessageNames.RazorFormatNewFileEndpointName, UseSingleObjectParameterDeserialization = true)]
public async Task<string?> FormatNewFileAsync(FormatNewFileParams request, CancellationToken cancellationToken)
{
// This endpoint is special because it deals with a file that doesn't exist yet, so there is no document syncing necessary!
var response = await _requestInvoker.ReinvokeRequestOnServerAsync<FormatNewFileParams, string?>(
RazorLSPConstants.RoslynFormatNewFileEndpointName,
RazorLSPConstants.RazorCSharpLanguageServerName,
SupportsFormatNewFile,
request,
cancellationToken).ConfigureAwait(false);
return response.Result;
}
private static bool SupportsFormatNewFile(JToken token)
{
var serverCapabilities = token.ToObject<VSInternalServerCapabilities>();
return serverCapabilities?.Experimental is JObject experimental
&& experimental.TryGetValue(RazorLSPConstants.RoslynFormatNewFileEndpointName, out var supportsFormatNewFile)
&& supportsFormatNewFile.ToObject<bool>();
}
}

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

@ -22,4 +22,6 @@ internal static class RazorLSPConstants
public const string HtmlLSPDelegationContentTypeName = "html-delegation";
public const string RoslynSimplifyMethodEndpointName = "roslyn/simplifyMethod";
public const string RoslynFormatNewFileEndpointName = "roslyn/formatNewFile";
}

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

@ -2,11 +2,14 @@
// Licensed under the MIT license. See License.txt in the project root for license information.
using System;
using System.Collections.Generic;
using System.Linq;
using System.Threading.Tasks;
using Microsoft.AspNetCore.Razor.Language;
using Microsoft.AspNetCore.Razor.LanguageServer.CodeActions.Models;
using Microsoft.AspNetCore.Razor.LanguageServer.Common;
using Microsoft.AspNetCore.Razor.LanguageServer.Extensions;
using Microsoft.AspNetCore.Razor.LanguageServer.Test;
using Microsoft.AspNetCore.Razor.LanguageServer.Test.Common;
using Microsoft.AspNetCore.Razor.Test.Common;
using Microsoft.CodeAnalysis;
@ -21,18 +24,23 @@ namespace Microsoft.AspNetCore.Razor.LanguageServer.CodeActions;
public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
{
private readonly DocumentContextFactory _emptyDocumentContextFactory;
private readonly TestLanguageServer _languageServer;
public ExtractToCodeBehindCodeActionResolverTest(ITestOutputHelper testOutput)
: base(testOutput)
{
_emptyDocumentContextFactory = new TestDocumentContextFactory();
_languageServer = new TestLanguageServer(new Dictionary<string, Func<object?, Task<object>>>()
{
[CustomMessageNames.RazorFormatNewFileEndpointName] = c => Task.FromResult<object>(null!),
});
}
[Fact]
public async Task Handle_MissingFile()
{
// Arrange
var resolver = new ExtractToCodeBehindCodeActionResolver(_emptyDocumentContextFactory, TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(_emptyDocumentContextFactory, TestLanguageServerFeatureOptions.Instance, _languageServer);
var data = JObject.FromObject(new ExtractToCodeBehindCodeActionParams()
{
Uri = new Uri("c:/Test.razor"),
@ -59,7 +67,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
codeDocument.SetUnsupported();
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var data = JObject.FromObject(CreateExtractToCodeBehindCodeActionParams(new Uri("c:/Test.razor"), contents, "@code", "Test"));
// Act
@ -78,7 +86,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
codeDocument.SetFileKind(FileKinds.Legacy);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var data = JObject.FromObject(CreateExtractToCodeBehindCodeActionParams(new Uri("c:/Test.razor"), contents, "@code", "Test"));
// Act
@ -103,7 +111,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
@ -165,7 +173,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
@ -235,7 +243,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
@ -315,7 +323,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
@ -397,7 +405,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
@ -467,7 +475,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@functions", @namespace);
var data = JObject.FromObject(actionParams);
@ -529,7 +537,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
@ -593,7 +601,7 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance);
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, _languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
@ -641,6 +649,60 @@ public class ExtractToCodeBehindCodeActionResolverTest : LanguageServerTestBase
editCodeBehindEdit.NewText);
}
[Fact]
public async Task Handle_ExtractCodeBlock_CallsRoslyn()
{
// Arrange
var documentPath = new Uri("c:/Test.razor");
var contents = """
@page "/test"
@code {
private int x = 1;
}
""";
var codeDocument = CreateCodeDocument(contents);
Assert.True(codeDocument.TryComputeNamespace(fallbackToRootNamespace: true, out var @namespace));
var languageServer = new TestLanguageServer(new Dictionary<string, Func<object?, Task<object>>>()
{
[CustomMessageNames.RazorFormatNewFileEndpointName] = c => Task.FromResult<object>("Hi there! I'm from Roslyn"),
});
var resolver = new ExtractToCodeBehindCodeActionResolver(CreateDocumentContextFactory(documentPath, codeDocument), TestLanguageServerFeatureOptions.Instance, languageServer);
var actionParams = CreateExtractToCodeBehindCodeActionParams(documentPath, contents, "@code", @namespace);
var data = JObject.FromObject(actionParams);
// Act
var workspaceEdit = await resolver.ResolveAsync(data, default);
// Assert
Assert.NotNull(workspaceEdit);
Assert.NotNull(workspaceEdit!.DocumentChanges);
Assert.Equal(3, workspaceEdit.DocumentChanges!.Value.Count());
var documentChanges = workspaceEdit.DocumentChanges!.Value.ToArray();
var createFileChange = documentChanges[0];
Assert.True(createFileChange.TryGetSecond(out var _));
var editCodeDocumentChange = documentChanges[1];
Assert.True(editCodeDocumentChange.TryGetFirst(out var textDocumentEdit1));
var editCodeDocumentEdit = textDocumentEdit1!.Edits.First();
Assert.True(editCodeDocumentEdit.Range.Start.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeStart));
Assert.Equal(actionParams.RemoveStart, removeStart);
Assert.True(editCodeDocumentEdit.Range.End.TryGetAbsoluteIndex(codeDocument.GetSourceText(), Logger, out var removeEnd));
Assert.Equal(actionParams.RemoveEnd, removeEnd);
var editCodeBehindChange = documentChanges[2];
Assert.True(editCodeBehindChange.TryGetFirst(out var textDocumentEdit2));
var editCodeBehindEdit = textDocumentEdit2!.Edits.First();
AssertEx.EqualOrDiff("""
Hi there! I'm from Roslyn
""",
editCodeBehindEdit.NewText);
}
private static RazorCodeDocument CreateCodeDocument(string text)
{
var projectItem = new TestRazorProjectItem("c:/Test.razor", "c:/Test.razor", "Test.razor") { Content = text };